UNPKG

@accessible/tabs

Version:

🅰 An accessible and versatile tabs component for React with keyboard navigation and labeling features taught in w3.org's WAI-ARIA tabs example

307 lines (273 loc) • 9.97 kB
import { createContext, useContext, useReducer, useState, useMemo, useRef, useEffect, cloneElement, createElement, Children, isValidElement } from 'react'; import { Button } from '@accessible/button'; import useKey from '@accessible/use-key'; import useConditionalFocus from '@accessible/use-conditional-focus'; import useId from '@accessible/use-id'; import useMergedRef from '@react-hook/merged-ref'; import useLayoutEffect from '@react-hook/passive-layout-effect'; import useChange from '@react-hook/change'; import clsx from 'clsx'; var __reactCreateElement__ = createElement; // Panel type. All tabs must be on the same child depth level as other tabs, // same with panels. Once one tab or panel is found, it will not traverse // deeper into the tree. Using this in favor of something more generalized // in order to not hurt render performance on large trees. var cloneChildrenWithIndex = (elements, type) => { var index = 0; var didUpdate = false; var children = Children.map(elements, child => { // bails out if not an element object if (! /*#__PURE__*/isValidElement(child)) return child; // bails out if certainly the wrong type if (type === Panel && (child.type === TabList || child.type === Tab) || type === Tab && child.type === Panel) return child; // found a match if (child.type === type) { // bail out if the indexes are user-provided if (child.props.index !== void 0) { index = child.props.index + 1; return child; } else { didUpdate = true; return /*#__PURE__*/cloneElement(child, { index: index++ }); } } // only checks the children if we're not on a depth with tabs/panels if (index === 0) { var nextChildren = cloneChildrenWithIndex(child.props.children, type); if (nextChildren === child.props.children) return child;else { didUpdate = true; return /*#__PURE__*/cloneElement(child, void 0, nextChildren); } } return child; }); return !didUpdate ? elements : (children === null || children === void 0 ? void 0 : children.length) === 1 ? children[0] : children; }; function noop() {} var TabsContext = /*#__PURE__*/createContext({ tabs: [], registerTab: () => noop, active: void 0, activate: noop, manualActivation: false, preventScroll: false }), { Consumer: TabsConsumer } = TabsContext, useTabs = () => useContext(TabsContext); function _ref5(state, action) { var { index } = action; if (action.type === 'register') { state = state.slice(0); state[index] = { element: action.element, id: action.id, disabled: action.disabled }; } else if (action.type === 'unregister') { state = state.slice(0); state[index] = void 0; } return state; } function Tabs(_ref) { var { active, defaultActive = 0, manualActivation = false, preventScroll = false, onChange = noop, children } = _ref; var [tabs, dispatchTabs] = useReducer(_ref5, []); var [userActive, setActive] = useState(defaultActive); useChange(userActive, onChange); var nextActive = active !== null && active !== void 0 ? active : userActive; function _registerTab(index, element, id, disabled) { dispatchTabs({ type: 'register', index, element, id, disabled }); return () => dispatchTabs({ type: 'unregister', index }); } function _activate2(index) { var _tabs; if ((_tabs = tabs[index || -1]) === null || _tabs === void 0 ? void 0 : _tabs.disabled) return; setActive(index); } var context = useMemo(() => ({ tabs, registerTab: _registerTab, active: nextActive, activate: _activate2, preventScroll, manualActivation }), [tabs, nextActive, manualActivation, preventScroll]); return /*#__PURE__*/__reactCreateElement__(TabsContext.Provider, { value: context }, cloneChildrenWithIndex(cloneChildrenWithIndex(children, Tab), Panel)); } function useTab(index) { var { tabs, activate: _activate, active } = useContext(TabsContext); function _activate3() { var _tabs$index3; return !((_tabs$index3 = tabs[index]) === null || _tabs$index3 === void 0 ? void 0 : _tabs$index3.disabled) && _activate(index); } return useMemo(() => { var _tabs$index, _tabs$index2, _tabs$index4; return { id: (_tabs$index = tabs[index]) === null || _tabs$index === void 0 ? void 0 : _tabs$index.id, tabRef: (_tabs$index2 = tabs[index]) === null || _tabs$index2 === void 0 ? void 0 : _tabs$index2.element, index: index, activate: _activate3, isActive: index === active, disabled: ((_tabs$index4 = tabs[index]) === null || _tabs$index4 === void 0 ? void 0 : _tabs$index4.disabled) || false }; }, [tabs, index, active, _activate]); } function Tab(_ref2) { var { id, index, disabled = false, activeClass, inactiveClass, activeStyle, inactiveStyle, onDelete = noop, children } = _ref2; id = useId(id); var { registerTab } = useTabs(); var triggerRef = useRef(null); var { tabs, manualActivation } = useTabs(); var { isActive, activate } = useTab(index); var ref = useMergedRef( // @ts-ignore children.ref, triggerRef); useKey(triggerRef, { // right arrow ArrowRight: () => focusNext(tabs, index), // left arrow ArrowLeft: () => focusPrev(tabs, index), // home Home: () => { var _tabs$, _tabs$$element; return (_tabs$ = tabs[0]) === null || _tabs$ === void 0 ? void 0 : (_tabs$$element = _tabs$.element) === null || _tabs$$element === void 0 ? void 0 : _tabs$$element.focus(); }, // end End: () => { var _tabs2, _tabs2$element; return (_tabs2 = tabs[tabs.length - 1]) === null || _tabs2 === void 0 ? void 0 : (_tabs2$element = _tabs2.element) === null || _tabs2$element === void 0 ? void 0 : _tabs2$element.focus(); }, // delete Delete: onDelete }); useEffect(() => registerTab(index, triggerRef.current, id, disabled), // eslint-disable-next-line react-hooks/exhaustive-deps [id, disabled, index]); return /*#__PURE__*/__reactCreateElement__(Button, null, /*#__PURE__*/cloneElement(children, { 'aria-controls': id, 'aria-selected': '' + isActive, 'aria-disabled': '' + (isActive || disabled), role: 'tab', className: clsx(children.props.className, isActive ? activeClass : inactiveClass) || void 0, style: Object.assign({}, children.props.style, isActive ? activeStyle : inactiveStyle), tabIndex: children.props.hasOwnProperty('tabIndex') ? children.props.tabIndex : isActive ? 0 : -1, onFocus: e => { var _children$props$onFoc, _children$props; if (!manualActivation) activate(); (_children$props$onFoc = (_children$props = children.props).onFocus) === null || _children$props$onFoc === void 0 ? void 0 : _children$props$onFoc.call(_children$props, e); }, onClick: e => { var _children$props$onCli, _children$props2; activate(); (_children$props$onCli = (_children$props2 = children.props).onClick) === null || _children$props$onCli === void 0 ? void 0 : _children$props$onCli.call(_children$props2, e); }, ref })); } function focusNext(tabs, currentIndex) { var _tabs$2, _tabs$2$element, _tabs3, _tabs3$element; if (currentIndex === tabs.length - 1) (_tabs$2 = tabs[0]) === null || _tabs$2 === void 0 ? void 0 : (_tabs$2$element = _tabs$2.element) === null || _tabs$2$element === void 0 ? void 0 : _tabs$2$element.focus();else (_tabs3 = tabs[currentIndex + 1]) === null || _tabs3 === void 0 ? void 0 : (_tabs3$element = _tabs3.element) === null || _tabs3$element === void 0 ? void 0 : _tabs3$element.focus(); } function focusPrev(tabs, currentIndex) { var _tabs4, _tabs4$element, _tabs5, _tabs5$element; if (currentIndex === 0) (_tabs4 = tabs[tabs.length - 1]) === null || _tabs4 === void 0 ? void 0 : (_tabs4$element = _tabs4.element) === null || _tabs4$element === void 0 ? void 0 : _tabs4$element.focus();else (_tabs5 = tabs[currentIndex - 1]) === null || _tabs5 === void 0 ? void 0 : (_tabs5$element = _tabs5.element) === null || _tabs5$element === void 0 ? void 0 : _tabs5$element.focus(); } function TabList(_ref3) { var { children } = _ref3; return /*#__PURE__*/cloneElement(children, { role: 'tablist' }); } function Panel(_ref4) { var { index, activeClass, inactiveClass, activeStyle, inactiveStyle, children } = _ref4; var { isActive, id } = useTab(index); var { manualActivation, preventScroll } = useTabs(); var prevActive = useRef(isActive); var panelRef = useRef(null); var ref = useMergedRef( // @ts-ignore children.ref, panelRef); useConditionalFocus(panelRef, manualActivation && !prevActive.current && isActive, { includeRoot: true, preventScroll }); // ensures the tab panel won't be granted the window's focus // by default, but receives focus when the visual state changes to // active useLayoutEffect(() => { prevActive.current = isActive; }, [isActive, index]); return /*#__PURE__*/cloneElement(children, { 'aria-hidden': "" + !isActive, id, className: clsx(children.props.className, isActive ? activeClass : inactiveClass) || void 0, style: Object.assign({ visibility: isActive ? 'visible' : 'hidden' }, children.props.style, isActive ? activeStyle : inactiveStyle), tabIndex: children.props.hasOwnProperty('tabIndex') ? children.props.tabIndex : isActive ? 0 : -1, ref }); } /* istanbul ignore next */ if (typeof process !== 'undefined' && "production" !== 'production') { Tabs.displayName = 'Tabs'; TabList.displayName = 'TabList'; Tab.displayName = 'Tab'; Panel.displayName = 'Panel'; } export { Panel, Tab, TabList, Tabs, TabsConsumer, TabsContext, useTab, useTabs }; //# sourceMappingURL=index.dev.mjs.map