import qsa from 'dom-helpers/querySelectorAll'; import addEventListener from 'dom-helpers/addEventListener'; import { useCallback, useRef, useEffect, useMemo, useContext } from 'react'; import * as React from 'react'; import { useUncontrolledProp } from 'uncontrollable'; import usePrevious from '@restart/hooks/usePrevious'; import useForceUpdate from '@restart/hooks/useForceUpdate'; import useEventListener from '@restart/hooks/useEventListener'; import useEventCallback from '@restart/hooks/useEventCallback'; import DropdownContext from './DropdownContext'; import DropdownMenu from './DropdownMenu'; import DropdownToggle, { isRoleMenu } from './DropdownToggle'; import DropdownItem from './DropdownItem'; import SelectableContext from './SelectableContext'; import { dataAttr } from './DataKey'; import useWindow from './useWindow'; import { jsx as _jsx } from "react/jsx-runtime"; function useRefWithUpdate() { const forceUpdate = useForceUpdate(); const ref = useRef(null); const attachRef = useCallback(element => { ref.current = element; // ensure that a menu set triggers an update for consumers forceUpdate(); }, [forceUpdate]); return [ref, attachRef]; } /** * @displayName Dropdown * @public */ function Dropdown({ defaultShow, show: rawShow, onSelect, onToggle: rawOnToggle, itemSelector = `* [${dataAttr('dropdown-item')}]`, focusFirstItemOnShow, placement = 'bottom-start', children }) { const window = useWindow(); const [show, onToggle] = useUncontrolledProp(rawShow, defaultShow, rawOnToggle); // We use normal refs instead of useCallbackRef in order to populate the // the value as quickly as possible, otherwise the effect to focus the element // may run before the state value is set const [menuRef, setMenu] = useRefWithUpdate(); const menuElement = menuRef.current; const [toggleRef, setToggle] = useRefWithUpdate(); const toggleElement = toggleRef.current; const lastShow = usePrevious(show); const lastSourceEvent = useRef(null); const focusInDropdown = useRef(false); const onSelectCtx = useContext(SelectableContext); const toggle = useCallback((nextShow, event, source = event == null ? void 0 : event.type) => { onToggle(nextShow, { originalEvent: event, source }); }, [onToggle]); const handleSelect = useEventCallback((key, event) => { onSelect == null ? void 0 : onSelect(key, event); toggle(false, event, 'select'); if (!event.isPropagationStopped()) { onSelectCtx == null ? void 0 : onSelectCtx(key, event); } }); const context = useMemo(() => ({ toggle, placement, show, menuElement, toggleElement, setMenu, setToggle }), [toggle, placement, show, menuElement, toggleElement, setMenu, setToggle]); if (menuElement && lastShow && !show) { focusInDropdown.current = menuElement.contains(menuElement.ownerDocument.activeElement); } const focusToggle = useEventCallback(() => { if (toggleElement && toggleElement.focus) { toggleElement.focus(); } }); const maybeFocusFirst = useEventCallback(() => { const type = lastSourceEvent.current; let focusType = focusFirstItemOnShow; if (focusType == null) { focusType = menuRef.current && isRoleMenu(menuRef.current) ? 'keyboard' : false; } if (focusType === false || focusType === 'keyboard' && !/^key.+$/.test(type)) { return; } const first = qsa(menuRef.current, itemSelector)[0]; if (first && first.focus) first.focus(); }); useEffect(() => { if (show) maybeFocusFirst();else if (focusInDropdown.current) { focusInDropdown.current = false; focusToggle(); } // only `show` should be changing }, [show, focusInDropdown, focusToggle, maybeFocusFirst]); useEffect(() => { lastSourceEvent.current = null; }); const getNextFocusedChild = (current, offset) => { if (!menuRef.current) return null; const items = qsa(menuRef.current, itemSelector); let index = items.indexOf(current) + offset; index = Math.max(0, Math.min(index, items.length)); return items[index]; }; useEventListener(useCallback(() => window.document, [window]), 'keydown', event => { var _menuRef$current, _toggleRef$current; const { key } = event; const target = event.target; const fromMenu = (_menuRef$current = menuRef.current) == null ? void 0 : _menuRef$current.contains(target); const fromToggle = (_toggleRef$current = toggleRef.current) == null ? void 0 : _toggleRef$current.contains(target); // Second only to https://github.com/twbs/bootstrap/blob/8cfbf6933b8a0146ac3fbc369f19e520bd1ebdac/js/src/dropdown.js#L400 // in inscrutability const isInput = /input|textarea/i.test(target.tagName); if (isInput && (key === ' ' || key !== 'Escape' && fromMenu || key === 'Escape' && target.type === 'search')) { return; } if (!fromMenu && !fromToggle) { return; } if (key === 'Tab' && (!menuRef.current || !show)) { return; } lastSourceEvent.current = event.type; const meta = { originalEvent: event, source: event.type }; switch (key) { case 'ArrowUp': { const next = getNextFocusedChild(target, -1); if (next && next.focus) next.focus(); event.preventDefault(); return; } case 'ArrowDown': event.preventDefault(); if (!show) { onToggle(true, meta); } else { const next = getNextFocusedChild(target, 1); if (next && next.focus) next.focus(); } return; case 'Tab': // on keydown the target is the element being tabbed FROM, we need that // to know if this event is relevant to this dropdown (e.g. in this menu). // On `keyup` the target is the element being tagged TO which we use to check // if focus has left the menu addEventListener(target.ownerDocument, 'keyup', e => { var _menuRef$current2; if (e.key === 'Tab' && !e.target || !((_menuRef$current2 = menuRef.current) != null && _menuRef$current2.contains(e.target))) { onToggle(false, meta); } }, { once: true }); break; case 'Escape': if (key === 'Escape') { event.preventDefault(); event.stopPropagation(); } onToggle(false, meta); break; default: } }); return /*#__PURE__*/_jsx(SelectableContext.Provider, { value: handleSelect, children: /*#__PURE__*/_jsx(DropdownContext.Provider, { value: context, children: children }) }); } Dropdown.displayName = 'Dropdown'; Dropdown.Menu = DropdownMenu; Dropdown.Toggle = DropdownToggle; Dropdown.Item = DropdownItem; export default Dropdown;