@accessible/disclosure
Version:
🅰 An accessible and versatile disclosure component for React
705 lines (599 loc) • 22.3 kB
JavaScript
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('react'), require('react-dom')) :
typeof define === 'function' && define.amd ? define(['exports', 'react', 'react-dom'], factory) :
(global = global || self, factory(global.Disclosure = {}, global.React, global.ReactDOM));
}(this, (function (exports, React, ReactDOM) { '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'
};
// 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
};
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]);
};
var useCallback = React.useCallback;
/**
* A hook for creating controlled toggles with on, off, and toggle callbacks.
* This is extremely useful for creating controlled inputs for components like Checkbox.
*
* @param defaultValue Sets the default value of the switch
* @param controlledValue Sets the controlled value of the switch, which will override
* the defaultValue
* @param onChange A callback invoked whenever the value in state changes
*/
function _ref$1(curr) {
return !curr;
}
function useSwitch(defaultValue, controlledValue, onChange) {
if (defaultValue === void 0) {
defaultValue = false;
}
if (onChange === void 0) {
onChange = noop;
}
var _React$useState = React.useState(controlledValue !== null && controlledValue !== void 0 ? controlledValue : defaultValue),
current = _React$useState[0],
setCurrent = _React$useState[1];
useChange(current, onChange);
return [controlledValue !== null && controlledValue !== void 0 ? controlledValue : current, _extends(useCallback(function () {
return setCurrent(_ref$1);
}, // eslint-disable-next-line react-hooks/exhaustive-deps
emptyArr), {
// eslint-disable-next-line react-hooks/exhaustive-deps
on: useCallback(function () {
return setCurrent(true);
}, emptyArr),
// eslint-disable-next-line react-hooks/exhaustive-deps
off: useCallback(function () {
return setCurrent(false);
}, emptyArr)
})];
}
var emptyArr = [];
function noop() {}
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;
});
};
};
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 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 __reactCreateElement__ = React.createElement;
var getContainer = function getContainer(container) {
return typeof document !== 'undefined' && document.querySelectorAll(container);
};
function _ref3(children, _ref2) {
var provider = _ref2.provider,
value = _ref2.value;
return __reactCreateElement__(provider, {
value: value
}, children);
}
var Portalize = function Portalize(_ref) {
var _ref$container = _ref.container,
container = _ref$container === void 0 ? '#portals' : _ref$container,
_ref$server = _ref.server,
server = _ref$server === void 0 ? true : _ref$server,
providers = _ref.providers,
children = _ref.children;
var _React$useState = React.useState(getContainer(container)),
nodes = _React$useState[0],
setNodes = _React$useState[1];
React.useEffect(function () {
setNodes(getContainer(container));
}, [container]);
if (nodes === false) {
// this branch only renders on the server
if (server) {
if (providers !== void 0 && providers.length > 0) {
children = providers.reduceRight(_ref3, children);
}
}
} else if (nodes.length > 0) {
var portals = [];
for (var i = 0; i < nodes.length; i++) {
portals.push( /*#__PURE__*/ReactDOM.createPortal(children, nodes[i]));
}
return /*#__PURE__*/__reactCreateElement__(React.Fragment, null, portals);
}
return null;
};
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__$1 = React.createElement;
var DisclosureContext = /*#__PURE__*/React.createContext({
isOpen: false,
open: noop$1,
close: noop$1,
toggle: noop$1
});
/**
* This hook provides the current value of the disclosure's context object
*/
function useDisclosure() {
return React.useContext(DisclosureContext);
}
/**
* This component creates the context for your disclosure target and trigger
* and contains some configuration options.
*/
function Disclosure(_ref) {
var id = _ref.id,
open = _ref.open,
defaultOpen = _ref.defaultOpen,
_ref$onChange = _ref.onChange,
onChange = _ref$onChange === void 0 ? noop$1 : _ref$onChange,
children = _ref.children;
id = useId(id);
var _useSwitch = useSwitch(defaultOpen, open, onChange),
isOpen = _useSwitch[0],
toggle = _useSwitch[1];
var context = React.useMemo(function () {
return {
id: id,
open: toggle.on,
close: toggle.off,
toggle: toggle,
isOpen: isOpen
};
}, [id, isOpen, toggle]);
return /*#__PURE__*/__reactCreateElement__$1(DisclosureContext.Provider, {
value: context
}, children);
}
function portalize(Component, portal) {
if (!portal) return Component;
var props = {
children: Component
};
if (typeof portal === 'string') props.container = portal;else _extends(props, portal);
return __reactCreateElement__$1(Portalize, props);
}
/**
* A React hook for creating a headless disclosure target to [WAI-ARIA authoring practices](https://www.w3.org/TR/wai-aria-practices-1.1/examples/disclosure/disclosure-faq.html).
*
* @param target A React ref or HTML element
* @param options Configuration options
*/
function useA11yTarget(target, options) {
if (options === void 0) {
options = {};
}
var _options = options,
preventScroll = _options.preventScroll,
_options$closeOnEscap = _options.closeOnEscape,
closeOnEscape = _options$closeOnEscap === void 0 ? true : _options$closeOnEscap,
openClass = _options.openClass,
closedClass = _options.closedClass,
openStyle = _options.openStyle,
closedStyle = _options.closedStyle;
var _useDisclosure = useDisclosure(),
id = _useDisclosure.id,
isOpen = _useDisclosure.isOpen,
close = _useDisclosure.close;
var prevOpen = usePrevious(isOpen); // Provides the target focus when it is in a new open state
useConditionalFocus(target, !prevOpen && isOpen, {
includeRoot: true,
preventScroll: preventScroll
}); // Handles closing the modal when the ESC key is pressed
useKey(target, {
Escape: function Escape() {
return closeOnEscape && close();
}
});
return {
'aria-hidden': !isOpen,
id: id,
className: isOpen ? openClass : closedClass,
style: _extends({
visibility: isOpen ? 'visible' : 'hidden'
}, isOpen ? openStyle : closedStyle)
};
}
/**
* This component wraps any React element and turns it into a
* disclosure target.
*/
function Target(_ref2) {
var _ref2$closeOnEscape = _ref2.closeOnEscape,
closeOnEscape = _ref2$closeOnEscape === void 0 ? true : _ref2$closeOnEscape,
portal = _ref2.portal,
openClass = _ref2.openClass,
closedClass = _ref2.closedClass,
openStyle = _ref2.openStyle,
closedStyle = _ref2.closedStyle,
preventScroll = _ref2.preventScroll,
children = _ref2.children;
var ref = React.useRef(null);
var childProps = children.props;
var a11yProps = useA11yTarget(ref, {
openClass: clsx(childProps.className, openClass) || void 0,
closedClass: clsx(childProps.className, closedClass) || void 0,
openStyle: childProps.style ? _extends({}, childProps.style, openStyle) : openStyle,
closedStyle: childProps.style ? _extends({}, childProps.style, closedStyle) : closedStyle,
closeOnEscape: closeOnEscape,
preventScroll: preventScroll
});
return portalize( /*#__PURE__*/React.cloneElement(children, _extends(a11yProps, {
ref: useMergedRef(ref, // @ts-expect-error
children.ref)
})), portal);
}
/**
* A React hook for creating a headless close button to [WAI-ARIA authoring practices](https://www.w3.org/TR/wai-aria-practices-1.1/examples/disclosure/disclosure-faq.html).
* In addition to providing accessibility props to your component, this
* hook will add events for interoperability between actual <button> elements
* and fake ones e.g. <a> and <div> to the target element.
*
* @param target A React ref or HTML element
* @param options Configuration options
*/
function useA11yCloseButton(target, _temp) {
var _ref3 = _temp === void 0 ? {} : _temp,
onClick = _ref3.onClick;
var _useDisclosure2 = useDisclosure(),
close = _useDisclosure2.close,
isOpen = _useDisclosure2.isOpen,
id = _useDisclosure2.id;
return _extends({
'aria-controls': id,
'aria-expanded': isOpen,
'aria-label': 'Close'
}, useA11yButton(target, function (e) {
close();
onClick === null || onClick === void 0 ? void 0 : onClick(e);
}));
}
/**
* This is a convenience component that wraps any React element and adds
* an onClick handler which closes the disclosure.
*/
function CloseButton(_ref4) {
var children = _ref4.children;
var ref = React.useRef(null);
var childProps = children.props;
var a11yProps = useA11yCloseButton(ref, {
onClick: childProps.onClick
});
return /*#__PURE__*/React.cloneElement(children, _extends(a11yProps, {
onClick: undefined,
'aria-label': childProps.hasOwnProperty('aria-label') ? childProps['aria-label'] : a11yProps['aria-label'],
ref: useMergedRef(ref, // @ts-expect-error
children.ref)
}));
}
/**
* A React hook for creating a headless disclosure trigger to [WAI-ARIA authoring practices](https://www.w3.org/TR/wai-aria-practices-1.1/examples/disclosure/disclosure-faq.html).
* In addition to providing accessibility props to your component, this
* hook will add events for interoperability between actual <button> elements
* and fake ones e.g. <a> and <div> to the target element
*
* @param target A React ref or HTML element
* @param options Configuration options
*/
function useA11yTrigger(target, options) {
if (options === void 0) {
options = {};
}
var _options2 = options,
openClass = _options2.openClass,
closedClass = _options2.closedClass,
openStyle = _options2.openStyle,
closedStyle = _options2.closedStyle,
onClick = _options2.onClick;
var _useDisclosure3 = useDisclosure(),
isOpen = _useDisclosure3.isOpen,
id = _useDisclosure3.id,
toggle = _useDisclosure3.toggle;
var prevOpen = usePrevious(isOpen);
useConditionalFocus(target, prevOpen && !isOpen, {
includeRoot: true
});
return _extends({
'aria-controls': id,
'aria-expanded': isOpen,
className: isOpen ? openClass : closedClass,
style: isOpen ? openStyle : closedStyle
}, useA11yButton(target, function (e) {
toggle();
onClick === null || onClick === void 0 ? void 0 : onClick(e);
}));
}
/**
* This component wraps any React element and adds an `onClick` handler
* which toggles the open state of the disclosure target.
*/
function Trigger(_ref5) {
var openClass = _ref5.openClass,
closedClass = _ref5.closedClass,
openStyle = _ref5.openStyle,
closedStyle = _ref5.closedStyle,
children = _ref5.children;
var ref = React.useRef(null);
var childProps = children.props;
var a11yProps = useA11yTrigger(ref, {
openClass: clsx(childProps.className, openClass) || void 0,
closedClass: clsx(childProps.className, closedClass) || void 0,
openStyle: childProps.style ? _extends({}, childProps.style, openStyle) : openStyle,
closedStyle: childProps.style ? _extends({}, childProps.style, closedStyle) : closedStyle,
onClick: childProps.onClick
});
return /*#__PURE__*/React.cloneElement(children, _extends(a11yProps, {
onClick: undefined,
ref: useMergedRef(ref, // @ts-expect-error
children.ref)
}));
}
function noop$1() {}
/* istanbul ignore next */
if (typeof process !== 'undefined' && "production" !== 'production') {
Disclosure.displayName = 'Disclosure';
Target.displayName = 'Target';
Trigger.displayName = 'Trigger';
CloseButton.displayName = 'CloseButton';
}
exports.CloseButton = CloseButton;
exports.Disclosure = Disclosure;
exports.Target = Target;
exports.Trigger = Trigger;
exports.useA11yCloseButton = useA11yCloseButton;
exports.useA11yTarget = useA11yTarget;
exports.useA11yTrigger = useA11yTrigger;
exports.useDisclosure = useDisclosure;
Object.defineProperty(exports, '__esModule', { value: true });
})));
//# sourceMappingURL=disclosure.dev.js.map