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..6053eb39f56 100644 --- a/packages/lab/src/__tests__/__e2e__/tabs-next/TabsNext.cy.tsx +++ b/packages/lab/src/__tests__/__e2e__/tabs-next/TabsNext.cy.tsx @@ -1,27 +1,473 @@ +import { + TabBar, + TabListNext, + TabNext, + TabNextPanel, + TabNextTrigger, + TabsNext, +} from "@salt-ds/lab"; import * as tabsStories from "@stories/tabs-next/tabs-next.stories"; import { composeStories } from "@storybook/react-vite"; +import { type ReactElement, 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", +]; + +const dynamicOverflowTabs = [ + "Home", + "Transactions", + "Loans", + "Checks", + "Liquidity", + "With", + "Lots", + "More", + "Additional", + "Tabs", + "Added", + "In order to", + "Showcase overflow", + "Menu", + "On", + "Larger", + "Screens", +]; + +type ResponsiveOverflowWindow = Cypress.AUTWindow & { + __setResponsiveOverflowWidth?: (width: number) => void; + __setDynamicOverflowWidth?: (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 = [ + { value: "Home", label: "Home" }, + { + value: "Transactions", + label: expanded ? "Transactions with a much longer label" : "Tx", + }, + { value: "Loans", label: "Loans" }, + ]; + + return ( + <> +
+ + + + {tabs.map(({ value, 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 OverflowWithoutInitialSelection() { + return ( +
+ + + + {selectorSafeTabs.map((label) => ( + + {label} + + ))} + + + +
+ ); +} + +function OverflowWithControlledSelection() { + const [selected, setSelected] = useState(selectorSafeTabs[0]); + + return ( +
+ setSelected(nextValue)} + > + + + {selectorSafeTabs.map((label) => ( + + {label} + + ))} + + + +
+ ); +} + +function OverflowWithIgnoredOverflowSelection() { + const [selected, setSelected] = useState(selectorSafeTabs[0]); + + return ( + <> +
+ undefined}> + + + {selectorSafeTabs.map((label) => ( + + {label} + + ))} + + + +
+ + + ); +} + +function TabsNextWithEmptyStringValue() { + const tabs = [ + { label: "Empty", value: "" }, + { label: "Transactions", value: "transactions" }, + { label: "Liquidity", value: "liquidity" }, + ]; + + return ( + + + + {tabs.map(({ label, value }) => ( + + {label} + + ))} + + + {tabs.map(({ label, value }) => ( + + {label} + + ))} + + ); +} + +function OverflowWithEmptyStringValue() { + const tabs = [ + { label: "Home", value: "home" }, + { label: "Transactions", value: "transactions" }, + { label: "Empty", value: "" }, + { label: "Liquidity", value: "liquidity" }, + { label: "Checks", value: "checks" }, + ]; + + return ( +
+ + + + {tabs.map(({ label, value }) => ( + + {label} + + ))} + + + +
+ ); +} + +function OverflowWithDisabledHiddenTab() { + return ( +
+ + + + {dynamicOverflowTabs.map((label) => ( + + {label} + + ))} + + + +
+ ); +} + +function OverflowAfterClassBasedWidthChange() { + const [wide, setWide] = useState(false); + + return ( + <> + +
+ + + + {selectorSafeTabs.map((label) => ( + + {label} + + ))} + + + +
+ + + ); +} + +function OverflowWithinContainer({ width }: { width: number }) { + return ( +
+ +
+ ); +} + +function DynamicOverflowBoundary() { + const [width, setWidth] = useState(408); + + useEffect(() => { + Object.assign(window, { + __setDynamicOverflowWidth: setWidth, + }); + + return () => { + delete ( + window as Window & { + __setDynamicOverflowWidth?: typeof setWidth; + } + ).__setDynamicOverflowWidth; + }; + }, []); + + return ( +
+ + + + {dynamicOverflowTabs.map((label) => ( + + {label} + + ))} + + + +
+ ); +} + +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("tablist", { name: "Overflow tab options" }) + .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"); + }); +} + +function mountTabsNext( + element: ReactElement, + options?: { width?: number | string }, +) { + cy.mount( +
{element}
, + ); +} + describe("Given a Tabstrip", () => { it("should render with tablist and tab roles", () => { - cy.mount(); + mountTabsNext(); cy.findByRole("tablist").should("be.visible"); cy.findAllByRole("tab").should("have.length", 5); }); it("should support keyboard navigation and wrap", () => { - cy.mount(); + mountTabsNext(); cy.realPress("Tab"); cy.findByRole("tab", { name: "Home" }).should("be.focused"); cy.realPress("ArrowRight"); @@ -38,13 +484,13 @@ describe("Given a Tabstrip", () => { it("should support selection with a mouse", () => { const changeSpy = cy.stub().as("changeSpy"); - cy.mount(); + mountTabsNext(); cy.findByRole("tab", { name: "Home" }).should( "have.attr", "aria-selected", "true", ); - cy.findByRole("tab", { name: "Transactions" }).realClick(); + cy.findByRole("tab", { name: "Transactions" }).click(); cy.findByRole("tab", { name: "Transactions" }).should( "have.attr", "aria-selected", @@ -60,7 +506,7 @@ describe("Given a Tabstrip", () => { it("should support selection with the keyboard", () => { const changeSpy = cy.stub().as("changeSpy"); - cy.mount(); + mountTabsNext(); cy.realPress("Tab"); cy.findByRole("tab", { name: "Home" }).should("be.focused"); cy.findByRole("tab", { name: "Home" }).should( @@ -97,37 +543,38 @@ describe("Given a Tabstrip", () => { cy.findByRole("tab", { name: "Loans" }).should("be.focused"); }); - it("should allow keyboard navigation into the overflow menu", () => { - cy.mount(); + it("should allow keyboard access into and out of the overflow menu", () => { + mountTabsNext(); - 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("tablist", { name: "Overflow tab options" }).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", () => { const changeSpy = cy.stub().as("changeSpy"); - cy.mount(); + mountTabsNext(); cy.findByRole("tab", { name: "Loans" }).should( "have.attr", "aria-disabled", "true", ); - cy.findByRole("tab", { name: "Loans" }).realClick(); + cy.findByRole("tab", { name: "Loans" }).click(); cy.findByRole("tab", { name: "Loans" }).should( "have.attr", "aria-selected", @@ -138,140 +585,567 @@ 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"); + mountTabsNext(); + cy.findAllByRole("tab").should("have.length", 5); + cy.findByRole("tab", { name: "Overflow" }).should("be.visible"); }); it("should allow keyboard navigation in the menu", () => { - cy.mount( + mountTabsNext( <> , ); - cy.get("[data-overflowbutton]").realClick(); + + cy.findByRole("tab", { name: "Overflow" }).click(); + cy.findByRole("tablist", { name: "Overflow tab options" }).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", () => { - cy.mount(); + it("should navigate past a disabled tab in the overflow menu", () => { + mountTabsNext(); - cy.get("[data-overflowbutton]").realClick(); - cy.findAllByRole("tab").filter(":visible").should("have.length", 17); + cy.findByRole("tab", { name: "Overflow" }).click(); + cy.findByRole("tablist", { name: "Overflow tab options" }).should( + "be.visible", + ); - cy.wait(500); + cy.findByRole("tab", { name: "Transactions" }) + .should("be.focused") + .and("have.attr", "aria-disabled", "true"); + + cy.realPress("ArrowDown"); + cy.findByRole("tab", { name: "Loans" }).should("be.focused"); + }); + + it("should restore focus correctly after opening the menu with a mouse", () => { + mountTabsNext( + <> + + + , + ); + + cy.findByRole("tab", { name: "Overflow" }).click(); + cy.findByRole("tablist", { name: "Overflow tab options" }).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", () => { + mountTabsNext(); + + cy.findByRole("tab", { name: "Overflow" }).click(); + cy.findByRole("tablist", { name: "Overflow tab options" }).should( + "be.visible", + ); + + cy.findAllByRole("tab").should("have.length", 18); 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(); + mountTabsNext(); + + cy.findAllByRole("tab").should("have.length", 5); - cy.findAllByRole("tab").filter(":visible").should("have.length", 4); + cy.findByRole("tab", { name: "Overflow" }).click(); + cy.findByRole("tablist", { name: "Overflow tab options" }).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" }).click(); + cy.findByRole("tablist", { name: "Overflow tab options" }).should( + "be.visible", + ); + + cy.findByRole("tab", { name: "Checks" }).should("be.focused"); - cy.get("[data-overflowbutton]").realClick(); cy.realPress("Enter"); + cy.findByRole("tablist", { name: "Overflow tab options" }).should( + "not.exist", + ); cy.findByRole("tab", { name: "Checks" }) .should("have.attr", "aria-selected", "true") .should("be.focused"); }); + it("should not temporarily remove an extra main tab when selecting from overflow", () => { + mountTabsNext(); + + 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" }).click(); + cy.findByRole("tablist", { name: "Overflow tab options" }).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.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", + ]); + }); + }); + + it("should announce when a selected overflow tab moves to the main list", () => { + mountTabsNext(); + + cy.findByRole("tab", { name: "Overflow" }).click(); + cy.findByRole("tablist", { name: "Overflow tab options" }).should( + "be.visible", + ); + + clickOverflowTab("Liquidity"); + assertSelectedMainTab("Liquidity"); + cy.get("[aria-live]", { timeout: 8000 }).should( + "contain.text", + "Liquidity moved to main tab list", + ); + }); + + it("should announce when the first selection comes from overflow", () => { + mountTabsNext(); + + cy.findByRole("tab", { name: "Overflow" }).click(); + cy.findByRole("tablist", { name: "Overflow tab options" }).should( + "be.visible", + ); + + clickOverflowTab("Liquidity"); + assertSelectedMainTab("Liquidity"); + cy.get("[aria-live]", { timeout: 8000 }).should( + "contain.text", + "Liquidity moved to main tab list", + ); + }); + + it("should make the first visible tab tabbable when there is no initial selection", () => { + mountTabsNext( + <> + + + , + ); + + cy.realPress("Tab"); + cy.findByRole("tab", { name: "Home" }).should("be.focused"); + + cy.realPress("Tab"); + cy.findByRole("button", { name: "After" }).should("be.focused"); + }); + + it("should announce when a controlled parent immediately applies an overflow selection", () => { + mountTabsNext(); + + cy.findByRole("tab", { name: "Overflow" }).click(); + cy.findByRole("tablist", { name: "Overflow tab options" }).should( + "be.visible", + ); + + clickOverflowTab("Liquidity"); + + assertSelectedMainTab("Liquidity"); + cy.get("[aria-live]", { timeout: 8000 }).should( + "contain.text", + "Liquidity moved to main tab list", + ); + }); + + it("should not announce when the same overflow value is later set externally after being ignored", () => { + mountTabsNext(); + + cy.findByRole("tab", { name: "Overflow" }).click(); + cy.findByRole("tablist", { name: "Overflow tab options" }).should( + "be.visible", + ); + + clickOverflowTab("Liquidity"); + cy.findByRole("tab", { name: "Home", selected: true }).should("be.visible"); + + cy.findByRole("button", { name: "Select Liquidity externally" }).click(); + + assertSelectedMainTab("Liquidity"); + cy.get("[aria-live]").should( + "not.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 = ""; + + mountTabsNext(); + + cy.findByRole("tablist", { name: "Portal contract tablist" }).within(() => { + cy.get('[data-slotid="main:Home"] [data-tab-host="Home"]').should( + "exist", + ); + }); + + 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" }).click(); + + cy.findByRole("tablist", { name: "Overflow tab options" }) + .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(); + mountTabsNext(); - cy.findByRole("tablist").invoke("css", "max-width", 140); - cy.wait(500); + cy.findByRole("tab", { name: "Home" }).should( + "have.attr", + "aria-selected", + "true", + ); - cy.findAllByRole("tab").filter(":visible").should("have.length", 1); + cy.findAllByRole("tab").should("have.length", 2); - 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 + cy.findByRole("tab", { name: "Overflow" }).click(); - cy.findByRole("tab", { name: "Liquidity" }) - .should("have.attr", "aria-selected", "true") - .should("be.focused"); + cy.findAllByRole("tab").should("have.length", 18); // 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", () => { + mountTabsNext(); + + cy.findByRole("tab", { name: "Overflow" }).click(); + cy.findByRole("tablist", { name: "Overflow tab options" }).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", () => { + mountTabsNext(); + + cy.findByRole("tab", { name: "Overflow" }).click(); + cy.findByRole("tablist", { name: "Overflow tab options" }).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("tablist", { name: "Overflow tab options" }).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("tablist", { name: "Overflow tab options" }).should( + "not.exist", + ); + }); + + it("should recompute overflow when sizing is driven by CSS classes", () => { + mountTabsNext(); + + cy.findByRole("tablist", { name: "Class sized overflow tablist" }).within( + () => { + cy.findByRole("tab", { name: "Overflow" }).should("be.visible"); + }, + ); + + cy.findByRole("button", { name: "Toggle class width" }).click(); + + cy.findByRole("tablist", { name: "Class sized overflow tablist" }).within( + () => { + cy.findByRole("tab", { name: "Overflow" }).should("not.exist"); + }, + ); + + cy.findByRole("button", { name: "Toggle class width" }).click(); + + 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", () => { + mountTabsNext(); + + cy.findByRole("tablist", { name: "Width change tablist" }).within(() => { + cy.findByRole("tab", { name: "Overflow" }).should("not.exist"); + }); + + cy.findByRole("button", { name: "Expand label" }).click(); + + 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", () => { + mountTabsNext(); + + cy.findByRole("tab", { name: "Overflow" }).click(); + cy.findByRole("tablist", { name: "Overflow tab options" }).should( + "be.visible", + ); + + clickOverflowTab("Liquidity"); + assertSelectedMainTab("Liquidity"); + + cy.findByRole("tab", { name: "Transactions" }).click(); + assertSelectedMainTab("Transactions"); + cy.findByRole("tab", { name: "Liquidity" }).should("be.visible"); }); it("should support adding tabs", () => { - cy.mount(); + mountTabsNext(); cy.findAllByRole("tab").should("have.length", 3); cy.findByRole("tab", { name: "Home" }).should( "have.attr", "aria-selected", "true", ); - cy.findByRole("button", { name: "Add tab" }).realClick(); + cy.findByRole("button", { name: "Add tab" }).click(); cy.findAllByRole("tab").should("have.length", 4); 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", () => { + mountTabsNext( +
+ +
, + ); + + cy.findByRole("button", { name: "Add tab" }).click(); + cy.findByRole("button", { name: "Add tab" }).click(); + cy.findByRole("button", { name: "Add tab" }).click(); + + 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(); + mountTabsNext(); cy.findAllByRole("tab").should("have.length", 3); cy.findByRole("tab", { name: "Home" }).should( "have.attr", "aria-selected", "true", ); - cy.findByRole("button", { name: "Add tab" }).realClick(); + cy.findByRole("button", { name: "Add tab" }).click(); cy.findByRole("dialog").should("be.visible"); - cy.findByLabelText("New tab name").realClick(); + cy.findByLabelText("New tab name").click(); cy.realType("New tab"); - cy.findByRole("button", { name: "Confirm" }).realClick(); + cy.findByRole("button", { name: "Confirm" }).click(); cy.findAllByRole("tab").should("have.length", 4); - cy.findByRole("tab", { name: "New tab" }).should("be.visible"); cy.findByRole("tab", { name: "Home" }).should( "have.attr", "aria-selected", "true", ); cy.findByRole("button", { name: "Add tab" }).should("be.focused"); + + cy.get("body").then(($body) => { + if ($body.find("[data-overflowbutton]").length > 0) { + cy.findByRole("tab", { name: "Overflow" }).click(); + cy.findByRole("tablist", { name: "Overflow tab options" }).within( + () => { + cy.findByRole("tab", { name: "New tab" }).should("be.visible"); + }, + ); + return; + } + + cy.findByRole("tab", { name: "New tab" }).should("be.visible"); + }); }); it("should add the correct aria when tab actions are used", () => { - 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( - // "have.attr", - // "id", - // actionId, - // ); - // }); + mountTabsNext(); cy.findByRole("tab", { name: "Home" }).should( "have.accessibleDescription", @@ -280,7 +1154,7 @@ describe("Given a Tabstrip", () => { }); it("should support closing tabs with a mouse", () => { - cy.mount(); + mountTabsNext(); cy.findByRole("tab", { name: "Home" }).should( "have.attr", @@ -289,7 +1163,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" }).click(); cy.findAllByRole("tab").should("have.length", 4); cy.findByRole("tab", { name: "Home" }).should( "have.attr", @@ -298,7 +1172,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" }).click(); cy.findAllByRole("tab").should("have.length", 3); cy.findByRole("tab", { name: "Home" }).should( "have.attr", @@ -307,7 +1181,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" }).click(); cy.findAllByRole("tab").should("have.length", 2); cy.findByRole("tab", { name: "Transactions" }).should( "have.attr", @@ -317,21 +1191,43 @@ describe("Given a Tabstrip", () => { cy.findByRole("tab", { name: "Transactions" }).should("be.focused"); }); + it("should restore focus when selected tab removal is async", () => { + mountTabsNext(); + + cy.findByRole("button", { name: "Home Dismiss tab" }).click(); + 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"); + mountTabsNext(); + + cy.findByRole("button", { name: "Home Dismiss tab" }).click(); + + 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(); + mountTabsNext(); 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 +1235,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,20 +1249,20 @@ describe("Given a Tabstrip", () => { }); it("should support closing with confirmation", () => { - cy.mount(); + mountTabsNext(); cy.findAllByRole("tab").should("have.length", 3); - cy.findAllByRole("button", { name: "Home Close tab" }).realClick(); + cy.findAllByRole("button", { name: "Home Dismiss tab" }).click(); cy.findByRole("dialog").should("be.visible"); - cy.findByRole("button", { name: "No" }).realClick(); + cy.findByRole("button", { name: "No" }).click(); 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" }).click(); cy.findByRole("dialog").should("be.visible"); - cy.findByRole("button", { name: "Yes" }).realClick(); + cy.findByRole("button", { name: "Yes" }).click(); cy.findByRole("dialog").should("not.to.exist"); cy.findAllByRole("tab").should("have.length", 2); cy.findByRole("tab", { name: "Transactions" }).should( @@ -378,81 +1274,197 @@ describe("Given a Tabstrip", () => { }); it("should set tab-index 0 on the panel when it contains non-tabbable elements", () => { - cy.mount(); + mountTabsNext(); cy.findByRole("tabpanel").should("have.attr", "tabIndex", "0"); }); it("should not set tab-index 0 on the panel when it contains tabbable elements", () => { - cy.mount(); + mountTabsNext(); cy.findByRole("tabpanel").should("not.have.attr", "tabIndex"); }); it("should associate panels with tabs", () => { - cy.mount(); + mountTabsNext(); cy.findByRole("tabpanel", { name: "Home" }).should("be.visible"); }); it("should dynamically overflow tabs", () => { - cy.mount(); - cy.findAllByRole("tab").filter(":visible").should("have.length", 4); + mountTabsNext(); + 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.window().then((win) => { + const dynamicOverflowWindow = win as ResponsiveOverflowWindow; + dynamicOverflowWindow.__setDynamicOverflowWidth?.(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.window().then((win) => { + const dynamicOverflowWindow = win as ResponsiveOverflowWindow; + dynamicOverflowWindow.__setDynamicOverflowWidth?.(248); + }); + cy.findAllByRole("tab").should("have.length", 3); }); - it("should support a controlled API", () => { - cy.mount(); + it("should support empty-string tab values", () => { + mountTabsNext(); - cy.findByRole("tab", { name: "Home" }).should( + cy.findByRole("tab", { name: "Empty" }).should( "have.attr", "aria-selected", "true", ); + cy.findByRole("tabpanel", { name: "Empty" }).should("be.visible"); + + cy.findByRole("tab", { name: "Transactions" }).click(); - cy.findByRole("tab", { name: "Transactions" }).realClick(); cy.findByRole("tab", { name: "Transactions" }).should( "have.attr", "aria-selected", "true", ); + cy.findByRole("tabpanel", { name: "Transactions" }).should("be.visible"); + }); + + it("should keep an empty-string selection visible after selecting it from overflow", () => { + mountTabsNext(); + + cy.findByRole("tab", { name: "Overflow" }).click(); + cy.findByRole("tablist", { name: "Overflow tab options" }).should( + "be.visible", + ); + + clickOverflowTab("Empty"); - cy.get("[data-overflowbutton]").realClick(); - cy.findByRole("tab", { name: "Lots" }).realClick(); - cy.findByRole("tab", { name: "Lots" }).should( + cy.findByRole("tab", { name: "Empty", selected: true }).should( + "be.focused", + ); + cy.findAllByRole("tab").should("have.length", 2); + }); + + it("should support a controlled API", () => { + mountTabsNext( +
+ +
, + ); + + cy.findByRole("tab", { name: "Home" }).should( + "have.attr", + "aria-selected", + "true", + ); + + cy.findByRole("tab", { name: "Transactions" }).click(); + cy.findByRole("tab", { name: "Transactions" }).should( "have.attr", "aria-selected", "true", ); - cy.findByRole("button", { name: "Lots Close tab" }).realClick(); - cy.findByRole("tab", { name: "More" }) + cy.findByRole("tab", { name: "Overflow" }).click(); + cy.findByRole("tablist", { name: "Overflow tab options" }).should( + "be.visible", + ); + + 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" }).click(); + 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"); + it("should follow visible tab order when navigating with arrow keys after selecting from overflow", () => { + mountTabsNext(); - cy.mount(); - cy.findAllByRole("tab").filter(":visible").should("have.length", 2); + // Wait for overflow to be ready + cy.findAllByRole("tab").should("have.length", 5); - // no horizontal overflow - cy.get("html").then((body) => { - console.log(body[0]); - const { clientWidth, scrollWidth } = body[0]; - expect(clientWidth).to.equal(scrollWidth); - }); - }, - ); + // Open overflow menu and select "With" + cy.findByRole("tab", { name: "Overflow" }).click(); + cy.findByRole("tablist", { name: "Overflow tab options" }).should( + "be.visible", + ); + + clickOverflowTab("With"); + cy.findByRole("tab", { name: "With", selected: true }) + .should("be.visible") + .and("be.focused"); + + // Go to the first tab + cy.realPress("Home"); + cy.findByRole("tab", { name: "Home" }).should("be.focused"); + + // Navigate right through all visible tabs + cy.realPress("ArrowRight"); + cy.findByRole("tab", { name: "Transactions" }).should("be.focused"); + + cy.realPress("ArrowRight"); + cy.findByRole("tab", { name: "Loans" }).should("be.focused"); + + // "With" should come before Overflow in the navigation order + cy.realPress("ArrowRight"); + cy.findByRole("tab", { name: "With" }).should("be.focused"); + + cy.realPress("ArrowRight"); + cy.findByRole("tab", { name: "Overflow" }).should("be.focused"); + + // Wrapping back to Home + cy.realPress("ArrowRight"); + cy.findByRole("tab", { name: "Home" }).should("be.focused"); + }); + + it("should close the overflow menu and move focus past the tablist when Tab is pressed from the menu", () => { + mountTabsNext( + <> + + + , + ); + + cy.findByRole("tab", { name: "Overflow" }).click(); + cy.findByRole("tablist", { name: "Overflow tab options" }).should( + "be.visible", + ); + + cy.findByRole("tab", { name: "Liquidity" }).should("be.focused"); + + cy.realPress("Tab"); + + cy.findByRole("tablist", { name: "Overflow tab options" }).should( + "not.exist", + ); + cy.findByRole("button", { name: "after" }).should("be.focused"); + }); + + it("should close the overflow menu and move focus to the overflow trigger when Shift+Tab is pressed from the menu", () => { + mountTabsNext( + <> + + + , + ); + + cy.findByRole("tab", { name: "Overflow" }).click(); + cy.findByRole("tablist", { name: "Overflow tab options" }).should( + "be.visible", + ); + + cy.findByRole("tab", { name: "Liquidity" }).should("be.focused"); + + cy.realPress(["Shift", "Tab"]); + + cy.findByRole("tablist", { name: "Overflow tab options" }).should( + "not.exist", + ); + cy.findByRole("tab", { name: "Overflow" }).should("be.focused"); + }); it( "should flip overflow menu placement if there is enough space", @@ -460,15 +1472,16 @@ describe("Given a Tabstrip", () => { () => { cy.get("body").invoke("css", "display", "block"); - cy.mount(); - cy.findAllByRole("tab").filter(":visible").should("have.length", 4); + mountTabsNext(, { width: 408 }); + cy.findAllByRole("tab").should("have.length", 5); - cy.get("[data-overflowbutton]").realClick(); - cy.wait(500); + cy.findByRole("tab", { name: "Overflow" }).click(); + cy.findByRole("tablist", { name: "Overflow tab options" }).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..f23674769c4 100644 --- a/packages/lab/src/tabs-next/TabListNext.tsx +++ b/packages/lab/src/tabs-next/TabListNext.tsx @@ -1,4 +1,10 @@ -import { capitalize, makePrefixer, useForkRef, useId } from "@salt-ds/core"; +import { + capitalize, + makePrefixer, + useAriaAnnouncer, + useForkRef, + useIsomorphicLayoutEffect, +} from "@salt-ds/core"; import { useComponentCssInjection } from "@salt-ds/styles"; import { useWindow } from "@salt-ds/window"; import { clsx } from "clsx"; @@ -6,15 +12,27 @@ import { type ComponentPropsWithoutRef, forwardRef, type KeyboardEvent, + useCallback, + useEffect, + useMemo, useRef, + useState, } from "react"; +import { useFocusWithRetry } from "./hooks/useFocusWithRetry"; import { useOverflow } from "./hooks/useOverflow"; -import { useRestoreActiveTab } from "./hooks/useRestoreActiveTab"; +import { useOverflowLayoutState } from "./hooks/useOverflowLayoutState"; +import { useTabListRecovery } from "./hooks/useTabListRecovery"; +import { useTabRemovalHandler } from "./hooks/useTabRemovalHandler"; +import { useTabSelectionFocus } from "./hooks/useTabSelectionFocus"; +import { TabListLayoutContext } 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"); +const MAX_FOCUS_RETRY_ATTEMPTS = 120; export interface TabListNextProps extends Omit, "onChange"> { @@ -29,11 +47,10 @@ export interface TabListNextProps } export const TabListNext = forwardRef( - function TabstripNext(props, ref) { + function TabListNext(props, ref) { const { appearance = "bordered", activeColor = "primary", - "aria-describedby": ariaDescribedBy, children, className, onKeyDown, @@ -47,67 +64,209 @@ export const TabListNext = forwardRef( }); const { + renderMode, selected, + setSelected, + setBootstrapOverflowReady, getNext, getPrevious, getFirst, getLast, - items, + getIndex, + item, + itemAt, activeTab, + selectionFromOverflowValueRef, 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 pendingRemovalRecoveryRef = useRef(false); + const pendingRemovalRecoveryRetriesRef = useRef(0); + const [slotVersion, setSlotVersion] = useState(0); + const handleRef = useForkRef(tabstripRef, ref); const overflowButtonRef = useRef(null); - const [visible, hidden, isMeasuring, realSelectedIndexRef] = useOverflow({ + const { announce } = useAriaAnnouncer(); + const overflowMenuOpen = renderMode === "portal" ? menuOpen : false; + + const [visibleValues, hiddenValues, isMeasuring] = useOverflow({ container: tabstripRef, - tabs: items, - children, + menuOpen: overflowMenuOpen, selected, + tabs: renderedTabs, overflowButton: overflowButtonRef, }); - useRestoreActiveTab({ - container: tabstripRef, - tabs: items, - realSelectedIndex: realSelectedIndexRef, - removedActiveTabRef, - }); + useEffect(() => { + setBootstrapOverflowReady( + renderMode === "inline" && renderedTabs.length > 0 && !isMeasuring, + ); + }, [ + isMeasuring, + renderMode, + renderedTabs.length, + setBootstrapOverflowReady, + ]); + + const { resolvedOverflowActiveValue, tabListLayoutContext } = + useOverflowLayoutState({ + hiddenValues, + menuOpen, + overflowMenuOpen, + visibleValues, + }); + 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(() => { + if (renderMode !== "portal") { + return; + } + + 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, renderMode, 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 getSelectedTabElement = useCallback(() => { + return ( + tabstripRef.current?.querySelector( + '[role="tab"][aria-selected="true"]', + ) ?? item(activeTab.current?.id)?.element + ); + }, [item, activeTab]); + const { focusElementWithRetry } = useFocusWithRetry({ + maxAttempts: MAX_FOCUS_RETRY_ATTEMPTS, + targetWindow, + }); + useTabSelectionFocus({ + announce, + focusElementWithRetry, + getRenderedTab, + getSelectedTabElement, + menuOpen, + resolvedOverflowActiveValue, + selected, + selectionFromOverflowValueRef, + targetWindow, + }); + + const handleTabRemoval = useTabRemovalHandler({ + activeTab, + focusElementWithRetry, + getFirst, + getIndex, + getLast, + getRemovedItems, + getRenderedTab, + getSelectedTabElement, + item, + itemAt, + maxRetryAttempts: MAX_FOCUS_RETRY_ATTEMPTS, + menuOpen, + overflowButtonRef, + overflowListRef, + pendingRemovalRecoveryRef, + pendingRemovalRecoveryRetriesRef, + removalRecoveryRafRef, + selected, + setSelected, + tabstripRef, + targetWindow, + }); + + useTabListRecovery({ + removalVersion, + targetWindow, + tabstripRef, + overflowListRef, + handleTabRemoval, + pendingRemovalRecoveryRef, + pendingRemovalRecoveryRetriesRef, + }); return (
( withBaseName(`activeColor${capitalize(activeColor)}`), className, )} - data-ismeasuring={isMeasuring ? true : undefined} + data-ismeasuring={ + renderMode === "portal" && 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 - + {renderMode === "inline" ? ( + children + ) : ( + + + {children} + {visibleValues.map((value) => ( + + ))} + {!menuOpen && hiddenValues.length > 0 ? ( + + ) : null} + + + )} - {visible} - - {hidden} -
); }, diff --git a/packages/lab/src/tabs-next/TabNext.tsx b/packages/lab/src/tabs-next/TabNext.tsx index 4ab4392ce60..01514084161 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"; @@ -13,10 +18,13 @@ import { useRef, useState, } from "react"; +import { createPortal } from "react-dom"; +import { useRenderedTabWidth } from "./hooks/useRenderedTabWidth"; import tabCss from "./TabNext.css"; import { TabNextContext } from "./TabNextContext"; import { useTabsNext } from "./TabsNextContext"; +import { getIntrinsicMeasuredWidth } 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,15 @@ export const TabNext = forwardRef( window: targetWindow, }); - const { selected, activeTab } = useTabsNext(); + const { + selected, + activeTab, + renderMode, + registerBootstrapTab, + setBootstrapTabReady, + registerRenderedTab, + updateRenderedTab, + } = useTabsNext(); const disabled = !!disabledProp; @@ -61,10 +77,13 @@ export const TabNext = forwardRef( const wasMouseDown = useRef(false); const [focusVisible, setFocusVisible] = useState(false); const [focused, setFocused] = useState(false); + const [hostElement, setHostElement] = useState(null); + const markerRef = useRef(null); + const tabRootRef = useRef(null); const handleFocusCapture = (event: FocusEvent) => { onFocusCapture?.(event); - if (value && id) { + if (id) { activeTab.current = { value, id }; } }; @@ -92,19 +111,40 @@ export const TabNext = forwardRef( const handleMouseDown = (event: MouseEvent) => { onMouseDown?.(event); + if (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 +158,87 @@ export const TabNext = forwardRef( [id, selected, value, focused, disabled, actions, registerAction], ); - return ( + useIsomorphicLayoutEffect(() => { + const doc = targetWindow?.document; + if (!doc) { + return; + } + + const host = doc.createElement("div"); + host.dataset.tabHost = value; + host.role = "presentation"; + host.style.display = "contents"; + setHostElement(host); + + return () => { + host.remove(); + }; + }, [targetWindow, value]); + + useIsomorphicLayoutEffect(() => { + if (renderMode !== "inline") { + return; + } + + return registerBootstrapTab(value); + }, [registerBootstrapTab, renderMode, value]); + + useIsomorphicLayoutEffect(() => { + setBootstrapTabReady(value, hostElement != null); + + return () => { + setBootstrapTabReady(value, false); + }; + }, [hostElement, setBootstrapTabReady, value]); + + useIsomorphicLayoutEffect(() => { + if (!hostElement || !id) { + return; + } + + return registerRenderedTab({ + host: hostElement, + id, + marker: markerRef.current, + root: tabRootRef.current, + trigger: null, + value, + width: getIntrinsicMeasuredWidth(tabRootRef.current), + }); + }, [hostElement, id, registerRenderedTab, value]); + + useIsomorphicLayoutEffect(() => { + const updates = { + marker: markerRef.current, + root: tabRootRef.current, + } as Partial<{ + host: HTMLDivElement; + id: string; + marker: HTMLElement | null; + root: HTMLElement | null; + trigger: HTMLButtonElement | null; + width: number; + }>; + + if (renderMode === "inline") { + updates.width = getIntrinsicMeasuredWidth(tabRootRef.current); + } + + updateRenderedTab(value, updates); + }, [renderMode, updateRenderedTab, value]); + + useRenderedTabWidth({ + hostElement, + renderMode, + tabRootRef, + 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 +264,21 @@ export const TabNext = forwardRef(
); + + return ( + <> +