UNPKG

@melt-ui/svelte

Version:
533 lines (532 loc) 22.7 kB
import { usePopper } from '../../internal/actions/index.js'; import { FIRST_LAST_KEYS, addHighlight, addMeltEventListener, back, createClickOutsideIgnore, createElHelpers, createTypeaheadSearch, derivedVisible, disabledAttr, effect, executeCallbacks, forward, generateId, getOptions, getPortalDestination, isBrowser, isElement, isElementDisabled, isHTMLButtonElement, isHTMLElement, isHTMLInputElement, isObject, kbd, last, makeElement, next, noop, omit, overridable, prev, removeHighlight, removeScroll, stripValues, styleToString, toWritableStores, toggle, withGet, getElementById, } from '../../internal/helpers/index.js'; import { dequal as deepEqual } from 'dequal'; import { tick } from 'svelte'; import { derived, get, readonly, writable } from 'svelte/store'; import { generateIds } from '../../internal/helpers/id.js'; import { createHiddenInput } from '../hidden-input/create.js'; import { createLabel } from '../label/create.js'; // prettier-ignore export const INTERACTION_KEYS = [kbd.ARROW_LEFT, kbd.ESCAPE, kbd.ARROW_RIGHT, kbd.SHIFT, kbd.CAPS_LOCK, kbd.CONTROL, kbd.ALT, kbd.META, kbd.ENTER, kbd.F1, kbd.F2, kbd.F3, kbd.F4, kbd.F5, kbd.F6, kbd.F7, kbd.F8, kbd.F9, kbd.F10, kbd.F11, kbd.F12]; const defaults = { positioning: { placement: 'bottom', sameWidth: true, }, scrollAlignment: 'nearest', loop: true, defaultOpen: false, closeOnOutsideClick: true, preventScroll: true, escapeBehavior: 'close', forceVisible: false, portal: 'body', builder: 'listbox', disabled: false, required: false, name: undefined, typeahead: true, highlightOnHover: true, onOutsideClick: undefined, preventTextSelectionOverflow: true, rootElement: undefined, }; export const listboxIdParts = ['trigger', 'menu', 'label']; /** * Creates an ARIA-1.2-compliant listbox. * * @TODO multi-select using `tags-input` builder? */ export function createListbox(props) { const withDefaults = { ...defaults, ...props }; // Trigger element for the popper portal. This will be our input element. const activeTrigger = withGet(writable(null)); // The currently highlighted menu item. const highlightedItem = withGet(writable(null)); const selectedWritable = withDefaults.selected ?? writable(withDefaults.defaultSelected); const selected = overridable(selectedWritable, withDefaults?.onSelectedChange); const highlighted = derived(highlightedItem, ($highlightedItem) => $highlightedItem ? getOptionProps($highlightedItem) : undefined); // Either the provided open store or a store with the default open value const openWritable = withDefaults.open ?? writable(withDefaults.defaultOpen); // The overridable open store which is the source of truth for the open state. const open = overridable(openWritable, withDefaults?.onOpenChange); const options = toWritableStores({ ...omit(withDefaults, 'open', 'defaultOpen', 'builder', 'ids'), multiple: withDefaults.multiple ?? false, }); const { scrollAlignment, loop, closeOnOutsideClick, escapeBehavior, preventScroll, portal, forceVisible, positioning, multiple, arrowSize, disabled, required, typeahead, name: nameProp, highlightOnHover, onOutsideClick, preventTextSelectionOverflow, rootElement, } = options; const $rootElement = rootElement.get(); if (isBrowser && $rootElement === undefined) { rootElement.set(document); } else { if (props?.portal === undefined) { portal.set($rootElement); } } const { name, selector } = createElHelpers(withDefaults.builder); const ids = toWritableStores({ ...generateIds(listboxIdParts), ...withDefaults.ids }); const { handleTypeaheadSearch } = createTypeaheadSearch({ onMatch: (element) => { highlightedItem.set(element); element.scrollIntoView({ block: scrollAlignment.get() }); }, getCurrentItem() { return highlightedItem.get(); }, }); /** ------- */ /** HELPERS */ /** ------- */ function getOptionProps(el) { const value = el.getAttribute('data-value'); const label = el.getAttribute('data-label'); const disabled = el.hasAttribute('data-disabled'); return { value: value ? JSON.parse(value) : value, label: label ?? el.textContent ?? undefined, disabled: disabled ? true : false, }; } const setOption = (newOption) => { selected.update(($option) => { const $multiple = multiple.get(); if ($multiple) { const optionArr = Array.isArray($option) ? [...$option] : []; return toggle(newOption, optionArr, (itemA, itemB) => deepEqual(itemA.value, itemB.value)); } return newOption; }); }; /** * Selects an item from the menu * @param index array index of the item to select. */ function selectItem(item) { const props = getOptionProps(item); setOption(props); } /** * Opens the menu, sets the active trigger, and highlights * the selected item (if one exists). It also optionally accepts the current * open state to prevent unnecessary updates if we know the menu is already open. */ async function openMenu() { open.set(true); // Wait a tick for the menu to open then highlight the selected item. await tick(); const menuElement = getElementById(ids.menu.get(), rootElement.get()); if (!isHTMLElement(menuElement)) return; const selectedItem = menuElement.querySelector('[aria-selected=true]'); if (!isHTMLElement(selectedItem)) return; highlightedItem.set(selectedItem); } /** Closes the menu & clears the active trigger */ function closeMenu() { open.set(false); highlightedItem.set(null); } /** * To properly anchor the popper to the input/trigger, we need to ensure both * the open state is true and the activeTrigger is not null. This helper store's * value is true when both of these conditions are met and keeps the code tidy. */ const isVisible = derivedVisible({ open, forceVisible, activeTrigger }); /* ------ */ /* STATES */ /* ------ */ /** * Determines if a given item is selected. * This is useful for displaying additional markup on the selected item. */ const isSelected = derived([selected], ([$selected]) => { return (value) => { if (Array.isArray($selected)) { return $selected.some((o) => deepEqual(o.value, value)); } if (isObject(value)) { return deepEqual($selected?.value, stripValues(value, undefined, true)); } return deepEqual($selected?.value, value); }; }); /** * Determines if a given item is highlighted. * This is useful for displaying additional markup on the highlighted item. */ const isHighlighted = derived([highlighted], ([$value]) => { return (item) => { return deepEqual($value?.value, item); }; }); /* -------- */ /* ELEMENTS */ /* -------- */ /** Action and attributes for the text input. */ const trigger = makeElement(name('trigger'), { stores: [open, highlightedItem, disabled, ids.menu, ids.trigger, ids.label], returned: ([$open, $highlightedItem, $disabled, $menuId, $triggerId, $labelId]) => { return { 'aria-activedescendant': $highlightedItem?.id, 'aria-autocomplete': 'list', 'aria-controls': $menuId, 'aria-expanded': $open, 'aria-labelledby': $labelId, 'data-state': $open ? 'open' : 'closed', // autocomplete: 'off', id: $triggerId, role: 'combobox', disabled: disabledAttr($disabled), type: withDefaults.builder === 'select' ? 'button' : undefined, }; }, action: (node) => { activeTrigger.set(node); const isInput = isHTMLInputElement(node); const unsubscribe = executeCallbacks(addMeltEventListener(node, 'click', () => { node.focus(); // Fix for safari not adding focus on trigger const $open = open.get(); if ($open) { closeMenu(); } else { openMenu(); } }), // Handle all input key events including typing, meta, and navigation. addMeltEventListener(node, 'keydown', (e) => { const $open = open.get(); /** * When the menu is closed... */ if (!$open) { // Pressing one of the interaction keys shouldn't open the menu. if (INTERACTION_KEYS.includes(e.key)) { return; } // Tab should not open the menu. if (e.key === kbd.TAB) { return; } // Pressing backspace when the input is blank shouldn't open the menu. if (e.key === kbd.BACKSPACE && isInput && node.value === '') { return; } // Clicking space on a button triggers a click event. We don't want to // open the menu in this case, and we let the click handler handle it. if (e.key === kbd.SPACE && isHTMLButtonElement(node)) { return; } // All other events should open the menu. openMenu(); tick().then(() => { const $selectedItem = selected.get(); if ($selectedItem) return; const menuEl = getElementById(ids.menu.get(), rootElement.get()); if (!isHTMLElement(menuEl)) return; const enabledItems = Array.from(menuEl.querySelectorAll(`${selector('item')}:not([data-disabled]):not([data-hidden])`)).filter((item) => isHTMLElement(item)); if (!enabledItems.length) return; if (e.key === kbd.ARROW_DOWN) { highlightedItem.set(enabledItems[0]); enabledItems[0].scrollIntoView({ block: scrollAlignment.get() }); } else if (e.key === kbd.ARROW_UP) { highlightedItem.set(last(enabledItems)); last(enabledItems).scrollIntoView({ block: scrollAlignment.get() }); } }); } /** * When the menu is open... */ // Pressing `esc` should close the menu. if (e.key === kbd.TAB) { closeMenu(); return; } // Pressing enter with a highlighted item should select it. if ((e.key === kbd.ENTER && !e.isComposing) || (e.key === kbd.SPACE && isHTMLButtonElement(node))) { e.preventDefault(); const $highlightedItem = highlightedItem.get(); if ($highlightedItem) { selectItem($highlightedItem); } if (!multiple.get()) { closeMenu(); } } // Pressing Alt + Up should close the menu. if (e.key === kbd.ARROW_UP && e.altKey) { closeMenu(); } // Navigation (up, down, etc.) should change the highlighted item. if (FIRST_LAST_KEYS.includes(e.key)) { e.preventDefault(); // Get all the menu items. const menuElement = getElementById(ids.menu.get(), rootElement.get()); if (!isHTMLElement(menuElement)) return; const itemElements = getOptions(menuElement); if (!itemElements.length) return; // Disabled items can't be highlighted. Skip them. const candidateNodes = itemElements.filter((opt) => !isElementDisabled(opt) && opt.dataset.hidden === undefined); // Get the index of the currently highlighted item. const $currentItem = highlightedItem.get(); const currentIndex = $currentItem ? candidateNodes.indexOf($currentItem) : -1; // Find the next menu item to highlight. const $loop = loop.get(); const $scrollAlignment = scrollAlignment.get(); let nextItem; switch (e.key) { case kbd.ARROW_DOWN: nextItem = next(candidateNodes, currentIndex, $loop); break; case kbd.ARROW_UP: nextItem = prev(candidateNodes, currentIndex, $loop); break; case kbd.PAGE_DOWN: nextItem = forward(candidateNodes, currentIndex, 10, $loop); break; case kbd.PAGE_UP: nextItem = back(candidateNodes, currentIndex, 10, $loop); break; case kbd.HOME: nextItem = candidateNodes[0]; break; case kbd.END: nextItem = last(candidateNodes); break; default: return; } // Highlight the new item and scroll it into view. highlightedItem.set(nextItem); nextItem?.scrollIntoView({ block: $scrollAlignment }); } else if (typeahead.get()) { const menuEl = getElementById(ids.menu.get(), rootElement.get()); if (!isHTMLElement(menuEl)) return; handleTypeaheadSearch(e.key, getOptions(menuEl)); } })); return { destroy() { activeTrigger.set(null); unsubscribe(); }, }; }, }); /** * Action and attributes for the menu element. */ const menu = makeElement(name('menu'), { stores: [isVisible, ids.menu], returned: ([$isVisible, $menuId]) => { return { hidden: $isVisible ? undefined : true, id: $menuId, role: 'listbox', style: $isVisible ? undefined : styleToString({ display: 'none' }), }; }, action: (node) => { let unsubPopper = noop; const unsubscribe = executeCallbacks( // Bind the popper portal to the input element. effect([isVisible, portal, closeOnOutsideClick, positioning, activeTrigger], ([$isVisible, $portal, $closeOnOutsideClick, $positioning, $activeTrigger]) => { unsubPopper(); if (!$isVisible || !$activeTrigger) return; tick().then(() => { unsubPopper(); const ignoreHandler = createClickOutsideIgnore(ids.trigger.get()); unsubPopper = usePopper(node, { anchorElement: $activeTrigger, open, options: { floating: $positioning, focusTrap: null, modal: { closeOnInteractOutside: $closeOnOutsideClick, onClose: closeMenu, shouldCloseOnInteractOutside: (e) => { onOutsideClick.get()?.(e); if (e.defaultPrevented) return false; const target = e.target; if (!isElement(target)) return false; if (target === $activeTrigger || $activeTrigger.contains(target)) { return false; } // return opposite of the result of the ignoreHandler if (ignoreHandler(e)) return false; return true; }, }, escapeKeydown: { handler: closeMenu, behaviorType: escapeBehavior }, portal: getPortalDestination(node, $portal), preventTextSelectionOverflow: { enabled: preventTextSelectionOverflow }, }, }).destroy; }); })); return { destroy: () => { unsubscribe(); unsubPopper(); }, }; }, }); // Use our existing label builder to create a label for the listbox input. const { elements: { root: labelBuilder }, } = createLabel(); const { action: labelAction } = get(labelBuilder); const label = makeElement(name('label'), { stores: [ids.label, ids.trigger], returned: ([$labelId, $triggerId]) => { return { id: $labelId, for: $triggerId, }; }, action: labelAction, }); const option = makeElement(name('option'), { stores: [isSelected], returned: ([$isSelected]) => (props) => { const selected = $isSelected(props.value); return { 'data-value': JSON.stringify(props.value), 'data-label': props.label, 'data-disabled': disabledAttr(props.disabled), 'aria-disabled': props.disabled ? true : undefined, 'aria-selected': selected, 'data-selected': selected ? '' : undefined, id: generateId(), role: 'option', }; }, action: (node) => { const unsubscribe = executeCallbacks(addMeltEventListener(node, 'click', (e) => { // If the item is disabled, `preventDefault` to stop the input losing focus. if (isElementDisabled(node)) { e.preventDefault(); return; } // Otherwise, select the item and close the menu. selectItem(node); if (!multiple.get()) { closeMenu(); } }), effect(highlightOnHover, ($highlightOnHover) => { if (!$highlightOnHover) return; const unsub = executeCallbacks(addMeltEventListener(node, 'mouseover', () => { highlightedItem.set(node); }), addMeltEventListener(node, 'mouseleave', () => { highlightedItem.set(null); })); return unsub; })); return { destroy: unsubscribe }; }, }); const group = makeElement(name('group'), { returned: () => { return (groupId) => ({ role: 'group', 'aria-labelledby': groupId, }); }, }); const groupLabel = makeElement(name('group-label'), { returned: () => { return (groupId) => ({ id: groupId, }); }, }); const hiddenInput = createHiddenInput({ value: derived([selected], ([$selected]) => { const value = Array.isArray($selected) ? $selected.map((o) => o.value) : $selected?.value; return typeof value === 'string' ? value : JSON.stringify(value); }), name: readonly(nameProp), required, prefix: withDefaults.builder, }); const arrow = makeElement(name('arrow'), { stores: arrowSize, returned: ($arrowSize) => ({ 'data-arrow': true, style: styleToString({ position: 'absolute', width: `var(--arrow-size, ${$arrowSize}px)`, height: `var(--arrow-size, ${$arrowSize}px)`, }), }), }); /* ------------------- */ /* LIFECYCLE & EFFECTS */ /* ------------------- */ /** * Handles moving the `data-highlighted` attribute between items when * the user moves their pointer or navigates with their keyboard. */ effect([highlightedItem], ([$highlightedItem]) => { if (!isBrowser) return; const menuElement = getElementById(ids.menu.get(), rootElement.get()); if (!isHTMLElement(menuElement)) return; getOptions(menuElement).forEach((node) => { if (node === $highlightedItem) { addHighlight(node); } else { removeHighlight(node); } }); }); effect([open, preventScroll], ([$open, $preventScroll]) => { if (!isBrowser || !$open || !$preventScroll) return; return removeScroll(); }); return { ids, elements: { trigger, group, option, menu, groupLabel, label, hiddenInput, arrow, }, states: { open, selected, highlighted, highlightedItem, }, helpers: { isSelected, isHighlighted, closeMenu, }, options, }; }