UNPKG

@create-figma-plugin/ui

Version:

Production-grade Preact components that replicate the Figma UI design

258 lines 10.8 kB
import { h } from 'preact'; import { createPortal } from 'preact/compat'; import { useCallback, useEffect, useRef, useState } from 'preact/hooks'; import menuStyles from '../../css/menu.module.css'; import { useMouseDownOutside } from '../../hooks/use-mouse-down-outside.js'; import { useScrollableMenu } from '../../hooks/use-scrollable-menu.js'; import { IconCheck16 } from '../../icons/icon-16/icon-check-16.js'; import { IconChevronDown16 } from '../../icons/icon-16/icon-chevron-down-16.js'; import { createClassName } from '../../utilities/create-class-name.js'; import { createComponent } from '../../utilities/create-component.js'; import { getCurrentFromRef } from '../../utilities/get-current-from-ref.js'; import { noop } from '../../utilities/no-op.js'; import { INVALID_ID, ITEM_ID_DATA_ATTRIBUTE_NAME } from '../../utilities/private/constants.js'; import dropdownStyles from './dropdown.module.css'; import { updateMenuElementLayout } from './private/update-menu-element-layout.js'; export const Dropdown = createComponent(function ({ disabled = false, icon, onChange = noop, onKeyDown = noop, onMouseDown = noop, onValueChange = noop, options, placeholder, propagateEscapeKeyDown = true, value, ...rest }, ref) { if (typeof icon === 'string' && icon.length !== 1) { throw new Error(`String \`icon\` must be a single character: "${icon}"`); } const rootElementRef = useRef(null); const menuElementRef = useRef(null); const [isMenuVisible, setIsMenuVisible] = useState(false); const index = findOptionIndexByValue(options, value); if (value !== null && index === -1) { throw new Error(`Invalid \`value\`: ${value}`); } const [selectedId, setSelectedId] = useState(index === -1 ? INVALID_ID : `${index}`); const children = typeof options[index] === 'undefined' ? '' : getDropdownOptionValue(options[index]); const { handleScrollableMenuKeyDown, handleScrollableMenuItemMouseMove } = useScrollableMenu({ itemIdDataAttributeName: ITEM_ID_DATA_ATTRIBUTE_NAME, menuElementRef, selectedId, setSelectedId }); const triggerRootBlur = useCallback(function () { getCurrentFromRef(rootElementRef).blur(); }, []); const triggerRootFocus = useCallback(function () { getCurrentFromRef(rootElementRef).focus(); }, []); const triggerMenuUpdateLayout = useCallback(function (selectedId) { const rootElement = getCurrentFromRef(rootElementRef); const menuElement = getCurrentFromRef(menuElementRef); updateMenuElementLayout(rootElement, menuElement, selectedId); }, []); const triggerMenuHide = useCallback(function () { setIsMenuVisible(false); setSelectedId(INVALID_ID); }, []); const triggerMenuShow = useCallback(function () { if (isMenuVisible === true) { return; } setIsMenuVisible(true); if (value === null) { triggerMenuUpdateLayout(selectedId); return; } const index = findOptionIndexByValue(options, value); if (index === -1) { throw new Error(`Invalid \`value\`: ${value}`); } const newSelectedId = `${index}`; setSelectedId(newSelectedId); triggerMenuUpdateLayout(newSelectedId); }, [isMenuVisible, options, selectedId, triggerMenuUpdateLayout, value]); const handleRootKeyDown = useCallback(function (event) { onKeyDown(event); const key = event.key; if (key === 'ArrowUp' || key === 'ArrowDown') { event.preventDefault(); if (isMenuVisible === false) { triggerMenuShow(); return; } handleScrollableMenuKeyDown(event); return; } if (key === 'Escape') { event.preventDefault(); if (propagateEscapeKeyDown === false) { event.stopPropagation(); } if (isMenuVisible === true) { triggerMenuHide(); return; } triggerRootBlur(); return; } if (key === 'Enter') { event.preventDefault(); if (isMenuVisible === false) { triggerMenuShow(); return; } if (selectedId !== INVALID_ID) { const selectedElement = getCurrentFromRef(menuElementRef).querySelector(`[${ITEM_ID_DATA_ATTRIBUTE_NAME}='${selectedId}']`); if (selectedElement === null) { throw new Error('`selectedElement` is `null`'); } selectedElement.checked = true; const changeEvent = new window.Event('change', { bubbles: true, cancelable: true }); selectedElement.dispatchEvent(changeEvent); } triggerMenuHide(); return; } if (key === 'Tab') { triggerMenuHide(); return; } }, [ handleScrollableMenuKeyDown, isMenuVisible, onKeyDown, propagateEscapeKeyDown, selectedId, triggerMenuHide, triggerMenuShow, triggerRootBlur ]); const handleRootMouseDown = useCallback(function (event) { onMouseDown(event); if (isMenuVisible === false) { triggerMenuShow(); } }, [isMenuVisible, onMouseDown, triggerMenuShow]); const handleMenuMouseDown = useCallback(function (event) { event.stopPropagation(); }, []); const handleOptionChange = useCallback(function (event) { onChange(event); const id = event.currentTarget.getAttribute(ITEM_ID_DATA_ATTRIBUTE_NAME); if (id === null) { throw new Error('`id` is `null`'); } const optionValue = options[parseInt(id, 10)]; const newValue = optionValue.value; onValueChange(newValue); triggerRootFocus(); triggerMenuHide(); }, [onChange, onValueChange, options, triggerMenuHide, triggerRootFocus]); const handleSelectedOptionClick = useCallback(function () { triggerRootFocus(); triggerMenuHide(); }, [triggerMenuHide, triggerRootFocus]); const handleMouseDownOutside = useCallback(function () { if (isMenuVisible === false) { return; } triggerMenuHide(); triggerRootBlur(); }, [isMenuVisible, triggerRootBlur, triggerMenuHide]); useMouseDownOutside({ onMouseDownOutside: handleMouseDownOutside, ref: rootElementRef }); useEffect(function () { function handleWindowScroll() { if (isMenuVisible === false) { return; } triggerRootFocus(); triggerMenuHide(); } window.addEventListener('scroll', handleWindowScroll); return function () { window.removeEventListener('scroll', handleWindowScroll); }; }, [isMenuVisible, triggerMenuHide, triggerRootFocus]); const refCallback = useCallback(function (rootElement) { rootElementRef.current = rootElement; if (ref === null) { return; } if (typeof ref === 'function') { ref(rootElement); return; } ref.current = rootElement; }, [ref, rootElementRef]); return (h("div", { ...rest, ref: refCallback, class: createClassName([ dropdownStyles.dropdown, typeof icon !== 'undefined' ? dropdownStyles.hasIcon : null, disabled === true ? dropdownStyles.disabled : null ]), onKeyDown: disabled === true ? undefined : handleRootKeyDown, onMouseDown: handleRootMouseDown, tabIndex: 0 }, typeof icon === 'undefined' ? null : (h("div", { class: dropdownStyles.icon }, icon)), value === null ? (h("div", { class: createClassName([ dropdownStyles.value, dropdownStyles.placeholder ]) }, placeholder)) : (h("div", { class: dropdownStyles.value }, children)), h("div", { class: dropdownStyles.chevronIcon }, h(IconChevronDown16, null)), createPortal(h("div", { ref: menuElementRef, class: createClassName([ menuStyles.menu, dropdownStyles.menu, disabled === true || isMenuVisible === false ? menuStyles.hidden : null ]), onMouseDown: handleMenuMouseDown }, options.map(function (option, index) { if (typeof option === 'string') { return h("hr", { key: index, class: menuStyles.optionSeparator }); } if ('header' in option) { return (h("h1", { key: index, class: menuStyles.optionHeader }, option.header)); } return (h("label", { key: index, class: createClassName([ menuStyles.optionValue, option.disabled === true ? menuStyles.optionValueDisabled : null, option.disabled !== true && `${index}` === selectedId ? menuStyles.optionValueSelected : null ]) }, h("input", { checked: value === option.value, class: menuStyles.input, disabled: option.disabled === true, onChange: value === option.value ? undefined : handleOptionChange, onClick: value === option.value ? handleSelectedOptionClick : undefined, onMouseMove: handleScrollableMenuItemMouseMove, tabIndex: -1, type: "radio", value: `${option.value}`, [ITEM_ID_DATA_ATTRIBUTE_NAME]: `${index}` }), option.value === value ? (h("div", { class: menuStyles.checkIcon }, h(IconCheck16, null))) : null, typeof option.text === 'undefined' ? option.value : option.text)); })), document.body))); }); function getDropdownOptionValue(option) { if (typeof option !== 'string') { if ('text' in option) { return option.text; } if ('value' in option) { return option.value; } } throw new Error('Invariant violation'); } function findOptionIndexByValue(options, value) { if (value === null) { return -1; } let index = 0; for (const option of options) { if (typeof option !== 'string' && 'value' in option && option.value === value) { return index; } index += 1; } return -1; } //# sourceMappingURL=dropdown.js.map