@accessible/disclosure
Version:
🅰 An accessible and versatile disclosure component for React
250 lines (236 loc) • 7.34 kB
JavaScript
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';
}