@create-figma-plugin/ui
Version:
Production-grade Preact components that replicate the Figma UI design
258 lines • 10.8 kB
JavaScript
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