UNPKG

@accessible/disclosure

Version:

🅰 An accessible and versatile disclosure component for React

705 lines (599 loc) • 22.3 kB
(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