UNPKG

@acusti/dropdown

Version:

React component that renders a dropdown with a trigger and supports searching, keyboard access, and more

622 lines (621 loc) 25.8 kB
/* eslint-disable jsx-a11y/click-events-have-key-events, jsx-a11y/mouse-events-have-key-events, jsx-a11y/no-static-element-interactions */ import InputText from '@acusti/input-text'; import { Style } from '@acusti/styling'; import useIsOutOfBounds from '@acusti/use-is-out-of-bounds'; import useKeyboardEvents, { isEventTargetUsingKeyEvent, } from '@acusti/use-keyboard-events'; import clsx from 'clsx'; import * as React from 'react'; import { getActiveItemElement, getItemElements, ITEM_SELECTOR, setActiveItem, } from './helpers.js'; import { BODY_CLASS_NAME, BODY_MAX_HEIGHT_VAR, BODY_MAX_WIDTH_VAR, BODY_SELECTOR, LABEL_CLASS_NAME, LABEL_TEXT_CLASS_NAME, ROOT_CLASS_NAME, STYLES, TRIGGER_CLASS_NAME, } from './styles.js'; const { Children, Fragment, useCallback, useEffect, useMemo, useRef, useState } = React; const noop = () => {}; // eslint-disable-line @typescript-eslint/no-empty-function const CHILDREN_ERROR = '@acusti/dropdown requires either 1 child (the dropdown body) or 2 children: the dropdown trigger and the dropdown body.'; const TEXT_INPUT_SELECTOR = 'input:not([type=radio]):not([type=checkbox]):not([type=range]),textarea'; export default function Dropdown({ allowCreate, allowEmpty = true, children, className, disabled, hasItems = true, isOpenOnMount, isSearchable, keepOpenOnSubmit = !hasItems, label, name, onClick, onClose, onMouseDown, onMouseUp, onOpen, onSubmitItem, placeholder, style: styleFromProps, tabIndex, value, }) { const childrenCount = Children.count(children); if (childrenCount !== 1 && childrenCount !== 2) { if (childrenCount === 0) { throw new Error(CHILDREN_ERROR + ' Received no children.'); } console.error(`${CHILDREN_ERROR} Received ${childrenCount} children.`); } let trigger; if (childrenCount > 1) { trigger = children[0]; } const isTriggerFromProps = React.isValidElement(trigger); const [isOpen, setIsOpen] = useState( isOpenOnMount !== null && isOpenOnMount !== void 0 ? isOpenOnMount : false, ); const [isOpening, setIsOpening] = useState(!isOpenOnMount); const [dropdownBodyElement, setDropdownBodyElement] = useState(null); const dropdownElementRef = useRef(null); const inputElementRef = useRef(null); const closingTimerRef = useRef(null); const isOpeningTimerRef = useRef(null); const currentInputMethodRef = useRef('mouse'); const clearEnteredCharactersTimerRef = useRef(null); const enteredCharactersRef = useRef(''); const mouseDownPositionRef = useRef(null); const outOfBounds = useIsOutOfBounds(dropdownBodyElement); const setDropdownOpenRef = useRef(() => setIsOpen(true)); const allowCreateRef = useRef(allowCreate); const allowEmptyRef = useRef(allowEmpty); const hasItemsRef = useRef(hasItems); const isOpenRef = useRef(isOpen); const isOpeningRef = useRef(isOpening); const keepOpenOnSubmitRef = useRef(keepOpenOnSubmit); const onCloseRef = useRef(onClose); const onOpenRef = useRef(onOpen); const onSubmitItemRef = useRef(onSubmitItem); const valueRef = useRef(value); useEffect(() => { allowCreateRef.current = allowCreate; allowEmptyRef.current = allowEmpty; hasItemsRef.current = hasItems; isOpenRef.current = isOpen; isOpeningRef.current = isOpening; keepOpenOnSubmitRef.current = keepOpenOnSubmit; onCloseRef.current = onClose; onOpenRef.current = onOpen; onSubmitItemRef.current = onSubmitItem; valueRef.current = value; }, [ allowCreate, allowEmpty, hasItems, isOpen, isOpening, keepOpenOnSubmit, onClose, onOpen, onSubmitItem, value, ]); const isMountedRef = useRef(false); useEffect(() => { if (!isMountedRef.current) { isMountedRef.current = true; // If isOpenOnMount, trigger onOpen right away if (isOpenRef.current && onOpenRef.current) { onOpenRef.current(); } return; } if (isOpen && onOpenRef.current) { onOpenRef.current(); } else if (!isOpen && onCloseRef.current) { onCloseRef.current(); } }, [isOpen]); const closeDropdown = useCallback(() => { setIsOpen(false); setIsOpening(false); mouseDownPositionRef.current = null; if (closingTimerRef.current) { clearTimeout(closingTimerRef.current); closingTimerRef.current = null; } }, []); const handleSubmitItem = useCallback( (event) => { var _a, _b, _c; const eventTarget = event.target; if (isOpenRef.current && !keepOpenOnSubmitRef.current) { const keepOpen = eventTarget.closest('[data-ukt-keep-open]'); // Don’t close dropdown if event occurs w/in data-ukt-keep-open element if ( !(keepOpen === null || keepOpen === void 0 ? void 0 : keepOpen.dataset.uktKeepOpen) || keepOpen.dataset.uktKeepOpen === 'false' ) { // A short timeout before closing is better UX when user selects an item so dropdown // doesn’t close before expected. It also enables using <Link />s in the dropdown body. closingTimerRef.current = setTimeout(closeDropdown, 90); } } if (!hasItemsRef.current) return; const element = getActiveItemElement(dropdownElementRef.current); if (!element && !allowCreateRef.current) { // If not allowEmpty, don’t allow submitting an empty item if (!allowEmptyRef.current) return; // If we have an input element as trigger & the user didn’t clear the text, do nothing if ( (_a = inputElementRef.current) === null || _a === void 0 ? void 0 : _a.value ) return; } let itemLabel = (_b = element === null || element === void 0 ? void 0 : element.innerText) !== null && _b !== void 0 ? _b : ''; if (inputElementRef.current) { if (!element) { itemLabel = inputElementRef.current.value; } else { inputElementRef.current.value = itemLabel; } if ( inputElementRef.current === inputElementRef.current.ownerDocument.activeElement ) { inputElementRef.current.blur(); } } const nextValue = (_c = element === null || element === void 0 ? void 0 : element.dataset.uktValue) !== null && _c !== void 0 ? _c : itemLabel; // If parent is controlling Dropdown via props.value and nextValue is the same, do nothing if (valueRef.current && valueRef.current === nextValue) return; if (onSubmitItemRef.current) { onSubmitItemRef.current({ element, event, label: itemLabel, value: nextValue, }); } }, [closeDropdown], ); const handleMouseMove = useCallback(({ clientX, clientY }) => { currentInputMethodRef.current = 'mouse'; const initialPosition = mouseDownPositionRef.current; if (!initialPosition) return; if ( Math.abs(initialPosition.clientX - clientX) < 12 && Math.abs(initialPosition.clientY - clientY) < 12 ) { return; } setIsOpening(false); }, []); const handleMouseOver = useCallback((event) => { if (!hasItemsRef.current) return; // If user isn’t currently using the mouse to navigate the dropdown, do nothing if (currentInputMethodRef.current !== 'mouse') return; // Ensure we have the dropdown root HTMLElement const dropdownElement = dropdownElementRef.current; if (!dropdownElement) return; const itemElements = getItemElements(dropdownElement); if (!itemElements) return; const eventTarget = event.target; const item = eventTarget.closest(ITEM_SELECTOR); const element = item !== null && item !== void 0 ? item : eventTarget; for (const itemElement of itemElements) { if (itemElement === element) { setActiveItem({ dropdownElement, element }); return; } } }, []); const handleMouseOut = useCallback((event) => { if (!hasItemsRef.current) return; const activeItem = getActiveItemElement(dropdownElementRef.current); if (!activeItem) return; const eventRelatedTarget = event.relatedTarget; if (activeItem !== event.target || activeItem.contains(eventRelatedTarget)) { return; } // If user moused out of activeItem (not into a descendant), it’s no longer active delete activeItem.dataset.uktActive; }, []); const handleMouseDown = useCallback( (event) => { if (onMouseDown) onMouseDown(event); if (isOpenRef.current) return; setIsOpen(true); setIsOpening(true); mouseDownPositionRef.current = { clientX: event.clientX, clientY: event.clientY, }; isOpeningTimerRef.current = setTimeout(() => { setIsOpening(false); isOpeningTimerRef.current = null; }, 1000); }, [onMouseDown], ); const handleMouseUp = useCallback( (event) => { if (onMouseUp) onMouseUp(event); // If dropdown is still opening or isn’t open or is closing, do nothing if (isOpeningRef.current || !isOpenRef.current || closingTimerRef.current) { return; } const eventTarget = event.target; // If click was outside dropdown body, don’t trigger submit if (!eventTarget.closest(BODY_SELECTOR)) { // Don’t close dropdown if isOpening or search input is focused if ( !isOpeningRef.current && inputElementRef.current !== eventTarget.ownerDocument.activeElement ) { closeDropdown(); } return; } // If dropdown has no items and click was within dropdown body, do nothing if (!hasItemsRef.current) return; handleSubmitItem(event); }, [closeDropdown, handleSubmitItem, onMouseUp], ); const handleKeyDown = useCallback( (event) => { const { altKey, ctrlKey, key, metaKey } = event; const eventTarget = event.target; const dropdownElement = dropdownElementRef.current; if (!dropdownElement) return; const onEventHandled = () => { event.stopPropagation(); event.preventDefault(); currentInputMethodRef.current = 'keyboard'; }; const isEventTargetingDropdown = dropdownElement.contains(eventTarget); if (!isOpenRef.current) { // If dropdown is closed, don’t handle key events if event target isn’t within dropdown if (!isEventTargetingDropdown) return; // Open the dropdown on spacebar, enter, or if isSearchable and user hits the ↑/↓ arrows if ( key === ' ' || key === 'Enter' || (hasItemsRef.current && (key === 'ArrowUp' || key === 'ArrowDown')) ) { onEventHandled(); setIsOpen(true); } return; } const isTargetUsingKeyEvents = isEventTargetUsingKeyEvent(event); // If dropdown isOpen + hasItems & eventTargetNotUsingKeyEvents, handle characters if (hasItemsRef.current && !isTargetUsingKeyEvents) { let isEditingCharacters = !ctrlKey && !metaKey && /^[A-Za-z0-9]$/.test(key); // User could also be editing characters if there are already characters entered // and they are hitting delete or spacebar if (!isEditingCharacters && enteredCharactersRef.current) { isEditingCharacters = key === ' ' || key === 'Backspace'; } if (isEditingCharacters) { onEventHandled(); if (key === 'Backspace') { enteredCharactersRef.current = enteredCharactersRef.current.slice( 0, -1, ); } else { enteredCharactersRef.current += key; } setActiveItem({ dropdownElement, // If props.allowCreate, only override the input’s value with an // exact text match so user can enter a value not in items isExactMatch: allowCreateRef.current, text: enteredCharactersRef.current, }); if (clearEnteredCharactersTimerRef.current) { clearTimeout(clearEnteredCharactersTimerRef.current); } clearEnteredCharactersTimerRef.current = setTimeout(() => { enteredCharactersRef.current = ''; clearEnteredCharactersTimerRef.current = null; }, 1500); return; } } // If dropdown isOpen, handle submitting the value if (key === 'Enter' || (key === ' ' && !inputElementRef.current)) { onEventHandled(); handleSubmitItem(event); return; } // If dropdown isOpen, handle closing it on escape or spacebar if !hasItems if ( key === 'Escape' || (isEventTargetingDropdown && key === ' ' && !hasItemsRef.current) ) { // Close dropdown if hasItems or event target not using key events if (hasItemsRef.current || !isTargetUsingKeyEvents) { closeDropdown(); } return; } // Handle ↑/↓ arrows if (hasItemsRef.current) { if (key === 'ArrowUp') { onEventHandled(); if (altKey || metaKey) { setActiveItem({ dropdownElement, index: 0 }); } else { setActiveItem({ dropdownElement, indexAddend: -1 }); } return; } if (key === 'ArrowDown') { onEventHandled(); if (altKey || metaKey) { // Using a negative index counts back from the end setActiveItem({ dropdownElement, index: -1 }); } else { setActiveItem({ dropdownElement, indexAddend: 1 }); } return; } } }, [closeDropdown, handleSubmitItem], ); useKeyboardEvents({ ignoreUsedKeyboardEvents: false, onKeyDown: handleKeyDown }); const cleanupEventListenersRef = useRef(noop); const handleRef = useCallback( (ref) => { dropdownElementRef.current = ref; if (!ref) { // If component was unmounted, cleanup handlers cleanupEventListenersRef.current(); cleanupEventListenersRef.current = noop; return; } const { ownerDocument } = ref; let inputElement = inputElementRef.current; // Check if trigger from props is a textual input or textarea element if (isTriggerFromProps && !inputElement && ref.firstElementChild) { if (ref.firstElementChild.matches(TEXT_INPUT_SELECTOR)) { inputElement = ref.firstElementChild; } else { inputElement = ref.firstElementChild.querySelector(TEXT_INPUT_SELECTOR); } inputElementRef.current = inputElement; } const handleGlobalMouseDown = ({ target }) => { const eventTarget = target; if ( dropdownElementRef.current && !dropdownElementRef.current.contains(eventTarget) ) { // Close dropdown on an outside click closeDropdown(); } }; const handleGlobalMouseUp = ({ target }) => { var _a; if (!isOpenRef.current || closingTimerRef.current) return; // If still isOpening (gets set false 1s after open triggers), set it to false onMouseUp if (isOpeningRef.current) { setIsOpening(false); if (isOpeningTimerRef.current) { clearTimeout(isOpeningTimerRef.current); isOpeningTimerRef.current = null; } return; } const eventTarget = target; // Only handle mouseup events from outside the dropdown here if ( !((_a = dropdownElementRef.current) === null || _a === void 0 ? void 0 : _a.contains(eventTarget)) ) { closeDropdown(); } }; // Close dropdown if any element is focused outside of this dropdown const handleGlobalFocusIn = ({ target }) => { if (!isOpenRef.current) return; const eventTarget = target; // If focused element is a descendant or a parent of the dropdown, do nothing if ( !dropdownElementRef.current || dropdownElementRef.current.contains(eventTarget) || eventTarget.contains(dropdownElementRef.current) ) { return; } closeDropdown(); }; document.addEventListener('focusin', handleGlobalFocusIn); document.addEventListener('mousedown', handleGlobalMouseDown); document.addEventListener('mouseup', handleGlobalMouseUp); if (ownerDocument !== document) { ownerDocument.addEventListener('focusin', handleGlobalFocusIn); ownerDocument.addEventListener('mousedown', handleGlobalMouseDown); ownerDocument.addEventListener('mouseup', handleGlobalMouseUp); } // If dropdown should be open on mount, focus it if (isOpenOnMount) { ref.focus(); } const handleInput = (event) => { const dropdownElement = dropdownElementRef.current; if (!dropdownElement) return; if (!isOpenRef.current) setIsOpen(true); const input = event.target; const isDeleting = enteredCharactersRef.current.length > input.value.length; enteredCharactersRef.current = input.value; // When deleting text, if there’s already an active item and // input isn’t empty, preserve the active item, else update it if ( isDeleting && input.value.length && getActiveItemElement(dropdownElement) ) { return; } setActiveItem({ dropdownElement, // If props.allowCreate, only override the input’s value with an // exact text match so user can enter a value not in items isExactMatch: allowCreateRef.current, text: enteredCharactersRef.current, }); }; if (inputElement) { inputElement.addEventListener('input', handleInput); } cleanupEventListenersRef.current = () => { document.removeEventListener('focusin', handleGlobalFocusIn); document.removeEventListener('mousedown', handleGlobalMouseDown); document.removeEventListener('mouseup', handleGlobalMouseUp); if (ownerDocument !== document) { ownerDocument.removeEventListener('focusin', handleGlobalFocusIn); ownerDocument.removeEventListener('mousedown', handleGlobalMouseDown); ownerDocument.removeEventListener('mouseup', handleGlobalMouseUp); } if (inputElement) { inputElement.removeEventListener('input', handleInput); } }; }, [closeDropdown, isOpenOnMount, isTriggerFromProps], ); if (!isTriggerFromProps) { if (isSearchable) { trigger = React.createElement(InputText, { autoComplete: 'off', className: TRIGGER_CLASS_NAME, disabled: disabled, initialValue: value !== null && value !== void 0 ? value : '', name: name, onFocus: setDropdownOpenRef.current, placeholder: placeholder, ref: inputElementRef, selectTextOnFocus: true, tabIndex: tabIndex, type: 'text', }); } else { trigger = React.createElement( 'button', { className: TRIGGER_CLASS_NAME, tabIndex: 0 }, trigger, ); } } if (label) { trigger = React.createElement( 'label', { className: LABEL_CLASS_NAME }, React.createElement('div', { className: LABEL_TEXT_CLASS_NAME }, label), trigger, ); } const style = useMemo( () => Object.assign( Object.assign( Object.assign({}, styleFromProps), outOfBounds.maxHeight != null && outOfBounds.maxHeight > 0 ? { [BODY_MAX_HEIGHT_VAR]: `calc(${outOfBounds.maxHeight}px - var(--uktdd-body-buffer))`, } : null, ), outOfBounds.maxWidth != null && outOfBounds.maxWidth > 0 ? { [BODY_MAX_WIDTH_VAR]: `calc(${outOfBounds.maxWidth}px - var(--uktdd-body-buffer))`, } : null, ), [outOfBounds.maxHeight, outOfBounds.maxWidth, styleFromProps], ); return React.createElement( Fragment, null, React.createElement(Style, { href: '@acusti/dropdown/Dropdown' }, STYLES), React.createElement( 'div', { className: clsx(ROOT_CLASS_NAME, className, { disabled, 'is-open': isOpen, 'is-searchable': isSearchable, }), onClick: onClick, onMouseDown: handleMouseDown, onMouseMove: handleMouseMove, onMouseOut: handleMouseOut, onMouseOver: handleMouseOver, onMouseUp: handleMouseUp, ref: handleRef, style: style, }, trigger, isOpen ? React.createElement( 'div', { className: clsx(BODY_CLASS_NAME, { 'calculating-position': !outOfBounds.hasLayout, 'has-items': hasItems, 'out-of-bounds-bottom': outOfBounds.bottom && !outOfBounds.top, 'out-of-bounds-left': outOfBounds.left && !outOfBounds.right, 'out-of-bounds-right': outOfBounds.right && !outOfBounds.left, 'out-of-bounds-top': outOfBounds.top && !outOfBounds.bottom, }), ref: setDropdownBodyElement, }, childrenCount > 1 ? children[1] : children, ) : null, ), ); } //# sourceMappingURL=Dropdown.js.map