UNPKG

@hakuna-matata-ui/tabs

Version:

Accessible Tabs component for React and Chakra UI

461 lines (399 loc) 12.5 kB
import { useClickable, UseClickableProps } from "@hakuna-matata-ui/clickable" import { createDescendantContext } from "@hakuna-matata-ui/descendant" import { useControllableState, useId, useSafeLayoutEffect, } from "@hakuna-matata-ui/hooks" import { createContext, EventKeyMap, getValidChildren, mergeRefs, } from "@hakuna-matata-ui/react-utils" import { callAllHandlers, determineLazyBehavior, Dict, focus, isUndefined, LazyBehavior, normalizeEventKey, } from "@hakuna-matata-ui/utils" import * as React from "react" /* ------------------------------------------------------------------------------------------------- * Create context to track descendants and their indices * -----------------------------------------------------------------------------------------------*/ export const [ TabsDescendantsProvider, useTabsDescendantsContext, useTabsDescendants, useTabsDescendant, ] = createDescendantContext<HTMLButtonElement>() /* ------------------------------------------------------------------------------------------------- * useTabs - The root react hook that manages all tab items * -----------------------------------------------------------------------------------------------*/ export interface UseTabsProps { /** * The orientation of the tab list. */ orientation?: "vertical" | "horizontal" /** * If `true`, the tabs will be manually activated and * display its panel by pressing Space or Enter. * * If `false`, the tabs will be automatically activated * and their panel is displayed when they receive focus. */ isManual?: boolean /** * Callback when the index (controlled or un-controlled) changes. */ onChange?: (index: number) => void /** * The index of the selected tab (in controlled mode) */ index?: number /** * The initial index of the selected tab (in uncontrolled mode) */ defaultIndex?: number /** * The id of the tab */ id?: string /** * Performance 🚀: * If `true`, rendering of the tab panel's will be deferred until it is selected. */ isLazy?: boolean /** * Performance 🚀: * The lazy behavior of tab panels' content when not active. * Only works when `isLazy={true}` * * - "unmount": The content of inactive tab panels are always unmounted. * - "keepMounted": The content of inactive tab panels is initially unmounted, * but stays mounted when selected. * * @default "unmount" */ lazyBehavior?: LazyBehavior /** * The writing mode direction. * * - When in RTL, the left and right navigation is flipped */ direction?: "rtl" | "ltr" } /** * Tabs hooks that provides all the states, and accessibility * helpers to keep all things working properly. * * Its returned object will be passed unto a Context Provider * so all child components can read from it. * There is no document link yet * @see Docs https://chakra-ui.com/docs/components/useTabs */ export function useTabs(props: UseTabsProps) { const { defaultIndex, onChange, index, isManual, isLazy, lazyBehavior = "unmount", orientation = "horizontal", direction = "ltr", ...htmlProps } = props /** * We use this to keep track of the index of the focused tab. * * Tabs can be automatically activated, this means selection follows focus. * When we navigate with the arrow keys, we move focus and selection to next/prev tab * * Tabs can also be manually activated, this means selection does not follow focus. * When we navigate with the arrow keys, we only move focus NOT selection. The user * will need not manually activate the tab using `Enter` or `Space`. * * This is why we need to keep track of the `focusedIndex` and `selectedIndex` */ const [focusedIndex, setFocusedIndex] = React.useState(defaultIndex ?? 0) const [selectedIndex, setSelectedIndex] = useControllableState({ defaultValue: defaultIndex ?? 0, value: index, onChange, }) /** * Sync focused `index` with controlled `selectedIndex` (which is the `props.index`) */ React.useEffect(() => { if (index != null) { setFocusedIndex(index) } }, [index]) /** * Think of `useDescendants` as a register for the tab nodes. */ const descendants = useTabsDescendants() /** * Generate a unique id or use user-provided id for the tabs widget */ const id = useId(props.id, `tabs`) return { id, selectedIndex, focusedIndex, setSelectedIndex, setFocusedIndex, isManual, isLazy, lazyBehavior, orientation, descendants, direction, htmlProps, } } export type UseTabsReturn = Omit< ReturnType<typeof useTabs>, "htmlProps" | "descendants" > export const [TabsProvider, useTabsContext] = createContext<UseTabsReturn>({ name: "TabsContext", errorMessage: "useTabsContext: `context` is undefined. Seems you forgot to wrap all tabs components within <Tabs />", }) type Child = React.ReactElement<any> export interface UseTabListProps { children?: React.ReactNode onKeyDown?: React.KeyboardEventHandler ref?: React.Ref<any> } /** * Tabs hook to manage multiple tab buttons, * and ensures only one tab is selected per time. * * @param props props object for the tablist */ export function useTabList<P extends UseTabListProps>(props: P) { const { focusedIndex, orientation, direction } = useTabsContext() const descendants = useTabsDescendantsContext() const onKeyDown = React.useCallback( (event: React.KeyboardEvent) => { const nextTab = () => { const next = descendants.nextEnabled(focusedIndex) if (next) focus(next.node) } const prevTab = () => { const prev = descendants.prevEnabled(focusedIndex) if (prev) focus(prev.node) } const firstTab = () => { const first = descendants.firstEnabled() if (first) focus(first.node) } const lastTab = () => { const last = descendants.lastEnabled() if (last) focus(last.node) } const isHorizontal = orientation === "horizontal" const isVertical = orientation === "vertical" const eventKey = normalizeEventKey(event) const ArrowStart = direction === "ltr" ? "ArrowLeft" : "ArrowRight" const ArrowEnd = direction === "ltr" ? "ArrowRight" : "ArrowLeft" const keyMap: EventKeyMap = { [ArrowStart]: () => isHorizontal && prevTab(), [ArrowEnd]: () => isHorizontal && nextTab(), ArrowDown: () => isVertical && nextTab(), ArrowUp: () => isVertical && prevTab(), Home: firstTab, End: lastTab, } const action = keyMap[eventKey] if (action) { event.preventDefault() action(event) } }, [descendants, focusedIndex, orientation, direction], ) return { ...props, role: "tablist", "aria-orientation": orientation, onKeyDown: callAllHandlers(props.onKeyDown, onKeyDown), } } export type UseTabListReturn = ReturnType<typeof useTabList> export interface UseTabOptions { id?: string isSelected?: boolean panelId?: string /** * If `true`, the `Tab` won't be toggleable */ isDisabled?: boolean } export interface UseTabProps extends Omit<UseClickableProps, "color">, UseTabOptions {} /** * Tabs hook to manage each tab button. * * A tab can be disabled and focusable, or both, * hence the use of `useClickable` to handle this scenario */ export function useTab<P extends UseTabProps>(props: P) { const { isDisabled, isFocusable, ...htmlProps } = props const { setSelectedIndex, isManual, id, setFocusedIndex, selectedIndex } = useTabsContext() const { index, register } = useTabsDescendant({ disabled: isDisabled && !isFocusable, }) const isSelected = index === selectedIndex const onClick = () => { setSelectedIndex(index) } const onFocus = () => { setFocusedIndex(index) const isDisabledButFocusable = isDisabled && isFocusable const shouldSelect = !isManual && !isDisabledButFocusable if (shouldSelect) { setSelectedIndex(index) } } const clickableProps = useClickable({ ...htmlProps, ref: mergeRefs(register, props.ref), isDisabled, isFocusable, onClick: callAllHandlers(props.onClick, onClick), }) const type: "button" | "submit" | "reset" = "button" return { ...clickableProps, id: makeTabId(id, index), role: "tab", tabIndex: isSelected ? 0 : -1, type, "aria-selected": isSelected, "aria-controls": makeTabPanelId(id, index), onFocus: isDisabled ? undefined : callAllHandlers(props.onFocus, onFocus), } } export interface UseTabPanelsProps { children?: React.ReactNode } /** * Tabs hook for managing the visibility of multiple tab panels. * * Since only one panel can be show at a time, we use `cloneElement` * to inject `selected` panel to each TabPanel. * * It returns a cloned version of its children with * all functionality included. */ export function useTabPanels<P extends UseTabPanelsProps>(props: P) { const context = useTabsContext() const { id, selectedIndex } = context const validChildren = getValidChildren(props.children) const children = validChildren.map((child, index) => React.cloneElement(child as Child, { isSelected: index === selectedIndex, id: makeTabPanelId(id, index), // Refers to the associated tab element, and also provides an accessible name to the tab panel. "aria-labelledby": makeTabId(id, index), }), ) return { ...props, children } } /** * Tabs hook for managing the visible/hidden states * of the tab panel. * * @param props props object for the tab panel */ export function useTabPanel(props: Dict) { const { isSelected, id, children, ...htmlProps } = props const { isLazy, lazyBehavior } = useTabsContext() const hasBeenSelected = React.useRef(false) if (isSelected) { hasBeenSelected.current = true } const shouldRenderChildren = determineLazyBehavior({ hasBeenSelected: hasBeenSelected.current, isSelected, isLazy, lazyBehavior, }) return { // Puts the tabpanel in the page `Tab` sequence. tabIndex: 0, ...htmlProps, children: shouldRenderChildren ? children : null, role: "tabpanel", hidden: !isSelected, id, } } /** * Tabs hook to show an animated indicators that * follows the active tab. * * The way we do it is by measuring the DOM Rect (or dimensions) * of the active tab, and return that as CSS style for * the indicator. */ export function useTabIndicator(): React.CSSProperties { const context = useTabsContext() const descendants = useTabsDescendantsContext() const { selectedIndex, orientation } = context const isHorizontal = orientation === "horizontal" const isVertical = orientation === "vertical" // Get the clientRect of the selected tab const [rect, setRect] = React.useState(() => { if (isHorizontal) return { left: 0, width: 0 } if (isVertical) return { top: 0, height: 0 } return undefined }) const [hasMeasured, setHasMeasured] = React.useState(false) // Update the selected tab rect when the selectedIndex changes useSafeLayoutEffect(() => { if (isUndefined(selectedIndex)) return undefined const tab = descendants.item(selectedIndex) if (isUndefined(tab)) return undefined // Horizontal Tab: Calculate width and left distance if (isHorizontal) { setRect({ left: tab.node.offsetLeft, width: tab.node.offsetWidth }) } // Vertical Tab: Calculate height and top distance if (isVertical) { setRect({ top: tab.node.offsetTop, height: tab.node.offsetHeight }) } // Prevent unwanted transition from 0 to measured rect // by setting the measured state in the next tick const id = requestAnimationFrame(() => { setHasMeasured(true) }) return () => { if (id) { cancelAnimationFrame(id) } } }, [selectedIndex, isHorizontal, isVertical, descendants]) return { position: "absolute", transitionProperty: "left, right, top, bottom", transitionDuration: hasMeasured ? "200ms" : "0ms", transitionTimingFunction: "cubic-bezier(0, 0, 0.2, 1)", ...rect, } } function makeTabId(id: string, index: number) { return `${id}--tab-${index}` } function makeTabPanelId(id: string, index: number) { return `${id}--tabpanel-${index}` }