UNPKG

@hakuna-matata-ui/tabs

Version:

Accessible Tabs component for React and Chakra UI

579 lines (503 loc) 17.6 kB
import { forwardRef, useMultiStyleConfig, omitThemingProps, StylesProvider, chakra, useStyles } from '@hakuna-matata-ui/system'; import { normalizeEventKey, callAllHandlers, determineLazyBehavior, isUndefined, focus, omit, cx, __DEV__ } from '@hakuna-matata-ui/utils'; import * as React from 'react'; import { useClickable } from '@hakuna-matata-ui/clickable'; import { createDescendantContext } from '@hakuna-matata-ui/descendant'; import { useControllableState, useId, useSafeLayoutEffect } from '@hakuna-matata-ui/hooks'; import { createContext, mergeRefs, getValidChildren } from '@hakuna-matata-ui/react-utils'; 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; } var _excluded$1 = ["defaultIndex", "onChange", "index", "isManual", "isLazy", "lazyBehavior", "orientation", "direction"], _excluded2$1 = ["isDisabled", "isFocusable"], _excluded3 = ["isSelected", "id", "children"]; /* ------------------------------------------------------------------------------------------------- * Create context to track descendants and their indices * -----------------------------------------------------------------------------------------------*/ var _createDescendantCont = createDescendantContext(), TabsDescendantsProvider = _createDescendantCont[0], useTabsDescendantsContext = _createDescendantCont[1], useTabsDescendants = _createDescendantCont[2], useTabsDescendant = _createDescendantCont[3]; /** * 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 */ function useTabs(props) { var defaultIndex = props.defaultIndex, onChange = props.onChange, index = props.index, isManual = props.isManual, isLazy = props.isLazy, _props$lazyBehavior = props.lazyBehavior, lazyBehavior = _props$lazyBehavior === void 0 ? "unmount" : _props$lazyBehavior, _props$orientation = props.orientation, orientation = _props$orientation === void 0 ? "horizontal" : _props$orientation, _props$direction = props.direction, direction = _props$direction === void 0 ? "ltr" : _props$direction, htmlProps = _objectWithoutPropertiesLoose(props, _excluded$1); /** * 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 _React$useState = React.useState(defaultIndex != null ? defaultIndex : 0), focusedIndex = _React$useState[0], setFocusedIndex = _React$useState[1]; var _useControllableState = useControllableState({ defaultValue: defaultIndex != null ? defaultIndex : 0, value: index, onChange: onChange }), selectedIndex = _useControllableState[0], setSelectedIndex = _useControllableState[1]; /** * Sync focused `index` with controlled `selectedIndex` (which is the `props.index`) */ React.useEffect(function () { if (index != null) { setFocusedIndex(index); } }, [index]); /** * Think of `useDescendants` as a register for the tab nodes. */ var descendants = useTabsDescendants(); /** * Generate a unique id or use user-provided id for the tabs widget */ var id = useId(props.id, "tabs"); return { id: id, selectedIndex: selectedIndex, focusedIndex: focusedIndex, setSelectedIndex: setSelectedIndex, setFocusedIndex: setFocusedIndex, isManual: isManual, isLazy: isLazy, lazyBehavior: lazyBehavior, orientation: orientation, descendants: descendants, direction: direction, htmlProps: htmlProps }; } var _createContext = createContext({ name: "TabsContext", errorMessage: "useTabsContext: `context` is undefined. Seems you forgot to wrap all tabs components within <Tabs />" }), TabsProvider = _createContext[0], useTabsContext = _createContext[1]; /** * Tabs hook to manage multiple tab buttons, * and ensures only one tab is selected per time. * * @param props props object for the tablist */ function useTabList(props) { var _useTabsContext = useTabsContext(), focusedIndex = _useTabsContext.focusedIndex, orientation = _useTabsContext.orientation, direction = _useTabsContext.direction; var descendants = useTabsDescendantsContext(); var onKeyDown = React.useCallback(function (event) { var _keyMap; var nextTab = function nextTab() { var next = descendants.nextEnabled(focusedIndex); if (next) focus(next.node); }; var prevTab = function prevTab() { var prev = descendants.prevEnabled(focusedIndex); if (prev) focus(prev.node); }; var firstTab = function firstTab() { var first = descendants.firstEnabled(); if (first) focus(first.node); }; var lastTab = function lastTab() { var last = descendants.lastEnabled(); if (last) focus(last.node); }; var isHorizontal = orientation === "horizontal"; var isVertical = orientation === "vertical"; var eventKey = normalizeEventKey(event); var ArrowStart = direction === "ltr" ? "ArrowLeft" : "ArrowRight"; var ArrowEnd = direction === "ltr" ? "ArrowRight" : "ArrowLeft"; var keyMap = (_keyMap = {}, _keyMap[ArrowStart] = function () { return isHorizontal && prevTab(); }, _keyMap[ArrowEnd] = function () { return isHorizontal && nextTab(); }, _keyMap.ArrowDown = function ArrowDown() { return isVertical && nextTab(); }, _keyMap.ArrowUp = function ArrowUp() { return isVertical && prevTab(); }, _keyMap.Home = firstTab, _keyMap.End = lastTab, _keyMap); var action = keyMap[eventKey]; if (action) { event.preventDefault(); action(event); } }, [descendants, focusedIndex, orientation, direction]); 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 */ function useTab(props) { var isDisabled = props.isDisabled, isFocusable = props.isFocusable, htmlProps = _objectWithoutPropertiesLoose(props, _excluded2$1); var _useTabsContext2 = useTabsContext(), setSelectedIndex = _useTabsContext2.setSelectedIndex, isManual = _useTabsContext2.isManual, id = _useTabsContext2.id, setFocusedIndex = _useTabsContext2.setFocusedIndex, selectedIndex = _useTabsContext2.selectedIndex; var _useTabsDescendant = useTabsDescendant({ disabled: isDisabled && !isFocusable }), index = _useTabsDescendant.index, register = _useTabsDescendant.register; var isSelected = index === selectedIndex; var onClick = function onClick() { setSelectedIndex(index); }; var onFocus = function onFocus() { setFocusedIndex(index); var isDisabledButFocusable = isDisabled && isFocusable; var shouldSelect = !isManual && !isDisabledButFocusable; if (shouldSelect) { setSelectedIndex(index); } }; var clickableProps = useClickable(_extends({}, htmlProps, { ref: mergeRefs(register, props.ref), isDisabled: isDisabled, isFocusable: isFocusable, onClick: callAllHandlers(props.onClick, onClick) })); var type = "button"; return _extends({}, clickableProps, { id: makeTabId(id, index), role: "tab", tabIndex: isSelected ? 0 : -1, type: type, "aria-selected": isSelected, "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 its children with * all functionality included. */ function useTabPanels(props) { var context = useTabsContext(); var id = context.id, selectedIndex = context.selectedIndex; var validChildren = getValidChildren(props.children); var children = validChildren.map(function (child, index) { return /*#__PURE__*/React.cloneElement(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 _extends({}, props, { children: children }); } /** * Tabs hook for managing the visible/hidden states * of the tab panel. * * @param props props object for the tab panel */ function useTabPanel(props) { var isSelected = props.isSelected, id = props.id, children = props.children, htmlProps = _objectWithoutPropertiesLoose(props, _excluded3); var _useTabsContext3 = useTabsContext(), isLazy = _useTabsContext3.isLazy, lazyBehavior = _useTabsContext3.lazyBehavior; var hasBeenSelected = React.useRef(false); if (isSelected) { hasBeenSelected.current = true; } var shouldRenderChildren = determineLazyBehavior({ hasBeenSelected: hasBeenSelected.current, isSelected: isSelected, isLazy: isLazy, lazyBehavior: lazyBehavior }); return _extends({ // Puts the tabpanel in the page `Tab` sequence. tabIndex: 0 }, htmlProps, { children: shouldRenderChildren ? children : null, role: "tabpanel", hidden: !isSelected, id: 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. */ function useTabIndicator() { var context = useTabsContext(); var descendants = useTabsDescendantsContext(); var selectedIndex = context.selectedIndex, orientation = context.orientation; var isHorizontal = orientation === "horizontal"; var isVertical = orientation === "vertical"; // Get the clientRect of the selected tab var _React$useState2 = React.useState(function () { if (isHorizontal) return { left: 0, width: 0 }; if (isVertical) return { top: 0, height: 0 }; return undefined; }), rect = _React$useState2[0], setRect = _React$useState2[1]; var _React$useState3 = React.useState(false), hasMeasured = _React$useState3[0], setHasMeasured = _React$useState3[1]; // Update the selected tab rect when the selectedIndex changes useSafeLayoutEffect(function () { if (isUndefined(selectedIndex)) return undefined; var 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 var id = requestAnimationFrame(function () { setHasMeasured(true); }); return function () { if (id) { cancelAnimationFrame(id); } }; }, [selectedIndex, isHorizontal, isVertical, descendants]); return _extends({ position: "absolute", transitionProperty: "left, right, top, bottom", transitionDuration: hasMeasured ? "200ms" : "0ms", transitionTimingFunction: "cubic-bezier(0, 0, 0.2, 1)" }, rect); } function makeTabId(id, index) { return id + "--tab-" + index; } function makeTabPanelId(id, index) { return id + "--tabpanel-" + index; } var _excluded = ["children", "className"], _excluded2 = ["htmlProps", "descendants"]; /** * Tabs * * Provides context and logic for all tabs components. */ var Tabs = /*#__PURE__*/forwardRef(function (props, ref) { var styles = useMultiStyleConfig("Tabs", props); var _omitThemingProps = omitThemingProps(props), children = _omitThemingProps.children, className = _omitThemingProps.className, rest = _objectWithoutPropertiesLoose(_omitThemingProps, _excluded); var _useTabs = useTabs(rest), htmlProps = _useTabs.htmlProps, descendants = _useTabs.descendants, ctx = _objectWithoutPropertiesLoose(_useTabs, _excluded2); var context = React.useMemo(function () { return ctx; }, [ctx]); var rootProps = omit(htmlProps, ["isFitted"]); return /*#__PURE__*/React.createElement(TabsDescendantsProvider, { value: descendants }, /*#__PURE__*/React.createElement(TabsProvider, { value: context }, /*#__PURE__*/React.createElement(StylesProvider, { value: styles }, /*#__PURE__*/React.createElement(chakra.div, _extends({ className: cx("chakra-tabs", className), ref: ref }, rootProps, { __css: styles.root }), children)))); }); if (__DEV__) { Tabs.displayName = "Tabs"; } /** * Tab button used to activate a specific tab panel. It renders a `button`, * and is responsible for automatic and manual selection modes. */ var Tab = /*#__PURE__*/forwardRef(function (props, ref) { var styles = useStyles(); var tabProps = useTab(_extends({}, props, { ref: ref })); var tabStyles = _extends({ outline: "0", display: "flex", alignItems: "center", justifyContent: "center" }, styles.tab); return /*#__PURE__*/React.createElement(chakra.button, _extends({}, tabProps, { className: cx("chakra-tabs__tab", props.className), __css: tabStyles })); }); if (__DEV__) { Tab.displayName = "Tab"; } /** * TabList is used to manage a list of tab buttons. It renders a `div` by default, * and is responsible the keyboard interaction between tabs. */ var TabList = /*#__PURE__*/forwardRef(function (props, ref) { var tablistProps = useTabList(_extends({}, props, { ref: ref })); var styles = useStyles(); var tablistStyles = _extends({ display: "flex" }, styles.tablist); return /*#__PURE__*/React.createElement(chakra.div, _extends({}, tablistProps, { className: cx("chakra-tabs__tablist", props.className), __css: tablistStyles })); }); if (__DEV__) { TabList.displayName = "TabList"; } /** * TabPanel * Used to render the content for a specific tab. */ var TabPanel = /*#__PURE__*/forwardRef(function (props, ref) { var panelProps = useTabPanel(_extends({}, props, { ref: ref })); var styles = useStyles(); return /*#__PURE__*/React.createElement(chakra.div, _extends({ outline: "0" }, panelProps, { className: cx("chakra-tabs__tab-panel", props.className), __css: styles.tabpanel })); }); if (__DEV__) { TabPanel.displayName = "TabPanel"; } /** * TabPanel * * Used to manage the rendering of multiple tab panels. It uses * `cloneElement` to hide/show tab panels. * * It renders a `div` by default. */ var TabPanels = /*#__PURE__*/forwardRef(function (props, ref) { var panelsProps = useTabPanels(props); var styles = useStyles(); return /*#__PURE__*/React.createElement(chakra.div, _extends({}, panelsProps, { width: "100%", ref: ref, className: cx("chakra-tabs__tab-panels", props.className), __css: styles.tabpanels })); }); if (__DEV__) { TabPanels.displayName = "TabPanels"; } /** * TabIndicator * * Used to render an active tab indicator that animates between * selected tabs. */ var TabIndicator = /*#__PURE__*/forwardRef(function (props, ref) { var indicatorStyle = useTabIndicator(); var style = _extends({}, props.style, indicatorStyle); var styles = useStyles(); return /*#__PURE__*/React.createElement(chakra.div, _extends({ ref: ref }, props, { className: cx("chakra-tabs__tab-indicator", props.className), style: style, __css: styles.indicator })); }); if (__DEV__) { TabIndicator.displayName = "TabIndicator"; } export { Tab, TabIndicator, TabList, TabPanel, TabPanels, Tabs, TabsDescendantsProvider, TabsProvider, useTab, useTabIndicator, useTabList, useTabPanel, useTabPanels, useTabs, useTabsContext, useTabsDescendant, useTabsDescendants, useTabsDescendantsContext };