From 1c8da1982f8025e5346fac022b72ffd9d29511fc Mon Sep 17 00:00:00 2001
From: Josh Wooding <12938082+joshwooding@users.noreply.github.com>
Date: Thu, 9 Apr 2026 14:34:21 +0100
Subject: [PATCH 1/3] Refactor TabsNext
---
.../__e2e__/tabs-next/TabsNext.cy.tsx | 733 +++++++++++++++---
packages/lab/src/tabs-next/TabBar.css | 14 +-
packages/lab/src/tabs-next/TabBar.tsx | 2 +-
.../src/tabs-next/TabListLayoutContext.tsx | 21 +
packages/lab/src/tabs-next/TabListNext.css | 13 +-
packages/lab/src/tabs-next/TabListNext.tsx | 588 +++++++++++++-
packages/lab/src/tabs-next/TabNext.tsx | 128 ++-
packages/lab/src/tabs-next/TabNextAction.tsx | 32 +-
packages/lab/src/tabs-next/TabNextPanel.tsx | 41 +-
packages/lab/src/tabs-next/TabNextTrigger.tsx | 109 ++-
.../lab/src/tabs-next/TabOverflowContext.ts | 20 +
.../lab/src/tabs-next/TabOverflowList.css | 10 +-
.../lab/src/tabs-next/TabOverflowList.tsx | 256 ++++--
packages/lab/src/tabs-next/TabSlot.tsx | 28 +
.../src/tabs-next/TabSlotRegistryContext.tsx | 16 +
packages/lab/src/tabs-next/TabsNext.css | 3 +
packages/lab/src/tabs-next/TabsNext.tsx | 254 +++++-
.../lab/src/tabs-next/TabsNextContext.tsx | 50 +-
.../lab/src/tabs-next/hooks/useCollection.ts | 228 ++++--
.../src/tabs-next/hooks/useFocusOutside.ts | 36 -
.../lab/src/tabs-next/hooks/useOverflow.ts | 392 +++++-----
.../tabs-next/hooks/useOverflowRovingFocus.ts | 101 +++
.../tabs-next/hooks/useRestoreActiveTab.ts | 149 ----
.../src/tabs-next/hooks/useTabListRecovery.ts | 88 +++
.../lab/src/tabs-next/widthMeasurement.ts | 38 +
.../stories/tabs-next/tabs-next.stories.tsx | 186 ++++-
site/docs/components/tabs/accessibility.mdx | 18 +-
site/docs/components/tabs/examples.mdx | 10 +-
site/docs/components/tabs/usage.mdx | 2 +
site/src/examples/tabs/ActiveColor.tsx | 4 +-
site/src/examples/tabs/AddANewTab.tsx | 5 +-
site/src/examples/tabs/Appearance.tsx | 2 +-
site/src/examples/tabs/DisabledTabs.tsx | 2 +-
.../{ClosableTabs.tsx => DismissibleTabs.tsx} | 24 +-
site/src/examples/tabs/DividerAndInset.tsx | 2 +-
site/src/examples/tabs/Overflow.tsx | 5 +-
site/src/examples/tabs/WithBadge.tsx | 8 +-
site/src/examples/tabs/WithIcon.tsx | 4 +-
site/src/examples/tabs/index.ts | 2 +-
39 files changed, 2818 insertions(+), 806 deletions(-)
create mode 100644 packages/lab/src/tabs-next/TabListLayoutContext.tsx
create mode 100644 packages/lab/src/tabs-next/TabOverflowContext.ts
create mode 100644 packages/lab/src/tabs-next/TabSlot.tsx
create mode 100644 packages/lab/src/tabs-next/TabSlotRegistryContext.tsx
create mode 100644 packages/lab/src/tabs-next/TabsNext.css
delete mode 100644 packages/lab/src/tabs-next/hooks/useFocusOutside.ts
create mode 100644 packages/lab/src/tabs-next/hooks/useOverflowRovingFocus.ts
delete mode 100644 packages/lab/src/tabs-next/hooks/useRestoreActiveTab.ts
create mode 100644 packages/lab/src/tabs-next/hooks/useTabListRecovery.ts
create mode 100644 packages/lab/src/tabs-next/widthMeasurement.ts
rename site/src/examples/tabs/{ClosableTabs.tsx => DismissibleTabs.tsx} (59%)
diff --git a/packages/lab/src/__tests__/__e2e__/tabs-next/TabsNext.cy.tsx b/packages/lab/src/__tests__/__e2e__/tabs-next/TabsNext.cy.tsx
index 54ed483d7be..a04870b9246 100644
--- a/packages/lab/src/__tests__/__e2e__/tabs-next/TabsNext.cy.tsx
+++ b/packages/lab/src/__tests__/__e2e__/tabs-next/TabsNext.cy.tsx
@@ -1,18 +1,255 @@
+import {
+ TabBar,
+ TabListNext,
+ TabNext,
+ TabNextTrigger,
+ TabsNext,
+} from "@salt-ds/lab";
import * as tabsStories from "@stories/tabs-next/tabs-next.stories";
import { composeStories } from "@storybook/react-vite";
+import { useEffect, useState } from "react";
const {
Bordered,
DisabledTabs,
Overflow,
AddTabs,
- Closable,
+ Dismissible,
AddWithDialog,
- CloseWithConfirmation,
+ DismissWithConfirmation,
WithInteractiveElementInPanel,
Controlled,
+ AsyncDismissibleTabs,
} = composeStories(tabsStories);
+const selectorSafeTabs = [
+ "Home",
+ "Transactions",
+ 'Loan "A"',
+ "Checks",
+ "Liquidity",
+];
+
+type ResponsiveOverflowWindow = Cypress.AUTWindow & {
+ __setResponsiveOverflowWidth?: (width: number) => void;
+};
+
+type OverflowOrderWindow = Cypress.AUTWindow & {
+ __overflowOrderObserver?: MutationObserver;
+ __overflowOrderSnapshots?: string[][];
+};
+
+type PortalContractWindow = Cypress.AUTWindow & {
+ __setPortalContractWidth?: (width: number) => void;
+};
+
+let nextTrackedTabInstanceId = 0;
+
+function TrackedTabContent({ label }: { label: string }) {
+ const [instanceId] = useState(() => {
+ nextTrackedTabInstanceId += 1;
+ return nextTrackedTabInstanceId;
+ });
+
+ return (
+ {`${label} instance ${instanceId}`}
+ );
+}
+
+function OverflowWithSelectorSafeValues() {
+ return (
+
+
+
+
+ {selectorSafeTabs.map((label) => (
+
+ {label}
+
+ ))}
+
+
+
+
+ );
+}
+
+function OverflowAfterWidthOnlyContentChange() {
+ const [expanded, setExpanded] = useState(false);
+ const tabs = [
+ "Home",
+ expanded ? "Transactions with a much longer label" : "Tx",
+ "Loans",
+ ];
+
+ return (
+ <>
+
+
+
+
+ {tabs.map((label) => (
+
+ {label}
+
+ ))}
+
+
+
+
+
+ >
+ );
+}
+
+function OverflowAfterContainerWidthChange() {
+ const [width, setWidth] = useState(150);
+
+ useEffect(() => {
+ Object.assign(window, {
+ __setResponsiveOverflowWidth: setWidth,
+ });
+
+ return () => {
+ delete (
+ window as Window & {
+ __setResponsiveOverflowWidth?: typeof setWidth;
+ }
+ ).__setResponsiveOverflowWidth;
+ };
+ }, []);
+
+ return (
+
+
+
+
+ {selectorSafeTabs.map((label) => (
+
+ {label}
+
+ ))}
+
+
+
+
+ );
+}
+
+function OverflowAfterClassBasedWidthChange() {
+ const [wide, setWide] = useState(false);
+
+ return (
+ <>
+
+
+
+
+
+ {selectorSafeTabs.map((label) => (
+
+ {label}
+
+ ))}
+
+
+
+
+
+ >
+ );
+}
+
+function OverflowWithinContainer({ width }: { width: number }) {
+ return (
+
+
+
+ );
+}
+
+function OverflowWithTrackedTabContent() {
+ const [width, setWidth] = useState(198);
+
+ useEffect(() => {
+ Object.assign(window, {
+ __setPortalContractWidth: setWidth,
+ });
+
+ return () => {
+ delete (
+ window as Window & {
+ __setPortalContractWidth?: typeof setWidth;
+ }
+ ).__setPortalContractWidth;
+ };
+ }, []);
+
+ return (
+
+
+
+
+ {selectorSafeTabs.map((label) => (
+
+
+
+
+
+ ))}
+
+
+
+
+ );
+}
+
+function clickOverflowTab(name: string | RegExp) {
+ cy.findByRole("dialog", { name: "Overflow Menu" })
+ .should("be.visible")
+ .within(() => {
+ cy.findByRole("tab", { name }).click();
+ });
+
+ cy.findByRole("tab", { name: "Overflow" }).should(
+ "have.attr",
+ "aria-expanded",
+ "false",
+ );
+}
+
+function assertSelectedMainTab(name: string) {
+ cy.findByRole("tablist").within(() => {
+ cy.findByRole("tab", { name, selected: true }).should("be.visible");
+ });
+}
+
describe("Given a Tabstrip", () => {
it("should render with tablist and tab roles", () => {
cy.mount();
@@ -97,26 +334,25 @@ describe("Given a Tabstrip", () => {
cy.findByRole("tab", { name: "Loans" }).should("be.focused");
});
- it("should allow keyboard navigation into the overflow menu", () => {
+ it("should allow keyboard access into and out of the overflow menu", () => {
cy.mount();
- cy.findAllByRole("tab").filter(":visible").should("have.length", 4);
+ cy.findAllByRole("tab").should("have.length", 5);
cy.realPress("Tab");
cy.findByRole("tab", { name: "Home" }).should("be.focused");
cy.realPress("ArrowLeft");
- cy.findAllByRole("tab").filter(":visible").should("have.length", 17);
- cy.findByRole("tab", { name: "Screens" }).should("be.focused");
+ cy.findByRole("tab", { name: "Overflow" }).should("be.focused");
- cy.realPress("ArrowUp");
- cy.findByRole("tab", { name: "Larger" }).should("be.focused");
+ cy.realPress("Enter");
- cy.realPress("ArrowLeft");
- cy.findByRole("tab", { name: "On" }).should("be.focused");
+ cy.findByRole("dialog", { name: "Overflow Menu" }).should("be.visible");
- cy.realPress("ArrowDown");
- cy.findByRole("tab", { name: "Larger" }).should("be.focused");
+ cy.findByRole("tab", { name: "Liquidity" }).should("be.focused");
+
+ cy.realPress("Escape");
+ cy.findByRole("tab", { name: "Overflow" }).should("be.focused");
});
it("should allow tabs to be disabled", () => {
@@ -139,10 +375,8 @@ describe("Given a Tabstrip", () => {
it("should overflow into a menu when there is not enough space to show all tabs", () => {
cy.mount();
- cy.findAllByRole("tab").should("have.length", 17);
- cy.findAllByRole("tab").filter(":visible").should("have.length", 4);
- cy.findAllByRole("tab").filter(":not(:visible)").should("have.length", 13);
- cy.get("[data-overflowbutton]").should("be.visible");
+ cy.findAllByRole("tab").should("have.length", 5);
+ cy.findByRole("tab", { name: "Overflow" }).should("be.visible");
});
it("should allow keyboard navigation in the menu", () => {
@@ -152,67 +386,352 @@ describe("Given a Tabstrip", () => {
>,
);
- cy.get("[data-overflowbutton]").realClick();
+
+ cy.findByRole("tab", { name: "Overflow" }).realClick();
+ cy.findByRole("dialog", { name: "Overflow Menu" }).should("be.visible");
+
cy.findByRole("tab", { name: "Liquidity" }).should("be.focused");
cy.realPress("ArrowDown");
cy.findByRole("tab", { name: "With" }).should("be.focused");
cy.realPress("End");
cy.findByRole("tab", { name: "Screens" }).should("be.focused");
+ cy.realPress("Home");
+ cy.findByRole("tab", { name: "Liquidity" }).should("be.focused");
cy.realPress("Escape");
- cy.findByRole("tab", { name: "Checks" }).should("be.focused");
+ cy.findByRole("tab", { name: "Overflow" }).should("be.focused");
cy.realPress("Tab");
cy.findByRole("button", { name: "end" }).should("be.focused");
});
- it("should close the overflow menu when a click is detected outside", () => {
+ it("should restore focus correctly after opening the menu with a mouse", () => {
+ cy.mount(
+ <>
+
+
+ >,
+ );
+
+ cy.findByRole("tab", { name: "Overflow" }).realClick();
+ cy.findByRole("dialog", { name: "Overflow Menu" }).should("be.visible");
+
+ cy.findByRole("tab", { name: "Liquidity" }).should("be.focused");
+ cy.realPress("Escape");
+ cy.findByRole("tab", { name: "Overflow" }).should("be.focused");
+ cy.realPress("Tab");
+ cy.findByRole("button", { name: "end" }).should("be.focused");
+ });
+
+ it("should dismiss the overflow menu when a click is detected outside", () => {
cy.mount();
- cy.get("[data-overflowbutton]").realClick();
- cy.findAllByRole("tab").filter(":visible").should("have.length", 17);
+ cy.findByRole("tab", { name: "Overflow" }).realClick();
+ cy.findByRole("dialog", { name: "Overflow Menu" }).should("be.visible");
- cy.wait(500);
+ cy.findAllByRole("tab").should("have.length", 13);
cy.get("body").click(0, 0);
- cy.findAllByRole("tab").filter(":visible").should("have.length", 4);
+ cy.findAllByRole("tab").should("have.length", 5);
});
it("should allow selection in the menu", () => {
cy.mount();
- cy.findAllByRole("tab").filter(":visible").should("have.length", 4);
+ cy.findAllByRole("tab").should("have.length", 5);
+
+ cy.findByRole("tab", { name: "Overflow" }).realClick();
+ cy.findByRole("dialog", { name: "Overflow Menu" }).should("be.visible");
- cy.get("[data-overflowbutton]").realClick();
- cy.findByRole("tab", { name: "Liquidity" }).realClick();
+ cy.findByRole("tab", { name: "Liquidity" }).should("be.focused");
- cy.findByRole("tab", { name: "Liquidity" })
+ clickOverflowTab("Liquidity");
+ cy.findByRole("tab", { name: "Liquidity", selected: true })
.should("have.attr", "aria-selected", "true")
- .should("be.focused");
+ .and("be.focused");
- cy.findAllByRole("tab").filter(":visible").should("have.length", 4);
+ cy.findByRole("tab", { name: "Overflow" }).realClick();
+ cy.findByRole("dialog", { name: "Overflow Menu" }).should("be.visible");
+
+ cy.findByRole("tab", { name: "Checks" }).should("be.focused");
- cy.get("[data-overflowbutton]").realClick();
cy.realPress("Enter");
cy.findByRole("tab", { name: "Checks" })
.should("have.attr", "aria-selected", "true")
.should("be.focused");
});
- it("should allow selection in the menu when only having enough space for the newly selected tab", () => {
+ it("should not temporarily remove an extra main tab when selecting from overflow", () => {
cy.mount();
- cy.findByRole("tablist").invoke("css", "max-width", 140);
- cy.wait(500);
+ cy.window().then((win) => {
+ const overflowOrderWindow = win as OverflowOrderWindow;
+ const tablist = win.document.querySelector('[role="tablist"]');
+
+ expect(tablist).to.exist;
+ if (!tablist) {
+ throw new Error("Expected tablist to exist");
+ }
+
+ const getMainTabOrder = () =>
+ Array.from(
+ tablist.querySelectorAll(':scope > [data-tabslot] [role="tab"]'),
+ ).map((tab) => tab.textContent?.trim() ?? "");
+
+ overflowOrderWindow.__overflowOrderSnapshots = [getMainTabOrder()];
+ overflowOrderWindow.__overflowOrderObserver = new win.MutationObserver(
+ () => {
+ overflowOrderWindow.__overflowOrderSnapshots?.push(getMainTabOrder());
+ },
+ );
+ overflowOrderWindow.__overflowOrderObserver.observe(tablist, {
+ childList: true,
+ subtree: true,
+ attributes: true,
+ attributeFilter: ["aria-selected"],
+ });
+ });
+
+ cy.findByRole("tab", { name: "Overflow" }).realClick();
+ cy.findByRole("dialog", { name: "Overflow Menu" }).should("be.visible");
+
+ clickOverflowTab("Liquidity");
+
+ cy.window().then(
+ (win) =>
+ new Cypress.Promise((resolve) => {
+ const overflowOrderWindow = win as OverflowOrderWindow;
+ win.requestAnimationFrame(() => {
+ win.requestAnimationFrame(() => {
+ overflowOrderWindow.__overflowOrderObserver?.disconnect();
+ resolve();
+ });
+ });
+ }),
+ );
- cy.findAllByRole("tab").filter(":visible").should("have.length", 1);
+ cy.window().then((win) => {
+ const overflowOrderWindow = win as OverflowOrderWindow;
+ const snapshots = overflowOrderWindow.__overflowOrderSnapshots ?? [];
+
+ expect(snapshots).to.deep.include([
+ "Home",
+ "Transactions",
+ "Loans",
+ "Liquidity",
+ ]);
+ expect(snapshots).not.to.deep.include([
+ "Home",
+ "Transactions",
+ "Liquidity",
+ ]);
+ });
+ });
- cy.get("[data-overflowbutton]").realClick();
- cy.findAllByRole("tab").filter(":visible").should("have.length", 14); // overflow menu shown
- cy.findByRole("tab", { name: "Liquidity" }).realClick();
- cy.findAllByRole("tab").filter(":visible").should("have.length", 1); // overflow menu hidden
+ it("should announce when a selected overflow tab moves to the main list", () => {
+ cy.mount();
- cy.findByRole("tab", { name: "Liquidity" })
- .should("have.attr", "aria-selected", "true")
- .should("be.focused");
+ cy.findByRole("tab", { name: "Overflow" }).realClick();
+ cy.findByRole("dialog", { name: "Overflow Menu" }).should("be.visible");
+
+ clickOverflowTab("Liquidity");
+ assertSelectedMainTab("Liquidity");
+ cy.get("[aria-live]", { timeout: 8000 }).should(
+ "contain.text",
+ "Liquidity moved to main tab list",
+ );
+ });
+
+ it("should preserve custom tab props and content instances while moving through overflow", () => {
+ let homeInstance = "";
+ let liquidityInstance = "";
+
+ cy.mount();
+
+ cy.get('[data-instance-label="Home"]')
+ .invoke("text")
+ .then((text) => {
+ homeInstance = text;
+ });
+ cy.get('[data-instance-label="Liquidity"]')
+ .invoke("text")
+ .then((text) => {
+ liquidityInstance = text;
+ });
+
+ cy.get('[data-root-marker="Home"][data-root-state="preserved"]').should(
+ "exist",
+ );
+
+ cy.window().then((win) => {
+ const portalContractWindow = win as PortalContractWindow;
+ portalContractWindow.__setPortalContractWidth?.(1000);
+ });
+
+ cy.findByRole("tab", { name: "Overflow" }).should("not.exist");
+
+ cy.then(() => {
+ cy.get('[data-instance-label="Home"]').should("have.text", homeInstance);
+ });
+ cy.then(() => {
+ cy.get('[data-instance-label="Liquidity"]').should(
+ "have.text",
+ liquidityInstance,
+ );
+ });
+
+ cy.window().then((win) => {
+ const portalContractWindow = win as PortalContractWindow;
+ portalContractWindow.__setPortalContractWidth?.(198);
+ });
+
+ cy.findByRole("tab", { name: "Overflow" }).should("be.visible");
+ cy.findByRole("tab", { name: "Overflow" }).realClick();
+
+ cy.findByRole("dialog", { name: "Overflow Menu" })
+ .should("be.visible")
+ .within(() => {
+ cy.get(
+ '[data-root-marker="Liquidity"][data-root-state="preserved"]',
+ ).should("exist");
+ });
+
+ cy.then(() => {
+ cy.get('[data-instance-label="Liquidity"]').should(
+ "have.text",
+ liquidityInstance,
+ );
+ });
+
+ clickOverflowTab(/^Liquidity instance /);
+
+ cy.findByRole("tablist", { name: "Portal contract tablist" }).within(() => {
+ cy.get(
+ '[data-root-marker="Liquidity"][data-root-state="preserved"]',
+ ).should("exist");
+ });
+ cy.then(() => {
+ cy.get('[data-instance-label="Liquidity"]').should(
+ "have.text",
+ liquidityInstance,
+ );
+ });
+ });
+
+ it("should allow selection in the menu when only having enough space for the newly selected tab", () => {
+ cy.mount();
+
+ cy.findByRole("tab", { name: "Home" }).should(
+ "have.attr",
+ "aria-selected",
+ "true",
+ );
+
+ cy.findAllByRole("tab").should("have.length", 2);
+
+ cy.findByRole("tab", { name: "Overflow" }).realClick();
+
+ cy.findAllByRole("tab").should("have.length", 16); // overflow menu shown
+
+ cy.findByRole("tab", { name: "Transactions" }).should("be.focused");
+
+ clickOverflowTab("Liquidity");
+
+ cy.findAllByRole("tab").should("have.length", 2); // overflow menu hidden
+
+ cy.findByRole("tab", { name: "Liquidity", selected: true }).should(
+ "be.focused",
+ );
+ });
+
+ it("should allow overflow selection when values contain selector characters", () => {
+ cy.mount();
+
+ cy.findByRole("tab", { name: "Overflow" }).realClick();
+ cy.findByRole("dialog", { name: "Overflow Menu" }).should("be.visible");
+
+ clickOverflowTab('Loan "A"');
+
+ cy.findByRole("tab", { name: 'Loan "A"', selected: true }).should(
+ "be.focused",
+ );
+ });
+
+ it("should keep the overflow menu closed when overflow returns after being removed by resize", () => {
+ cy.mount();
+
+ cy.findByRole("tab", { name: "Overflow" }).realClick();
+ cy.findByRole("dialog", { name: "Overflow Menu" }).should("be.visible");
+
+ cy.window().then((win) => {
+ const responsiveWindow = win as ResponsiveOverflowWindow;
+ responsiveWindow.__setResponsiveOverflowWidth?.(1000);
+ });
+ cy.findByRole("tab", { name: "Overflow" }).should("not.exist");
+ cy.findByRole("dialog", { name: "Overflow Menu" }).should("not.exist");
+
+ cy.window().then((win) => {
+ const responsiveWindow = win as ResponsiveOverflowWindow;
+ responsiveWindow.__setResponsiveOverflowWidth?.(150);
+ });
+ cy.findByRole("tab", { name: "Overflow" })
+ .should("be.visible")
+ .and("have.attr", "aria-expanded", "false");
+ cy.findByRole("dialog", { name: "Overflow Menu" }).should("not.exist");
+ });
+
+ it("should recompute overflow when sizing is driven by CSS classes", () => {
+ cy.mount();
+
+ cy.findByRole("tablist", { name: "Class sized overflow tablist" }).within(
+ () => {
+ cy.findByRole("tab", { name: "Overflow" }).should("be.visible");
+ },
+ );
+
+ cy.findByRole("button", { name: "Toggle class width" }).realClick();
+
+ cy.findByRole("tablist", { name: "Class sized overflow tablist" }).within(
+ () => {
+ cy.findByRole("tab", { name: "Overflow" }).should("not.exist");
+ },
+ );
+
+ cy.findByRole("button", { name: "Toggle class width" }).realClick();
+
+ cy.findByRole("tablist", { name: "Class sized overflow tablist" }).within(
+ () => {
+ cy.findByRole("tab", { name: "Overflow" })
+ .should("be.visible")
+ .and("have.attr", "aria-expanded", "false");
+ },
+ );
+ });
+
+ it("should recompute overflow when tab content changes width without resizing the container", () => {
+ cy.mount();
+
+ cy.findByRole("tablist", { name: "Width change tablist" }).within(() => {
+ cy.findByRole("tab", { name: "Overflow" }).should("not.exist");
+ });
+
+ cy.findByRole("button", { name: "Expand label" }).realClick();
+
+ cy.findByRole("tablist", { name: "Width change tablist" }).within(() => {
+ cy.findByRole("tab", { name: "Overflow" }).should("be.visible");
+ });
+ });
+
+ it("should keep a pinned overflow tab visible when selection moves to an already visible tab", () => {
+ cy.mount();
+
+ cy.findByRole("tab", { name: "Overflow" }).realClick();
+ cy.findByRole("dialog", { name: "Overflow Menu" }).should("be.visible");
+
+ clickOverflowTab("Liquidity");
+ assertSelectedMainTab("Liquidity");
+
+ cy.findByRole("tab", { name: "Transactions" }).realClick();
+ assertSelectedMainTab("Transactions");
+ cy.findByRole("tab", { name: "Liquidity" }).should("be.visible");
});
it("should support adding tabs", () => {
@@ -228,12 +747,33 @@ describe("Given a Tabstrip", () => {
cy.findByRole("tab", { name: "Home" }).should(
"have.attr",
"aria-selected",
- "true",
+ "false",
);
- cy.findByRole("tab", { name: "New tab" }).should("be.visible");
+ cy.findByRole("tab", { name: "New tab" })
+ .should("be.visible")
+ .and("have.attr", "aria-selected", "true");
cy.findByRole("button", { name: "Add tab" }).should("be.focused");
});
+ it("should reserve space for the add button when tabs overflow", () => {
+ cy.mount();
+
+ cy.findByRole("button", { name: "Add tab" }).realClick();
+ cy.findByRole("button", { name: "Add tab" }).realClick();
+ cy.findByRole("button", { name: "Add tab" }).realClick();
+
+ cy.findByRole("tab", { name: "Overflow" }).should("be.visible");
+
+ cy.findByRole("tablist").then(($tablist) => {
+ cy.findByRole("button", { name: "Add tab" }).then(($button) => {
+ const tablistRect = $tablist[0].getBoundingClientRect();
+ const buttonRect = $button[0].getBoundingClientRect();
+
+ expect(tablistRect.right).to.be.at.most(buttonRect.left);
+ });
+ });
+ });
+
it("should support adding tabs with confirmation", () => {
cy.mount();
cy.findAllByRole("tab").should("have.length", 3);
@@ -260,13 +800,13 @@ describe("Given a Tabstrip", () => {
});
it("should add the correct aria when tab actions are used", () => {
- cy.mount();
+ cy.mount();
// TODO: enable when aria-actions is supported in browsers.
// cy.findByRole("tab", { name: "Home" })
// .invoke("attr", "aria-actions")
// .then((actionId) => {
- // cy.findByRole("button", { name: "Home Close tab" }).should(
+ // cy.findByRole("button", { name: "Home Dismiss tab" }).should(
// "have.attr",
// "id",
// actionId,
@@ -280,7 +820,7 @@ describe("Given a Tabstrip", () => {
});
it("should support closing tabs with a mouse", () => {
- cy.mount();
+ cy.mount();
cy.findByRole("tab", { name: "Home" }).should(
"have.attr",
@@ -289,7 +829,7 @@ describe("Given a Tabstrip", () => {
);
cy.findAllByRole("tab").should("have.length", 5);
- cy.findByRole("button", { name: "Liquidity Close tab" }).realClick();
+ cy.findByRole("button", { name: "Liquidity Dismiss tab" }).realClick();
cy.findAllByRole("tab").should("have.length", 4);
cy.findByRole("tab", { name: "Home" }).should(
"have.attr",
@@ -298,7 +838,7 @@ describe("Given a Tabstrip", () => {
);
cy.findByRole("tab", { name: "Checks" }).should("be.focused");
- cy.findByRole("button", { name: "Loans Close tab" }).realClick();
+ cy.findByRole("button", { name: "Loans Dismiss tab" }).realClick();
cy.findAllByRole("tab").should("have.length", 3);
cy.findByRole("tab", { name: "Home" }).should(
"have.attr",
@@ -307,7 +847,7 @@ describe("Given a Tabstrip", () => {
);
cy.findByRole("tab", { name: "Checks" }).should("be.focused");
- cy.findByRole("button", { name: "Home Close tab" }).realClick();
+ cy.findByRole("button", { name: "Home Dismiss tab" }).realClick();
cy.findAllByRole("tab").should("have.length", 2);
cy.findByRole("tab", { name: "Transactions" }).should(
"have.attr",
@@ -317,21 +857,43 @@ describe("Given a Tabstrip", () => {
cy.findByRole("tab", { name: "Transactions" }).should("be.focused");
});
+ it("should restore focus when selected tab removal is async", () => {
+ cy.mount();
+
+ cy.findByRole("button", { name: "Home Dismiss tab" }).realClick();
+ cy.findByRole("tab", { name: "Transactions" })
+ .should("have.attr", "aria-selected", "true")
+ .and("be.focused");
+ });
+
+ it("should call onChange with null when selection moves automatically after removal", () => {
+ const changeSpy = cy.stub().as("changeSpy");
+ cy.mount();
+
+ cy.findByRole("button", { name: "Home Dismiss tab" }).realClick();
+
+ cy.findByRole("tab", { name: "Transactions" })
+ .should("have.attr", "aria-selected", "true")
+ .and("be.focused");
+
+ cy.get("@changeSpy").should("have.been.calledWith", null, "Transactions");
+ });
+
it("should support closing with a keyboard", () => {
- cy.mount();
+ cy.mount();
cy.findAllByRole("tab").should("have.length", 5);
cy.realPress("Tab");
cy.findByRole("tab", { name: "Home" }).should("be.focused");
cy.realPress("Tab");
- cy.findByRole("button", { name: "Home Close tab" }).should("be.focused");
+ cy.findByRole("button", { name: "Home Dismiss tab" }).should("be.focused");
cy.realPress("ArrowRight");
cy.findByRole("tab", { name: "Transactions" }).should("be.focused");
cy.realPress("Tab");
- cy.findByRole("button", { name: "Transactions Close tab" }).should(
+ cy.findByRole("button", { name: "Transactions Dismiss tab" }).should(
"be.focused",
);
@@ -339,7 +901,7 @@ describe("Given a Tabstrip", () => {
cy.findByRole("tab", { name: "Transactions" }).should("be.focused");
cy.realPress(["Shift", "Tab"]);
- cy.findByRole("button", { name: "Home Close tab" }).should("be.focused");
+ cy.findByRole("button", { name: "Home Dismiss tab" }).should("be.focused");
cy.realPress("Enter");
@@ -353,17 +915,17 @@ describe("Given a Tabstrip", () => {
});
it("should support closing with confirmation", () => {
- cy.mount();
+ cy.mount();
cy.findAllByRole("tab").should("have.length", 3);
- cy.findAllByRole("button", { name: "Home Close tab" }).realClick();
+ cy.findAllByRole("button", { name: "Home Dismiss tab" }).realClick();
cy.findByRole("dialog").should("be.visible");
cy.findByRole("button", { name: "No" }).realClick();
cy.findByRole("dialog").should("not.to.exist");
- cy.findByRole("button", { name: "Home Close tab" }).should("be.focused");
+ cy.findByRole("button", { name: "Home Dismiss tab" }).should("be.focused");
- cy.findAllByRole("button", { name: "Home Close tab" }).realClick();
+ cy.findAllByRole("button", { name: "Home Dismiss tab" }).realClick();
cy.findByRole("dialog").should("be.visible");
cy.findByRole("button", { name: "Yes" }).realClick();
@@ -395,15 +957,13 @@ describe("Given a Tabstrip", () => {
it("should dynamically overflow tabs", () => {
cy.mount();
- cy.findAllByRole("tab").filter(":visible").should("have.length", 4);
+ cy.findAllByRole("tab").should("have.length", 5);
- cy.findByRole("tablist").invoke("css", "max-width", 500);
- cy.wait(500);
- cy.findAllByRole("tab").filter(":visible").should("have.length", 6);
+ cy.findByTestId("tabs-next-overflow-boundary").invoke("css", "width", 548);
+ cy.findAllByRole("tab").should("have.length", 7);
- cy.findByRole("tablist").invoke("css", "max-width", 200);
- cy.wait(500);
- cy.findAllByRole("tab").filter(":visible").should("have.length", 2);
+ cy.findByTestId("tabs-next-overflow-boundary").invoke("css", "width", 248);
+ cy.findAllByRole("tab").should("have.length", 3);
});
it("should support a controlled API", () => {
@@ -422,38 +982,22 @@ describe("Given a Tabstrip", () => {
"true",
);
- cy.get("[data-overflowbutton]").realClick();
- cy.findByRole("tab", { name: "Lots" }).realClick();
- cy.findByRole("tab", { name: "Lots" }).should(
- "have.attr",
- "aria-selected",
- "true",
- );
+ cy.findByRole("tab", { name: "Overflow" }).realClick();
+ cy.findByRole("dialog", { name: "Overflow Menu" }).should("be.visible");
- cy.findByRole("button", { name: "Lots Close tab" }).realClick();
- cy.findByRole("tab", { name: "More" })
+ cy.findByRole("tab", { name: "Loans" }).should("be.focused");
+
+ clickOverflowTab("Lots");
+ cy.findByRole("tab", { name: "Lots", selected: true })
+ .should("be.focused")
+ .and("have.attr", "aria-selected", "true");
+
+ cy.findByRole("button", { name: "Lots Dismiss tab" }).realClick();
+ cy.findByRole("tab", { name: "Transactions" })
.should("have.attr", "aria-selected", "true")
.and("be.focused");
});
- it(
- "should not cause page overflow when overflow menu is not visible",
- { viewportWidth: 280, viewportHeight: 280 },
- () => {
- cy.get("body").invoke("css", "display", "block");
-
- cy.mount();
- cy.findAllByRole("tab").filter(":visible").should("have.length", 2);
-
- // no horizontal overflow
- cy.get("html").then((body) => {
- console.log(body[0]);
- const { clientWidth, scrollWidth } = body[0];
- expect(clientWidth).to.equal(scrollWidth);
- });
- },
- );
-
it(
"should flip overflow menu placement if there is enough space",
{ viewportWidth: 430 },
@@ -461,14 +1005,13 @@ describe("Given a Tabstrip", () => {
cy.get("body").invoke("css", "display", "block");
cy.mount();
- cy.findAllByRole("tab").filter(":visible").should("have.length", 4);
+ cy.findAllByRole("tab").should("have.length", 5);
- cy.get("[data-overflowbutton]").realClick();
- cy.wait(500);
+ cy.findByRole("tab", { name: "Overflow" }).realClick();
+ cy.findByRole("dialog", { name: "Overflow Menu" }).should("be.visible");
// no horizontal overflow, menu should flip in horizontally
cy.get("html").then((body) => {
- console.log(body[0]);
const { clientWidth, scrollWidth } = body[0];
expect(clientWidth).to.equal(scrollWidth);
});
diff --git a/packages/lab/src/tabs-next/TabBar.css b/packages/lab/src/tabs-next/TabBar.css
index 4ae1feb3335..886b31eef93 100644
--- a/packages/lab/src/tabs-next/TabBar.css
+++ b/packages/lab/src/tabs-next/TabBar.css
@@ -2,9 +2,21 @@
display: flex;
align-items: center;
flex-direction: row;
- gap: var(--salt-spacing-100);
position: relative;
box-sizing: border-box;
+ min-width: 0;
+ max-width: 100%;
+}
+
+.saltTabBar-strip {
+ display: flex;
+ align-items: center;
+ flex-direction: row;
+ flex: 1 1 auto;
+ gap: var(--salt-spacing-100);
+ box-sizing: border-box;
+ min-width: 0;
+ max-width: 100%;
}
.saltTabBar-divider::before {
diff --git a/packages/lab/src/tabs-next/TabBar.tsx b/packages/lab/src/tabs-next/TabBar.tsx
index 229a66283a1..fa2e66088fe 100644
--- a/packages/lab/src/tabs-next/TabBar.tsx
+++ b/packages/lab/src/tabs-next/TabBar.tsx
@@ -42,7 +42,7 @@ export const TabBar = forwardRef(
{...rest}
ref={ref}
>
- {children}
+ {children}
);
},
diff --git a/packages/lab/src/tabs-next/TabListLayoutContext.tsx b/packages/lab/src/tabs-next/TabListLayoutContext.tsx
new file mode 100644
index 00000000000..3c52822c0f3
--- /dev/null
+++ b/packages/lab/src/tabs-next/TabListLayoutContext.tsx
@@ -0,0 +1,21 @@
+import { createContext } from "@salt-ds/core";
+import { useContext } from "react";
+
+export type TabSlotLocation = "hidden" | "main" | "overflow";
+
+export interface TabListLayoutContextValue {
+ getLocation: (value: string) => TabSlotLocation;
+ overflowActiveValue: string | null;
+ setOverflowActiveValue: (value: string | null) => void;
+ moveOverflowFocus: (
+ key: "ArrowDown" | "ArrowUp" | "Home" | "End",
+ value: string,
+ ) => boolean;
+}
+
+export const TabListLayoutContext =
+ createContext("TabListLayoutContext", null);
+
+export function useTabListLayout() {
+ return useContext(TabListLayoutContext);
+}
diff --git a/packages/lab/src/tabs-next/TabListNext.css b/packages/lab/src/tabs-next/TabListNext.css
index 4a67dccc4d6..4ad3557d0ed 100644
--- a/packages/lab/src/tabs-next/TabListNext.css
+++ b/packages/lab/src/tabs-next/TabListNext.css
@@ -9,8 +9,8 @@
min-height: calc(var(--salt-size-base) + var(--salt-spacing-100));
gap: var(--salt-spacing-100);
max-width: 100%;
- width: 100%;
min-width: 0;
+ flex: 0 1 auto;
}
.saltTabListNext-center {
@@ -33,6 +33,13 @@
--saltTabListNext-activeColor: var(--salt-container-tertiary-background);
}
-.saltTabListNext-overflowWarning {
- display: none;
+.saltTabListNext-measureContainer {
+ position: absolute;
+ top: 0;
+ left: 0;
+ height: 0;
+ overflow: hidden;
+ pointer-events: none;
+ visibility: hidden;
+ white-space: nowrap;
}
diff --git a/packages/lab/src/tabs-next/TabListNext.tsx b/packages/lab/src/tabs-next/TabListNext.tsx
index b9198ebe48b..58c71696dd9 100644
--- a/packages/lab/src/tabs-next/TabListNext.tsx
+++ b/packages/lab/src/tabs-next/TabListNext.tsx
@@ -1,17 +1,36 @@
-import { capitalize, makePrefixer, useForkRef, useId } from "@salt-ds/core";
+import {
+ capitalize,
+ makePrefixer,
+ useAriaAnnouncer,
+ useForkRef,
+ useIsomorphicLayoutEffect,
+ usePrevious,
+} from "@salt-ds/core";
import { useComponentCssInjection } from "@salt-ds/styles";
import { useWindow } from "@salt-ds/window";
import { clsx } from "clsx";
+import { computeAccessibleName } from "dom-accessibility-api";
import {
type ComponentPropsWithoutRef,
forwardRef,
type KeyboardEvent,
+ useCallback,
+ useEffect,
+ useMemo,
useRef,
+ useState,
} from "react";
+import { useEventCallback } from "../utils/useEventCallback";
import { useOverflow } from "./hooks/useOverflow";
-import { useRestoreActiveTab } from "./hooks/useRestoreActiveTab";
+import { useTabListRecovery } from "./hooks/useTabListRecovery";
+import {
+ TabListLayoutContext,
+ type TabSlotLocation,
+} from "./TabListLayoutContext";
import tablistNextCss from "./TabListNext.css";
import { TabOverflowList } from "./TabOverflowList";
+import { TabSlot } from "./TabSlot";
+import { TabSlotRegistryContext } from "./TabSlotRegistryContext";
import { useTabsNext } from "./TabsNextContext";
const withBaseName = makePrefixer("saltTabListNext");
@@ -28,12 +47,38 @@ export interface TabListNextProps
appearance?: "bordered" | "transparent";
}
+function getTabAccessibleName(element: HTMLElement) {
+ return computeAccessibleName(element).trim();
+}
+
+function getVisibleSelectedTab(
+ tabstrip: HTMLDivElement | null,
+ excludedId?: string,
+) {
+ if (!tabstrip) {
+ return null;
+ }
+
+ const tabs = tabstrip.querySelectorAll(
+ ':scope > [data-tabslot] [role="tab"][aria-selected="true"]',
+ );
+
+ return (
+ Array.from(tabs).find((tab) => {
+ if (excludedId && tab.id === excludedId) {
+ return false;
+ }
+
+ return tab.isConnected;
+ }) ?? null
+ );
+}
+
export const TabListNext = forwardRef(
- function TabstripNext(props, ref) {
+ function TabListNext(props, ref) {
const {
appearance = "bordered",
activeColor = "primary",
- "aria-describedby": ariaDescribedBy,
children,
className,
onKeyDown,
@@ -48,66 +93,517 @@ export const TabListNext = forwardRef(
const {
selected,
+ setSelected,
getNext,
getPrevious,
getFirst,
getLast,
- items,
+ getIndex,
+ item,
+ itemAt,
activeTab,
+ selectionFromOverflowRef,
menuOpen,
setMenuOpen,
- removedActiveTabRef,
+ sortItems,
+ getRemovedItems,
+ getRenderedTab,
+ renderedTabs,
+ removalVersion,
} = useTabsNext();
const tabstripRef = useRef(null);
+ const overflowListRef = useRef(null);
+ const slotMapRef = useRef