@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
JavaScript
(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