From 806921ed31f6301ec6d3fb64ee39be6406020602 Mon Sep 17 00:00:00 2001 From: Josh Wooding <12938082+joshwooding@users.noreply.github.com> Date: Thu, 22 Aug 2024 19:41:52 +0100 Subject: [PATCH] Updates --- .../__e2e__/tabs-next/TabstripNext.cy.tsx | 7 +- packages/lab/src/tabs-next/TabNext.tsx | 4 +- .../lab/src/tabs-next/TabOverflowList.css | 4 + .../lab/src/tabs-next/TabOverflowList.tsx | 90 +++---- packages/lab/src/tabs-next/TabstripNext.tsx | 31 ++- packages/lab/src/tabs-next/useOverflow.tsx | 219 +++++++++++++----- packages/lab/src/tabs-next/useTabstrip.tsx | 13 +- .../tabstrip-next.qa.stories.tsx | 4 +- .../tabstrip-next/tabstrip-next.stories.tsx | 14 +- 9 files changed, 259 insertions(+), 127 deletions(-) diff --git a/packages/lab/src/__tests__/__e2e__/tabs-next/TabstripNext.cy.tsx b/packages/lab/src/__tests__/__e2e__/tabs-next/TabstripNext.cy.tsx index 5617163663c..1ba6734d8a7 100644 --- a/packages/lab/src/__tests__/__e2e__/tabs-next/TabstripNext.cy.tsx +++ b/packages/lab/src/__tests__/__e2e__/tabs-next/TabstripNext.cy.tsx @@ -1,8 +1,9 @@ import { StackLayout } from "@salt-ds/core"; import * as tabstripStories from "@stories/tabstrip-next/tabstrip-next.stories"; import { composeStories } from "@storybook/react"; +import { DefaultLeftAligned } from "@stories/tabstrip-next/tabstrip-next.stories"; -const { DefaultLeftAligned: DefaultTabstrip, ControlledTabstrip } = +const { DefaultLeftAligned: DefaultTabstrip, LotsOfTabsTabstrip } = composeStories(tabstripStories); describe("Given a Tabstrip", () => { @@ -76,11 +77,11 @@ describe("Given a Tabstrip", () => { }); describe("WHEN size is not the full width of it's parent", () => { it("THEN should not overflow if it has enough space", () => { - cy.mount(); + cy.mount(); cy.findByRole("tab", { name: /more tabs/ }).should("not.exist"); }); it("THEN should overflow if it there is not enough space", () => { - cy.mount(); + cy.mount(); cy.findByRole("tab", { name: /more tabs/ }).should("exist"); }); }); diff --git a/packages/lab/src/tabs-next/TabNext.tsx b/packages/lab/src/tabs-next/TabNext.tsx index 97c033c404d..5aea36b8e3b 100644 --- a/packages/lab/src/tabs-next/TabNext.tsx +++ b/packages/lab/src/tabs-next/TabNext.tsx @@ -57,7 +57,9 @@ export const TabNext = forwardRef( }; useEffect(() => { - return registerItem({ id: value, element: tabRef.current }); + if (value && tabRef.current) { + return registerItem({ id: value, element: tabRef.current }); + } }, [value]); const closeButtonId = useId(); diff --git a/packages/lab/src/tabs-next/TabOverflowList.css b/packages/lab/src/tabs-next/TabOverflowList.css index a3458ca4dc6..0c960a24d17 100644 --- a/packages/lab/src/tabs-next/TabOverflowList.css +++ b/packages/lab/src/tabs-next/TabOverflowList.css @@ -44,6 +44,10 @@ justify-content: start; } +.saltTabOverflow-list [role="tab"] .saltTabNext-label { + justify-content: start; +} + .saltTabOverflow-list [role="tab"]::after { display: none; } diff --git a/packages/lab/src/tabs-next/TabOverflowList.tsx b/packages/lab/src/tabs-next/TabOverflowList.tsx index 1676fdea611..b9399c0eaac 100644 --- a/packages/lab/src/tabs-next/TabOverflowList.tsx +++ b/packages/lab/src/tabs-next/TabOverflowList.tsx @@ -6,60 +6,72 @@ import { Children, type ComponentPropsWithoutRef, type ReactNode, + type Ref, + forwardRef, useState, } from "react"; import tabOverflowListCss from "./TabOverflowList.css"; interface TabOverflowListProps extends ComponentPropsWithoutRef<"button"> { + buttonRef?: Ref; children?: ReactNode; + isMeasuring?: boolean; } const withBaseName = makePrefixer("saltTabOverflow"); -export function TabOverflowList(props: TabOverflowListProps) { - const { children, ...rest } = props; - const [hidden, setHidden] = useState(true); +export const TabOverflowList = forwardRef( + function TabOverflowList(props, ref) { + const { buttonRef, children, isMeasuring, ...rest } = props; + const [hidden, setHidden] = useState(true); - const targetWindow = useWindow(); - useComponentCssInjection({ - testId: "salt-tabstrip-next-overflow", - css: tabOverflowListCss, - window: targetWindow, - }); + const targetWindow = useWindow(); + useComponentCssInjection({ + testId: "salt-tabstrip-next-overflow", + css: tabOverflowListCss, + window: targetWindow, + }); - const handleClick = () => { - setHidden((old) => !old); - }; + const handleClick = () => { + setHidden((old) => !old); + }; - const handleFocus = () => { - setHidden(false); - }; + const handleFocus = () => { + setHidden(false); + }; - const handleBlur = () => { - setHidden(true); - }; + const handleBlur = () => { + setHidden(true); + }; - const handleListClick = () => { - setHidden(true); - }; + const handleListClick = () => { + setHidden(true); + }; - if (Children.count(children) === 0) return null; + if (Children.count(children) === 0 && !isMeasuring) return null; - return ( -
- - {/* biome-ignore lint/a11y/useKeyWithClickEvents: */} -
-
{children}
+ return ( +
+ + {/* biome-ignore lint/a11y/useKeyWithClickEvents: */} +
+
{children}
+
-
- ); -} + ); + }, +); diff --git a/packages/lab/src/tabs-next/TabstripNext.tsx b/packages/lab/src/tabs-next/TabstripNext.tsx index fd2eb056768..011d5787da1 100644 --- a/packages/lab/src/tabs-next/TabstripNext.tsx +++ b/packages/lab/src/tabs-next/TabstripNext.tsx @@ -62,15 +62,22 @@ export const TabstripNext = forwardRef( const tabstripRef = useRef(null); const handleRef = useForkRef(tabstripRef, ref); - const { registerItem, setSelected, selected, handleKeyDown } = useTabstrip({ - defaultSelected: defaultValue, - selected: value, - }); + const addButtonRef = useRef(null); + const overflowButtonRef = useRef(null); + + const { registerItem, setSelected, selected, handleKeyDown, items } = + useTabstrip({ + defaultSelected: defaultValue, + selected: value, + }); - const [visible, hidden] = useOverflow({ + const [visible, hidden, isMeasuring] = useOverflow({ container: tabstripRef, + tabs: items, children, selected, + addButton: addButtonRef, + overflowButton: overflowButtonRef, }); const [focusInside, setFocusInside] = useState(false); @@ -119,11 +126,21 @@ export const TabstripNext = forwardRef( > {visible} {onAdd && ( - )} - {hidden} + + {hidden} +
); diff --git a/packages/lab/src/tabs-next/useOverflow.tsx b/packages/lab/src/tabs-next/useOverflow.tsx index 7bdd97ff348..c14afddbe0f 100644 --- a/packages/lab/src/tabs-next/useOverflow.tsx +++ b/packages/lab/src/tabs-next/useOverflow.tsx @@ -1,84 +1,183 @@ -import { useIsomorphicLayoutEffect } from "@salt-ds/core"; +import { + useIsomorphicLayoutEffect, + useResizeObserver, + useValueEffect, +} from "@salt-ds/core"; import { useWindow } from "@salt-ds/window"; import { Children, type ReactNode, type RefObject, + useCallback, useMemo, - useState, } from "react"; +import type { Item } from "./useTabstrip"; -export function useOverflow({ - container, - selected, - children, -}: { +interface UseOverflowProps { container: RefObject; selected?: string; children: ReactNode; + tabs: Item[]; + addButton: RefObject; + overflowButton: RefObject; +} + +function getAvailableWidth({ + container, + targetWindow, + addButton, +}: Pick & { + targetWindow: Window; }) { - const [visible, setVisible] = useState([]); - const [hidden, setHidden] = useState([]); - const [count, setCount] = useState(Number.POSITIVE_INFINITY); + if (!container.current || !targetWindow) return 0; + + const containerStyles = targetWindow.getComputedStyle(container.current); + const gap = Number.parseInt(containerStyles.gap || "0"); + + const containerPadding = + Number.parseInt(containerStyles.paddingLeft || "0") + + Number.parseInt(containerStyles.paddingRight || "0"); + + const addButtonWidth = addButton.current + ? addButton.current.offsetWidth + gap + : 0; + + return container.current.clientWidth - containerPadding - addButtonWidth; +} + +export function useOverflow({ + tabs, + container, + addButton, + overflowButton, + children, + selected, +}: UseOverflowProps) { + const [{ visibleCount, isMeasuring }, setVisibleItems] = useValueEffect({ + visibleCount: tabs.length, + isMeasuring: false, + }); const targetWindow = useWindow(); - const childArray = useMemo(() => Children.toArray(children), [children]); + const updateOverflow = useCallback(() => { + const computeVisible = (visibleCount: number) => { + if (container.current && targetWindow) { + const items = Array.from( + container.current.querySelectorAll("[role=tab]"), + ); + const selectedTab = container.current.querySelector( + "[role=tab][aria-selected=true]", + ); - useIsomorphicLayoutEffect(() => { - if (!container.current) return; - - const observer = new ResizeObserver(() => { - if (!container.current) return; - - const availableWidth = container.current.clientWidth; - const gap = Number.parseInt( - targetWindow?.getComputedStyle(container.current)?.gap || "0", - ); - - const elements = - container.current.querySelectorAll("[role=tab]"); - - let i = 0; - let currentWidth = 0; - while (i < elements.length) { - const element = elements[i]; - if (element) { - currentWidth += element.offsetWidth + gap; - if (currentWidth > availableWidth) { - break; + let maxWidth = getAvailableWidth({ + container, + targetWindow, + addButton, + }); + + const containerStyles = targetWindow.getComputedStyle( + container.current, + ); + const gap = Number.parseInt(containerStyles.gap || "0"); + + let currentWidth = 0; + let newVisibleCount = 0; + + const visible = []; + + while (newVisibleCount < items.length) { + const element = items[newVisibleCount]; + if (element) { + if (currentWidth + element.offsetWidth + gap > maxWidth) { + break; + } + currentWidth += element.offsetWidth + gap; + visible.push(element); } + newVisibleCount++; } - i++; - } - setCount(Math.max(1, i)); - }); - observer.observe(container.current); + if (newVisibleCount >= items.length) { + return newVisibleCount; + } + + const overflowButtonWidth = overflowButton.current + ? overflowButton.current.offsetWidth + gap + : 0; + maxWidth -= overflowButtonWidth; + + while (currentWidth > maxWidth) { + const removed = visible.pop(); + if (!removed) break; + currentWidth -= removed.offsetWidth + gap; + newVisibleCount--; + } + + if (selectedTab && !visible.includes(selectedTab)) { + while (currentWidth + selectedTab.offsetWidth + gap > maxWidth) { + const removed = visible.pop(); + if (!removed) break; + currentWidth -= removed.offsetWidth + gap; + newVisibleCount--; + } + } - return () => { - observer.disconnect(); + return Math.max(1, newVisibleCount); + } + return visibleCount; }; - }, []); - useIsomorphicLayoutEffect(() => { - const nextVisible = childArray.slice(0, count || 1); - const nextHidden = childArray.slice(count || 1); - - const hiddenSelectedIndex = nextHidden.findIndex( - (child) => child?.props?.value === selected, - ); - - if (selected && hiddenSelectedIndex !== -1) { - const lastVisibleId = nextVisible.pop(); - if (lastVisibleId) { - const removed = nextHidden.splice(hiddenSelectedIndex, 1); - nextHidden.unshift(lastVisibleId); - nextVisible.push(removed[0]); + setVisibleItems(function* () { + // Show all + yield { + visibleCount: tabs.length, + isMeasuring: true, + }; + + // Measure the visible count + const newVisibleCount = computeVisible(tabs.length); + const isMeasuring = newVisibleCount < tabs.length && newVisibleCount > 0; + yield { + visibleCount: newVisibleCount, + isMeasuring, + }; + + // ensure the visible count is correct + if (isMeasuring) { + yield { + visibleCount: computeVisible(newVisibleCount), + isMeasuring: false, + }; } - } - setVisible(nextVisible); - setHidden(nextHidden); - }, [childArray, count, selected]); + }); + }, [setVisibleItems, targetWindow]); + + useIsomorphicLayoutEffect(() => { + updateOverflow(); + }, [updateOverflow, selected, tabs.length]); + + useResizeObserver({ + ref: container, + onResize: updateOverflow, + }); + + const childArray = useMemo(() => Children.toArray(children), [children]); + const visible = childArray.slice(0, visibleCount || 1); + const hidden = childArray.slice(visibleCount || 1); + + const hiddenSelectedIndex = hidden.findIndex( + // @ts-ignore + (child) => child?.props?.value === selected, + ); + + if (selected && hiddenSelectedIndex !== -1) { + const removed = hidden.splice(hiddenSelectedIndex, 1); + visible.push(removed[0]); + } + + if (isMeasuring) { + return [childArray, [], isMeasuring] as const; + } - return [visible, hidden] as const; + return [visible, hidden, isMeasuring] as const; } diff --git a/packages/lab/src/tabs-next/useTabstrip.tsx b/packages/lab/src/tabs-next/useTabstrip.tsx index f1dce570aae..d7e1a799da5 100644 --- a/packages/lab/src/tabs-next/useTabstrip.tsx +++ b/packages/lab/src/tabs-next/useTabstrip.tsx @@ -7,7 +7,7 @@ import { useState, } from "react"; -interface Item { +export interface Item { id: string; element?: HTMLElement | null; } @@ -47,16 +47,12 @@ function useCollection() { const [items, setItems] = useState([]); const itemMap = useRef>(new Map()); - useEffect(() => { - console.log(items, itemMap.current); - }, [items]); - const registerItem = (item: Item) => { setItems((old) => { - const index = items.findIndex(({ id }) => id === item.id); const newItems = old.slice(); + const index = newItems.findIndex(({ id }) => id === item.id); if (index !== -1) { - const newItem = { ...items[index], ...item }; + const newItem = { ...newItems[index], ...item }; newItems[index] = newItem; itemMap.current.set(item.id, newItem); } else { @@ -112,7 +108,7 @@ export function useTabstrip({ selected?: string; defaultSelected?: string; }) { - const { registerItem, item, getNext, getPrevious, getFirst, getLast } = + const { registerItem, item, getNext, getPrevious, getFirst, getLast, items } = useCollection(); const [selected, setSelectedState] = useControlled({ controlled: selectedProp, @@ -170,6 +166,7 @@ export function useTabstrip({ return { registerItem, + items, setSelected, setActive, selected, diff --git a/packages/lab/stories/tabstrip-next/tabstrip-next.qa.stories.tsx b/packages/lab/stories/tabstrip-next/tabstrip-next.qa.stories.tsx index def5c82a20b..0d6b3d0a22b 100644 --- a/packages/lab/stories/tabstrip-next/tabstrip-next.qa.stories.tsx +++ b/packages/lab/stories/tabstrip-next/tabstrip-next.qa.stories.tsx @@ -45,7 +45,7 @@ export const LotsOfTabsTabstrip: TabstripStory = ({ { + onChange={(_, value) => { setValue(value); }} > @@ -59,7 +59,7 @@ export const LotsOfTabsTabstrip: TabstripStory = ({ {...tabstripProps} value={value} variant="inline" - onChange={(_, { value }) => { + onChange={(_, value) => { setValue(value); }} > diff --git a/packages/lab/stories/tabstrip-next/tabstrip-next.stories.tsx b/packages/lab/stories/tabstrip-next/tabstrip-next.stories.tsx index 219273468a5..8a31f8ea120 100644 --- a/packages/lab/stories/tabstrip-next/tabstrip-next.stories.tsx +++ b/packages/lab/stories/tabstrip-next/tabstrip-next.stories.tsx @@ -24,7 +24,7 @@ export default { type TabstripStory = StoryFn< TabstripNextProps & { width?: number; - tabs: string[]; + tabs?: string[]; } >; @@ -38,7 +38,7 @@ const tabToIcon: Record = { const TabstripTemplate: TabstripStory = ({ width = 600, - tabs, + tabs = [], ...tabstripProps }) => { return ( @@ -61,7 +61,7 @@ DefaultLeftAligned.args = { export const MainTabBleedingIntoPrimaryBackground: TabstripStory = ({ width = 600, - tabs, + tabs = [], ...tabstripProps }) => { return ( @@ -93,7 +93,7 @@ MainTabBleedingIntoPrimaryBackground.args = { export const MainTabBleedingIntoSecondaryBackground: TabstripStory = ({ width = 600, - tabs, + tabs = [], ...tabstripProps }) => { return ( @@ -132,7 +132,7 @@ Inline.args = { export const InlineWithSecondaryBackground: TabstripStory = ({ width = 600, - tabs, + tabs = [], ...tabstripProps }) => { return ( @@ -170,7 +170,7 @@ RightAligned.args = { export const WithIcon: TabstripStory = ({ width = 600, - tabs, + tabs = [], ...tabstripProps }) => { return ( @@ -199,7 +199,7 @@ WithIcon.args = { export const WithBadge: TabstripStory = ({ width = 600, - tabs, + tabs = [], ...tabstripProps }) => { return (