UNPKG

reblend-ui

Version:

Utilities for creating robust overlay components

247 lines (244 loc) 9.94 kB
"use strict"; exports.__esModule = true; exports.default = void 0; var _querySelectorAll = require("dom-helpers/querySelectorAll"); var _addEventListener = require("dom-helpers/addEventListener"); var _reblendjs = require("reblendjs"); var _reblendHooks = require("reblend-hooks"); var _DropdownContext = require("./DropdownContext"); var _DropdownMenu = require("./DropdownMenu"); var _DropdownToggle = require("./DropdownToggle"); var _DropdownItem = require("./DropdownItem"); var _SelectableContext = require("./SelectableContext"); var _DataKey = require("./DataKey"); var _useWindow = require("./useWindow"); function useRefWithUpdate( /* @Reblend: Transformed from function to class */ ) { const forceUpdate = _reblendHooks.useForceUpdate.bind(this)(); this.state.forceUpdate = forceUpdate; const ref = _reblendjs.useRef.bind(this)(null); this.state.ref = ref; const attachRef = _reblendjs.useCallback.bind(this)(element => { this.state.ref.current = element; // ensure that a menu set triggers an update for consumers this.state.forceUpdate(); }); this.state.attachRef = attachRef; return [this.state.ref, this.state.attachRef]; } /** * @displayName Dropdown * @public */ class Dropdown extends _reblendjs.default { static ELEMENT_NAME = "Dropdown"; constructor() { super(); } async initState() { const [window] = _useWindow.default.bind(this)(); this.state.window = window; const [show, setShow] = _reblendjs.useState.bind(this)(this.props.defaultShow, "show"); this.state.show = show; this.state.setShow = setShow; _reblendjs.useEffect.bind(this)(() => { if (typeof this.props.rawShow !== 'undefined') { this.state.setShow(this.props.rawShow); } }, (() => this.props.rawShow).bind(this)); const onToggle = _reblendjs.useCallback.bind(this)((nextShow, meta) => { if (typeof this.props.rawShow !== 'undefined') { // controlled, so we don't update the state return this.props.rawOnToggle?.(nextShow, meta); } else if (nextShow !== this.state.show) { // uncontrolled, so we update the state this.state.setShow(nextShow); this.props.rawOnToggle?.(nextShow, meta); } }); // 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 this.state.onToggle = onToggle; const [menuRef, setMenu] = useRefWithUpdate.bind(this)(); this.state.menuRef = menuRef; this.state.setMenu = setMenu; const [toggleRef, setToggle] = useRefWithUpdate.bind(this)(); this.state.toggleRef = toggleRef; this.state.setToggle = setToggle; const lastShow = _reblendjs.useMemo.bind(this)(({ previous }) => previous, "lastShow", (() => this.state.show).bind(this)); this.state.lastShow = lastShow; const lastSourceEvent = _reblendjs.useRef.bind(this)(null); this.state.lastSourceEvent = lastSourceEvent; const focusInDropdown = _reblendjs.useRef.bind(this)(false); this.state.focusInDropdown = focusInDropdown; const [onSelectCtx] = _reblendjs.useContext.bind(this)(_SelectableContext.default, "onSelectCtx"); this.state.onSelectCtx = onSelectCtx; const toggle = _reblendjs.useCallback.bind(this)((nextShow, event, source = event?.type) => { this.state.onToggle(nextShow, { originalEvent: event, source }); }); this.state.toggle = toggle; const handleSelect = _reblendHooks.useEventCallback.bind(this)((key, event) => { this.props.onSelect?.(key, event); this.state.toggle(false, event, 'select'); /* if (!event.isPropagationStopped()) { this.state.onSelectCtx?.(key, event); } */ }); this.state.handleSelect = handleSelect; _reblendjs.useEffect.bind(this)(() => { const context = { toggle: this.state.toggle, placement: this.props.placement, show: !!this.state.show, menuElement: this.state.menuRef.current, toggleElement: this.state.toggleRef.current, setMenu: this.state.setMenu, setToggle: this.state.setToggle }; _DropdownContext.default.update(context); }, (() => [this.props.placement, this.state.show, this.state.menuRef.current, this.state.toggleRef.current, this.state.setMenu, this.state.setToggle]).bind(this)); _reblendjs.useEffect.bind(this)(() => { if (this.state.menuRef.current && this.state.lastShow && !this.state.show) { this.state.focusInDropdown.current = this.state.menuRef.current.contains(this.state.menuRef.current.ownerDocument.activeElement); } }); const focusToggle = _reblendHooks.useEventCallback.bind(this)(() => { if (this.state.toggleRef.current && this.state.toggleRef.current.focus) { this.state.toggleRef.current.focus(); } }); this.state.focusToggle = focusToggle; const maybeFocusFirst = _reblendHooks.useEventCallback.bind(this)(() => { const type = this.state.lastSourceEvent.current; let focusType = this.props.focusFirstItemOnShow; if (focusType == null) { focusType = this.state.menuRef.current && (0, _DropdownToggle.isRoleMenu)(this.state.menuRef.current) ? 'keyboard' : false; } if (focusType === false || focusType === 'keyboard' && !/^key.+$/.test(type)) { return; } const first = (0, _querySelectorAll.default)(this.state.menuRef.current, this.props.itemSelector)[0]; if (first && first.focus) first.focus(); }); this.state.maybeFocusFirst = maybeFocusFirst; _reblendjs.useEffect.bind(this)(() => { if (this.state.show) this.state.maybeFocusFirst();else if (this.state.focusInDropdown.current) { this.state.focusInDropdown.current = false; this.state.focusToggle(); } // only `show` should be changing }, (() => [this.state.show, this.state.focusInDropdown]).bind(this)); _reblendjs.useEffect.bind(this)(() => { this.state.lastSourceEvent.current = null; }); const getNextFocusedChild = (current, offset) => { if (!this.state.menuRef.current) return null; const items = (0, _querySelectorAll.default)(this.state.menuRef.current, this.props.itemSelector); let index = items.indexOf(current) + offset; index = Math.max(0, Math.min(index, items.length)); return items[index]; }; this.state.getNextFocusedChild = getNextFocusedChild; _reblendHooks.useEventListener.bind(this)(_reblendjs.useCallback.bind(this)(() => this.state.window.document), 'keydown', event => { const { key } = event; const target = event.target; const fromMenu = this.state.menuRef.current?.contains(target); const fromToggle = this.state.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' && (!this.state.menuRef.current || !this.state.show)) { return; } this.state.lastSourceEvent.current = event.type; const meta = { originalEvent: event, source: event.type }; switch (key) { case 'ArrowUp': { const next = this.state.getNextFocusedChild(target, -1); if (next && next.focus) next.focus(); event.preventDefault(); return; } case 'ArrowDown': event.preventDefault(); if (!this.state.show) { this.state.onToggle(true, meta); } else { const next = this.state.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 (0, _addEventListener.default)(target.ownerDocument, 'keyup', e => { if (e.key === 'Tab' && !e.target || !this.state.menuRef.current?.contains(e.target)) { this.state.onToggle(false, meta); } }, { once: true }); break; case 'Escape': if (key === 'Escape') { event.preventDefault(); event.stopPropagation(); } this.state.onToggle(false, meta); break; default: } }); _SelectableContext.default.update(this.state.handleSelect); } async initProps({ defaultShow, show: rawShow, onSelect, onToggle: rawOnToggle, itemSelector = `* [${(0, _DataKey.dataAttr)('dropdown-item')}]`, focusFirstItemOnShow, placement = 'bottom-start', children }) { this.props = {}; this.props.defaultShow = defaultShow; this.props.rawShow = rawShow; this.props.onSelect = onSelect; this.props.rawOnToggle = rawOnToggle; this.props.itemSelector = itemSelector; this.props.focusFirstItemOnShow = focusFirstItemOnShow; this.props.placement = placement; this.props.children = children; } async html() { return this.props.children; } } /* @Reblend: Transformed from function to class */ Dropdown.displayName = 'Dropdown'; Dropdown.Menu = _DropdownMenu.default; Dropdown.Toggle = _DropdownToggle.default; Dropdown.Item = _DropdownItem.default; var _default = exports.default = Dropdown;