@melt-ui/svelte
Version:

533 lines (532 loc) • 22.7 kB
JavaScript
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,
};
}