From c38343076dda0bedc04cd03a2c32cc4aa9fcceac Mon Sep 17 00:00:00 2001 From: Josh Wooding <12938082+joshwooding@users.noreply.github.com> Date: Wed, 31 Jul 2024 12:25:26 +0100 Subject: [PATCH] WIP --- .storybook/preview.tsx | 1 - packages/lab/src/tabs-next/TabNext.css | 17 +- packages/lab/src/tabs-next/TabNext.tsx | 155 +++++++++++------- packages/lab/src/tabs-next/TabNextContext.tsx | 28 ++-- .../lab/src/tabs-next/TabOverflowList.css | 4 - .../lab/src/tabs-next/TabOverflowList.tsx | 22 ++- packages/lab/src/tabs-next/TabstripNext.css | 18 +- packages/lab/src/tabs-next/TabstripNext.tsx | 60 ++++--- packages/lab/src/tabs-next/useOverflow.tsx | 67 +++++--- packages/lab/src/tabs-next/useTabstrip.tsx | 38 ++++- .../tabstrip-next/tabstrip-next.stories.tsx | 94 +++++++---- 11 files changed, 298 insertions(+), 206 deletions(-) diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 74e8d029941..61b3ff9befd 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -117,7 +117,6 @@ export const argTypes: ArgTypes = { }; export const parameters: Parameters = { - actions: { argTypesRegex: "^on[A-Z].*" }, layout: "centered", // Show props description in Controls panel controls: { expanded: true, sort: "requiredFirst" }, diff --git a/packages/lab/src/tabs-next/TabNext.css b/packages/lab/src/tabs-next/TabNext.css index 334acc9d794..a54ddb596ea 100644 --- a/packages/lab/src/tabs-next/TabNext.css +++ b/packages/lab/src/tabs-next/TabNext.css @@ -1,10 +1,10 @@ /* Class applied to root Tab element */ .saltTabNext { + display: inline-flex; align-items: center; justify-content: center; appearance: none; -webkit-appearance: none; - display: inline-flex; background: var(--salt-navigable-primary-background); gap: var(--salt-spacing-100); border: none; @@ -29,6 +29,14 @@ font-size: var(--salt-text-fontSize); } +.saltTabNext-label { + display: flex; + gap: var(--salt-spacing-100); + align-items: center; + justify-content: center; + flex: 1; +} + .saltTabNext::after { content: ""; position: absolute; @@ -50,8 +58,8 @@ background: var(--salt-navigable-indicator-hover); } -.saltTabNext:disabled:hover::after, -.saltTabNext:disabled:focus-visible::after { +.saltTabNext[aria-disabled="true"]:hover::after, +.saltTabNext[aria-disabled="true"]:focus-visible::after { background: none; } @@ -69,8 +77,7 @@ background: var(--salt-navigable-indicator-active); } -.saltTabNext:disabled { +.saltTabNext[aria-disabled="true"] { cursor: var(--salt-navigable-cursor-disabled); color: var(--salt-content-primary-foreground-disabled); } - diff --git a/packages/lab/src/tabs-next/TabNext.tsx b/packages/lab/src/tabs-next/TabNext.tsx index 340a26cefc7..97c033c404d 100644 --- a/packages/lab/src/tabs-next/TabNext.tsx +++ b/packages/lab/src/tabs-next/TabNext.tsx @@ -1,81 +1,114 @@ -import { makePrefixer, useForkRef, useId } from "@salt-ds/core"; +import { Button, makePrefixer, useForkRef } from "@salt-ds/core"; import { useComponentCssInjection } from "@salt-ds/styles"; import { useWindow } from "@salt-ds/window"; +import clsx from "clsx"; import { + type ComponentPropsWithoutRef, + type MouseEvent, + type ReactElement, forwardRef, - ReactElement, - ComponentPropsWithoutRef, useEffect, + useId, useRef, + useState, } from "react"; -import clsx from "clsx"; -import { useTabs } from "./TabNextContext"; +import { CloseIcon } from "@salt-ds/icons"; import tabCss from "./TabNext.css"; +import { useTabs } from "./TabNextContext"; const withBaseName = makePrefixer("saltTabNext"); -export interface TabNextProps extends ComponentPropsWithoutRef<"button"> { +export interface TabNextProps extends ComponentPropsWithoutRef<"div"> { /* Value prop is mandatory and must be unique in order for overflow to work. */ + disabled?: boolean; value: string; + onClose?: () => void; } -export const TabNext = forwardRef(function Tab( - props, - ref -): ReactElement { - const { - children, - className, - disabled: disabledProp, - onClick, - onFocus, - value, - ...rest - } = props; - const targetWindow = useWindow(); - useComponentCssInjection({ - testId: "salt-tab-next", - css: tabCss, - window: targetWindow, - }); - const { - disabled: tabstripDisabled, - registerItem, - variant, - setSelected, - selected, - active, - } = useTabs(); - const disabled = tabstripDisabled || disabledProp; +export const TabNext = forwardRef( + function Tab(props, ref): ReactElement { + const { + children, + className, + disabled: disabledProp, + onClick, + onClose, + onFocus, + value, + ...rest + } = props; + const targetWindow = useWindow(); + useComponentCssInjection({ + testId: "salt-tab-next", + css: tabCss, + window: targetWindow, + }); + const { registerItem, variant, setSelected, selected, focusInside } = + useTabs(); + const disabled = disabledProp; + + const tabRef = useRef(null); + const handleRef = useForkRef(ref, tabRef); + + const handleClick = (event: MouseEvent) => { + onClick?.(event); + setSelected(value); + }; + + useEffect(() => { + return registerItem({ id: value, element: tabRef.current }); + }, [value]); + + const closeButtonId = useId(); + const labelId = useId(); + + const [focused, setFocused] = useState(false); - const tabRef = useRef(null); - const handleRef = useForkRef(ref, tabRef); + const handleFocus = () => { + setFocused(true); + }; - const handleClick = (event: MouseEvent) => { - onClick?.(event); - setSelected(value); - }; + const handleBlur = () => { + setFocused(false); + }; - useEffect(() => { - return registerItem({ id: value, element: tabRef.current }); - }, [value]); + const handleClose = (event: MouseEvent) => { + onClose?.(); + event.stopPropagation(); + }; - return ( - - ); -}); + return ( +
+ + {children} + + {onClose ? ( + + ) : null} +
+ ); + }, +); diff --git a/packages/lab/src/tabs-next/TabNextContext.tsx b/packages/lab/src/tabs-next/TabNextContext.tsx index 8e8fa14ec8e..a7de59dd6c8 100644 --- a/packages/lab/src/tabs-next/TabNextContext.tsx +++ b/packages/lab/src/tabs-next/TabNextContext.tsx @@ -2,32 +2,24 @@ import { createContext } from "@salt-ds/core"; import { type ReactNode, type SyntheticEvent, useContext } from "react"; interface TabValue { - value: string; - label: ReactNode; + id: string; + element: HTMLElement; } export interface TabsContextValue { - activeColor: "primary" | "secondary"; - disabled?: boolean; - activate: (event: SyntheticEvent) => void; - isActive: (id: string) => boolean; - setFocusable: (id: string) => void; - isFocusable: (id: string) => boolean; - registerTab: (tab: TabValue) => void; - unregisterTab: (id: string) => void; + registerItem: (tab: TabValue) => void; variant: "main" | "inline"; + setSelected: (id: string) => void; + selected?: string; + focusInside: boolean; } export const TabsContext = createContext("TabsContext", { - activeColor: "primary", - disabled: false, - activate: () => undefined, - isActive: () => false, - setFocusable: () => undefined, - isFocusable: () => false, - registerTab: () => undefined, - unregisterTab: () => undefined, + registerItem: () => undefined, variant: "main", + setSelected: () => undefined, + selected: undefined, + focusInside: false, }); export function useTabs() { diff --git a/packages/lab/src/tabs-next/TabOverflowList.css b/packages/lab/src/tabs-next/TabOverflowList.css index 2b1a45905e5..a3458ca4dc6 100644 --- a/packages/lab/src/tabs-next/TabOverflowList.css +++ b/packages/lab/src/tabs-next/TabOverflowList.css @@ -1,4 +1,3 @@ - .saltTabOverflow { position: relative; } @@ -27,7 +26,6 @@ pointer-events: none; } - .saltTabOverflow-list [role="tab"] { color: var(--salt-content-primary-foreground); background: var(--salt-selectable-background); @@ -50,13 +48,11 @@ display: none; } - .saltTabOverflow-list [role="tab"][aria-disabled="true"] { color: var(--salt-content-primary-foreground-disabled); cursor: var(--salt-selectable-cursor-disabled); } - .saltTabOverflow-list [role="tab"]:focus-visible { outline: var(--salt-focused-outline); outline-offset: calc(var(--salt-size-border) * -2); diff --git a/packages/lab/src/tabs-next/TabOverflowList.tsx b/packages/lab/src/tabs-next/TabOverflowList.tsx index 070ae2a3bf8..1676fdea611 100644 --- a/packages/lab/src/tabs-next/TabOverflowList.tsx +++ b/packages/lab/src/tabs-next/TabOverflowList.tsx @@ -1,16 +1,14 @@ +import { Button, makePrefixer } from "@salt-ds/core"; +import { OverflowMenuIcon } from "@salt-ds/icons"; +import { useComponentCssInjection } from "@salt-ds/styles"; +import { useWindow } from "@salt-ds/window"; import { - forwardRef, Children, - ReactNode, + type ComponentPropsWithoutRef, + type ReactNode, useState, - ComponentPropsWithoutRef, } from "react"; -import { Button, makePrefixer } from "@salt-ds/core"; -import { OverflowMenuIcon } from "@salt-ds/icons"; -import { clsx } from "clsx"; import tabOverflowListCss from "./TabOverflowList.css"; -import { useWindow } from "@salt-ds/window"; -import { useComponentCssInjection } from "@salt-ds/styles"; interface TabOverflowListProps extends ComponentPropsWithoutRef<"button"> { children?: ReactNode; @@ -24,7 +22,7 @@ export function TabOverflowList(props: TabOverflowListProps) { const targetWindow = useWindow(); useComponentCssInjection({ - testId: "salt-tabstrip-nex-overflow", + testId: "salt-tabstrip-next-overflow", css: tabOverflowListCss, window: targetWindow, }); @@ -41,6 +39,10 @@ export function TabOverflowList(props: TabOverflowListProps) { setHidden(true); }; + const handleListClick = () => { + setHidden(true); + }; + if (Children.count(children) === 0) return null; return ( @@ -48,11 +50,13 @@ export function TabOverflowList(props: TabOverflowListProps) { + {/* biome-ignore lint/a11y/useKeyWithClickEvents: */}
{children}
diff --git a/packages/lab/src/tabs-next/TabstripNext.css b/packages/lab/src/tabs-next/TabstripNext.css index d9202e99517..a86e7ae1f2f 100644 --- a/packages/lab/src/tabs-next/TabstripNext.css +++ b/packages/lab/src/tabs-next/TabstripNext.css @@ -2,23 +2,14 @@ .saltTabstripNext { display: flex; flex-wrap: nowrap; - justify-content: var(--tabstripNext-justifyContent); + justify-content: var(--tabstripNext-justifyContent, flex-start); align-items: center; position: relative; background: transparent; width: 100%; - font-family: var(--salt-text-fontFamily); - font-size: var(--salt-text-fontSize); - font-weight: var(--salt-text-fontWeight); - line-height: var(--salt-text-lineHeight); + min-height: calc(var(--salt-size-base) + var(--salt-spacing-100)); gap: var(--salt-spacing-100); } -.saltTabstripNext-container { - display: block; - width: 100%; - height: fit-content; - position: relative; -} .saltTabstripNext-main { padding-left: var(--salt-spacing-300); @@ -34,11 +25,6 @@ border-bottom: var(--salt-size-border) var(--salt-separable-borderStyle) var(--salt-separable-secondary-borderColor); } -.saltTabstripNext .saltTabNext-wrapper:not(:last-child) { - padding-right: var(--salt-spacing-100); - box-sizing: border-box; -} - .saltTabstripNext-activeColorPrimary { --saltTabstripNext-activeColor: var(--salt-container-primary-background); } diff --git a/packages/lab/src/tabs-next/TabstripNext.tsx b/packages/lab/src/tabs-next/TabstripNext.tsx index 9b51bb10511..fd2eb056768 100644 --- a/packages/lab/src/tabs-next/TabstripNext.tsx +++ b/packages/lab/src/tabs-next/TabstripNext.tsx @@ -1,21 +1,22 @@ -import { capitalize, makePrefixer, useForkRef } from "@salt-ds/core"; +import { Button, capitalize, makePrefixer, useForkRef } from "@salt-ds/core"; +import { AddIcon } from "@salt-ds/icons"; +import { useComponentCssInjection } from "@salt-ds/styles"; +import { useWindow } from "@salt-ds/window"; import clsx from "clsx"; import { - ComponentPropsWithoutRef, + type ComponentPropsWithoutRef, + type SyntheticEvent, forwardRef, - ReactNode, - SyntheticEvent, - KeyboardEvent, useMemo, useRef, + useState, } from "react"; -import { useComponentCssInjection } from "@salt-ds/styles"; -import { useWindow } from "@salt-ds/window"; -import tabstripCss from "./TabstripNext.css"; + import { TabsContext } from "./TabNextContext"; -import { useTabstrip } from "./useTabstrip"; import { TabOverflowList } from "./TabOverflowList"; +import tabstripCss from "./TabstripNext.css"; import { useOverflow } from "./useOverflow"; +import { useTabstrip } from "./useTabstrip"; const withBaseName = makePrefixer("saltTabstripNext"); @@ -25,7 +26,7 @@ export interface TabstripNextProps activeColor?: "primary" | "secondary"; /* Tabs alignment. Defaults to "left" */ align?: "left" | "center" | "right"; - /* Value for the uncontrolled version. */ + /* Value for the controlled version. */ value?: string; /* Callback for the controlled version. */ onChange?: (event: SyntheticEvent, value: string) => void; @@ -33,6 +34,7 @@ export interface TabstripNextProps defaultValue?: string; /* The Tabs variant */ variant?: "main" | "inline"; + onAdd?: () => void; } export const TabstripNext = forwardRef( @@ -44,6 +46,7 @@ export const TabstripNext = forwardRef( className, value, defaultValue, + onAdd, onChange, onKeyDown, style, @@ -59,34 +62,36 @@ export const TabstripNext = forwardRef( const tabstripRef = useRef(null); const handleRef = useForkRef(tabstripRef, ref); - const { - registerItem, - setActive, - setSelected, - selected, - active, - handleKeyDown, - } = useTabstrip({ - container: tabstripRef.current, + const { registerItem, setSelected, selected, handleKeyDown } = useTabstrip({ defaultSelected: defaultValue, selected: value, }); const [visible, hidden] = useOverflow({ - container: tabstripRef.current, + container: tabstripRef, children, selected, }); + const [focusInside, setFocusInside] = useState(false); + + const handleFocus = () => { + setFocusInside(true); + }; + + const handleBlur = () => { + setFocusInside(false); + }; + const contextValue = useMemo( () => ({ registerItem, variant, setSelected, selected, - active, + focusInside, }), - [variant, setSelected, selected, active, registerItem] + [variant, setSelected, selected, registerItem, focusInside], ); const tabstripStyle = { @@ -103,17 +108,24 @@ export const TabstripNext = forwardRef( withBaseName(variant), withBaseName("horizontal"), withBaseName(`activeColor${capitalize(activeColor)}`), - className + className, )} style={tabstripStyle} ref={handleRef} onKeyDown={handleKeyDown} + onFocus={handleFocus} + onBlur={handleBlur} {...rest} > {visible} + {onAdd && ( + + )} {hidden} ); - } + }, ); diff --git a/packages/lab/src/tabs-next/useOverflow.tsx b/packages/lab/src/tabs-next/useOverflow.tsx index 1c78ea2b7ec..7bdd97ff348 100644 --- a/packages/lab/src/tabs-next/useOverflow.tsx +++ b/packages/lab/src/tabs-next/useOverflow.tsx @@ -1,25 +1,42 @@ -import { Children, useMemo, useState } from "react"; import { useIsomorphicLayoutEffect } from "@salt-ds/core"; import { useWindow } from "@salt-ds/window"; +import { + Children, + type ReactNode, + type RefObject, + useMemo, + useState, +} from "react"; -export function useOverflow({ container, selected, children }) { - const [visible, setVisible] = useState([]); - const [hidden, setHidden] = useState([]); - const [count, setCount] = useState(Infinity); +export function useOverflow({ + container, + selected, + children, +}: { + container: RefObject; + selected?: string; + children: ReactNode; +}) { + const [visible, setVisible] = useState([]); + const [hidden, setHidden] = useState([]); + const [count, setCount] = useState(Number.POSITIVE_INFINITY); const targetWindow = useWindow(); const childArray = useMemo(() => Children.toArray(children), [children]); useIsomorphicLayoutEffect(() => { - if (!container) return; + if (!container.current) return; const observer = new ResizeObserver(() => { - const availableWidth = container.offsetWidth; - const gap = parseInt( - targetWindow?.getComputedStyle(container)?.gap || "0" + if (!container.current) return; + + const availableWidth = container.current.clientWidth; + const gap = Number.parseInt( + targetWindow?.getComputedStyle(container.current)?.gap || "0", ); - const elements = container.querySelectorAll("[role=tab]"); + const elements = + container.current.querySelectorAll("[role=tab]"); let i = 0; let currentWidth = 0; @@ -36,28 +53,32 @@ export function useOverflow({ container, selected, children }) { setCount(Math.max(1, i)); }); - observer.observe(container); + observer.observe(container.current); return () => { observer.disconnect(); }; - }, [container]); + }, []); useIsomorphicLayoutEffect(() => { const nextVisible = childArray.slice(0, count || 1); - let nextHidden = childArray.slice(count || 1); - // if (selected && nextHidden.includes(selectedId)) { - // const lastVisibleId = nextVisible.pop(); - // if (lastVisibleId) { - // nextHidden = nextHidden.filter((id) => id !== selectedId); - // nextHidden.unshift(lastVisibleId); - // } - // nextVisible.push(selectedId); - // move(selectedId); - // } + 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]); + } + } setVisible(nextVisible); setHidden(nextHidden); - }, [childArray, count]); + }, [childArray, count, selected]); return [visible, hidden] as const; } diff --git a/packages/lab/src/tabs-next/useTabstrip.tsx b/packages/lab/src/tabs-next/useTabstrip.tsx index 6edda349386..f1dce570aae 100644 --- a/packages/lab/src/tabs-next/useTabstrip.tsx +++ b/packages/lab/src/tabs-next/useTabstrip.tsx @@ -1,5 +1,11 @@ -import { KeyboardEvent, useCallback, useEffect, useRef, useState } from "react"; -import { useControlled, useIsomorphicLayoutEffect } from "@salt-ds/core"; +import { useControlled } from "@salt-ds/core"; +import { + type KeyboardEvent, + useCallback, + useEffect, + useRef, + useState, +} from "react"; interface Item { id: string; @@ -37,10 +43,14 @@ function sortBasedOnDOMPosition(items: Item[]): Item[] { return items; } -function useCollection({ root }: { root: HTMLElement }) { +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); @@ -96,21 +106,21 @@ function useCollection({ root }: { root: HTMLElement }) { } export function useTabstrip({ - container, selected: selectedProp, defaultSelected, +}: { + selected?: string; + defaultSelected?: string; }) { const { registerItem, item, getNext, getPrevious, getFirst, getLast } = - useCollection({ - root: container, - }); + useCollection(); const [selected, setSelectedState] = useControlled({ controlled: selectedProp, default: defaultSelected, name: "TabstripNext", state: "selected", }); - const [active, setActive] = useState(selected); + const [active, setActive] = useState(selected); const movedRef = useRef(false); const handleKeyDown = (event: KeyboardEvent) => { @@ -134,7 +144,7 @@ export function useTabstrip({ } }; - const setSelected = useCallback((action) => { + const setSelected = useCallback((action: string) => { setSelectedState(action); setActive(action); }, []); @@ -148,6 +158,16 @@ export function useTabstrip({ } }, [active]); + useEffect(() => { + const itemElement = item(selected)?.element; + if (itemElement) { + requestAnimationFrame(() => { + itemElement.focus({ preventScroll: true }); + itemElement.scrollIntoView({ block: "nearest", inline: "nearest" }); + }); + } + }, [selected]); + return { registerItem, setSelected, diff --git a/packages/lab/stories/tabstrip-next/tabstrip-next.stories.tsx b/packages/lab/stories/tabstrip-next/tabstrip-next.stories.tsx index 7bdcee627bd..219273468a5 100644 --- a/packages/lab/stories/tabstrip-next/tabstrip-next.stories.tsx +++ b/packages/lab/stories/tabstrip-next/tabstrip-next.stories.tsx @@ -1,4 +1,4 @@ -import { Badge, Button, StackLayout } from "@salt-ds/core"; +import { Badge } from "@salt-ds/core"; import { BankCheckIcon, CreditCardIcon, @@ -6,7 +6,7 @@ import { LineChartIcon, ReceiptIcon, } from "@salt-ds/icons"; -import { TabstripNext, TabNext, type TabstripNextProps } from "@salt-ds/lab"; +import { TabNext, TabstripNext, type TabstripNextProps } from "@salt-ds/lab"; import type { StoryFn } from "@storybook/react"; import { type ComponentType, useState } from "react"; @@ -184,7 +184,7 @@ export const WithIcon: TabstripStory = ({ key={label} disabled={label === "Transactions"} > - {label} + {label} ); })} @@ -220,39 +220,6 @@ WithBadge.args = { tabs: ["Home", "Transactions", "Loans", "Checks", "Liquidity"], }; -export const ControlledTabstrip: TabstripStory = ({ - width = 600, - tabs, - ...tabstripProps -}) => { - const [value, setValue] = useState(tabs[0]); - - return ( -
- - - { - setValue(value); - }} - > - {tabs.map((label) => ( - - {label} - - ))} - - - -
- ); -}; -ControlledTabstrip.args = { - tabs: ["Home", "Transactions", "Loans", "Checks", "Liquidity"], -}; - export const LotsOfTabsTabstrip = TabstripTemplate.bind({}); LotsOfTabsTabstrip.args = { tabs: [ @@ -275,3 +242,58 @@ LotsOfTabsTabstrip.args = { "Screens", ], }; + +export const Dismissable: TabstripStory = ({ + width = 600, + ...tabstripProps +}) => { + const [tabs, setTabs] = useState([ + "Home", + "Transactions", + "Loans", + "Checks", + "Liquidity", + ]); + + return ( +
+ + {tabs.map((label) => ( + { + setTabs(tabs.filter((tab) => tab !== label)); + }} + > + {label} + {label === "Transactions" && } + + ))} + +
+ ); +}; + +export const AddTabs: TabstripStory = ({ width = 600, ...tabstripProps }) => { + const [tabs, setTabs] = useState(["Tab 1", "Tab 2", "Tab 3"]); + + return ( +
+ { + setTabs((old) => old.concat([`Tab ${old.length + 1}`])); + }} + {...tabstripProps} + > + {tabs.map((label) => ( + + {label} + {label === "Transactions" && } + + ))} + +
+ ); +};