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();