UNPKG

@bianic-ui/tabs

Version:

Accessible Tabs component for React and Bianic UI

392 lines (352 loc) 11 kB
function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); } function _objectWithoutPropertiesLoose(source, excluded) { if (source == null) return {}; var target = {}; var sourceKeys = Object.keys(source); var key, i; for (i = 0; i < sourceKeys.length; i++) { key = sourceKeys[i]; if (excluded.indexOf(key) >= 0) continue; target[key] = source[key]; } return target; } import { useClickable } from "@bianic-ui/clickable"; import { useDescendant, useDescendants } from "@bianic-ui/descendant"; import { useControllableState, useId, useSafeLayoutEffect } from "@bianic-ui/hooks"; import { callAllHandlers, createOnKeyDown, getValidChildren, isUndefined, mergeRefs, createContext } from "@bianic-ui/utils"; import { cloneElement, useState, useRef, useEffect } from "react"; /** * Tabs hooks that provides all the states, and accessibility * helpers to keep all things working properly. * * It's returned object will be passed unto a Context Provider * so all child components can read from it. * * @see Docs https://bianic-ui.com/components/useTabs */ export function useTabs(props) { var { defaultIndex, onChange, index, isManual, isLazy, orientation = "horizontal" } = props, htmlProps = _objectWithoutPropertiesLoose(props, ["defaultIndex", "onChange", "index", "isManual", "isLazy", "orientation"]); /** * 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` */ var [focusedIndex, setFocusedIndex] = useState(defaultIndex != null ? defaultIndex : 0); var [selectedIndex, setSelectedIndex] = useControllableState({ defaultValue: defaultIndex != null ? defaultIndex : 0, value: index, onChange, shouldUpdate: (prevIndex, nextIndex) => prevIndex !== nextIndex, propsMap: { value: "index", defaultValue: "defaultIndex" } }); /** * Sync focused `index` with controlled `selectedIndex` (which is the `props.index`) */ useEffect(() => { if (!isUndefined(index)) { setFocusedIndex(index); } }, [index]); /** * Think of `useDescendants` as a register for the tab nodes. * * This manager is used to store only the tab nodes that are not disabled, and focusable. * If we have the following code * * ```jsx * <Tab>Tab 1</Tab> * <Tab isDisabled>Tab 2</Tab> * <Tab>Tab 3</Tab> * ``` * * The manager will only hold references to "Tab 1" and "Tab 3", since `Tab 2` is disabled */ var enabledDomContext = useDescendants(); /** * This manager is used to store all tab nodes whether disabled or not. * If we have the following code * * ```jsx * <Tab>Tab 1</Tab> * <Tab isDisabled>Tab 2</Tab> * <Tab>Tab 3</Tab> * ``` * * The manager will only hold references to "Tab 1", "Tab 2" "Tab 3". * * We need this for correct indexing of tabs in event a tab is disabled */ var domContext = useDescendants(); /** * generate a unique id or use user-provided id for * the tabs widget */ var id = useId(props.id, "tabs"); return { id, selectedIndex, focusedIndex, setSelectedIndex, setFocusedIndex, isManual, isLazy, orientation, enabledDomContext, domContext, htmlProps }; } var [TabsProvider, useTabsContext] = createContext({ name: "TabsContext", errorMessage: "useTabsContext: `context` is undefined. Seems you forgot to wrap all tabs components within <Tabs />" }); export { TabsProvider }; /** * 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(props) { var { setFocusedIndex, focusedIndex, orientation, enabledDomContext } = useTabsContext(); var count = enabledDomContext.descendants.length; /** * Function to update the selected tab index */ var setIndex = index => { var tab = enabledDomContext.descendants[index]; if (tab == null ? void 0 : tab.element) { tab.element.focus(); setFocusedIndex(index); } }; // Helper functions for keyboard navigation var nextTab = () => { var nextIndex = (focusedIndex + 1) % count; setIndex(nextIndex); }; var prevTab = () => { var prevIndex = (focusedIndex - 1 + count) % count; setIndex(prevIndex); }; var firstTab = () => setIndex(0); var lastTab = () => setIndex(count - 1); var isHorizontal = orientation === "horizontal"; var isVertical = orientation === "vertical"; var onKeyDown = createOnKeyDown({ keyMap: { ArrowRight: () => isHorizontal && nextTab(), ArrowLeft: () => isHorizontal && prevTab(), ArrowDown: () => isVertical && nextTab(), ArrowUp: () => isVertical && prevTab(), Home: () => firstTab(), End: () => lastTab() } }); return _extends({}, props, { role: "tablist", "aria-orientation": orientation, onKeyDown: callAllHandlers(props.onKeyDown, onKeyDown) }); } /** * 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(props) { var { isDisabled, isFocusable } = props, htmlProps = _objectWithoutPropertiesLoose(props, ["isDisabled", "isFocusable"]); var { setSelectedIndex, isManual, id, setFocusedIndex, enabledDomContext, domContext, selectedIndex } = useTabsContext(); var ref = useRef(null); /** * Think of `useDescendant` as the function that registers tab node * to the `enabledDomContext`, and returns it's index. * * Tab is registered if it's enabled or focusable */ var enabledIndex = useDescendant({ disabled: Boolean(isDisabled), focusable: Boolean(isFocusable), context: enabledDomContext, element: ref.current }); /** * Registers all tabs (whether disabled or not) */ var index = useDescendant({ context: domContext, element: ref.current }); var isSelected = index === selectedIndex; var onClick = () => { setFocusedIndex(enabledIndex); setSelectedIndex(index); }; var onFocus = () => { var isDisabledButFocusable = isDisabled && isFocusable; var shouldSelect = !isManual && !isDisabledButFocusable; if (shouldSelect) { setSelectedIndex(index); } }; var clickable = useClickable(_extends({}, htmlProps, { ref: mergeRefs(ref, props.ref), isDisabled, isFocusable, onClick: callAllHandlers(props.onClick, onClick) })); var type = "button"; return _extends({}, clickable, { id: makeTabId(id, index), role: "tab", tabIndex: isSelected ? 0 : -1, type, "aria-selected": isSelected ? true : undefined, "aria-controls": makeTabPanelId(id, index), onFocus: isDisabled ? undefined : callAllHandlers(props.onFocus, onFocus) }); } /** * 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 it's children with * all functionality included. */ export function useTabPanels(props) { var context = useTabsContext(); var { id, selectedIndex } = context; var validChildren = getValidChildren(props.children); var children = validChildren.map((child, index) => /*#__PURE__*/cloneElement(child, { isSelected: index === selectedIndex, id: makeTabPanelId(id, index) })); return _extends({}, 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) { var { isSelected, id } = props, htmlProps = _objectWithoutPropertiesLoose(props, ["isSelected", "id"]); var { isLazy } = useTabsContext(); return _extends({}, htmlProps, { children: !isLazy || isSelected ? props.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() { var context = useTabsContext(); var { selectedIndex, orientation, domContext } = context; var isHorizontal = orientation === "horizontal"; var isVertical = orientation === "vertical"; // Get the clientRect of the selected tab var [rect, setRect] = useState(() => { if (isHorizontal) return { left: 0, width: 0 }; if (isVertical) return { top: 0, height: 0 }; }); var [hasMeasured, setHasMeasured] = useState(false); // Update the selected tab rect when the selectedIndex changes useSafeLayoutEffect(() => { var _tab$element; if (isUndefined(selectedIndex)) return; var tab = domContext.descendants[selectedIndex]; var tabRect = tab == null ? void 0 : (_tab$element = tab.element) == null ? void 0 : _tab$element.getBoundingClientRect(); // Horizontal Tab: Calculate width and left distance if (isHorizontal && tabRect) { var { left, width } = tabRect; setRect({ left, width }); } // Vertical Tab: Calculate height and top distance if (isVertical && tabRect) { var { top, height } = tabRect; setRect({ top, height }); } // Prevent unwanted transition from 0 to measured rect // by setting the measured state in the next tick var frameId = requestAnimationFrame(() => { setHasMeasured(true); }); return () => { cancelAnimationFrame(frameId); }; }, [selectedIndex, isHorizontal, isVertical, domContext.descendants]); return _extends({ position: "absolute", transition: hasMeasured ? "all 200ms cubic-bezier(0, 0, 0.2, 1)" : "none" }, rect); } function makeTabId(id, index) { return id + "--tab-" + index; } function makeTabPanelId(id, index) { return id + "--tabpanel-" + index; } //# sourceMappingURL=use-tabs.js.map