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>(new Map()); + const removalRecoveryRafRef = useRef(null); + const selectionFocusOuterRafRef = useRef(null); + const pendingRemovalRecoveryRef = useRef(false); + const pendingRemovalRecoveryRetriesRef = useRef(0); + const [slotVersion, setSlotVersion] = useState(0); + const clearPendingRemovalRecovery = () => { + pendingRemovalRecoveryRef.current = false; + pendingRemovalRecoveryRetriesRef.current = 0; + }; + const handleRef = useForkRef(tabstripRef, ref); const overflowButtonRef = useRef(null); - const [visible, hidden, isMeasuring, realSelectedIndexRef] = useOverflow({ + const { announce } = useAriaAnnouncer(); + + const [visibleValues, hiddenValues, isMeasuring] = useOverflow({ container: tabstripRef, - tabs: items, - children, + menuOpen, selected, + tabs: renderedTabs, overflowButton: overflowButtonRef, }); + const [overflowActiveValue, setOverflowActiveValue] = useState< + string | null + >(null); - useRestoreActiveTab({ - container: tabstripRef, - tabs: items, - realSelectedIndex: realSelectedIndexRef, - removedActiveTabRef, - }); + useEffect(() => { + if (!menuOpen) { + setOverflowActiveValue(null); + return; + } + + setOverflowActiveValue((currentValue) => { + if (currentValue && hiddenValues.includes(currentValue)) { + return currentValue; + } + + return hiddenValues[0] ?? null; + }); + }, [hiddenValues, menuOpen]); + + const hiddenValueSet = useMemo(() => new Set(hiddenValues), [hiddenValues]); + const visibleValueSet = useMemo( + () => new Set(visibleValues), + [visibleValues], + ); + + const getLocation = useCallback( + (value: string): TabSlotLocation => { + if (visibleValueSet.has(value)) { + return "main"; + } + + if (menuOpen && hiddenValueSet.has(value)) { + return "overflow"; + } + + return "hidden"; + }, + [hiddenValueSet, menuOpen, visibleValueSet], + ); + + const moveOverflowFocus = useCallback( + (key: "ArrowDown" | "ArrowUp" | "Home" | "End", value: string) => { + if (hiddenValues.length < 1) { + return false; + } + + const currentIndex = hiddenValues.indexOf(value); + const fallbackIndex = overflowActiveValue + ? hiddenValues.indexOf(overflowActiveValue) + : 0; + const startIndex = + currentIndex >= 0 ? currentIndex : Math.max(0, fallbackIndex); + const lastIndex = hiddenValues.length - 1; + let nextIndex = startIndex; + + switch (key) { + case "ArrowDown": + nextIndex = startIndex >= lastIndex ? 0 : startIndex + 1; + break; + case "ArrowUp": + nextIndex = startIndex <= 0 ? lastIndex : startIndex - 1; + break; + case "Home": + nextIndex = 0; + break; + case "End": + nextIndex = lastIndex; + break; + } + + const nextValue = hiddenValues[nextIndex]; + if (!nextValue) { + return false; + } + + setOverflowActiveValue(nextValue); + return true; + }, + [hiddenValues, overflowActiveValue], + ); + + const tabListLayoutContext = useMemo( + () => ({ + getLocation, + overflowActiveValue, + setOverflowActiveValue, + moveOverflowFocus, + }), + [getLocation, moveOverflowFocus, overflowActiveValue], + ); + const registerSlot = useCallback( + (slotId: string, element: HTMLDivElement | null) => { + const currentElement = slotMapRef.current.get(slotId) ?? null; + if (currentElement === element) { + return; + } + + if (element) { + slotMapRef.current.set(slotId, element); + } else { + slotMapRef.current.delete(slotId); + } + + setSlotVersion((currentVersion) => currentVersion + 1); + }, + [], + ); + const slotRegistryContext = useMemo( + () => ({ registerSlot }), + [registerSlot], + ); + const slotAssignments = useMemo(() => { + const nextAssignments = new Map(); + + for (const value of visibleValues) { + nextAssignments.set(value, `main:${value}`); + } + + for (const value of hiddenValues) { + nextAssignments.set( + value, + menuOpen ? `overflow:${value}` : `measure:${value}`, + ); + } + + return { + map: nextAssignments, + version: slotVersion, + }; + }, [hiddenValues, menuOpen, slotVersion, visibleValues]); + + useIsomorphicLayoutEffect(() => { + for (const [value, slotId] of slotAssignments.map) { + const host = getRenderedTab(value)?.host; + const slot = slotMapRef.current.get(slotId); + + if (host && slot && host.parentElement !== slot) { + slot.appendChild(host); + } + } + }, [getRenderedTab, slotAssignments]); const handleKeyDown = (event: KeyboardEvent) => { onKeyDown?.(event); + if (menuOpen) return; + const actionMap = { ArrowRight: getNext, ArrowLeft: getPrevious, Home: getFirst, End: getLast, - ArrowUp: menuOpen ? getPrevious : undefined, - ArrowDown: menuOpen ? getNext : undefined, }; const action = actionMap[event.key as keyof typeof actionMap]; if (action) { event.preventDefault(); + // Item registration/sorting is raf-driven; flush before keyboard nav to + // avoid navigating against stale collection order/registration. + sortItems(); const activeTabId = activeTab.current?.id; if (!activeTabId) return; const nextItem = action(activeTabId); if (nextItem) { - nextItem.element?.parentElement?.scrollIntoView({ - block: "nearest", - inline: "nearest", - }); + // Scrolling is handled by TabTrigger. nextItem.element?.focus({ preventScroll: true }); } } }; - const warningId = useId(); + const previousSelected = usePrevious(selected, [selected]); + + const selectionChangedFromOverflow = + !!selected && + !!previousSelected && + selected !== previousSelected && + selectionFromOverflowRef.current; + + const getSelectedTabElement = useCallback(() => { + return ( + tabstripRef.current?.querySelector( + '[role="tab"][aria-selected="true"]', + ) ?? item(activeTab.current?.id)?.element + ); + }, [item, activeTab]); + + const cancelScheduledSelectionFocus = useCallback(() => { + if (selectionFocusOuterRafRef.current != null && targetWindow) { + targetWindow.cancelAnimationFrame(selectionFocusOuterRafRef.current); + selectionFocusOuterRafRef.current = null; + } + }, [targetWindow]); + + const focusElementWithRetry = useCallback( + (getElement: () => HTMLElement | null | undefined) => { + const doc = targetWindow?.document; + if (!doc) { + getElement()?.focus({ preventScroll: true }); + return; + } + + cancelScheduledSelectionFocus(); + + let attempts = 0; + + const focusElement = () => { + const element = getElement(); + if (!element?.isConnected) { + if (attempts >= 120 || !targetWindow?.requestAnimationFrame) { + return; + } + + attempts += 1; + selectionFocusOuterRafRef.current = + targetWindow.requestAnimationFrame(focusElement); + return; + } + + element.focus({ preventScroll: true }); + + if (doc.activeElement === element || attempts >= 120) { + selectionFocusOuterRafRef.current = null; + return; + } + + attempts += 1; + if (targetWindow?.requestAnimationFrame) { + selectionFocusOuterRafRef.current = + targetWindow.requestAnimationFrame(focusElement); + } else { + queueMicrotask(focusElement); + } + }; + + focusElement(); + }, + [cancelScheduledSelectionFocus, targetWindow], + ); + + const scheduleSelectedTabFocus = useCallback(() => { + focusElementWithRetry(getSelectedTabElement); + }, [focusElementWithRetry, getSelectedTabElement]); + + useIsomorphicLayoutEffect(() => { + if (!menuOpen || !overflowActiveValue) { + return; + } + + focusElementWithRetry(() => getRenderedTab(overflowActiveValue)?.trigger); + }, [focusElementWithRetry, getRenderedTab, menuOpen, overflowActiveValue]); + + useIsomorphicLayoutEffect(() => { + const doc = targetWindow?.document; + if ( + !doc || + !selected || + !previousSelected || + selected === previousSelected + ) { + return; + } + + const activeElement = doc.activeElement; + const focusWasLost = + !activeElement || + activeElement === doc.body || + activeElement === doc.documentElement || + !activeElement.isConnected; + + if (!focusWasLost) { + return; + } + + scheduleSelectedTabFocus(); + }, [previousSelected, scheduleSelectedTabFocus, selected, targetWindow]); + + useEffect(() => { + return () => { + cancelScheduledSelectionFocus(); + if (removalRecoveryRafRef.current != null) { + cancelAnimationFrame(removalRecoveryRafRef.current); + } + }; + }, [cancelScheduledSelectionFocus]); + + // Handle select from menu + useIsomorphicLayoutEffect(() => { + if (!selectionChangedFromOverflow || !selected) { + return; + } + + scheduleSelectedTabFocus(); + + const selectedTab = getSelectedTabElement(); + const selectedTabName = selectedTab + ? getTabAccessibleName(selectedTab) || selected + : selected; + + announce(`${selectedTabName} moved to main tab list`); + selectionFromOverflowRef.current = false; + }, [ + announce, + getSelectedTabElement, + scheduleSelectedTabFocus, + selected, + selectionChangedFromOverflow, + selectionFromOverflowRef, + ]); + + const handleTabRemoval = useEventCallback(() => { + const doc = targetWindow?.document; + if (!doc) return; + + const activeTabWasSelected = activeTab.current?.value === selected; + + const focusWasLost = () => { + const activeElement = doc.activeElement; + + if (!activeElement) return true; + if ( + activeElement === doc.body || + activeElement === doc.documentElement + ) { + return true; + } + + return !activeElement.isConnected; + }; + + const shouldRecoverFocus = () => { + if (focusWasLost()) return true; + + const activeElement = doc.activeElement; + if (!(activeElement instanceof HTMLElement)) return false; + + if (!menuOpen) { + if (activeElement === overflowButtonRef.current) { + return activeTabWasSelected; + } + + return ( + tabstripRef.current?.contains(activeElement) && + activeElement.getAttribute("role") !== "tab" + ); + } + + if (activeElement === overflowButtonRef.current) { + return true; + } + + if (overflowListRef.current?.contains(activeElement)) { + // When closing tabs from the overflow menu, focus can remain within + // the floating UI rather than falling back to body. + return activeElement.getAttribute("role") !== "tab"; + } + + return false; + }; + + // If focus was lost due to deletion, browsers may report body/html or a + // disconnected active element depending on browser behavior. + if (!shouldRecoverFocus()) { + if ( + pendingRemovalRecoveryRef.current && + removalRecoveryRafRef.current == null + ) { + if (pendingRemovalRecoveryRetriesRef.current < 120) { + pendingRemovalRecoveryRetriesRef.current += 1; + removalRecoveryRafRef.current = requestAnimationFrame(() => { + removalRecoveryRafRef.current = null; + handleTabRemoval(); + }); + } else { + clearPendingRemovalRecovery(); + } + } + return; + } + + if (!activeTab.current) { + clearPendingRemovalRecovery(); + return; + } + + const removedItems = getRemovedItems(); + const removed = removedItems.get(activeTab.current.id); + if (!removed) { + clearPendingRemovalRecovery(); + return; + } + + clearPendingRemovalRecovery(); + const removedWasSelected = removed.value === selected; + const baseIndex = removed.staleIndex ?? -1; + const removedId = removed.id; + + const restoreFocus = () => { + const restoredTab = item(removedId); + const restoredRenderedTab = getRenderedTab(removed.value); + const focusMovedToOverflowButton = + doc.activeElement === overflowButtonRef.current; + + // Overflow menu updates can temporarily remount tabs. If the tab is + // back and focus was lost to a disconnected node, restore focus to the + // remounted tab rather than treating it as a real deletion. + if ( + restoredTab?.element?.isConnected && + restoredRenderedTab?.trigger === restoredTab.element && + !(removedWasSelected && focusMovedToOverflowButton) + ) { + if (shouldRecoverFocus()) { + focusElementWithRetry(() => item(removedId)?.element); + } + return; + } + + if (!shouldRecoverFocus()) { + return; + } + + let nextTab = + (baseIndex >= 0 ? itemAt(baseIndex) : null) ?? + (baseIndex > 0 ? itemAt(baseIndex - 1) : null) ?? + getLast() ?? + getFirst(); + + if (nextTab?.element === overflowButtonRef.current) { + const overflowIndex = getIndex(nextTab.id); + nextTab = + (overflowIndex > 0 ? itemAt(overflowIndex - 1) : null) ?? + (baseIndex > 0 ? itemAt(baseIndex - 1) : null) ?? + getFirst(); + } + + if (!nextTab?.element) return; + + if ( + removedWasSelected && + !menuOpen && + !getVisibleSelectedTab(tabstripRef.current, removedId) + ) { + activeTab.current = { id: nextTab.id, value: nextTab.value }; + setSelected(null, nextTab.value); + } + + focusElementWithRetry(() => { + if (removedWasSelected) { + return getSelectedTabElement() ?? nextTab?.element; + } + + return nextTab?.element; + }); + }; + + requestAnimationFrame(() => requestAnimationFrame(() => restoreFocus())); + }); + + useTabListRecovery({ + removalVersion, + targetWindow, + tabstripRef, + overflowListRef, + handleTabRemoval, + pendingRemovalRecoveryRef, + pendingRemovalRecoveryRetriesRef, + }); return (
( data-ismeasuring={isMeasuring ? true : undefined} ref={handleRef} onKeyDown={handleKeyDown} - aria-describedby={clsx(ariaDescribedBy, warningId) || undefined} {...rest} > - {!isMeasuring && hidden.length > 0 && ( - - Note: This tab list includes overflow; tab positions may be - inaccurate or change when a tab is selected - - )} - {visible} - - {hidden} - + + + {children} + {visibleValues.map((value) => ( + + ))} + {!menuOpen && hiddenValues.length > 0 ? ( + + ) : null} + + +
); }, diff --git a/packages/lab/src/tabs-next/TabNext.tsx b/packages/lab/src/tabs-next/TabNext.tsx index 4ab4392ce60..4fd49cfe018 100644 --- a/packages/lab/src/tabs-next/TabNext.tsx +++ b/packages/lab/src/tabs-next/TabNext.tsx @@ -1,4 +1,9 @@ -import { makePrefixer, useId } from "@salt-ds/core"; +import { + makePrefixer, + useForkRef, + useId, + useIsomorphicLayoutEffect, +} from "@salt-ds/core"; import { useComponentCssInjection } from "@salt-ds/styles"; import { useWindow } from "@salt-ds/window"; import { clsx } from "clsx"; @@ -9,14 +14,17 @@ import { type MouseEvent, type ReactElement, useCallback, + useEffect, useMemo, useRef, useState, } from "react"; +import { createPortal } from "react-dom"; import tabCss from "./TabNext.css"; import { TabNextContext } from "./TabNextContext"; import { useTabsNext } from "./TabsNextContext"; +import { getMeasuredWidth } from "./widthMeasurement"; const withBaseName = makePrefixer("saltTabNext"); @@ -26,7 +34,7 @@ export interface TabNextProps extends ComponentPropsWithoutRef<"div"> { */ disabled?: boolean; /** - * The value of the tab. + * The value of the tab. This must be unique within a `TabsNext` instance. */ value: string; } @@ -52,7 +60,8 @@ export const TabNext = forwardRef( window: targetWindow, }); - const { selected, activeTab } = useTabsNext(); + const { selected, activeTab, registerRenderedTab, updateRenderedTab } = + useTabsNext(); const disabled = !!disabledProp; @@ -61,6 +70,15 @@ export const TabNext = forwardRef( const wasMouseDown = useRef(false); const [focusVisible, setFocusVisible] = useState(false); const [focused, setFocused] = useState(false); + const markerRef = useRef(null); + const tabRootRef = useRef(null); + const hostRef = useRef(null); + + if (!hostRef.current && targetWindow?.document) { + hostRef.current = targetWindow.document.createElement("div"); + hostRef.current.dataset.tabHost = value; + hostRef.current.style.display = "contents"; + } const handleFocusCapture = (event: FocusEvent) => { onFocusCapture?.(event); @@ -92,19 +110,40 @@ export const TabNext = forwardRef( const handleMouseDown = (event: MouseEvent) => { onMouseDown?.(event); + if (value && id) { + activeTab.current = { value, id }; + } wasMouseDown.current = true; }; - const [actions, setActions] = useState([]); + const [actionIds, setActionIds] = useState(() => new Set()); const registerAction = useCallback((id: string) => { - setActions((old) => old.concat(id)); + setActionIds((old) => { + if (old.has(id)) { + return old; + } + + const next = new Set(old); + next.add(id); + return next; + }); return () => { - setActions((old) => old.filter((action) => action !== id)); + setActionIds((old) => { + if (!old.has(id)) { + return old; + } + + const next = new Set(old); + next.delete(id); + return next; + }); }; }, []); + const actions = useMemo(() => Array.from(actionIds), [actionIds]); + const context = useMemo( () => ({ tabId: id, @@ -118,7 +157,72 @@ export const TabNext = forwardRef( [id, selected, value, focused, disabled, actions, registerAction], ); - return ( + useIsomorphicLayoutEffect(() => { + if (!hostRef.current || !id) { + return; + } + + return registerRenderedTab({ + host: hostRef.current, + id, + marker: markerRef.current, + root: tabRootRef.current, + trigger: null, + value, + width: 0, + }); + }, [id, registerRenderedTab, value]); + + useIsomorphicLayoutEffect(() => { + updateRenderedTab(value, { + marker: markerRef.current, + root: tabRootRef.current, + }); + }, [updateRenderedTab, value]); + + useEffect(() => { + const element = tabRootRef.current; + const resizeObserverCtor = ( + targetWindow as + | (Window & { ResizeObserver?: typeof ResizeObserver }) + | undefined + )?.ResizeObserver; + if (!element || !resizeObserverCtor) { + return; + } + + const updateWidth = () => { + // Preserve the strip width while a tab is rendered in the overflow menu. + // Overflow items stretch to the menu width, and hidden measurement tabs + // can collapse to a different intrinsic size. Neither width is suitable + // for deciding whether the tab fits back in the main strip. + if ( + element.closest(".saltTabOverflow-list") || + element.closest(".saltTabListNext-measureContainer") + ) { + return; + } + + updateRenderedTab(value, { + width: getMeasuredWidth(element), + }); + }; + + updateWidth(); + + const observer = new resizeObserverCtor(() => { + updateWidth(); + }); + + observer.observe(element); + return () => { + observer.disconnect(); + }; + }, [targetWindow, updateRenderedTab, value]); + + const handleTabRootRef = useForkRef(tabRootRef, ref); + + const tabMarkup = (
( className, )} data-overflowitem="true" - ref={ref} + data-value={value} + ref={handleTabRootRef} onMouseDown={handleMouseDown} onFocusCapture={handleFocusCapture} onFocus={handleFocus} @@ -143,5 +248,12 @@ export const TabNext = forwardRef(
); + + return ( + <> +