UNPKG

@accessible/disclosure

Version:

🅰 An accessible and versatile disclosure component for React

250 lines (236 loc) • 7.34 kB
import * as React from 'react'; const __reactCreateElement__ = React.createElement; import useKey from '@accessible/use-key'; import useConditionalFocus from '@accessible/use-conditional-focus'; import useSwitch from '@react-hook/switch'; import useMergedRef from '@react-hook/merged-ref'; import usePrevious from '@react-hook/previous'; import useId from '@accessible/use-id'; import { useA11yButton } from '@accessible/button'; import Portalize from 'react-portalize'; import clsx from 'clsx'; const DisclosureContext = /*#__PURE__*/React.createContext({ isOpen: false, open: noop, close: noop, toggle: noop }); /** * This hook provides the current value of the disclosure's context object */ export function useDisclosure() { return React.useContext(DisclosureContext); } /** * This component creates the context for your disclosure target and trigger * and contains some configuration options. */ export function Disclosure({ id, open, defaultOpen, onChange = noop, children }) { id = useId(id); const [isOpen, toggle] = useSwitch(defaultOpen, open, onChange); const context = React.useMemo(() => ({ id, open: toggle.on, close: toggle.off, toggle, isOpen }), [id, isOpen, toggle]); return /*#__PURE__*/__reactCreateElement__(DisclosureContext.Provider, { value: context }, children); } function portalize(Component, portal) { if (!portal) return Component; const props = { children: Component }; if (typeof portal === 'string') props.container = portal;else Object.assign(props, portal); return __reactCreateElement__(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 */ export function useA11yTarget(target, options = {}) { const { preventScroll, closeOnEscape = true, openClass, closedClass, openStyle, closedStyle } = options; const { id, isOpen, close } = useDisclosure(); const prevOpen = usePrevious(isOpen); // Provides the target focus when it is in a new open state useConditionalFocus(target, !prevOpen && isOpen, { includeRoot: true, preventScroll }); // Handles closing the modal when the ESC key is pressed useKey(target, { Escape: () => closeOnEscape && close() }); return { 'aria-hidden': !isOpen, id, className: isOpen ? openClass : closedClass, style: Object.assign({ visibility: isOpen ? 'visible' : 'hidden' }, isOpen ? openStyle : closedStyle) }; } /** * This component wraps any React element and turns it into a * disclosure target. */ export function Target({ closeOnEscape = true, portal, openClass, closedClass, openStyle, closedStyle, preventScroll, children }) { const ref = React.useRef(null); const childProps = children.props; const a11yProps = useA11yTarget(ref, { openClass: clsx(childProps.className, openClass) || void 0, closedClass: clsx(childProps.className, closedClass) || void 0, openStyle: childProps.style ? Object.assign({}, childProps.style, openStyle) : openStyle, closedStyle: childProps.style ? Object.assign({}, childProps.style, closedStyle) : closedStyle, closeOnEscape, preventScroll }); return portalize( /*#__PURE__*/React.cloneElement(children, Object.assign(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 */ export function useA11yCloseButton(target, { onClick } = {}) { const { close, isOpen, id } = useDisclosure(); return Object.assign({ 'aria-controls': id, 'aria-expanded': isOpen, 'aria-label': 'Close' }, useA11yButton(target, 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. */ export function CloseButton({ children }) { const ref = React.useRef(null); const childProps = children.props; const a11yProps = useA11yCloseButton(ref, { onClick: childProps.onClick }); return /*#__PURE__*/React.cloneElement(children, Object.assign(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 */ export function useA11yTrigger(target, options = {}) { const { openClass, closedClass, openStyle, closedStyle, onClick } = options; const { isOpen, id, toggle } = useDisclosure(); const prevOpen = usePrevious(isOpen); useConditionalFocus(target, prevOpen && !isOpen, { includeRoot: true }); return Object.assign({ 'aria-controls': id, 'aria-expanded': isOpen, className: isOpen ? openClass : closedClass, style: isOpen ? openStyle : closedStyle }, useA11yButton(target, 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. */ export function Trigger({ openClass, closedClass, openStyle, closedStyle, children }) { const ref = React.useRef(null); const childProps = children.props; const a11yProps = useA11yTrigger(ref, { openClass: clsx(childProps.className, openClass) || void 0, closedClass: clsx(childProps.className, closedClass) || void 0, openStyle: childProps.style ? Object.assign({}, childProps.style, openStyle) : openStyle, closedStyle: childProps.style ? Object.assign({}, childProps.style, closedStyle) : closedStyle, onClick: childProps.onClick }); return /*#__PURE__*/React.cloneElement(children, Object.assign(a11yProps, { onClick: undefined, ref: useMergedRef(ref, // @ts-expect-error children.ref) })); } function noop() {} /* istanbul ignore next */ if (typeof process !== 'undefined' && process.env.NODE_ENV !== 'production') { Disclosure.displayName = 'Disclosure'; Target.displayName = 'Target'; Trigger.displayName = 'Trigger'; CloseButton.displayName = 'CloseButton'; }