From eb6f2c5e0c65a3432aea100256f1b1140bec07fb Mon Sep 17 00:00:00 2001 From: Grace Kind Date: Sun, 31 May 2026 22:56:16 -0500 Subject: [PATCH] Refactor tab bars --- package.json | 2 +- src/css/style.css | 18 +- src/js/components/tab-bar.js | 79 +++++++ src/js/templates/tabBar.template.js | 26 --- src/js/views/hashtag.view.js | 17 +- src/js/views/home.view.js | 47 +--- src/js/views/listDetail.view.js | 16 +- src/js/views/notifications.view.js | 19 +- src/js/views/profile.view.js | 14 +- src/js/views/search.view.js | 33 +-- tests/e2e/specs/flows/likePost.test.js | 4 +- .../specs/views/notifications.view.test.js | 4 +- tests/e2e/specs/views/profile.view.test.js | 36 ++-- tests/unit/specs/components/tab-bar.test.js | 204 ++++++++++++++++++ .../specs/templates/tabBar.template.test.js | 126 ----------- 15 files changed, 376 insertions(+), 269 deletions(-) create mode 100644 src/js/components/tab-bar.js delete mode 100644 src/js/templates/tabBar.template.js create mode 100644 tests/unit/specs/components/tab-bar.test.js delete mode 100644 tests/unit/specs/templates/tabBar.template.test.js diff --git a/package.json b/package.json index a3cd1a5b..06fec294 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.14.141", + "version": "0.14.142", "type": "module", "scripts": { "start": "rm -rf build && NODE_ENV=development eleventy --serve", diff --git a/src/css/style.css b/src/css/style.css index fb7c8ebb..4db05170 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -887,12 +887,12 @@ header { } } -.tab-bar { +tab-bar { display: flex; height: 46px; } -.tab-bar .tab-bar-button { +tab-bar .tab-bar-button { border: none; position: relative; background: none; @@ -908,28 +908,28 @@ header { justify-content: center; } -.tab-bar .tab-bar-button.active { +tab-bar .tab-bar-button.active { color: var(--text-color); } -.tab-bar .tab-bar-button .tab-bar-button-label { +tab-bar .tab-bar-button .tab-bar-button-label { display: inline-block; min-width: 3em; border-bottom: 3px solid transparent; padding-bottom: 12px; } -.tab-bar .tab-bar-button.active .tab-bar-button-label { +tab-bar .tab-bar-button.active .tab-bar-button-label { border-bottom-color: var(--tab-bar-button-active-color); } @media (min-width: 800px) { - .tab-bar .tab-bar-button:hover { + tab-bar .tab-bar-button:hover { background-color: var(--post-hover-color); } } -.tab-bar-horizontal-scroll-container { +tab-bar:not([full-width]) { overflow-x: auto; overflow-y: hidden; scrollbar-width: none; @@ -7058,11 +7058,11 @@ toggle-switch { cursor: not-allowed; } -.tab-bar.tab-bar-full-width { +tab-bar[full-width] { width: 100%; } -.tab-bar.tab-bar-full-width .tab-bar-button { +tab-bar[full-width] .tab-bar-button { flex: 1; } diff --git a/src/js/components/tab-bar.js b/src/js/components/tab-bar.js new file mode 100644 index 00000000..9d438e00 --- /dev/null +++ b/src/js/components/tab-bar.js @@ -0,0 +1,79 @@ +import { html, render } from "/js/lib/lit-html.js"; +import { Component } from "/js/components/component.js"; +import { classnames } from "/js/utils.js"; + +class TabBar extends Component { + static observedAttributes = ["active-tab", "full-width"]; + + connectedCallback() { + if (this.initialized) return; + this._tabs = this._tabs ?? []; + this.lastScrolledTab = null; + this.render(); + this.initialized = true; + } + + attributeChangedCallback() { + if (!this.initialized) return; + this.render(); + } + + set tabs(tabs) { + this._tabs = tabs ?? []; + if (this.initialized) this.render(); + } + + get tabs() { + return this._tabs; + } + + get activeTab() { + return this.getAttribute("active-tab"); + } + + get fullWidth() { + return this.hasAttribute("full-width"); + } + + render() { + const activeTab = this.activeTab; + render( + html`${this._tabs.map( + (tab) => + html``, + )}`, + this, + ); + this.scrollActiveIntoView(); + } + + scrollActiveIntoView() { + if (this.fullWidth) return; + const activeTab = this.activeTab; + if (activeTab === this.lastScrolledTab) return; + const activeButton = this.querySelector(".tab-bar-button.active"); + if (!activeButton) return; + const behavior = this.lastScrolledTab === null ? "instant" : "smooth"; + this.lastScrolledTab = activeTab; + requestAnimationFrame(() => { + activeButton.scrollIntoView({ + behavior, + inline: "nearest", + block: "nearest", + }); + }); + } +} + +TabBar.register(); diff --git a/src/js/templates/tabBar.template.js b/src/js/templates/tabBar.template.js deleted file mode 100644 index 245c0297..00000000 --- a/src/js/templates/tabBar.template.js +++ /dev/null @@ -1,26 +0,0 @@ -import { html } from "/js/lib/lit-html.js"; -import { classnames } from "/js/utils.js"; - -export function tabBarTemplate({ - tabs, - activeTab, - onTabClick, - fullWidth = false, -}) { - return html` -
- ${tabs.map( - (tab) => - html``, - )} -
- `; -} diff --git a/src/js/views/hashtag.view.js b/src/js/views/hashtag.view.js index ba506a7d..76d9e3cd 100644 --- a/src/js/views/hashtag.view.js +++ b/src/js/views/hashtag.view.js @@ -4,7 +4,7 @@ import { postFeedTemplate } from "/js/templates/postFeed.template.js"; import { headerTemplate } from "/js/templates/header.template.js"; import { auth } from "/js/auth.js"; import { mainLayoutTemplate } from "/js/templates/mainLayout.template.js"; -import { tabBarTemplate } from "/js/templates/tabBar.template.js"; +import "/js/components/tab-bar.js"; import { HASHTAG_FEED_PAGE_SIZE } from "/js/config.js"; import { pageEffect } from "/js/router.js"; import { Signal } from "/js/signals.js"; @@ -93,13 +93,14 @@ class HashtagView extends View { children: html`
${headerTemplate({ title: `#${hashtag}`, - bottomItemTemplate: () => - tabBarTemplate({ - tabs: sortOptions, - activeTab: currentSort, - onTabClick: handleTabClick, - fullWidth: true, - }), + bottomItemTemplate: () => html` + handleTabClick(event.detail)} + > + `, })} ${sortOptions.map((sort) => { const feed = dataLayer.derived.$hydratedHashtagFeeds.get( diff --git a/src/js/views/home.view.js b/src/js/views/home.view.js index fd32fd31..9c024a2b 100644 --- a/src/js/views/home.view.js +++ b/src/js/views/home.view.js @@ -4,7 +4,7 @@ import { linkToProfile } from "/js/navigation.js"; import { postFeedTemplate } from "/js/templates/postFeed.template.js"; import { headerTemplate } from "/js/templates/header.template.js"; import { mainLayoutTemplate } from "/js/templates/mainLayout.template.js"; -import { tabBarTemplate } from "/js/templates/tabBar.template.js"; +import "/js/components/tab-bar.js"; import { PostSeenObserver } from "/js/postSeenObserver.js"; import { FEED_PAGE_SIZE, DISCOVER_FEED_URI } from "/js/config.js"; import { bindToPage, pageEffect } from "/js/router.js"; @@ -203,16 +203,14 @@ class HomeView extends View { leftButton: "menu", onClickMenuButton: () => handleMenuClick(), bottomItemTemplate: () => html` -
- ${tabBarTemplate({ - tabs: pinnedItems.map((item) => ({ - value: item.uri, - label: item.displayName, - })), - activeTab: currentFeedUri, - onTabClick: handleTabClick, - })} -
+ ({ + value: item.uri, + label: item.displayName, + }))} + active-tab=${currentFeedUri} + @tab-click=${(event) => handleTabClick(event.detail)} + > `, })}
@@ -265,33 +263,6 @@ class HomeView extends View { }); }); - let prevTabScrollFeedUri = null; - - // Scroll to active tab when current feed uri changes - pageEffect(root, () => { - const pinnedItems = dataLayer.derived.$hydratedPinnedItems.get(); - if (!pinnedItems) return; - const currentFeedUri = $currentFeedUri.get(); - if (currentFeedUri === prevTabScrollFeedUri) return; - const behavior = prevTabScrollFeedUri ? "smooth" : "instant"; - prevTabScrollFeedUri = currentFeedUri; - requestAnimationFrame(() => { - const container = root.querySelector( - ".tab-bar-horizontal-scroll-container", - ); - const activeTabButton = container?.querySelector( - ".tab-bar-button.active", - ); - if (activeTabButton) { - activeTabButton.scrollIntoView({ - behavior, - inline: "nearest", - block: "nearest", - }); - } - }); - }); - async function loadCurrentFeed({ reload = false } = {}) { const currentFeedUri = $currentFeedUri.get(); await dataLayer.requests.loadNextFeedPage(currentFeedUri, { diff --git a/src/js/views/listDetail.view.js b/src/js/views/listDetail.view.js index 2640d6fb..803c4752 100644 --- a/src/js/views/listDetail.view.js +++ b/src/js/views/listDetail.view.js @@ -7,7 +7,7 @@ import { profileFeedTemplate } from "/js/templates/profileFeed.template.js"; import { auth } from "/js/auth.js"; import { mainLayoutTemplate } from "/js/templates/mainLayout.template.js"; import { headerTemplate } from "/js/templates/header.template.js"; -import { tabBarTemplate } from "/js/templates/tabBar.template.js"; +import "/js/components/tab-bar.js"; import { pinIconTemplate } from "/js/templates/icons/pinIcon.template.js"; import { richTextTemplate } from "/js/templates/richText.template.js"; import { pageEffect } from "/js/router.js"; @@ -196,15 +196,15 @@ class ListDetailView extends View { class="list-detail-tab-bar" data-scroll-lock-sticky > - ${tabBarTemplate({ - tabs: [ + $activeTab.set(value), - fullWidth: true, - })} + ]} + active-tab=${activeTab} + full-width + @tab-click=${(event) => $activeTab.set(event.detail)} + > ` : ""}
- tabBarTemplate({ - tabs: [ + bottomItemTemplate: () => html` + handleTabClick(event.detail)} + > + `, })}
diff --git a/src/js/views/profile.view.js b/src/js/views/profile.view.js index a35c99b1..cf5113f6 100644 --- a/src/js/views/profile.view.js +++ b/src/js/views/profile.view.js @@ -15,7 +15,7 @@ import { getFacetsFromText } from "/js/facetHelpers.js"; import { pageEffect } from "/js/router.js"; import { AUTHOR_FEED_PAGE_SIZE, BSKY_LABELER_DID } from "/js/config.js"; import { showToast } from "/js/toasts.js"; -import { tabBarTemplate } from "/js/templates/tabBar.template.js"; +import "/js/components/tab-bar.js"; import { feedGeneratorListItemTemplate } from "/js/templates/feedGeneratorListItem.template.js"; import { feedGeneratorListItemSkeletonTemplate } from "/js/templates/feedGeneratorListItemSkeleton.template.js"; import { linkToList } from "/js/navigation.js"; @@ -418,8 +418,8 @@ class ProfileView extends View {
` : html`
- ${tabBarTemplate({ - tabs: [ + handleTabClick(event.detail)} + >
${isLabeler ? html`
+ handleTabChange(event.detail)} + > + ` : ""}
`, diff --git a/tests/e2e/specs/flows/likePost.test.js b/tests/e2e/specs/flows/likePost.test.js index 30b5ccd6..263e4549 100644 --- a/tests/e2e/specs/flows/likePost.test.js +++ b/tests/e2e/specs/flows/likePost.test.js @@ -38,7 +38,7 @@ test.describe("Like post flow", () => { await page.goto(`/profile/${userProfile.did}`); const profileView = page.locator("#profile-view"); - const tabBar = profileView.locator(".tab-bar"); + const tabBar = profileView.locator("tab-bar"); await expect(tabBar.locator('[data-testid="tab-likes"]')).toBeVisible({ timeout: 10000, }); @@ -75,7 +75,7 @@ test.describe("Like post flow", () => { await page.goto(`/profile/${userProfile.did}`); const profileView = page.locator("#profile-view"); - const tabBar = profileView.locator(".tab-bar"); + const tabBar = profileView.locator("tab-bar"); await expect(tabBar.locator('[data-testid="tab-likes"]')).toBeVisible({ timeout: 10000, }); diff --git a/tests/e2e/specs/views/notifications.view.test.js b/tests/e2e/specs/views/notifications.view.test.js index 8b85ac6f..afe1feae 100644 --- a/tests/e2e/specs/views/notifications.view.test.js +++ b/tests/e2e/specs/views/notifications.view.test.js @@ -926,7 +926,7 @@ test.describe("Notifications view", () => { await page.goto("/notifications"); const view = page.locator("#notifications-view"); - const tabBar = view.locator(".tab-bar"); + const tabBar = view.locator("tab-bar"); await expect(tabBar.locator(".tab-bar-button").nth(0)).toContainText( "All", { timeout: 10000 }, @@ -944,7 +944,7 @@ test.describe("Notifications view", () => { await page.goto("/notifications"); const view = page.locator("#notifications-view"); - const tabBar = view.locator(".tab-bar"); + const tabBar = view.locator("tab-bar"); await expect(tabBar.locator(".tab-bar-button").nth(0)).toHaveClass( /active/, { timeout: 10000 }, diff --git a/tests/e2e/specs/views/profile.view.test.js b/tests/e2e/specs/views/profile.view.test.js index 3759f507..2f701e7d 100644 --- a/tests/e2e/specs/views/profile.view.test.js +++ b/tests/e2e/specs/views/profile.view.test.js @@ -280,7 +280,7 @@ test.describe("Profile view", () => { await page.goto(`/profile/${otherUser.did}`); const view = page.locator("#profile-view"); - const tabBar = view.locator(".tab-bar"); + const tabBar = view.locator("tab-bar"); await expect(tabBar.locator(".tab-bar-button")).toHaveCount(3, { timeout: 10000, }); @@ -305,7 +305,7 @@ test.describe("Profile view", () => { await page.goto(`/profile/${otherUser.did}`); const view = page.locator("#profile-view"); - const tabBar = view.locator(".tab-bar"); + const tabBar = view.locator("tab-bar"); // Posts tab should be active by default await expect(tabBar.locator('[data-testid="tab-posts"]')).toHaveClass( @@ -348,7 +348,7 @@ test.describe("Profile view", () => { await page.goto(`/profile/${userProfile.did}`); const view = page.locator("#profile-view"); - const tabBar = view.locator(".tab-bar"); + const tabBar = view.locator("tab-bar"); await expect(tabBar.locator(".tab-bar-button")).toHaveCount(4, { timeout: 10000, }); @@ -579,7 +579,7 @@ test.describe("Profile view", () => { await expect(view.locator('[data-testid="blocked-badge"]')).toBeVisible({ timeout: 10000, }); - await expect(view.locator(".tab-bar")).not.toBeVisible(); + await expect(view.locator("tab-bar")).not.toBeVisible(); await expect(view.locator(".feed-end-message")).toContainText( "Posts hidden", ); @@ -1006,7 +1006,7 @@ test.describe("Profile view", () => { await expect(view.locator(".feed-end-message")).toContainText( "Posts hidden", ); - await expect(view.locator(".tab-bar")).not.toBeVisible(); + await expect(view.locator("tab-bar")).not.toBeVisible(); await expect( view.locator('[data-testid="follow-button"]'), ).not.toBeVisible(); @@ -1478,7 +1478,7 @@ test.describe("Profile view", () => { await page.goto(`/profile/${labelerUser.did}`); const view = page.locator("#profile-view"); - const tabBar = view.locator(".tab-bar"); + const tabBar = view.locator("tab-bar"); await expect( tabBar.locator('[data-testid="tab-labeler-settings"]'), ).toBeVisible({ timeout: 10000 }); @@ -1758,7 +1758,7 @@ test.describe("Profile view", () => { await page.goto(`/profile/${otherUser.did}`); const view = page.locator("#profile-view"); - const tabBar = view.locator(".tab-bar"); + const tabBar = view.locator("tab-bar"); await expect(tabBar.locator(".tab-bar-button")).toHaveCount(2, { timeout: 10000, }); @@ -1915,7 +1915,7 @@ test.describe("Profile view", () => { await page.goto(`/profile/${userWithFeeds.did}`); const view = page.locator("#profile-view"); - const tabBar = view.locator(".tab-bar"); + const tabBar = view.locator("tab-bar"); await expect(tabBar.locator('[data-testid="tab-feeds"]')).toBeVisible({ timeout: 10000, }); @@ -1931,7 +1931,7 @@ test.describe("Profile view", () => { await page.goto(`/profile/${otherUser.did}`); const view = page.locator("#profile-view"); - const tabBar = view.locator(".tab-bar"); + const tabBar = view.locator("tab-bar"); await expect(tabBar.locator(".tab-bar-button").first()).toBeVisible({ timeout: 10000, }); @@ -1951,7 +1951,7 @@ test.describe("Profile view", () => { await page.goto(`/profile/${userWithFeeds.did}`); const view = page.locator("#profile-view"); - const tabBar = view.locator(".tab-bar"); + const tabBar = view.locator("tab-bar"); await expect(tabBar.locator('[data-testid="tab-feeds"]')).toBeVisible({ timeout: 10000, }); @@ -1982,7 +1982,7 @@ test.describe("Profile view", () => { await page.goto(`/profile/${userWithFeeds.did}`); const view = page.locator("#profile-view"); - const tabBar = view.locator(".tab-bar"); + const tabBar = view.locator("tab-bar"); await expect(tabBar.locator('[data-testid="tab-feeds"]')).toBeVisible({ timeout: 10000, }); @@ -2085,7 +2085,7 @@ test.describe("Profile view", () => { await page.goto(`/profile/${userProfile.did}`); const view = page.locator("#profile-view"); - const tabBar = view.locator(".tab-bar"); + const tabBar = view.locator("tab-bar"); await expect(tabBar.locator('[data-testid="tab-feeds"]')).toBeVisible({ timeout: 10000, }); @@ -2100,7 +2100,7 @@ test.describe("Profile view", () => { await page.goto(`/profile/${userProfile.did}`); const view = page.locator("#profile-view"); - const tabBar = view.locator(".tab-bar"); + const tabBar = view.locator("tab-bar"); await expect(tabBar.locator(".tab-bar-button").first()).toBeVisible({ timeout: 10000, }); @@ -2142,7 +2142,7 @@ test.describe("Profile view", () => { await page.goto(`/profile/${userWithLists.did}`); const view = page.locator("#profile-view"); - const tabBar = view.locator(".tab-bar"); + const tabBar = view.locator("tab-bar"); await expect(tabBar.locator('[data-testid="tab-lists"]')).toBeVisible({ timeout: 10000, }); @@ -2158,7 +2158,7 @@ test.describe("Profile view", () => { await page.goto(`/profile/${otherUser.did}`); const view = page.locator("#profile-view"); - const tabBar = view.locator(".tab-bar"); + const tabBar = view.locator("tab-bar"); await expect(tabBar.locator(".tab-bar-button").first()).toBeVisible({ timeout: 10000, }); @@ -2176,7 +2176,7 @@ test.describe("Profile view", () => { await page.goto(`/profile/${userWithLists.did}`); const view = page.locator("#profile-view"); - const tabBar = view.locator(".tab-bar"); + const tabBar = view.locator("tab-bar"); await expect(tabBar.locator('[data-testid="tab-lists"]')).toBeVisible({ timeout: 10000, }); @@ -2211,7 +2211,7 @@ test.describe("Profile view", () => { await page.goto(`/profile/${userWithLists.did}`); const view = page.locator("#profile-view"); - const tabBar = view.locator(".tab-bar"); + const tabBar = view.locator("tab-bar"); await expect(tabBar.locator('[data-testid="tab-lists"]')).toBeVisible({ timeout: 10000, }); @@ -2256,7 +2256,7 @@ test.describe("Profile view", () => { await page.goto(`/profile/${userProfile.did}`); const view = page.locator("#profile-view"); - const tabBar = view.locator(".tab-bar"); + const tabBar = view.locator("tab-bar"); await expect(tabBar.locator('[data-testid="tab-lists"]')).toBeVisible({ timeout: 10000, }); diff --git a/tests/unit/specs/components/tab-bar.test.js b/tests/unit/specs/components/tab-bar.test.js new file mode 100644 index 00000000..f11a950c --- /dev/null +++ b/tests/unit/specs/components/tab-bar.test.js @@ -0,0 +1,204 @@ +import { TestSuite } from "../../testSuite.js"; +import { assert, assertEquals, mock } from "../../testHelpers.js"; +import "/js/components/tab-bar.js"; + +const t = new TestSuite("TabBar"); + +function waitForAnimationFrame() { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +const sampleTabs = [ + { value: "one", label: "One" }, + { value: "two", label: "Two" }, + { value: "three", label: "Three" }, +]; + +function createTabBar({ + tabs = sampleTabs, + activeTab = null, + fullWidth = false, +} = {}) { + const element = document.createElement("tab-bar"); + element.tabs = tabs; + if (activeTab !== null) element.setAttribute("active-tab", activeTab); + if (fullWidth) element.setAttribute("full-width", ""); + return element; +} + +let originalScrollIntoView; +let scrollSpy; + +t.beforeEach(async () => { + document.body.innerHTML = ""; + // Drain any requestAnimationFrame callbacks queued by previous tests + // before installing the spy, so we don't capture stale calls. + await new Promise((resolve) => setTimeout(resolve, 0)); + originalScrollIntoView = + window.HTMLElement.prototype.scrollIntoView ?? function () {}; + scrollSpy = mock(); + window.HTMLElement.prototype.scrollIntoView = function (options) { + scrollSpy(this, options); + }; +}); + +t.afterEach(() => { + window.HTMLElement.prototype.scrollIntoView = originalScrollIntoView; +}); + +t.describe("TabBar - rendering", (it) => { + it("should render a button for each tab", () => { + const element = createTabBar(); + document.body.appendChild(element); + const buttons = element.querySelectorAll(".tab-bar-button"); + assertEquals(buttons.length, 3); + }); + + it("should render tab labels", () => { + const element = createTabBar(); + document.body.appendChild(element); + const buttons = element.querySelectorAll(".tab-bar-button"); + assertEquals(buttons[0].textContent.trim(), "One"); + assertEquals(buttons[1].textContent.trim(), "Two"); + assertEquals(buttons[2].textContent.trim(), "Three"); + }); + + it("should mark the active tab with the active class", () => { + const element = createTabBar({ activeTab: "two" }); + document.body.appendChild(element); + const activeButtons = element.querySelectorAll(".tab-bar-button.active"); + assertEquals(activeButtons.length, 1); + assertEquals(activeButtons[0].textContent.trim(), "Two"); + }); + + it("should re-render when tabs property changes", () => { + const element = createTabBar(); + document.body.appendChild(element); + element.tabs = [{ value: "x", label: "X" }]; + const buttons = element.querySelectorAll(".tab-bar-button"); + assertEquals(buttons.length, 1); + assertEquals(buttons[0].textContent.trim(), "X"); + }); + + it("should re-render when active-tab attribute changes", () => { + const element = createTabBar({ activeTab: "one" }); + document.body.appendChild(element); + element.setAttribute("active-tab", "three"); + const activeButtons = element.querySelectorAll(".tab-bar-button.active"); + assertEquals(activeButtons.length, 1); + assertEquals(activeButtons[0].textContent.trim(), "Three"); + }); +}); + +t.describe("TabBar - tab-click events", (it) => { + it("should dispatch tab-click with the tab value when a button is clicked", () => { + const element = createTabBar(); + document.body.appendChild(element); + const handler = mock(); + element.addEventListener("tab-click", (event) => handler(event.detail)); + element.querySelectorAll(".tab-bar-button")[1].click(); + assertEquals(handler.calls.length, 1); + assertEquals(handler.calls[0][0], "two"); + }); +}); + +t.describe("TabBar - initial scroll", (it) => { + it("should scroll the active tab into view on connect", async () => { + const element = createTabBar({ activeTab: "two" }); + document.body.appendChild(element); + await waitForAnimationFrame(); + assertEquals(scrollSpy.calls.length, 1); + assertEquals(scrollSpy.calls[0][0].textContent.trim(), "Two"); + }); + + it("should use 'instant' behavior on first scroll", async () => { + const element = createTabBar({ activeTab: "two" }); + document.body.appendChild(element); + await waitForAnimationFrame(); + assertEquals(scrollSpy.calls[0][1].behavior, "instant"); + }); + + it("should not scroll if no active tab is present", async () => { + const element = createTabBar(); + document.body.appendChild(element); + await waitForAnimationFrame(); + assertEquals(scrollSpy.calls.length, 0); + }); +}); + +t.describe("TabBar - binding order", (it) => { + it("should scroll instantly when tabs are set after active-tab", async () => { + const element = document.createElement("tab-bar"); + element.setAttribute("active-tab", "two"); + document.body.appendChild(element); + await waitForAnimationFrame(); + assertEquals(scrollSpy.calls.length, 0); + + element.tabs = sampleTabs; + await waitForAnimationFrame(); + + assertEquals(scrollSpy.calls.length, 1); + assertEquals(scrollSpy.calls[0][1].behavior, "instant"); + assertEquals(scrollSpy.calls[0][0].textContent.trim(), "Two"); + }); +}); + +t.describe("TabBar - active-tab attribute changes", (it) => { + it("should scroll the new active tab into view", async () => { + const element = createTabBar({ activeTab: "one" }); + document.body.appendChild(element); + await waitForAnimationFrame(); + scrollSpy.calls.length = 0; + + element.setAttribute("active-tab", "three"); + await waitForAnimationFrame(); + + assertEquals(scrollSpy.calls.length, 1); + assertEquals(scrollSpy.calls[0][0].textContent.trim(), "Three"); + }); + + it("should use 'smooth' behavior on subsequent scrolls", async () => { + const element = createTabBar({ activeTab: "one" }); + document.body.appendChild(element); + await waitForAnimationFrame(); + + element.setAttribute("active-tab", "two"); + await waitForAnimationFrame(); + + const lastCall = scrollSpy.calls[scrollSpy.calls.length - 1]; + assertEquals(lastCall[1].behavior, "smooth"); + }); +}); + +t.describe("TabBar - full-width", (it) => { + it("should not scroll on connect when full-width is set", async () => { + const element = createTabBar({ activeTab: "two", fullWidth: true }); + document.body.appendChild(element); + await waitForAnimationFrame(); + assertEquals(scrollSpy.calls.length, 0); + }); + + it("should not scroll on active-tab change when full-width is set", async () => { + const element = createTabBar({ activeTab: "one", fullWidth: true }); + document.body.appendChild(element); + await waitForAnimationFrame(); + + element.setAttribute("active-tab", "three"); + await waitForAnimationFrame(); + + assertEquals(scrollSpy.calls.length, 0); + }); +}); + +t.describe("TabBar - reinitialization protection", (it) => { + it("should not reinitialize when connectedCallback fires again", () => { + const element = createTabBar({ activeTab: "one" }); + document.body.appendChild(element); + const initialButton = element.querySelector(".tab-bar-button"); + element.connectedCallback(); + const afterButton = element.querySelector(".tab-bar-button"); + assert(initialButton === afterButton); + }); +}); + +await t.run(); diff --git a/tests/unit/specs/templates/tabBar.template.test.js b/tests/unit/specs/templates/tabBar.template.test.js deleted file mode 100644 index 6c599e6f..00000000 --- a/tests/unit/specs/templates/tabBar.template.test.js +++ /dev/null @@ -1,126 +0,0 @@ -import { TestSuite } from "../../testSuite.js"; -import { assert, assertEquals, mock } from "../../testHelpers.js"; -import { tabBarTemplate } from "/js/templates/tabBar.template.js"; -import { render } from "/js/lib/lit-html.js"; - -const t = new TestSuite("tabBarTemplate"); - -const tabs = [ - { value: "one", label: "One" }, - { value: "two", label: "Two" }, - { value: "three", label: "Three" }, -]; - -function renderTemplate(props) { - const container = document.createElement("div"); - render(tabBarTemplate(props), container); - return container; -} - -t.describe("rendering", (it) => { - it("should render a tab-bar container", () => { - const container = renderTemplate({ - tabs, - activeTab: "one", - onTabClick: () => {}, - }); - assert(container.querySelector(".tab-bar") !== null); - }); - - it("should render a button for each tab", () => { - const container = renderTemplate({ - tabs, - activeTab: "one", - onTabClick: () => {}, - }); - const buttons = container.querySelectorAll(".tab-bar-button"); - assertEquals(buttons.length, 3); - }); - - it("should render tab labels", () => { - const container = renderTemplate({ - tabs, - activeTab: "one", - onTabClick: () => {}, - }); - const buttons = container.querySelectorAll(".tab-bar-button"); - assertEquals(buttons[0].textContent.trim(), "One"); - assertEquals(buttons[1].textContent.trim(), "Two"); - assertEquals(buttons[2].textContent.trim(), "Three"); - }); - - it("should render no buttons when tabs is empty", () => { - const container = renderTemplate({ - tabs: [], - activeTab: null, - onTabClick: () => {}, - }); - const buttons = container.querySelectorAll(".tab-bar-button"); - assertEquals(buttons.length, 0); - }); -}); - -t.describe("active state", (it) => { - it("should mark the active tab with the active class", () => { - const container = renderTemplate({ - tabs, - activeTab: "two", - onTabClick: () => {}, - }); - const activeButtons = container.querySelectorAll(".tab-bar-button.active"); - assertEquals(activeButtons.length, 1); - assertEquals(activeButtons[0].textContent.trim(), "Two"); - }); - - it("should not mark inactive tabs as active", () => { - const container = renderTemplate({ - tabs, - activeTab: "one", - onTabClick: () => {}, - }); - const buttons = container.querySelectorAll(".tab-bar-button"); - assert(!buttons[1].classList.contains("active")); - assert(!buttons[2].classList.contains("active")); - }); - - it("should have no active tab when activeTab matches nothing", () => { - const container = renderTemplate({ - tabs, - activeTab: "nonexistent", - onTabClick: () => {}, - }); - const activeButtons = container.querySelectorAll(".tab-bar-button.active"); - assertEquals(activeButtons.length, 0); - }); -}); - -t.describe("interaction", (it) => { - it("should call onTabClick with the tab value when clicked", () => { - const onTabClick = mock(); - const container = renderTemplate({ - tabs, - activeTab: "one", - onTabClick, - }); - const buttons = container.querySelectorAll(".tab-bar-button"); - buttons[1].click(); - assertEquals(onTabClick.calls.length, 1); - assertEquals(onTabClick.calls[0][0], "two"); - }); - - it("should call onTabClick with the correct value for each button", () => { - const onTabClick = mock(); - const container = renderTemplate({ - tabs, - activeTab: "one", - onTabClick, - }); - const buttons = container.querySelectorAll(".tab-bar-button"); - buttons[0].click(); - buttons[2].click(); - assertEquals(onTabClick.calls[0][0], "one"); - assertEquals(onTabClick.calls[1][0], "three"); - }); -}); - -await t.run(); -- 2.51.2