@melt-ui/svelte
Advanced tools
Comparing version 0.54.1 to 0.55.0
/// <reference types="svelte" /> | ||
import type { MeltActionReturn } from '../../internal/types.js'; | ||
import { type Readable } from 'svelte/store'; | ||
import type { ComboboxEvents } from './events.js'; | ||
import type { ComboboxOption, ComboboxOptionProps, ComboboxSelected, CreateComboboxProps } from './types.js'; | ||
import type { ComboboxSelected, CreateComboboxProps } from './types.js'; | ||
export declare const INTERACTION_KEYS: string[]; | ||
@@ -10,18 +9,11 @@ /** | ||
* | ||
* @TODO expose a nice mechanism for clearing the input. | ||
* @TODO would it be useful to have a callback for when an item is selected? | ||
* @TODO multi-select using `tags-input` builder? | ||
*/ | ||
export declare function createCombobox<Value, Multiple extends boolean = false, S extends ComboboxSelected<Multiple, Value> = ComboboxSelected<Multiple, Value>>(props?: CreateComboboxProps<Value, Multiple, S>): { | ||
ids: { | ||
input: string; | ||
menu: string; | ||
label: string; | ||
}; | ||
elements: { | ||
input: import("../../internal/helpers/index.js").ExplicitBuilderReturn<[{ | ||
input: import("../../internal/helpers/index.js").ExplicitBuilderReturn<[import("../../internal/helpers/index.js").ExplicitBuilderReturn<[{ | ||
update: (updater: import("svelte/store").Updater<boolean>, sideEffect?: ((newValue: boolean) => void) | undefined) => void; | ||
set: (this: void, value: boolean) => void; | ||
subscribe(this: void, run: import("svelte/store").Subscriber<boolean>, invalidate?: import("svelte/store").Invalidator<boolean> | undefined): import("svelte/store").Unsubscriber; | ||
}, import("svelte/store").Writable<HTMLElement | null>, import("svelte/store").Writable<string>], (node: HTMLInputElement) => MeltActionReturn<ComboboxEvents['input']>, ([$open, $highlightedItem, $inputValue]: [boolean, HTMLElement | null, string]) => { | ||
}, import("svelte/store").Writable<HTMLElement | null>, import("svelte/store").Writable<boolean>], (node: HTMLElement) => MeltActionReturn<"input" | "click" | "keydown">, ([$open, $highlightedItem, $disabled]: [boolean, HTMLElement | null, boolean]) => { | ||
readonly 'aria-activedescendant': string | undefined; | ||
@@ -33,7 +25,45 @@ readonly 'aria-autocomplete': "list"; | ||
readonly 'data-melt-id': string; | ||
readonly autocomplete: "off"; | ||
readonly id: string; | ||
readonly role: "combobox"; | ||
readonly disabled: true | undefined; | ||
}, string>, import("svelte/store").Writable<string>], (node: HTMLInputElement) => MeltActionReturn<ComboboxEvents['input']>, ([$trigger, $inputValue]: [{ | ||
readonly 'aria-activedescendant': string | undefined; | ||
readonly 'aria-autocomplete': "list"; | ||
readonly 'aria-controls': string; | ||
readonly 'aria-expanded': boolean; | ||
readonly 'aria-labelledby': string; | ||
readonly 'data-melt-id': string; | ||
readonly id: string; | ||
readonly role: "combobox"; | ||
readonly disabled: true | undefined; | ||
} & { | ||
[x: `data-melt-${string}`]: ""; | ||
} & { | ||
action: (node: HTMLElement) => MeltActionReturn<"input" | "click" | "keydown">; | ||
}, string]) => { | ||
readonly role: "combobox"; | ||
readonly value: string; | ||
readonly disabled: true | undefined; | ||
readonly 'aria-expanded': boolean; | ||
readonly 'aria-controls': string; | ||
readonly 'aria-activedescendant': string | undefined; | ||
readonly 'aria-autocomplete': "list"; | ||
readonly 'aria-labelledby': string; | ||
readonly id: string; | ||
}, string>; | ||
trigger: import("../../internal/helpers/index.js").ExplicitBuilderReturn<[{ | ||
update: (updater: import("svelte/store").Updater<boolean>, sideEffect?: ((newValue: boolean) => void) | undefined) => void; | ||
set: (this: void, value: boolean) => void; | ||
subscribe(this: void, run: import("svelte/store").Subscriber<boolean>, invalidate?: import("svelte/store").Invalidator<boolean> | undefined): import("svelte/store").Unsubscriber; | ||
}, import("svelte/store").Writable<HTMLElement | null>, import("svelte/store").Writable<boolean>], (node: HTMLElement) => MeltActionReturn<"input" | "click" | "keydown">, ([$open, $highlightedItem, $disabled]: [boolean, HTMLElement | null, boolean]) => { | ||
readonly 'aria-activedescendant': string | undefined; | ||
readonly 'aria-autocomplete': "list"; | ||
readonly 'aria-controls': string; | ||
readonly 'aria-expanded': boolean; | ||
readonly 'aria-labelledby': string; | ||
readonly 'data-melt-id': string; | ||
readonly id: string; | ||
readonly role: "combobox"; | ||
readonly disabled: true | undefined; | ||
}, string>; | ||
option: import("../../internal/helpers/index.js").ExplicitBuilderReturn<[{ | ||
@@ -43,3 +73,3 @@ update: (updater: import("svelte/store").Updater<S | undefined>, sideEffect?: ((newValue: S | undefined) => void) | undefined) => void; | ||
subscribe(this: void, run: import("svelte/store").Subscriber<S | undefined>, invalidate?: import("svelte/store").Invalidator<S | undefined> | undefined): import("svelte/store").Unsubscriber; | ||
}], (node: HTMLElement) => MeltActionReturn<ComboboxEvents['item']>, ([$selected]: [S | undefined]) => (props: ComboboxOptionProps<Value>) => { | ||
}], (node: HTMLElement) => MeltActionReturn<"click" | "pointermove">, ([$selected]: [S | undefined]) => (props: import("./types.js").ComboboxOptionProps<unknown>) => { | ||
readonly 'data-value': string; | ||
@@ -55,3 +85,3 @@ readonly 'data-label': string | undefined; | ||
}, string>; | ||
menu: import("../../internal/helpers/index.js").ExplicitBuilderReturn<[Readable<boolean>], (node: HTMLElement) => MeltActionReturn<ComboboxEvents['menu']>, ([$isVisible]: [boolean]) => { | ||
menu: import("../../internal/helpers/index.js").ExplicitBuilderReturn<[import("svelte/store").Readable<boolean>], (node: HTMLElement) => MeltActionReturn<"pointerleave">, ([$isVisible]: [boolean]) => { | ||
readonly hidden: true | undefined; | ||
@@ -70,4 +100,6 @@ readonly id: string; | ||
subscribe(this: void, run: import("svelte/store").Subscriber<S | undefined>, invalidate?: import("svelte/store").Invalidator<S | undefined> | undefined): import("svelte/store").Unsubscriber; | ||
}], import("svelte/action").Action<any, any, Record<never, any>>, ([$selected]: [S | undefined]) => { | ||
value: Value | Value[] | undefined; | ||
}, import("svelte/store").Writable<boolean>, import("svelte/store").Writable<string | undefined>], import("svelte/action").Action<any, any, Record<never, any>>, ([$selected, $required, $name]: [S | undefined, boolean, string | undefined]) => { | ||
required: boolean | undefined; | ||
value: unknown; | ||
name: string | undefined; | ||
type: string; | ||
@@ -79,4 +111,10 @@ 'aria-hidden': boolean; | ||
}, string>; | ||
arrow: import("../../internal/helpers/index.js").ExplicitBuilderReturn<import("svelte/store").Writable<number | undefined>, import("svelte/action").Action<any, any, Record<never, any>>, ($arrowSize: number | undefined) => { | ||
'data-arrow': boolean; | ||
style: string; | ||
}, string>; | ||
}; | ||
states: { | ||
touchedInput: import("svelte/store").Writable<boolean>; | ||
inputValue: import("svelte/store").Writable<string>; | ||
open: { | ||
@@ -92,13 +130,20 @@ update: (updater: import("svelte/store").Updater<boolean>, sideEffect?: ((newValue: boolean) => void) | undefined) => void; | ||
}; | ||
highlighted: Readable<ComboboxOption<Value> | undefined>; | ||
touchedInput: import("svelte/store").Writable<boolean>; | ||
inputValue: import("svelte/store").Writable<string>; | ||
highlighted: import("svelte/store").Readable<import("./types.js").ComboboxOption<unknown> | undefined>; | ||
}; | ||
ids: { | ||
trigger: string; | ||
menu: string; | ||
label: string; | ||
}; | ||
helpers: { | ||
isSelected: Readable<(value: Value) => boolean>; | ||
isHighlighted: Readable<(item: Value) => boolean>; | ||
isSelected: import("svelte/store").Readable<(value: unknown) => boolean>; | ||
isHighlighted: import("svelte/store").Readable<(item: unknown) => boolean>; | ||
closeMenu: () => void; | ||
}; | ||
options: { | ||
multiple: import("svelte/store").Writable<Multiple>; | ||
disabled: import("svelte/store").Writable<boolean>; | ||
forceVisible: import("svelte/store").Writable<boolean>; | ||
name: import("svelte/store").Writable<string | undefined>; | ||
required: import("svelte/store").Writable<boolean>; | ||
onOpenChange?: import("svelte/store").Writable<import("../../internal/helpers/index.js").ChangeFn<boolean> | undefined> | undefined; | ||
@@ -111,2 +156,3 @@ preventScroll: import("svelte/store").Writable<boolean>; | ||
}>; | ||
arrowSize?: import("svelte/store").Writable<number | undefined> | undefined; | ||
scrollAlignment: import("svelte/store").Writable<"center" | "nearest">; | ||
@@ -119,3 +165,4 @@ loop: import("svelte/store").Writable<boolean>; | ||
closeOnEscape: import("svelte/store").Writable<boolean>; | ||
typeahead: import("svelte/store").Writable<boolean>; | ||
}; | ||
}; |
@@ -1,153 +0,17 @@ | ||
import { useEscapeKeydown, usePopper } from '../../internal/actions/index.js'; | ||
import { FIRST_LAST_KEYS, addEventListener, addHighlight, addMeltEventListener, back, builder, createClickOutsideIgnore, createElHelpers, derivedVisible, disabledAttr, effect, executeCallbacks, forward, generateId, getElementByMeltId, getOptions, getPortalDestination, hiddenInputAttrs, isBrowser, isElementDisabled, isHTMLElement, isHTMLInputElement, kbd, last, next, noop, omit, overridable, prev, removeHighlight, removeScroll, styleToString, toWritableStores, toggle, } from '../../internal/helpers/index.js'; | ||
import { dequal as deepEqual } from 'dequal'; | ||
import { onMount, tick } from 'svelte'; | ||
import { derived, get, writable } from 'svelte/store'; | ||
import { createLabel } from '../label/create.js'; | ||
import { useEscapeKeydown } from '../../internal/actions/index.js'; | ||
import { addEventListener, addMeltEventListener, builder, createElHelpers, effect, executeCallbacks, isHTMLInputElement, kbd, noop, omit, } from '../../internal/helpers/index.js'; | ||
import { writable } from 'svelte/store'; | ||
import { createListbox } from '../listbox/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, | ||
closeOnEscape: true, | ||
forceVisible: false, | ||
portal: undefined, | ||
}; | ||
const { name, selector } = createElHelpers('combobox'); | ||
const { name } = createElHelpers('combobox'); | ||
/** | ||
* Creates an ARIA-1.2-compliant combobox. | ||
* | ||
* @TODO expose a nice mechanism for clearing the input. | ||
* @TODO would it be useful to have a callback for when an item is selected? | ||
* @TODO multi-select using `tags-input` builder? | ||
*/ | ||
export function createCombobox(props) { | ||
const withDefaults = { ...defaults, ...props }; | ||
// Trigger element for the popper portal. This will be our input element. | ||
const activeTrigger = writable(null); | ||
// The currently highlighted menu item. | ||
const highlightedItem = 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 listbox = createListbox({ ...props, builder: 'combobox', typeahead: false }); | ||
const inputValue = writable(''); | ||
const touchedInput = writable(false); | ||
const options = toWritableStores({ | ||
...omit(withDefaults, 'open', 'defaultOpen'), | ||
multiple: withDefaults.multiple ?? false, | ||
}); | ||
const { scrollAlignment, loop, closeOnOutsideClick, closeOnEscape, preventScroll, portal, forceVisible, positioning, multiple, } = options; | ||
const ids = { | ||
input: generateId(), | ||
menu: generateId(), | ||
label: generateId(), | ||
}; | ||
/** ------- */ | ||
/** 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 = get(multiple); | ||
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(currentOpenState = false) { | ||
/** | ||
* We're checking the open state here because the menu may have | ||
* been programatically opened by the user using a controlled store. | ||
* In that case we don't want to update the open state, but we do | ||
* want to update the active trigger and highlighted item as normal. | ||
*/ | ||
if (!currentOpenState) { | ||
open.set(true); | ||
} | ||
const triggerEl = getElementByMeltId(ids.input); | ||
if (!triggerEl) | ||
return; | ||
// The active trigger is used to anchor the menu to the input element. | ||
activeTrigger.set(triggerEl); | ||
// Wait a tick for the menu to open then highlight the selected item. | ||
await tick(); | ||
const menuElement = document.getElementById(ids.menu); | ||
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); | ||
touchedInput.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)); | ||
} | ||
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); | ||
}; | ||
}); | ||
/* -------- */ | ||
@@ -158,13 +22,6 @@ /* ELEMENTS */ | ||
const input = builder(name('input'), { | ||
stores: [open, highlightedItem, inputValue], | ||
returned: ([$open, $highlightedItem, $inputValue]) => { | ||
stores: [listbox.elements.trigger, inputValue], | ||
returned: ([$trigger, $inputValue]) => { | ||
return { | ||
'aria-activedescendant': $highlightedItem?.id, | ||
'aria-autocomplete': 'list', | ||
'aria-controls': ids.menu, | ||
'aria-expanded': $open, | ||
'aria-labelledby': ids.label, | ||
'data-melt-id': ids.input, | ||
autocomplete: 'off', | ||
id: ids.input, | ||
...omit($trigger, 'action'), | ||
role: 'combobox', | ||
@@ -175,117 +32,3 @@ value: $inputValue, | ||
action: (node) => { | ||
const unsubscribe = executeCallbacks(addMeltEventListener(node, 'click', () => { | ||
const $open = get(open); | ||
if ($open) { | ||
return; | ||
} | ||
openMenu($open); | ||
}), | ||
// Handle all input key events including typing, meta, and navigation. | ||
addMeltEventListener(node, 'keydown', (e) => { | ||
const $open = get(open); | ||
/** | ||
* 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 && node.value === '') { | ||
return; | ||
} | ||
// All other events should open the menu. | ||
openMenu($open); | ||
tick().then(() => { | ||
const $selectedItem = get(selected); | ||
if ($selectedItem) | ||
return; | ||
const menuEl = document.getElementById(ids.menu); | ||
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: get(scrollAlignment) }); | ||
} | ||
else if (e.key === kbd.ARROW_UP) { | ||
highlightedItem.set(last(enabledItems)); | ||
last(enabledItems).scrollIntoView({ block: get(scrollAlignment) }); | ||
} | ||
}); | ||
} | ||
/** | ||
* When the menu is open... | ||
*/ | ||
// Pressing `esc` should close the menu. | ||
if (e.key === kbd.TAB || e.key === kbd.ESCAPE) { | ||
closeMenu(); | ||
return; | ||
} | ||
// Pressing enter with a highlighted item should select it. | ||
if (e.key === kbd.ENTER) { | ||
e.preventDefault(); | ||
const $highlightedItem = get(highlightedItem); | ||
if ($highlightedItem) { | ||
selectItem($highlightedItem); | ||
} | ||
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 = document.getElementById(ids.menu); | ||
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 = get(highlightedItem); | ||
const currentIndex = $currentItem ? candidateNodes.indexOf($currentItem) : -1; | ||
// Find the next menu item to highlight. | ||
const $loop = get(loop); | ||
const $scrollAlignment = get(scrollAlignment); | ||
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 }); | ||
} | ||
}), addMeltEventListener(node, 'input', (e) => { | ||
const unsubscribe = executeCallbacks(addMeltEventListener(node, 'input', (e) => { | ||
if (!isHTMLInputElement(e.target)) | ||
@@ -304,3 +47,3 @@ return; | ||
handler: () => { | ||
closeMenu(); | ||
listbox.helpers.closeMenu(); | ||
}, | ||
@@ -311,4 +54,6 @@ }); | ||
} | ||
const { destroy } = listbox.elements.trigger(node); | ||
return { | ||
destroy() { | ||
destroy?.(); | ||
unsubscribe(); | ||
@@ -320,189 +65,19 @@ unsubEscapeKeydown(); | ||
}); | ||
/** | ||
* Action and attributes for the menu element. | ||
*/ | ||
const menu = builder(name('menu'), { | ||
stores: [isVisible], | ||
returned: ([$isVisible]) => { | ||
return { | ||
hidden: $isVisible ? undefined : true, | ||
id: ids.menu, | ||
role: 'listbox', | ||
style: styleToString({ display: $isVisible ? undefined : 'none' }), | ||
}; | ||
}, | ||
action: (node) => { | ||
let unsubPopper = noop; | ||
let unsubScroll = noop; | ||
const unsubscribe = executeCallbacks( | ||
// Bind the popper portal to the input element. | ||
effect([ | ||
isVisible, | ||
preventScroll, | ||
closeOnEscape, | ||
portal, | ||
closeOnOutsideClick, | ||
positioning, | ||
activeTrigger, | ||
], ([$isVisible, $preventScroll, $closeOnEscape, $portal, $closeOnOutsideClick, $positioning, $activeTrigger,]) => { | ||
unsubPopper(); | ||
unsubScroll(); | ||
if (!$isVisible || !$activeTrigger) | ||
return; | ||
if ($preventScroll) { | ||
unsubScroll = removeScroll(); | ||
} | ||
const ignoreHandler = createClickOutsideIgnore(ids.input); | ||
const popper = usePopper(node, { | ||
anchorElement: $activeTrigger, | ||
open, | ||
options: { | ||
floating: $positioning, | ||
focusTrap: null, | ||
clickOutside: $closeOnOutsideClick | ||
? { | ||
handler: (e) => { | ||
const target = e.target; | ||
if (target === $activeTrigger) | ||
return; | ||
closeMenu(); | ||
}, | ||
ignore: ignoreHandler, | ||
} | ||
: null, | ||
escapeKeydown: $closeOnEscape | ||
? { | ||
handler: () => { | ||
closeMenu(); | ||
}, | ||
} | ||
: null, | ||
portal: getPortalDestination(node, $portal), | ||
}, | ||
}); | ||
if (popper && popper.destroy) { | ||
unsubPopper = popper.destroy; | ||
} | ||
})); | ||
return { | ||
destroy: () => { | ||
unsubscribe(); | ||
unsubPopper(); | ||
unsubScroll(); | ||
}, | ||
}; | ||
}, | ||
}); | ||
// Use our existing label builder to create a label for the combobox input. | ||
const { elements: { root: labelBuilder }, } = createLabel(); | ||
const { action: labelAction } = get(labelBuilder); | ||
const label = builder(name('label'), { | ||
returned: () => { | ||
return { | ||
id: ids.label, | ||
for: ids.input, | ||
}; | ||
}, | ||
action: labelAction, | ||
}); | ||
const option = builder(name('option'), { | ||
stores: [selected], | ||
returned: ([$selected]) => (props) => { | ||
const selected = Array.isArray($selected) | ||
? $selected.some((o) => deepEqual(o.value, props.value)) | ||
: deepEqual($selected?.value, 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', | ||
style: styleToString({ cursor: props.disabled ? 'default' : 'pointer' }), | ||
}; | ||
}, | ||
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); | ||
closeMenu(); | ||
})); | ||
return { destroy: unsubscribe }; | ||
}, | ||
}); | ||
const hiddenInput = builder(name('hidden-input'), { | ||
stores: [selected], | ||
returned: ([$selected]) => { | ||
const value = Array.isArray($selected) ? $selected.map((o) => o.value) : $selected?.value; | ||
return { | ||
...hiddenInputAttrs, | ||
value, | ||
}; | ||
}, | ||
}); | ||
/* ------------------- */ | ||
/* LIFECYCLE & EFFECTS */ | ||
/* ------------------- */ | ||
onMount(() => { | ||
if (!isBrowser) | ||
return; | ||
const menuEl = document.getElementById(ids.menu); | ||
if (!menuEl) | ||
return; | ||
const triggerEl = getElementByMeltId(ids.input); | ||
if (triggerEl) { | ||
activeTrigger.set(triggerEl); | ||
effect(listbox.states.open, ($open) => { | ||
if (!$open) { | ||
touchedInput.set(false); | ||
} | ||
const selectedEl = menuEl.querySelector('[data-selected]'); | ||
if (!isHTMLElement(selectedEl)) | ||
return; | ||
}); | ||
/** | ||
* 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 = document.getElementById(ids.menu); | ||
if (!isHTMLElement(menuElement)) | ||
return; | ||
getOptions(menuElement).forEach((node) => { | ||
if (node === $highlightedItem) { | ||
addHighlight(node); | ||
} | ||
else { | ||
removeHighlight(node); | ||
} | ||
}); | ||
}); | ||
return { | ||
ids, | ||
...listbox, | ||
elements: { | ||
...listbox.elements, | ||
input, | ||
option, | ||
menu, | ||
label, | ||
hiddenInput, | ||
}, | ||
states: { | ||
open, | ||
selected, | ||
highlighted, | ||
...listbox.states, | ||
touchedInput, | ||
inputValue, | ||
}, | ||
helpers: { | ||
isSelected, | ||
isHighlighted, | ||
}, | ||
options, | ||
}; | ||
} |
import type { GroupedEvents, MeltComponentEvents } from '../../internal/types.js'; | ||
export declare const comboboxEvents: { | ||
input: readonly ["click", "keydown", "input"]; | ||
trigger: readonly ["click", "keydown", "input"]; | ||
menu: readonly ["pointerleave"]; | ||
@@ -5,0 +6,0 @@ item: readonly ["pointermove", "click"]; |
@@ -0,5 +1,5 @@ | ||
import { listboxEvents } from '../listbox/events'; | ||
export const comboboxEvents = { | ||
...listboxEvents, | ||
input: ['click', 'keydown', 'input'], | ||
menu: ['pointerleave'], | ||
item: ['pointermove', 'click'], | ||
}; |
@@ -1,116 +0,11 @@ | ||
import type { BuilderReturn, WhenTrue } from '../../internal/types.js'; | ||
import type { ChangeFn } from '../../internal/helpers/index.js'; | ||
import type { Writable } from 'svelte/store'; | ||
import type { BuilderReturn } from '../../internal/types.js'; | ||
import type { CreateListboxProps, ListboxSelected } from '../listbox/types.js'; | ||
import type { createCombobox } from './create.js'; | ||
import type { FloatingConfig } from '../../internal/actions/index.js'; | ||
export type { ComboboxComponentEvents } from './events.js'; | ||
export type ComboboxOption<Value> = { | ||
value: Value; | ||
label?: string; | ||
}; | ||
export type ComboboxSelected<Multiple extends boolean, Value> = WhenTrue<Multiple, ComboboxOption<Value>[], ComboboxOption<Value>>; | ||
export type CreateComboboxProps<Value, Multiple extends boolean = false, S extends ComboboxSelected<Multiple, Value> = ComboboxSelected<Multiple, Value>> = { | ||
/** | ||
* Options for positioning the popover menu. | ||
* | ||
* @default placement: 'bottom' | ||
*/ | ||
positioning?: FloatingConfig; | ||
/** | ||
* Determines behavior when scrolling items into view. | ||
* @see https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView#block | ||
*/ | ||
scrollAlignment?: 'nearest' | 'center'; | ||
/** | ||
* Whether or not the combobox should loop through the list when | ||
* the end or beginning is reached. | ||
* | ||
* @default true | ||
*/ | ||
loop?: boolean; | ||
/** | ||
* Whether or not the combobox should be open by default | ||
* when the component is rendered. | ||
* | ||
* This should only be used when you are not passing a controlled `open` store. | ||
* | ||
* @default false | ||
*/ | ||
defaultOpen?: boolean; | ||
/** | ||
* An optional controlled store that manages the open state of the combobox. | ||
*/ | ||
open?: Writable<boolean>; | ||
/** | ||
* Change function that is called when the combobox's `open` state changes. | ||
* | ||
* @see https://melt-ui.com/docs/controlled#change-functions | ||
*/ | ||
onOpenChange?: ChangeFn<boolean>; | ||
/** | ||
* The default selected option. | ||
* | ||
* This will be overridden if you also pass a `selected` store prop. | ||
* | ||
* @default undefined | ||
*/ | ||
defaultSelected?: S; | ||
/** | ||
* An optional controlled store that manages the selected option of the combobox. | ||
*/ | ||
selected?: Writable<S>; | ||
/** | ||
* A change handler for the selected store called when the selected would normally change. | ||
* | ||
* @see https://melt-ui.com/docs/controlled#change-functions | ||
*/ | ||
onSelectedChange?: ChangeFn<S | undefined>; | ||
/** | ||
* Whether or not to close the combobox menu when the user clicks | ||
* outside of the combobox. | ||
* | ||
* @default true | ||
*/ | ||
closeOnOutsideClick?: boolean; | ||
/** | ||
* Whether or not to close the combobox menu when the user presses | ||
* the escape key. | ||
* | ||
* @default true | ||
*/ | ||
closeOnEscape?: boolean; | ||
/** | ||
* Whether or not to prevent scrolling the page when the | ||
* combobox menu is open. | ||
* | ||
* @default true | ||
*/ | ||
preventScroll?: boolean; | ||
/** | ||
* If not undefined, the combobox menu will be rendered within the provided element or selector. | ||
* | ||
* @default 'body' | ||
*/ | ||
portal?: HTMLElement | string | null; | ||
/** | ||
* Whether the menu content should be displayed even if it is not open. | ||
* This is useful for animating the content in and out using transitions. | ||
* | ||
* @see https://melt-ui.com/docs/transitions | ||
* | ||
* @default false | ||
*/ | ||
forceVisible?: boolean; | ||
multiple?: Multiple; | ||
}; | ||
export type ComboboxOptionProps<Value> = ComboboxOption<Value> & { | ||
/** | ||
* Is the item disabled? | ||
*/ | ||
disabled?: boolean; | ||
}; | ||
export type Combobox<Value = unknown, Multiple extends boolean = false, S extends ComboboxSelected<Multiple, Value> = ComboboxSelected<Multiple, Value>> = BuilderReturn<typeof createCombobox<Value, Multiple, S>>; | ||
export type ComboboxElements<Value = unknown, Multiple extends boolean = false, S extends ComboboxSelected<Multiple, Value> = ComboboxSelected<Multiple, Value>> = Combobox<Value, Multiple, S>['elements']; | ||
export type ComboboxOptions<Value = unknown, Multiple extends boolean = false, S extends ComboboxSelected<Multiple, Value> = ComboboxSelected<Multiple, Value>> = Combobox<Value, Multiple, S>['options']; | ||
export type ComboboxStates<Value = unknown, Multiple extends boolean = false, S extends ComboboxSelected<Multiple, Value> = ComboboxSelected<Multiple, Value>> = Combobox<Value, Multiple, S>['states']; | ||
export type ComboboxHelpers<Value = unknown, Multiple extends boolean = false, S extends ComboboxSelected<Multiple, Value> = ComboboxSelected<Multiple, Value>> = Combobox<Value, Multiple, S>['helpers']; | ||
export type { ListboxOption as ComboboxOption, ListboxSelected as ComboboxSelected, ListboxOptionProps as ComboboxOptionProps, } from '../listbox/types.js'; | ||
export type CreateComboboxProps<Value, Multiple extends boolean = false, S extends ListboxSelected<Multiple, Value> = ListboxSelected<Multiple, Value>> = Omit<CreateListboxProps<Value, Multiple, S>, 'builder' | 'typeahead'>; | ||
export type Combobox<Value = unknown, Multiple extends boolean = false, S extends ListboxSelected<Multiple, Value> = ListboxSelected<Multiple, Value>> = BuilderReturn<typeof createCombobox<Value, Multiple, S>>; | ||
export type ComboboxElements<Value = unknown, Multiple extends boolean = false, S extends ListboxSelected<Multiple, Value> = ListboxSelected<Multiple, Value>> = Combobox<Value, Multiple, S>['elements']; | ||
export type ComboboxOptions<Value = unknown, Multiple extends boolean = false, S extends ListboxSelected<Multiple, Value> = ListboxSelected<Multiple, Value>> = Combobox<Value, Multiple, S>['options']; | ||
export type ComboboxStates<Value = unknown, Multiple extends boolean = false, S extends ListboxSelected<Multiple, Value> = ListboxSelected<Multiple, Value>> = Combobox<Value, Multiple, S>['states']; | ||
export type ComboboxHelpers<Value = unknown, Multiple extends boolean = false, S extends ListboxSelected<Multiple, Value> = ListboxSelected<Multiple, Value>> = Combobox<Value, Multiple, S>['helpers']; |
@@ -1,2 +0,2 @@ | ||
import { addMeltEventListener, builder, createElHelpers, disabledAttr, executeCallbacks, generateId, hiddenInputAttrs, isBrowser, isHTMLElement, isHTMLInputElement, last, next, omit, overridable, prev, styleToString, toWritableStores, } from '../../internal/helpers/index.js'; | ||
import { addMeltEventListener, builder, createElHelpers, disabledAttr, executeCallbacks, generateId, hiddenInputAttrs, isBrowser, isHTMLElement, isHTMLInputElement, last, next, omit, overridable, prev, toWritableStores, } from '../../internal/helpers/index.js'; | ||
import { tick } from 'svelte'; | ||
@@ -3,0 +3,0 @@ import { derived, get, readonly, writable } from 'svelte/store'; |
@@ -57,7 +57,7 @@ /// <reference types="svelte" /> | ||
}>; | ||
arrowSize: import("svelte/store").Writable<number>; | ||
closeOnOutsideClick: import("svelte/store").Writable<boolean>; | ||
closeOnEscape: import("svelte/store").Writable<boolean>; | ||
arrowSize: import("svelte/store").Writable<number>; | ||
disableFocusTrap: import("svelte/store").Writable<boolean>; | ||
}; | ||
}; |
@@ -1,2 +0,2 @@ | ||
import { addEventListener, addMeltEventListener, builder, createElHelpers, disabledAttr, effect, executeCallbacks, getDirectionalKeys, getElemDirection, hiddenInputAttrs, isHTMLElement, kbd, omit, overridable, styleToString, toWritableStores, } from '../../internal/helpers/index.js'; | ||
import { addEventListener, addMeltEventListener, builder, createElHelpers, disabledAttr, effect, executeCallbacks, getDirectionalKeys, getElemDirection, hiddenInputAttrs, isHTMLElement, kbd, omit, overridable, toWritableStores, } from '../../internal/helpers/index.js'; | ||
import { onMount } from 'svelte'; | ||
@@ -3,0 +3,0 @@ import { derived, get, writable } from 'svelte/store'; |
/// <reference types="svelte" /> | ||
import type { MeltActionReturn } from '../../internal/types.js'; | ||
import type { SelectEvents } from './events.js'; | ||
import type { CreateSelectProps, SelectOptionProps, SelectSelected } from './types.js'; | ||
import type { CreateSelectProps, SelectSelected } from './types.js'; | ||
export declare function createSelect<Value = unknown, Multiple extends boolean = false, S extends SelectSelected<Multiple, Value> = SelectSelected<Multiple, Value>>(props?: CreateSelectProps<Value, Multiple, S>): { | ||
ids: { | ||
menu: string; | ||
trigger: string; | ||
label: string; | ||
}; | ||
elements: { | ||
menu: import("../../internal/helpers/index.js").ExplicitBuilderReturn<[import("svelte/store").Readable<boolean>, import("svelte/store").Writable<string | HTMLElement | null | undefined>], (node: HTMLElement) => MeltActionReturn<SelectEvents['menu']>, ([$isVisible, $portal]: [boolean, string | HTMLElement | null | undefined]) => { | ||
style: string; | ||
id: string; | ||
group: import("../../internal/helpers/index.js").ExplicitBuilderReturn<import("svelte/store").Stores | undefined, import("svelte/action").Action<any, any, Record<never, any>>, () => (groupId: string) => { | ||
role: string; | ||
'aria-labelledby': string; | ||
role: string; | ||
'data-portal': string | undefined; | ||
}, string>; | ||
groupLabel: import("../../internal/helpers/index.js").ExplicitBuilderReturn<import("svelte/store").Stores | undefined, import("svelte/action").Action<any, any, Record<never, any>>, () => (groupId: string) => { | ||
id: string; | ||
}, string>; | ||
trigger: import("../../internal/helpers/index.js").ExplicitBuilderReturn<[{ | ||
@@ -23,31 +16,38 @@ update: (updater: import("svelte/store").Updater<boolean>, sideEffect?: ((newValue: boolean) => void) | undefined) => void; | ||
subscribe(this: void, run: import("svelte/store").Subscriber<boolean>, invalidate?: import("svelte/store").Invalidator<boolean> | undefined): import("svelte/store").Unsubscriber; | ||
}, import("svelte/store").Writable<boolean>, import("svelte/store").Writable<boolean>], (node: HTMLElement) => MeltActionReturn<SelectEvents['trigger']>, ([$open, $disabled, $required]: [boolean, boolean, boolean]) => { | ||
readonly role: "combobox"; | ||
readonly type: "button"; | ||
readonly 'aria-autocomplete': "none"; | ||
readonly 'aria-haspopup': "listbox"; | ||
}, import("svelte/store").Writable<HTMLElement | null>, import("svelte/store").Writable<boolean>], (node: HTMLElement) => import("../../internal/types.js").MeltActionReturn<"input" | "click" | "keydown">, ([$open, $highlightedItem, $disabled]: [boolean, HTMLElement | null, boolean]) => { | ||
readonly 'aria-activedescendant': string | undefined; | ||
readonly 'aria-autocomplete': "list"; | ||
readonly 'aria-controls': string; | ||
readonly 'aria-expanded': boolean; | ||
readonly 'aria-required': boolean; | ||
readonly 'data-state': "open" | "closed"; | ||
readonly 'data-disabled': true | undefined; | ||
readonly 'aria-labelledby': string; | ||
readonly disabled: true | undefined; | ||
readonly 'data-melt-id': string; | ||
readonly id: string; | ||
readonly tabindex: 0; | ||
readonly role: "combobox"; | ||
readonly disabled: true | undefined; | ||
}, string>; | ||
option: import("../../internal/helpers/index.js").ExplicitBuilderReturn<{ | ||
option: import("../../internal/helpers/index.js").ExplicitBuilderReturn<[{ | ||
update: (updater: import("svelte/store").Updater<S | undefined>, sideEffect?: ((newValue: S | undefined) => void) | undefined) => void; | ||
set: (this: void, value: S | undefined) => void; | ||
subscribe(this: void, run: import("svelte/store").Subscriber<S | undefined>, invalidate?: import("svelte/store").Invalidator<S | undefined> | undefined): import("svelte/store").Unsubscriber; | ||
}, (node: HTMLElement) => MeltActionReturn<SelectEvents['option']>, ($selected: S | undefined) => (props: SelectOptionProps<Value>) => { | ||
readonly role: "option"; | ||
readonly 'aria-selected': boolean; | ||
readonly 'data-selected': "" | undefined; | ||
}], (node: HTMLElement) => import("../../internal/types.js").MeltActionReturn<"click" | "pointermove">, ([$selected]: [S | undefined]) => (props: import("./types.js").SelectOptionProps<unknown>) => { | ||
readonly 'data-value': string; | ||
readonly 'data-label': string | undefined; | ||
readonly 'data-disabled': true | undefined; | ||
readonly tabindex: -1; | ||
readonly 'aria-disabled': true | undefined; | ||
readonly 'aria-selected': boolean; | ||
readonly 'data-selected': "" | undefined; | ||
readonly id: string; | ||
readonly role: "option"; | ||
readonly style: string; | ||
}, string>; | ||
menu: import("../../internal/helpers/index.js").ExplicitBuilderReturn<[import("svelte/store").Readable<boolean>], (node: HTMLElement) => import("../../internal/types.js").MeltActionReturn<"pointerleave">, ([$isVisible]: [boolean]) => { | ||
readonly hidden: true | undefined; | ||
readonly id: string; | ||
readonly role: "listbox"; | ||
readonly style: string; | ||
}, string>; | ||
label: import("../../internal/helpers/index.js").ExplicitBuilderReturn<import("svelte/store").Stores | undefined, (node: HTMLElement) => import("../../internal/types.js").MeltActionReturn<"mousedown">, () => { | ||
id: string; | ||
for: string; | ||
}, string>; | ||
hiddenInput: import("../../internal/helpers/index.js").ExplicitBuilderReturn<[{ | ||
@@ -57,7 +57,6 @@ update: (updater: import("svelte/store").Updater<S | undefined>, sideEffect?: ((newValue: S | undefined) => void) | undefined) => void; | ||
subscribe(this: void, run: import("svelte/store").Subscriber<S | undefined>, invalidate?: import("svelte/store").Invalidator<S | undefined> | undefined): import("svelte/store").Unsubscriber; | ||
}, import("svelte/store").Writable<boolean>, import("svelte/store").Writable<boolean>, import("svelte/store").Writable<string | undefined>], import("svelte/action").Action<any, any, Record<never, any>>, ([$selected, $required, $disabled, $nameStore]: [S | undefined, boolean, boolean, string | undefined]) => { | ||
}, import("svelte/store").Writable<boolean>, import("svelte/store").Writable<string | undefined>], import("svelte/action").Action<any, any, Record<never, any>>, ([$selected, $required, $name]: [S | undefined, boolean, string | undefined]) => { | ||
required: boolean | undefined; | ||
value: unknown; | ||
name: string | undefined; | ||
value: Value | Value[] | undefined; | ||
required: boolean; | ||
disabled: boolean | undefined; | ||
type: string; | ||
@@ -69,25 +68,9 @@ 'aria-hidden': boolean; | ||
}, string>; | ||
group: import("../../internal/helpers/index.js").ExplicitBuilderReturn<import("svelte/store").Stores | undefined, import("svelte/action").Action<any, any, Record<never, any>>, () => (groupId: string) => { | ||
role: string; | ||
'aria-labelledby': string; | ||
}, string>; | ||
groupLabel: import("../../internal/helpers/index.js").ExplicitBuilderReturn<import("svelte/store").Stores | undefined, import("svelte/action").Action<any, any, Record<never, any>>, () => (groupId: string) => { | ||
id: string; | ||
}, string>; | ||
arrow: import("../../internal/helpers/index.js").ExplicitBuilderReturn<import("svelte/store").Writable<number>, import("svelte/action").Action<any, any, Record<never, any>>, ($arrowSize: number) => { | ||
arrow: import("../../internal/helpers/index.js").ExplicitBuilderReturn<import("svelte/store").Writable<number | undefined>, import("svelte/action").Action<any, any, Record<never, any>>, ($arrowSize: number | undefined) => { | ||
'data-arrow': boolean; | ||
style: string; | ||
}, string>; | ||
separator: import("../../internal/helpers/index.js").ExplicitBuilderReturn<[import("svelte/store").Writable<import("../../internal/types.js").Orientation>, import("svelte/store").Writable<boolean>], import("svelte/action").Action<any, any, Record<never, any>>, ([$orientation, $decorative]: [import("../../internal/types.js").Orientation, boolean]) => { | ||
role: string; | ||
'aria-orientation': "vertical" | undefined; | ||
'aria-hidden': boolean; | ||
'data-orientation': import("../../internal/types.js").Orientation; | ||
}, "separator">; | ||
label: import("../../internal/helpers/index.js").ExplicitBuilderReturn<import("svelte/store").Stores | undefined, <Node_1 extends any>(node: Node_1) => MeltActionReturn<SelectEvents['label']>, () => { | ||
id: string; | ||
for: string; | ||
}, string>; | ||
}; | ||
states: { | ||
selectedLabel: import("svelte/store").Readable<string>; | ||
open: { | ||
@@ -103,6 +86,13 @@ update: (updater: import("svelte/store").Updater<boolean>, sideEffect?: ((newValue: boolean) => void) | undefined) => void; | ||
}; | ||
selectedLabel: import("svelte/store").Readable<string>; | ||
highlighted: import("svelte/store").Readable<import("./types.js").SelectOption<unknown> | undefined>; | ||
}; | ||
ids: { | ||
trigger: string; | ||
menu: string; | ||
label: string; | ||
}; | ||
helpers: { | ||
isSelected: import("svelte/store").Readable<(value: Value) => boolean>; | ||
isSelected: import("svelte/store").Readable<(value: unknown) => boolean>; | ||
isHighlighted: import("svelte/store").Readable<(item: unknown) => boolean>; | ||
closeMenu: () => void; | ||
}; | ||
@@ -115,2 +105,3 @@ options: { | ||
required: import("svelte/store").Writable<boolean>; | ||
onOpenChange?: import("svelte/store").Writable<import("../../internal/helpers/index.js").ChangeFn<boolean> | undefined> | undefined; | ||
preventScroll: import("svelte/store").Writable<boolean>; | ||
@@ -122,7 +113,12 @@ portal: import("svelte/store").Writable<string | HTMLElement | null | undefined>; | ||
}>; | ||
arrowSize?: import("svelte/store").Writable<number | undefined> | undefined; | ||
scrollAlignment: import("svelte/store").Writable<"center" | "nearest">; | ||
loop: import("svelte/store").Writable<boolean>; | ||
defaultSelected?: import("svelte/store").Writable<S | undefined> | undefined; | ||
selected?: import("svelte/store").Writable<import("svelte/store").Writable<S> | undefined> | undefined; | ||
onSelectedChange?: import("svelte/store").Writable<import("../../internal/helpers/index.js").ChangeFn<S | undefined> | undefined> | undefined; | ||
closeOnOutsideClick: import("svelte/store").Writable<boolean>; | ||
closeOnEscape: import("svelte/store").Writable<boolean>; | ||
arrowSize: import("svelte/store").Writable<number>; | ||
typeahead: import("svelte/store").Writable<boolean>; | ||
}; | ||
}; |
@@ -1,367 +0,7 @@ | ||
import { createLabel, createSeparator } from '../index.js'; | ||
import { usePopper } from '../../internal/actions/index.js'; | ||
import { FIRST_LAST_KEYS, SELECTION_KEYS, addEventListener, addHighlight, addMeltEventListener, back, builder, createClickOutsideIgnore, createElHelpers, createTypeaheadSearch, derivedVisible, disabledAttr, effect, executeCallbacks, forward, generateId, getFirstOption, getNextFocusable, getOptions, getPortalDestination, getPreviousFocusable, handleRovingFocus, hiddenInputAttrs, isBrowser, isElementDisabled, isHTMLElement, kbd, last, next, noop, omit, overridable, prev, removeHighlight, removeScroll, sleep, styleToString, toWritableStores, toggle, } from '../../internal/helpers/index.js'; | ||
import { dequal as deepEqual } from 'dequal'; | ||
import { onMount, tick } from 'svelte'; | ||
import { derived, get, writable } from 'svelte/store'; | ||
import { getElementByMeltId } from '../../internal/helpers/builder'; | ||
const defaults = { | ||
arrowSize: 8, | ||
required: false, | ||
disabled: false, | ||
positioning: { | ||
placement: 'bottom', | ||
sameWidth: true, | ||
}, | ||
preventScroll: true, | ||
loop: false, | ||
name: undefined, | ||
defaultOpen: false, | ||
forceVisible: false, | ||
portal: undefined, | ||
closeOnEscape: true, | ||
closeOnOutsideClick: true, | ||
}; | ||
import { builder, createElHelpers } from '../../internal/helpers/index.js'; | ||
import { derived } from 'svelte/store'; | ||
import { createListbox } from '../listbox/create.js'; | ||
const { name } = createElHelpers('select'); | ||
export function createSelect(props) { | ||
const withDefaults = { ...defaults, ...props }; | ||
const options = toWritableStores({ | ||
...omit(withDefaults, 'selected', 'defaultSelected', 'onSelectedChange', 'onOpenChange', 'open', 'defaultOpen'), | ||
multiple: withDefaults.multiple ?? false, | ||
}); | ||
const { positioning, arrowSize, required, disabled, loop, preventScroll, name: nameStore, portal, forceVisible, closeOnEscape, closeOnOutsideClick, multiple, } = options; | ||
const openWritable = withDefaults.open ?? writable(withDefaults.defaultOpen); | ||
const open = overridable(openWritable, withDefaults?.onOpenChange); | ||
const selectedWritable = withDefaults.selected ?? writable(withDefaults.defaultSelected); | ||
const selected = overridable(selectedWritable, withDefaults?.onSelectedChange); | ||
const activeTrigger = writable(null); | ||
/** | ||
* Keeps track of the next/previous focusable element when the menu closes. | ||
* This is because we are portaling the menu to the body and we need | ||
* to be able to focus the next element in the DOM when the menu closes. | ||
* | ||
* Without keeping track of this, the focus would be reset to the top of | ||
* the page (or the first focusable element in the body). | ||
*/ | ||
const nextFocusable = writable(null); | ||
const prevFocusable = writable(null); | ||
/** | ||
* Keeps track of if the user is using the keyboard to navigate the menu. | ||
* This is used to determine how we handle focus on open behavior differently | ||
* than when the user is using the mouse. | ||
*/ | ||
let isUsingKeyboard = false; | ||
const ids = { | ||
menu: generateId(), | ||
trigger: generateId(), | ||
label: generateId(), | ||
}; | ||
const { typed, handleTypeaheadSearch } = createTypeaheadSearch(); | ||
/* ------- */ | ||
/* Helpers */ | ||
/* ------- */ | ||
const isSelected = derived([selected], ([$selected]) => { | ||
return (value) => { | ||
if (Array.isArray($selected)) { | ||
return $selected.some((o) => deepEqual(o.value, value)); | ||
} | ||
return deepEqual($selected?.value, value); | ||
}; | ||
}); | ||
function isMouse(e) { | ||
return e.pointerType === 'mouse'; | ||
} | ||
function getSelectedOption(menuElement) { | ||
const selectedOption = menuElement.querySelector('[data-selected]'); | ||
return isHTMLElement(selectedOption) ? selectedOption : null; | ||
} | ||
function onOptionPointerMove(e) { | ||
if (!isMouse(e)) | ||
return; | ||
const currentTarget = e.currentTarget; | ||
if (!isHTMLElement(currentTarget)) | ||
return; | ||
handleRovingFocus(currentTarget); | ||
} | ||
function onOptionLeave() { | ||
const menuElement = document.getElementById(ids.menu); | ||
if (!isHTMLElement(menuElement)) | ||
return; | ||
handleRovingFocus(menuElement); | ||
} | ||
/** | ||
* Keyboard event handler for menu navigation | ||
* @param e The keyboard event | ||
*/ | ||
function handleMenuNavigation(e) { | ||
e.preventDefault(); | ||
// currently focused menu item | ||
const currentFocusedItem = document.activeElement; | ||
// menu element being navigated | ||
const currentTarget = e.currentTarget; | ||
if (!isHTMLElement(currentFocusedItem) || !isHTMLElement(currentTarget)) | ||
return; | ||
// menu items of the current menu | ||
const items = getOptions(currentTarget); | ||
if (!items.length) | ||
return; | ||
// Disabled items can't be highlighted. Skip them. | ||
const candidateNodes = items.filter((opt) => !isElementDisabled(opt)); | ||
// Get the index of the currently highlighted item. | ||
const currentIndex = candidateNodes.indexOf(currentFocusedItem); | ||
// Find the next menu item to highlight. | ||
let nextItem; | ||
const $loop = get(loop); | ||
switch (e.key) { | ||
case kbd.ARROW_DOWN: | ||
nextItem = next(candidateNodes, currentIndex, $loop); | ||
break; | ||
case kbd.PAGE_DOWN: | ||
nextItem = forward(candidateNodes, currentIndex, 10, $loop); | ||
break; | ||
case kbd.ARROW_UP: | ||
nextItem = prev(candidateNodes, currentIndex, $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; | ||
} | ||
handleRovingFocus(nextItem); | ||
} | ||
function handleTabNavigation(e) { | ||
if (e.shiftKey) { | ||
const $prevFocusable = get(prevFocusable); | ||
if ($prevFocusable) { | ||
e.preventDefault(); | ||
$prevFocusable.focus(); | ||
prevFocusable.set(null); | ||
} | ||
} | ||
else { | ||
const $nextFocusable = get(nextFocusable); | ||
if ($nextFocusable) { | ||
e.preventDefault(); | ||
$nextFocusable.focus(); | ||
nextFocusable.set(null); | ||
} | ||
} | ||
} | ||
const isVisible = derivedVisible({ open, forceVisible, activeTrigger }); | ||
const selectedLabel = derived(selected, ($selected) => { | ||
if (Array.isArray($selected)) { | ||
return $selected.map((o) => o.label).join(', '); | ||
} | ||
return $selected?.label ?? ''; | ||
}); | ||
/* -------- */ | ||
/* Builders */ | ||
/* -------- */ | ||
const menu = builder(name('menu'), { | ||
stores: [isVisible, portal], | ||
returned: ([$isVisible, $portal]) => { | ||
return { | ||
style: styleToString({ | ||
display: $isVisible ? undefined : 'none', | ||
}), | ||
id: ids.menu, | ||
'aria-labelledby': ids.trigger, | ||
role: 'listbox', | ||
'data-portal': $portal ? '' : undefined, | ||
}; | ||
}, | ||
action: (node) => { | ||
let unsubPopper = noop; | ||
let unsubScroll = noop; | ||
const unsubDerived = effect([isVisible, preventScroll, positioning, portal, closeOnEscape, closeOnOutsideClick], ([$isVisible, $preventScroll, $positioning, $portal, $closeOnEscape, $closeOnOutsideClick,]) => { | ||
unsubPopper(); | ||
unsubScroll(); | ||
const $activeTrigger = get(activeTrigger); | ||
if (!($isVisible && $activeTrigger)) | ||
return; | ||
if ($preventScroll) { | ||
unsubScroll = removeScroll(); | ||
} | ||
const ignoreHandler = createClickOutsideIgnore(ids.trigger); | ||
tick().then(() => { | ||
const popper = usePopper(node, { | ||
anchorElement: $activeTrigger, | ||
open, | ||
options: { | ||
floating: $positioning, | ||
clickOutside: $closeOnOutsideClick | ||
? { | ||
ignore: ignoreHandler, | ||
} | ||
: null, | ||
escapeKeydown: $closeOnEscape | ||
? { | ||
handler: () => { | ||
open.set(false); | ||
}, | ||
} | ||
: null, | ||
portal: getPortalDestination(node, $portal), | ||
}, | ||
}); | ||
if (popper && popper.destroy) { | ||
unsubPopper = popper.destroy; | ||
} | ||
}); | ||
}); | ||
const unsubEventListeners = executeCallbacks(addMeltEventListener(node, 'keydown', (e) => { | ||
const menuEl = e.currentTarget; | ||
const target = e.target; | ||
if (!isHTMLElement(menuEl) || !isHTMLElement(target)) | ||
return; | ||
const isModifierKey = e.ctrlKey || e.altKey || e.metaKey; | ||
const isCharacterKey = e.key.length === 1; | ||
if (e.key === kbd.TAB) { | ||
e.preventDefault(); | ||
open.set(false); | ||
handleTabNavigation(e); | ||
} | ||
if (FIRST_LAST_KEYS.includes(e.key)) { | ||
e.preventDefault(); | ||
if (menuEl === target) { | ||
const selectedOption = getSelectedOption(menuEl); | ||
if (selectedOption) { | ||
handleRovingFocus(selectedOption); | ||
return; | ||
} | ||
} | ||
handleMenuNavigation(e); | ||
} | ||
if (!isModifierKey && isCharacterKey) { | ||
handleTypeaheadSearch(e.key, getOptions(node)); | ||
} | ||
})); | ||
return { | ||
destroy() { | ||
unsubDerived(); | ||
unsubPopper(); | ||
unsubScroll(); | ||
unsubEventListeners(); | ||
}, | ||
}; | ||
}, | ||
}); | ||
const trigger = builder(name('trigger'), { | ||
stores: [open, disabled, required], | ||
returned: ([$open, $disabled, $required]) => { | ||
return { | ||
role: 'combobox', | ||
type: 'button', | ||
'aria-autocomplete': 'none', | ||
'aria-haspopup': 'listbox', | ||
'aria-controls': ids.menu, | ||
'aria-expanded': $open, | ||
'aria-required': $required, | ||
'data-state': $open ? 'open' : 'closed', | ||
'data-disabled': disabledAttr($disabled), | ||
'aria-labelledby': ids.label, | ||
disabled: disabledAttr($disabled), | ||
'data-melt-id': ids.trigger, | ||
id: ids.trigger, | ||
tabindex: 0, | ||
}; | ||
}, | ||
action: (node) => { | ||
const unsub = executeCallbacks(addMeltEventListener(node, 'click', (e) => { | ||
if (get(disabled)) { | ||
e.preventDefault(); | ||
return; | ||
} | ||
const $open = get(open); | ||
const triggerEl = e.currentTarget; | ||
if (!isHTMLElement(triggerEl)) | ||
return; | ||
open.update((prev) => { | ||
const isOpen = !prev; | ||
if (isOpen) { | ||
nextFocusable.set(getNextFocusable(triggerEl)); | ||
prevFocusable.set(getPreviousFocusable(triggerEl)); | ||
activeTrigger.set(triggerEl); | ||
} | ||
return isOpen; | ||
}); | ||
if (!$open) | ||
e.preventDefault(); | ||
}), addMeltEventListener(node, 'keydown', (e) => { | ||
const triggerEl = e.currentTarget; | ||
if (!isHTMLElement(triggerEl)) | ||
return; | ||
if (SELECTION_KEYS.includes(e.key) || | ||
e.key === kbd.ARROW_DOWN || | ||
e.key === kbd.ARROW_UP) { | ||
if (e.key === kbd.ARROW_DOWN || e.key === kbd.ARROW_UP) { | ||
/** | ||
* We don't want to scroll the page when the user presses the | ||
* down arrow when focused on the trigger, so we prevent that | ||
* default behavior. | ||
*/ | ||
e.preventDefault(); | ||
} | ||
open.update((prev) => { | ||
const isOpen = !prev; | ||
if (isOpen) { | ||
e.preventDefault(); | ||
nextFocusable.set(getNextFocusable(triggerEl)); | ||
prevFocusable.set(getPreviousFocusable(triggerEl)); | ||
activeTrigger.set(triggerEl); | ||
} | ||
return isOpen; | ||
}); | ||
const menu = document.getElementById(ids.menu); | ||
if (!menu) | ||
return; | ||
const selectedOption = menu.querySelector('[data-selected]'); | ||
if (isHTMLElement(selectedOption)) { | ||
handleRovingFocus(selectedOption); | ||
return; | ||
} | ||
const options = getOptions(menu); | ||
if (!options.length) | ||
return; | ||
handleRovingFocus(options[0]); | ||
} | ||
})); | ||
return { | ||
destroy: unsub, | ||
}; | ||
}, | ||
}); | ||
// Use our existing label builder to create a label for the select trigger. | ||
const { elements: { root: labelBuilder }, } = createLabel(); | ||
const { action: labelAction } = get(labelBuilder); | ||
const label = builder(name('label'), { | ||
returned: () => { | ||
return { | ||
id: ids.label, | ||
for: ids.trigger, | ||
}; | ||
}, | ||
action: (node) => { | ||
const destroy = executeCallbacks(labelAction(node).destroy ?? noop, addMeltEventListener(node, 'click', (e) => { | ||
e.preventDefault(); | ||
const triggerEl = document.getElementById(ids.trigger); | ||
if (!isHTMLElement(triggerEl)) | ||
return; | ||
triggerEl.focus(); | ||
})); | ||
return { | ||
destroy, | ||
}; | ||
}, | ||
}); | ||
const { elements: { root: separator }, } = createSeparator({ | ||
decorative: true, | ||
}); | ||
const listbox = createListbox({ ...props, builder: 'select' }); | ||
const group = builder(name('group'), { | ||
@@ -382,219 +22,20 @@ returned: () => { | ||
}); | ||
const arrow = builder(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)`, | ||
}), | ||
}), | ||
}); | ||
const 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 = get(multiple); | ||
if ($multiple) { | ||
const optionArr = Array.isArray($option) ? $option : []; | ||
return toggle(newOption, optionArr, (itemA, itemB) => deepEqual(itemA.value, itemB.value)); | ||
} | ||
return newOption; | ||
}); | ||
}; | ||
const option = builder(name('option'), { | ||
stores: selected, | ||
returned: ($selected) => { | ||
return (props) => { | ||
const isSelected = Array.isArray($selected) | ||
? $selected.some((o) => deepEqual(o.value, props.value)) | ||
: deepEqual($selected?.value, props?.value); | ||
return { | ||
role: 'option', | ||
'aria-selected': isSelected, | ||
'data-selected': isSelected ? '' : undefined, | ||
'data-value': JSON.stringify(props.value), | ||
'data-label': props.label ?? undefined, | ||
'data-disabled': disabledAttr(props.disabled), | ||
tabindex: -1, | ||
}; | ||
}; | ||
}, | ||
action: (node) => { | ||
const unsub = executeCallbacks(addMeltEventListener(node, 'click', (e) => { | ||
const itemElement = e.currentTarget; | ||
if (!isHTMLElement(itemElement)) | ||
return; | ||
const props = getOptionProps(node); | ||
if (props.disabled) { | ||
e.preventDefault(); | ||
return; | ||
} | ||
handleRovingFocus(itemElement); | ||
setOption(props); | ||
const $multiple = get(multiple); | ||
if (!$multiple) | ||
open.set(false); | ||
}), addMeltEventListener(node, 'keydown', (e) => { | ||
const $typed = get(typed); | ||
const isTypingAhead = $typed.length > 0; | ||
if (isTypingAhead && e.key === kbd.SPACE) { | ||
e.preventDefault(); | ||
return; | ||
} | ||
if (e.key === kbd.ENTER || e.key === kbd.SPACE) { | ||
e.preventDefault(); | ||
const props = getOptionProps(node); | ||
node.setAttribute('data-selected', ''); | ||
setOption(props); | ||
const $multiple = get(multiple); | ||
if (!$multiple) | ||
open.set(false); | ||
} | ||
}), addMeltEventListener(node, 'pointermove', (e) => { | ||
const props = getOptionProps(node); | ||
if (props.disabled) { | ||
e.preventDefault(); | ||
return; | ||
} | ||
const itemEl = e.currentTarget; | ||
if (!isHTMLElement(itemEl)) | ||
return; | ||
if (props.disabled) { | ||
const menuElement = document.getElementById(ids.menu); | ||
if (!menuElement) | ||
return; | ||
handleRovingFocus(menuElement); | ||
} | ||
onOptionPointerMove(e); | ||
}), addMeltEventListener(node, 'pointerleave', (e) => { | ||
if (!isMouse(e)) | ||
return; | ||
onOptionLeave(); | ||
}), addMeltEventListener(node, 'focusin', (e) => { | ||
const itemEl = e.currentTarget; | ||
if (!isHTMLElement(itemEl)) | ||
return; | ||
addHighlight(itemEl); | ||
}), addMeltEventListener(node, 'focusout', (e) => { | ||
const itemEl = e.currentTarget; | ||
if (!isHTMLElement(itemEl)) | ||
return; | ||
removeHighlight(itemEl); | ||
})); | ||
return { | ||
destroy: unsub, | ||
}; | ||
}, | ||
}); | ||
const hiddenInput = builder(name('hidden-input'), { | ||
stores: [selected, required, disabled, nameStore], | ||
returned: ([$selected, $required, $disabled, $nameStore]) => { | ||
const value = Array.isArray($selected) ? $selected.map((o) => o.value) : $selected?.value; | ||
return { | ||
...hiddenInputAttrs, | ||
name: $nameStore, | ||
value, | ||
required: $required, | ||
disabled: disabledAttr($disabled), | ||
}; | ||
}, | ||
}); | ||
/* ------------------- */ | ||
/* Lifecycle & Effects */ | ||
/* ------------------- */ | ||
onMount(() => { | ||
const triggerEl = getElementByMeltId(ids.trigger); | ||
if (triggerEl) { | ||
activeTrigger.set(triggerEl); | ||
const selectedLabel = derived(listbox.states.selected, ($selected) => { | ||
if (Array.isArray($selected)) { | ||
return $selected.map((o) => o.label).join(', '); | ||
} | ||
return $selected?.label ?? ''; | ||
}); | ||
let hasOpened = false; | ||
effect(open, ($open) => { | ||
if ($open) { | ||
hasOpened = true; | ||
} | ||
}); | ||
effect([open, activeTrigger], function handleFocus([$open, $activeTrigger]) { | ||
const unsubs = []; | ||
if (!isBrowser) | ||
return; | ||
if ($open && get(preventScroll)) { | ||
unsubs.push(removeScroll()); | ||
} | ||
sleep(1).then(() => { | ||
const menuEl = document.getElementById(ids.menu); | ||
if (menuEl && $open && isUsingKeyboard) { | ||
// Focus on selected option or first option | ||
const selectedOption = getSelectedOption(menuEl); | ||
if (!selectedOption) { | ||
const firstOption = getFirstOption(menuEl); | ||
if (!firstOption) | ||
return; | ||
handleRovingFocus(firstOption); | ||
} | ||
else { | ||
handleRovingFocus(selectedOption); | ||
} | ||
} | ||
else if (menuEl && $open) { | ||
// focus on the menu element | ||
handleRovingFocus(menuEl); | ||
} | ||
else if ($activeTrigger && hasOpened) { | ||
// Hacky way to prevent the keydown event from triggering on the trigger | ||
handleRovingFocus($activeTrigger); | ||
} | ||
}); | ||
return () => { | ||
unsubs.forEach((unsub) => unsub()); | ||
}; | ||
}); | ||
effect([open, activeTrigger], ([$open, $activeTrigger]) => { | ||
if (!isBrowser) | ||
return; | ||
const handlePointer = () => (isUsingKeyboard = false); | ||
const handleKeyDown = (e) => { | ||
isUsingKeyboard = true; | ||
if (e.key === kbd.ESCAPE && $open) { | ||
open.set(false); | ||
if (!$activeTrigger) | ||
return; | ||
handleRovingFocus($activeTrigger); | ||
} | ||
}; | ||
return executeCallbacks(addEventListener(document, 'keydown', handleKeyDown, { capture: true }), addEventListener(document, 'pointerdown', handlePointer, { capture: true, once: true }), addEventListener(document, 'pointermove', handlePointer, { capture: true, once: true })); | ||
}); | ||
return { | ||
ids, | ||
...listbox, | ||
elements: { | ||
menu, | ||
trigger, | ||
option, | ||
hiddenInput, | ||
...listbox.elements, | ||
group, | ||
groupLabel, | ||
arrow, | ||
separator, | ||
label, | ||
}, | ||
states: { | ||
open, | ||
selected, | ||
...listbox.states, | ||
selectedLabel, | ||
}, | ||
helpers: { | ||
isSelected, | ||
}, | ||
options, | ||
}; | ||
} |
@@ -1,125 +0,11 @@ | ||
import type { FloatingConfig } from '../../internal/actions/index.js'; | ||
import type { BuilderReturn, WhenTrue } from '../../internal/types.js'; | ||
import type { Writable } from 'svelte/store'; | ||
import type { BuilderReturn } from '../../internal/types.js'; | ||
import type { CreateListboxProps, ListboxSelected } from '../listbox/types.js'; | ||
import type { createSelect } from './create.js'; | ||
import type { ChangeFn } from '../../internal/helpers/index.js'; | ||
export type { SelectComponentEvents } from './events.js'; | ||
export type SelectOption<Value> = { | ||
value: Value; | ||
label?: string; | ||
}; | ||
export type SelectSelected<Multiple extends boolean, Value> = WhenTrue<Multiple, SelectOption<Value>[], SelectOption<Value>>; | ||
export type CreateSelectProps<Value = unknown, Multiple extends boolean = false, S extends SelectSelected<Multiple, Value> = SelectSelected<Multiple, Value>> = { | ||
/** | ||
* Options for positioning the popover menu. | ||
* | ||
* @default placement: 'bottom' | ||
*/ | ||
positioning?: FloatingConfig; | ||
/** | ||
* The size of the arrow in pixels. | ||
* @default 8 | ||
*/ | ||
arrowSize?: number; | ||
/** | ||
* Whether or not the select is required. | ||
* | ||
* @default false | ||
*/ | ||
required?: boolean; | ||
/** | ||
* Whether or not the select input is disabled. | ||
* | ||
* @default false | ||
*/ | ||
disabled?: boolean; | ||
/** | ||
* An optional controlled store that manages the open state of the select menu. | ||
*/ | ||
defaultOpen?: boolean; | ||
/** | ||
* A controlled open state store for the menu. If provided, the | ||
* value of this store will override the `defaultOpen` prop. | ||
* | ||
* @default Writable<false> | ||
*/ | ||
open?: Writable<boolean>; | ||
/** | ||
* A callback for when the open state changes. | ||
* | ||
* @see https://melt-ui.com/docs/controlled#change-functions | ||
*/ | ||
onOpenChange?: ChangeFn<boolean>; | ||
/** | ||
* The name for the select input. | ||
* | ||
* @default undefined | ||
*/ | ||
name?: string; | ||
/** | ||
* Whether or not to prevent scrolling the body when the menu is open. | ||
* | ||
* @default true | ||
*/ | ||
preventScroll?: boolean; | ||
/** | ||
* Whether or not to loop through the menu options once the end or beginning is reached. | ||
*/ | ||
loop?: boolean; | ||
/** | ||
* Whether or not to close the select menu when the user presses | ||
* the escape key. | ||
* | ||
* @default true | ||
*/ | ||
closeOnEscape?: boolean; | ||
/** | ||
* Whether or not to close the select menu when the user clicks | ||
* outside of the menu. | ||
* | ||
* @default true | ||
*/ | ||
closeOnOutsideClick?: boolean; | ||
/** | ||
* If not undefined, the select menu will be rendered within the provided element or selector. | ||
* | ||
* @default 'body' | ||
*/ | ||
portal?: HTMLElement | string | null; | ||
/** | ||
* Whether the menu content should be displayed even if it is not open. | ||
* This is useful for animating the content in and out using transitions. | ||
* | ||
* @see https://melt-ui.com/docs/transitions | ||
* | ||
* @default false | ||
*/ | ||
forceVisible?: boolean; | ||
/** | ||
* The default value set on the select input. | ||
* | ||
* This will be overridden if you also pass a `value` store prop. | ||
* | ||
* @default undefined | ||
*/ | ||
defaultSelected?: S; | ||
/** | ||
* An optional controlled store that manages the value state of the combobox. | ||
*/ | ||
selected?: Writable<S | undefined>; | ||
/** | ||
* A change handler for the value store called when the value would normally change. | ||
* | ||
* @see https://melt-ui.com/docs/controlled#change-functions | ||
*/ | ||
onSelectedChange?: ChangeFn<S | undefined>; | ||
multiple?: Multiple; | ||
}; | ||
export type SelectOptionProps<Value = unknown> = SelectOption<Value> & { | ||
disabled?: boolean; | ||
}; | ||
export type Select<Value = unknown, Multiple extends boolean = false, S extends SelectSelected<Multiple, Value> = SelectSelected<Multiple, Value>> = BuilderReturn<typeof createSelect<Value, Multiple, S>>; | ||
export type SelectElements<Value = unknown, Multiple extends boolean = false, S extends SelectSelected<Multiple, Value> = SelectSelected<Multiple, Value>> = BuilderReturn<typeof createSelect<Value, Multiple, S>>['elements']; | ||
export type SelectOptions<Value = unknown, Multiple extends boolean = false, S extends SelectSelected<Multiple, Value> = SelectSelected<Multiple, Value>> = BuilderReturn<typeof createSelect<Value, Multiple, S>>['options']; | ||
export type SelectStates<Value = unknown, Multiple extends boolean = false, S extends SelectSelected<Multiple, Value> = SelectSelected<Multiple, Value>> = BuilderReturn<typeof createSelect<Value, Multiple, S>>['states']; | ||
export type SelectHelpers<Value = unknown, Multiple extends boolean = false, S extends SelectSelected<Multiple, Value> = SelectSelected<Multiple, Value>> = BuilderReturn<typeof createSelect<Value, Multiple, S>>['helpers']; | ||
export type { ListboxOption as SelectOption, ListboxSelected as SelectSelected, ListboxOptionProps as SelectOptionProps, } from '../listbox/types.js'; | ||
export type CreateSelectProps<Value = unknown, Multiple extends boolean = false, S extends ListboxSelected<Multiple, Value> = ListboxSelected<Multiple, Value>> = Omit<CreateListboxProps<Value, Multiple, S>, 'builder'>; | ||
export type Select<Value = unknown, Multiple extends boolean = false, S extends ListboxSelected<Multiple, Value> = ListboxSelected<Multiple, Value>> = BuilderReturn<typeof createSelect<Value, Multiple, S>>; | ||
export type SelectElements<Value = unknown, Multiple extends boolean = false, S extends ListboxSelected<Multiple, Value> = ListboxSelected<Multiple, Value>> = Select<Value, Multiple, S>['elements']; | ||
export type SelectOptions<Value = unknown, Multiple extends boolean = false, S extends ListboxSelected<Multiple, Value> = ListboxSelected<Multiple, Value>> = Select<Value, Multiple, S>['options']; | ||
export type SelectStates<Value = unknown, Multiple extends boolean = false, S extends ListboxSelected<Multiple, Value> = ListboxSelected<Multiple, Value>> = Select<Value, Multiple, S>['states']; | ||
export type SelectHelpers<Value = unknown, Multiple extends boolean = false, S extends ListboxSelected<Multiple, Value> = ListboxSelected<Multiple, Value>> = Select<Value, Multiple, S>['helpers']; |
@@ -44,4 +44,4 @@ /// <reference types="svelte" /> | ||
}>; | ||
arrowSize: Writable<number>; | ||
closeOnEscape: Writable<boolean>; | ||
arrowSize: Writable<number>; | ||
group: Writable<string | boolean | undefined>; | ||
@@ -48,0 +48,0 @@ openDelay: Writable<number>; |
@@ -8,2 +8,8 @@ import { type Writable } from 'svelte/store'; | ||
onMatch?: (element: HTMLElement) => void; | ||
/** | ||
* Get the current item, usually the active element. | ||
* @returns The current item | ||
* @default () => document.activeElement | ||
*/ | ||
getCurrentItem?: () => Element | null | undefined; | ||
}; | ||
@@ -10,0 +16,0 @@ export type HandleTypeaheadSearch = {}; |
@@ -13,2 +13,3 @@ import { get, writable } from 'svelte/store'; | ||
onMatch: handleRovingFocus, | ||
getCurrentItem: () => document.activeElement, | ||
}; | ||
@@ -22,5 +23,3 @@ export function createTypeaheadSearch(args = {}) { | ||
const handleTypeaheadSearch = (key, items) => { | ||
const currentItem = document.activeElement; | ||
if (!isHTMLElement(currentItem)) | ||
return; | ||
const currentItem = withDefaults.getCurrentItem(); | ||
const $typed = get(typed); | ||
@@ -31,3 +30,3 @@ if (!Array.isArray($typed)) { | ||
$typed.push(key.toLowerCase()); | ||
typed.update(() => $typed); | ||
typed.set($typed); | ||
const candidateItems = items.filter((item) => { | ||
@@ -43,3 +42,3 @@ if (item.getAttribute('disabled') === 'true' || | ||
const normalizeSearch = isRepeated ? $typed[0] : $typed.join(''); | ||
const currentItemIndex = currentItem ? candidateItems.indexOf(currentItem) : -1; | ||
const currentItemIndex = isHTMLElement(currentItem) ? candidateItems.indexOf(currentItem) : -1; | ||
let wrappedItems = wrapArray(candidateItems, Math.max(currentItemIndex, 0)); | ||
@@ -50,3 +49,3 @@ const excludeCurrentItem = normalizeSearch.length === 1; | ||
} | ||
const nextItem = wrappedItems.find((item) => item.innerText.toLowerCase().startsWith(normalizeSearch.toLowerCase())); | ||
const nextItem = wrappedItems.find((item) => item?.innerText && item.innerText.toLowerCase().startsWith(normalizeSearch.toLowerCase())); | ||
if (isHTMLElement(nextItem) && nextItem !== currentItem) { | ||
@@ -53,0 +52,0 @@ withDefaults.onMatch(nextItem); |
{ | ||
"name": "@melt-ui/svelte", | ||
"version": "0.54.1", | ||
"version": "0.55.0", | ||
"license": "MIT", | ||
@@ -5,0 +5,0 @@ "repository": "github:melt-ui/melt-ui", |
@@ -1,4 +0,2 @@ | ||
<h1 align="center"> | ||
<img align="center" src="https://raw.githubusercontent.com/melt-ui/melt-ui/main/static/banner.png" /> | ||
</h1> | ||
![](static/banner.png) | ||
@@ -63,2 +61,7 @@ [Melt UI](https://www.melt-ui.com/) is a set of headless, accessible component builders for Svelte. | ||
Melt UI is an open-source project built by the community for the community. It wouldn't be possible | ||
if it wasn't for the work of some amazing people. | ||
[![Contributors](https://contrib.rocks/image?repo=melt-ui/melt-ui)](<[https://github.com/codemaniac-sahil/news-webapp-api](https://github.com/melt-ui/melt-ui)https://github.com/melt-ui/melt-ui/graphs/contributors>) | ||
## Community | ||
@@ -65,0 +68,0 @@ |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
360
84
610926
14001