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

683 lines (574 loc) • 22.8 kB
(function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('react')) : typeof define === 'function' && define.amd ? define(['exports', 'react'], factory) : (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.Tabs = {}, global.React)); }(this, (function (exports, React) { 'use strict'; 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); } var usePassiveLayoutEffect = React[typeof document !== 'undefined' && document.createElement !== void 0 ? 'useLayoutEffect' : 'useEffect']; var useLatest = function useLatest(current) { var storedValue = React.useRef(current); storedValue.current = current; return storedValue; }; function useEvent(target, type, listener, cleanup) { var storedListener = useLatest(listener); var storedCleanup = useLatest(cleanup); usePassiveLayoutEffect(function () { var targetEl = target && 'current' in target ? target.current : target; if (!targetEl) return; var didUnsubscribe = 0; function listener() { if (didUnsubscribe) return; for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) { args[_key] = arguments[_key]; } storedListener.current.apply(this, args); } targetEl.addEventListener(type, listener); var cleanup = storedCleanup.current; return function () { didUnsubscribe = 1; targetEl.removeEventListener(type, listener); cleanup && cleanup(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [target, type]); } function useKey(target, listeners) { useEvent(target, 'keydown', function (event) { var listener = listeners[LEGACY_COMPAT[event.key] || event.key]; if (listener) listener(event); }); } // IE 11 and some versions of Edge have non-standard value var LEGACY_COMPAT = { Up: 'ArrowUp', Right: 'ArrowRight', Down: 'ArrowDown', Left: 'ArrowLeft', Esc: 'Escape', Spacebar: ' ', Del: 'Delete', Crsel: 'CrSel', Exsel: 'ExSel', Add: '+', Subtract: '-', Multiply: '*', Divide: '/', Decimal: '.', Scroll: 'ScrollLock' }; var useMergedRef = function useMergedRef() { for (var _len = arguments.length, refs = new Array(_len), _key = 0; _key < _len; _key++) { refs[_key] = arguments[_key]; } return function (element) { return refs.forEach(function (ref) { if (typeof ref === 'function') ref(element);else if (ref && typeof ref === 'object') ref.current = element; }); }; }; function useA11yButton(target, onClick) { var clickedMouse = React.useRef(false); var setClickedMouse = function setClickedMouse() { return clickedMouse.current = true; }; useEvent(target, 'touchstart', setClickedMouse); useEvent(target, 'mousedown', setClickedMouse); useEvent(target, 'click', function (event) { // Only fire onClick if the keyboard was not used to initiate the click clickedMouse.current && onClick(event); clickedMouse.current = false; }); // @ts-expect-error useKey(target, { Enter: onClick, ' ': onClick }); return { role: 'button', tabIndex: 0 }; } var Button = function Button(_ref) { var children = _ref.children; var ref = React.useRef(null); var props = children.props; var _useA11yButton = useA11yButton(ref, props.onClick), role = _useA11yButton.role, tabIndex = _useA11yButton.tabIndex; return /*#__PURE__*/React.cloneElement(children, { onClick: undefined, role: props.hasOwnProperty('role') ? props.role : role, tabIndex: props.hasOwnProperty('tabIndex') ? props.tabIndex : tabIndex, // @ts-expect-error ref: useMergedRef(ref, children.ref) }); }; /* istanbul ignore next */ if (typeof process !== 'undefined' && "production" !== 'production') { Button.displayName = 'AccessibleButton'; } // Credit: // https://github.com/davidtheclark/tabbable var candidateSelector = 'input,select,textarea,a[href],button,[tabindex],' + 'audio[controls],video[controls],' + '[contenteditable]:not([contenteditable="false"])'; var matches = typeof Element === 'undefined' ? function () { return false; } : Element.prototype.matches || // @ts-ignore Element.prototype.msMatchesSelector || Element.prototype.webkitMatchesSelector; function _ref(a) { return a.node; } var tabbable = function tabbable(el, includeRootNode) { if (includeRootNode === void 0) { includeRootNode = false; } var regularTabbables = []; var orderedTabbables = []; var candidates = el.querySelectorAll(candidateSelector); if (includeRootNode && matches.call(el, candidateSelector)) { candidates = Array.prototype.slice.apply(candidates); candidates.unshift(el); } var i, candidate, candidateTabindex; for (i = 0; i < candidates.length; i++) { candidate = candidates[i]; if (!isNodeMatchingSelectorTabbable(candidate)) continue; candidateTabindex = getTabindex(candidate); if (candidateTabindex === 0) { regularTabbables.push(candidate); } else { orderedTabbables.push({ documentOrder: i, tabIndex: candidateTabindex, node: candidate }); } } return orderedTabbables.sort(sortOrderedTabbables).map(_ref).concat(regularTabbables); }; var isNodeMatchingSelectorTabbable = function isNodeMatchingSelectorTabbable(node) { return !(!isNodeMatchingSelectorFocusable(node) || node.tagName === 'INPUT' && node.type === 'radio' && !isTabbableRadio(node) || getTabindex(node) < 0); }; var isNodeMatchingSelectorFocusable = function isNodeMatchingSelectorFocusable(node) { return !(node.disabled || isInput(node) && node.type === 'hidden' || // offsetParent being null will allow detecting cases where an element // is invisible or inside an invisible element, as long as the element // does not use position: fixed. For them, their visibility has to be // checked directly as well. node.offsetParent === null || getComputedStyle(node).visibility === 'hidden'); }; var getTabindex = function getTabindex(node) { var tabindexAttr = parseInt(node.getAttribute('tabindex') || '', 10); if (!isNaN(tabindexAttr)) return tabindexAttr; // Browsers do not return `tabIndex` correctly for contentEditable nodes; // so if they don't have a tabindex attribute specifically set, assume it's 0. if (node.contentEditable === 'true') return 0; return node.tabIndex; }; // @ts-ignore var sortOrderedTabbables = function sortOrderedTabbables(a, b) { return a.tabIndex === b.tabIndex ? a.documentOrder - b.documentOrder : a.tabIndex - b.tabIndex; }; var isInput = function isInput(node) { return node.tagName === 'INPUT'; }; var isTabbableRadio = function isTabbableRadio(node) { if (!node.name) return true; // This won't account for the edge case where you have radio groups with the // same in separate forms on the same page. if (node.ownerDocument) { var radioSet = node.ownerDocument.querySelectorAll('input[type="radio"][name="' + node.name + '"]'); for (var i = 0; i < radioSet.length; i++) { if (radioSet[i].checked) return radioSet[i] === node; } return true; } return false; }; function useConditionalFocus(target, shouldFocus, _temp) { if (shouldFocus === void 0) { shouldFocus = false; } var _ref = _temp === void 0 ? defaultOptions : _temp, includeRoot = _ref.includeRoot, preventScroll = _ref.preventScroll; var doFocus_ = function doFocus_() { var element = target && 'current' in target ? target.current : target; if (!element || !shouldFocus) return; var tabbableEls = tabbable(element, includeRoot); if (tabbableEls.length > 0) tabbableEls[0].focus({ preventScroll: preventScroll }); }; var doFocus = useLatest(doFocus_); React.useEffect(function () { doFocus.current(); }, [doFocus, shouldFocus]); useEvent(target, 'transitionend', doFocus_); } var defaultOptions = { includeRoot: false, preventScroll: false }; var ID = 0; var genId = function genId() { return ID++; }; var serverHandoffComplete = false; var useId = function useId(fallbackId, prefix) { if (prefix === void 0) { prefix = '🅰'; } var _React$useState = React.useState(serverHandoffComplete ? genId : void 0), id = _React$useState[0], setId = _React$useState[1]; usePassiveLayoutEffect(function () { if (!serverHandoffComplete) { serverHandoffComplete = true; setId(ID++); } }, []); return fallbackId ? fallbackId : id === void 0 ? id : prefix + id; }; function usePrevious(value, initialValue) { var storedValue = React.useRef(initialValue); React.useEffect(function () { storedValue.current = value; }, [value]); return storedValue.current; } var useChange = function useChange(value, onChange) { var storedOnChange = useLatest(onChange); var prevValue = usePrevious(value, value); React.useEffect(function () { if (value !== prevValue) storedOnChange.current(value, prevValue); }, [value, prevValue, storedOnChange]); }; function toVal(mix) { var k, y, str = ''; if (typeof mix === 'string' || typeof mix === 'number') { str += mix; } else if (typeof mix === 'object') { if (Array.isArray(mix)) { for (k = 0; k < mix.length; k++) { if (mix[k]) { if (y = toVal(mix[k])) { str && (str += ' '); str += y; } } } } else { for (k in mix) { if (mix[k]) { str && (str += ' '); str += k; } } } } return str; } function clsx () { var i = 0, tmp, x, str = ''; while (i < arguments.length) { if (tmp = arguments[i++]) { if (x = toVal(tmp)) { str && (str += ' '); str += x; } } } return str; } var __reactCreateElement__ = React.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 = function cloneChildrenWithIndex(elements, type) { var index = 0; var didUpdate = false; var children = React.Children.map(elements, function (child) { // bails out if not an element object if (! /*#__PURE__*/React.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__*/React.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__*/React.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__*/React.createContext({ tabs: [], registerTab: function registerTab() { return noop; }, active: void 0, activate: noop, manualActivation: false, preventScroll: false }), TabsConsumer = TabsContext.Consumer, useTabs = function useTabs() { return React.useContext(TabsContext); }; function _ref5(state, action) { var index = action.index; 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 = _ref.active, _ref$defaultActive = _ref.defaultActive, defaultActive = _ref$defaultActive === void 0 ? 0 : _ref$defaultActive, _ref$manualActivation = _ref.manualActivation, manualActivation = _ref$manualActivation === void 0 ? false : _ref$manualActivation, _ref$preventScroll = _ref.preventScroll, preventScroll = _ref$preventScroll === void 0 ? false : _ref$preventScroll, _ref$onChange = _ref.onChange, onChange = _ref$onChange === void 0 ? noop : _ref$onChange, children = _ref.children; var _React$useReducer = React.useReducer(_ref5, []), tabs = _React$useReducer[0], dispatchTabs = _React$useReducer[1]; var _React$useState = React.useState(defaultActive), userActive = _React$useState[0], setActive = _React$useState[1]; useChange(userActive, onChange); var nextActive = active !== null && active !== void 0 ? active : userActive; function _registerTab(index, element, id, disabled) { dispatchTabs({ type: 'register', index: index, element: element, id: id, disabled: disabled }); return function () { return dispatchTabs({ type: 'unregister', index: index }); }; } function _activate2(index) { var _tabs; if ((_tabs = tabs[index || -1]) === null || _tabs === void 0 ? void 0 : _tabs.disabled) return; setActive(index); } var context = React.useMemo(function () { return { tabs: tabs, registerTab: _registerTab, active: nextActive, activate: _activate2, preventScroll: preventScroll, manualActivation: manualActivation }; }, [tabs, nextActive, manualActivation, preventScroll]); return /*#__PURE__*/__reactCreateElement__(TabsContext.Provider, { value: context }, cloneChildrenWithIndex(cloneChildrenWithIndex(children, Tab), Panel)); } function useTab(index) { var _React$useContext = React.useContext(TabsContext), tabs = _React$useContext.tabs, _activate = _React$useContext.activate, active = _React$useContext.active; function _activate3() { var _tabs$index3; return !((_tabs$index3 = tabs[index]) === null || _tabs$index3 === void 0 ? void 0 : _tabs$index3.disabled) && _activate(index); } return React.useMemo(function () { 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 = _ref2.id, index = _ref2.index, _ref2$disabled = _ref2.disabled, disabled = _ref2$disabled === void 0 ? false : _ref2$disabled, activeClass = _ref2.activeClass, inactiveClass = _ref2.inactiveClass, activeStyle = _ref2.activeStyle, inactiveStyle = _ref2.inactiveStyle, _ref2$onDelete = _ref2.onDelete, onDelete = _ref2$onDelete === void 0 ? noop : _ref2$onDelete, children = _ref2.children; id = useId(id); var _useTabs = useTabs(), registerTab = _useTabs.registerTab; var triggerRef = React.useRef(null); var _useTabs2 = useTabs(), tabs = _useTabs2.tabs, manualActivation = _useTabs2.manualActivation; var _useTab = useTab(index), isActive = _useTab.isActive, activate = _useTab.activate; var ref = useMergedRef( // @ts-ignore children.ref, triggerRef); useKey(triggerRef, { // right arrow ArrowRight: function ArrowRight() { return focusNext(tabs, index); }, // left arrow ArrowLeft: function ArrowLeft() { return focusPrev(tabs, index); }, // home Home: function 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: function 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 }); React.useEffect(function () { return registerTab(index, triggerRef.current, id, disabled); }, // eslint-disable-next-line react-hooks/exhaustive-deps [id, disabled, index]); return /*#__PURE__*/__reactCreateElement__(Button, null, /*#__PURE__*/React.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: _extends({}, children.props.style, isActive ? activeStyle : inactiveStyle), tabIndex: children.props.hasOwnProperty('tabIndex') ? children.props.tabIndex : isActive ? 0 : -1, onFocus: function 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: function 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: 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.children; return /*#__PURE__*/React.cloneElement(children, { role: 'tablist' }); } function Panel(_ref4) { var index = _ref4.index, activeClass = _ref4.activeClass, inactiveClass = _ref4.inactiveClass, activeStyle = _ref4.activeStyle, inactiveStyle = _ref4.inactiveStyle, children = _ref4.children; var _useTab2 = useTab(index), isActive = _useTab2.isActive, id = _useTab2.id; var _useTabs3 = useTabs(), manualActivation = _useTabs3.manualActivation, preventScroll = _useTabs3.preventScroll; var prevActive = React.useRef(isActive); var panelRef = React.useRef(null); var ref = useMergedRef( // @ts-ignore children.ref, panelRef); useConditionalFocus(panelRef, manualActivation && !prevActive.current && isActive, { includeRoot: true, preventScroll: 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 usePassiveLayoutEffect(function () { prevActive.current = isActive; }, [isActive, index]); return /*#__PURE__*/React.cloneElement(children, { 'aria-hidden': "" + !isActive, id: id, className: clsx(children.props.className, isActive ? activeClass : inactiveClass) || void 0, style: _extends({ visibility: isActive ? 'visible' : 'hidden' }, children.props.style, isActive ? activeStyle : inactiveStyle), tabIndex: children.props.hasOwnProperty('tabIndex') ? children.props.tabIndex : isActive ? 0 : -1, ref: ref }); } /* istanbul ignore next */ if (typeof process !== 'undefined' && "production" !== 'production') { Tabs.displayName = 'Tabs'; TabList.displayName = 'TabList'; Tab.displayName = 'Tab'; Panel.displayName = 'Panel'; } exports.Panel = Panel; exports.Tab = Tab; exports.TabList = TabList; exports.Tabs = Tabs; exports.TabsConsumer = TabsConsumer; exports.TabsContext = TabsContext; exports.useTab = useTab; exports.useTabs = useTabs; Object.defineProperty(exports, '__esModule', { value: true }); }))); //# sourceMappingURL=tabs.dev.js.map