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