@hakuna-matata-ui/tabs
Version:
Accessible Tabs component for React and Chakra UI
461 lines (399 loc) • 12.5 kB
text/typescript
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}`
}