@melt-ui/svelte
Advanced tools
Comparing version 0.15.0 to 0.16.0
/// <reference types="svelte" /> | ||
import type { FloatingConfig } from '../../internal/actions'; | ||
import type { TextDirection } from '../../internal/types'; | ||
import { type Writable } from 'svelte/store'; | ||
export type CreateDropdownMenuArgs = { | ||
positioning?: FloatingConfig; | ||
/** | ||
* The size of the arrow in pixels. | ||
* @default 8 | ||
*/ | ||
arrowSize?: number; | ||
/** | ||
* The direction of the text in the dropdown menu | ||
* | ||
* @default 'ltr' | ||
*/ | ||
dir?: TextDirection; | ||
}; | ||
export type CreateDropdownSubMenuArgs = CreateDropdownMenuArgs & { | ||
disabled?: boolean; | ||
}; | ||
export type CreateMenuRadioGroupArgs = { | ||
value?: string; | ||
}; | ||
export type ItemArgs = { | ||
onSelect?: (e: Event) => void; | ||
}; | ||
export type CheckboxItemArgs = ItemArgs & { | ||
checked: Writable<boolean | 'indeterminate'>; | ||
}; | ||
export type RadioItemArgs = { | ||
value: string; | ||
disabled?: boolean; | ||
}; | ||
export type RadioItemActionArgs = ItemArgs; | ||
export declare function createDropdownMenu(args?: CreateDropdownMenuArgs): { | ||
import { type Menu } from '../menu'; | ||
export type CreateDropdownMenu = Menu['builder']; | ||
export type CreateDropdownMenuSub = Menu['submenu']; | ||
export type DropdownMenuItemArgs = Menu['item']; | ||
export type DropdownMenuCheckboxItemArgs = Menu['checkboxItem']; | ||
export type CreateDropdownMenuRadioGroup = Menu['radioGroup']; | ||
export type DropdownMenuRadioItemArgs = Menu['radioItem']; | ||
export type DropdownMenuRadioItemActionArgs = Menu['radioItemAction']; | ||
export declare function createDropdownMenu(args?: CreateDropdownMenu): { | ||
trigger: { | ||
@@ -81,3 +55,3 @@ action: (node: HTMLElement) => { | ||
}; | ||
open: Writable<boolean>; | ||
open: import("svelte/store").Writable<boolean>; | ||
item: { | ||
@@ -88,3 +62,3 @@ role: string; | ||
'data-melt-part': string; | ||
action: (node: HTMLElement, params?: ItemArgs) => { | ||
action: (node: HTMLElement, params?: import("../menu").ItemArgs) => { | ||
destroy: () => void; | ||
@@ -98,3 +72,3 @@ }; | ||
'data-melt-part': string; | ||
action: (node: HTMLElement, params: CheckboxItemArgs) => { | ||
action: (node: HTMLElement, params: import("../menu").CheckboxItemArgs) => { | ||
destroy: () => void; | ||
@@ -108,4 +82,4 @@ }; | ||
}>; | ||
options: Writable<CreateDropdownMenuArgs>; | ||
createSubMenu: (args?: CreateDropdownSubMenuArgs) => { | ||
options: import("svelte/store").Writable<import("../menu").CreateMenuArgs>; | ||
createSubMenu: (args?: import("../menu").CreateSubmenuArgs | undefined) => { | ||
subTrigger: { | ||
@@ -163,3 +137,3 @@ action: (node: HTMLElement) => { | ||
}; | ||
subOpen: Writable<boolean>; | ||
subOpen: import("svelte/store").Writable<boolean>; | ||
subArrow: import("svelte/store").Readable<{ | ||
@@ -169,5 +143,5 @@ 'data-arrow': boolean; | ||
}>; | ||
subOptions: Writable<CreateDropdownSubMenuArgs>; | ||
subOptions: import("svelte/store").Writable<import("../menu").CreateSubmenuArgs>; | ||
}; | ||
createMenuRadioGroup: (args?: CreateMenuRadioGroupArgs) => { | ||
createMenuRadioGroup: (args?: import("../menu").CreateRadioGroupArgs) => { | ||
radioGroup: { | ||
@@ -178,6 +152,6 @@ role: string; | ||
radioItem: { | ||
action: (node: HTMLElement, params?: RadioItemActionArgs) => { | ||
action: (node: HTMLElement, params?: import("../menu").ItemArgs) => { | ||
destroy: () => void; | ||
}; | ||
subscribe(this: void, run: import("svelte/store").Subscriber<(itemArgs: RadioItemArgs) => { | ||
subscribe(this: void, run: import("svelte/store").Subscriber<(itemArgs: import("../menu").RadioItemArgs) => { | ||
disabled: boolean; | ||
@@ -192,3 +166,3 @@ role: string; | ||
'data-melt-part': string; | ||
}>, invalidate?: import("svelte/store").Invalidator<(itemArgs: RadioItemArgs) => { | ||
}>, invalidate?: import("svelte/store").Invalidator<(itemArgs: import("../menu").RadioItemArgs) => { | ||
disabled: boolean; | ||
@@ -206,3 +180,3 @@ role: string; | ||
isChecked: import("svelte/store").Readable<(itemValue: string) => boolean>; | ||
value: Writable<string | null>; | ||
value: import("svelte/store").Writable<string | null>; | ||
}; | ||
@@ -209,0 +183,0 @@ separator: import("svelte/store").Readable<{ |
@@ -1,18 +0,3 @@ | ||
import { usePopper } from '../../internal/actions/popper'; | ||
import { derivedWithUnsubscribe, effect, isBrowser, kbd, sleep, styleToString, generateId, isHTMLElement, isElementDisabled, noop, executeCallbacks, addEventListener, hiddenAction, createTypeaheadSearch, handleRovingFocus, } from '../../internal/helpers'; | ||
import { onMount, tick } from 'svelte'; | ||
import { derived, get, writable } from 'svelte/store'; | ||
import { createSeparator } from '../separator'; | ||
const SELECTION_KEYS = [kbd.ENTER, kbd.SPACE]; | ||
const FIRST_KEYS = [kbd.ARROW_DOWN, kbd.PAGE_UP, kbd.HOME]; | ||
const LAST_KEYS = [kbd.ARROW_UP, kbd.PAGE_DOWN, kbd.END]; | ||
const FIRST_LAST_KEYS = [...FIRST_KEYS, ...LAST_KEYS]; | ||
const SUB_OPEN_KEYS = { | ||
ltr: [...SELECTION_KEYS, kbd.ARROW_RIGHT], | ||
rtl: [...SELECTION_KEYS, kbd.ARROW_LEFT], | ||
}; | ||
const SUB_CLOSE_KEYS = { | ||
ltr: [kbd.ARROW_LEFT], | ||
rtl: [kbd.ARROW_RIGHT], | ||
}; | ||
import { writable } from 'svelte/store'; | ||
import { createMenuBuilder } from '../menu'; | ||
const defaults = { | ||
@@ -23,2 +8,3 @@ arrowSize: 8, | ||
}, | ||
preventScroll: true, | ||
}; | ||
@@ -30,1047 +16,19 @@ export function createDropdownMenu(args) { | ||
const rootActiveTrigger = 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. | ||
*/ | ||
const isUsingKeyboard = writable(false); | ||
/** | ||
* Stores used to manage the grace area for submenus. This prevents us | ||
* from closing a submenu when the user is moving their mouse from the | ||
* trigger to the submenu. | ||
*/ | ||
const lastPointerX = writable(0); | ||
const pointerGraceIntent = writable(null); | ||
const pointerDir = writable('right'); | ||
const pointerMovingToSubmenu = derivedWithUnsubscribe([pointerDir, pointerGraceIntent], ([$pointerDir, $pointerGraceIntent]) => { | ||
return (e) => { | ||
const isMovingTowards = $pointerDir === $pointerGraceIntent?.side; | ||
return isMovingTowards && isPointerInGraceArea(e, $pointerGraceIntent?.area); | ||
}; | ||
const nextFocusable = writable(null); | ||
const prevFocusable = writable(null); | ||
const { trigger, menu, item, checkboxItem, arrow, createSubMenu, createMenuRadioGroup, separator, } = createMenuBuilder({ | ||
rootOptions, | ||
rootOpen, | ||
rootActiveTrigger, | ||
nextFocusable, | ||
prevFocusable, | ||
disableTriggerRefocus: true, | ||
}); | ||
const { typed, handleTypeaheadSearch } = createTypeaheadSearch(); | ||
const rootIds = { | ||
menu: generateId(), | ||
trigger: generateId(), | ||
}; | ||
const rootMenu = { | ||
...derived([rootOpen], ([$rootOpen]) => { | ||
return { | ||
role: 'menu', | ||
hidden: $rootOpen ? undefined : true, | ||
style: styleToString({ | ||
display: $rootOpen ? undefined : 'none', | ||
}), | ||
id: rootIds.menu, | ||
'aria-labelledby': rootIds.trigger, | ||
'data-melt-part': 'menu', | ||
'data-melt-menu': '', | ||
'data-state': $rootOpen ? 'open' : 'closed', | ||
tabindex: -1, | ||
}; | ||
}), | ||
action: (node) => { | ||
let unsubPopper = noop; | ||
const unsubDerived = effect([rootOpen, rootActiveTrigger, rootOptions], ([$rootOpen, $rootActiveTrigger, $rootOptions]) => { | ||
unsubPopper(); | ||
if ($rootOpen && $rootActiveTrigger) { | ||
tick().then(() => { | ||
setMeltMenuAttribute(node); | ||
const popper = usePopper(node, { | ||
anchorElement: $rootActiveTrigger, | ||
open: rootOpen, | ||
options: { | ||
floating: $rootOptions.positioning, | ||
}, | ||
}); | ||
if (popper && popper.destroy) { | ||
unsubPopper = popper.destroy; | ||
} | ||
}); | ||
} | ||
}); | ||
const unsubEvents = executeCallbacks(addEventListener(node, 'keydown', (e) => { | ||
const target = e.target; | ||
if (!isHTMLElement(target)) | ||
return; | ||
const menuElement = e.currentTarget; | ||
if (!isHTMLElement(menuElement)) | ||
return; | ||
/** | ||
* Submenu key events bubble through portals and | ||
* we only care about key events that happen inside this menu. | ||
*/ | ||
const isKeyDownInside = target.closest('[data-melt-menu]') === menuElement; | ||
if (!isKeyDownInside) | ||
return; | ||
if (FIRST_LAST_KEYS.includes(e.key)) { | ||
handleMenuNavigation(e); | ||
} | ||
/** | ||
* Menus should not be navigated using tab, so we prevent it. | ||
* @see https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_general_within | ||
*/ | ||
if (e.key === kbd.TAB) { | ||
e.preventDefault(); | ||
return; | ||
} | ||
/** | ||
* Check for typeahead search and handle it. | ||
*/ | ||
const isCharacterKey = e.key.length === 1; | ||
const isModifierKey = e.ctrlKey || e.altKey || e.metaKey; | ||
if (!isModifierKey && isCharacterKey) { | ||
handleTypeaheadSearch(e.key, getMenuItems(menuElement)); | ||
} | ||
})); | ||
return { | ||
destroy() { | ||
unsubDerived(); | ||
unsubEvents(); | ||
unsubPopper(); | ||
}, | ||
}; | ||
}, | ||
}; | ||
const rootTrigger = { | ||
...derived([rootOpen], ([$rootOpen]) => { | ||
return { | ||
'aria-controls': rootIds.menu, | ||
'aria-expanded': $rootOpen, | ||
'data-state': $rootOpen ? 'open' : 'closed', | ||
id: rootIds.trigger, | ||
'data-melt-part': 'trigger', | ||
}; | ||
}), | ||
action: (node) => { | ||
applyAttrsIfDisabled(node); | ||
const unsub = executeCallbacks(addEventListener(node, 'pointerdown', (e) => { | ||
const $rootOpen = get(rootOpen); | ||
const triggerElement = e.currentTarget; | ||
if (!isHTMLElement(triggerElement)) | ||
return; | ||
rootOpen.update((prev) => { | ||
const isOpen = !prev; | ||
if (isOpen) { | ||
rootActiveTrigger.set(triggerElement); | ||
} | ||
else { | ||
rootActiveTrigger.set(null); | ||
} | ||
return isOpen; | ||
}); | ||
if (!$rootOpen) | ||
e.preventDefault(); | ||
}), addEventListener(node, 'keydown', (e) => { | ||
const triggerElement = e.currentTarget; | ||
if (!isHTMLElement(triggerElement)) | ||
return; | ||
if (SELECTION_KEYS.includes(e.key) || e.key === kbd.ARROW_DOWN) { | ||
if (e.key === kbd.ARROW_DOWN) { | ||
/** | ||
* 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(); | ||
} | ||
rootOpen.update((prev) => { | ||
const isOpen = !prev; | ||
if (isOpen) { | ||
rootActiveTrigger.set(triggerElement); | ||
} | ||
else { | ||
rootActiveTrigger.set(null); | ||
} | ||
return isOpen; | ||
}); | ||
const menuId = triggerElement.getAttribute('aria-controls'); | ||
if (!menuId) | ||
return; | ||
const menu = document.getElementById(menuId); | ||
if (!isHTMLElement(menu)) | ||
return; | ||
const menuItems = getMenuItems(menu); | ||
if (!menuItems.length) | ||
return; | ||
const nextFocusedElement = menuItems[0]; | ||
if (!isHTMLElement(nextFocusedElement)) | ||
return; | ||
handleRovingFocus(nextFocusedElement); | ||
} | ||
e.preventDefault(); | ||
})); | ||
return { | ||
destroy: unsub, | ||
}; | ||
}, | ||
}; | ||
const rootArrow = derived(rootOptions, ($rootOptions) => ({ | ||
'data-arrow': true, | ||
'data-melt-part': 'arrow', | ||
style: styleToString({ | ||
position: 'absolute', | ||
width: `var(--arrow-size, ${$rootOptions.arrowSize}px)`, | ||
height: `var(--arrow-size, ${$rootOptions.arrowSize}px)`, | ||
}), | ||
})); | ||
const item = hiddenAction({ | ||
role: 'menuitem', | ||
tabindex: -1, | ||
'data-orientation': 'vertical', | ||
'data-melt-part': 'item', | ||
action: (node, params = {}) => { | ||
const { onSelect } = params; | ||
setMeltMenuAttribute(node); | ||
applyAttrsIfDisabled(node); | ||
const unsub = executeCallbacks(addEventListener(node, 'pointerdown', (e) => { | ||
const itemElement = e.currentTarget; | ||
if (!isHTMLElement(itemElement)) | ||
return; | ||
if (isElementDisabled(itemElement)) { | ||
e.preventDefault(); | ||
return; | ||
} | ||
}), addEventListener(node, 'click', (e) => { | ||
const itemElement = e.currentTarget; | ||
if (!isHTMLElement(itemElement)) | ||
return; | ||
if (isElementDisabled(itemElement)) { | ||
e.preventDefault(); | ||
return; | ||
} | ||
if (e.defaultPrevented) { | ||
if (!isHTMLElement(itemElement)) | ||
return; | ||
handleRovingFocus(itemElement); | ||
return; | ||
} | ||
onSelect?.(e); | ||
if (e.defaultPrevented) | ||
return; | ||
rootOpen.set(false); | ||
}), addEventListener(node, 'keydown', (e) => { | ||
onItemKeyDown(e); | ||
}), addEventListener(node, 'pointermove', (e) => { | ||
const itemElement = e.currentTarget; | ||
if (!isHTMLElement(itemElement)) | ||
return; | ||
if (isElementDisabled(itemElement)) { | ||
onItemLeave(e); | ||
return; | ||
} | ||
onMenuItemPointerMove(e); | ||
}), addEventListener(node, 'pointerleave', (e) => { | ||
onMenuItemPointerLeave(e); | ||
}), addEventListener(node, 'focusin', (e) => { | ||
const itemElement = e.currentTarget; | ||
if (!isHTMLElement(itemElement)) | ||
return; | ||
itemElement.setAttribute('data-highlighted', ''); | ||
}), addEventListener(node, 'focusout', (e) => { | ||
const itemElement = e.currentTarget; | ||
if (!isHTMLElement(itemElement)) | ||
return; | ||
itemElement.removeAttribute('data-highlighted'); | ||
})); | ||
return { | ||
destroy: unsub, | ||
}; | ||
}, | ||
}); | ||
const checkboxItemDefaults = { | ||
checked: writable(false), | ||
}; | ||
const checkboxItem = hiddenAction({ | ||
role: 'menuitemcheckbox', | ||
tabindex: -1, | ||
'data-orientation': 'vertical', | ||
'data-melt-part': 'item', | ||
action: (node, params) => { | ||
setMeltMenuAttribute(node); | ||
applyAttrsIfDisabled(node); | ||
const { checked, onSelect } = { ...checkboxItemDefaults, ...params }; | ||
const $checked = get(checked); | ||
node.setAttribute('aria-checked', isIndeterminate($checked) ? 'mixed' : String($checked)); | ||
node.setAttribute('data-state', getCheckedState($checked)); | ||
const unsub = executeCallbacks(addEventListener(node, 'pointerdown', (e) => { | ||
const itemElement = e.currentTarget; | ||
if (!isHTMLElement(itemElement)) | ||
return; | ||
if (isElementDisabled(itemElement)) { | ||
e.preventDefault(); | ||
return; | ||
} | ||
}), addEventListener(node, 'click', (e) => { | ||
const itemElement = e.currentTarget; | ||
if (!isHTMLElement(itemElement)) | ||
return; | ||
if (isElementDisabled(itemElement)) { | ||
e.preventDefault(); | ||
return; | ||
} | ||
if (e.defaultPrevented) { | ||
if (!isHTMLElement(itemElement)) | ||
return; | ||
handleRovingFocus(itemElement); | ||
return; | ||
} | ||
onSelect?.(e); | ||
if (e.defaultPrevented) | ||
return; | ||
checked.update((prev) => { | ||
if (isIndeterminate(prev)) | ||
return true; | ||
return !prev; | ||
}); | ||
rootOpen.set(false); | ||
}), addEventListener(node, 'keydown', (e) => { | ||
onItemKeyDown(e); | ||
}), addEventListener(node, 'pointermove', (e) => { | ||
const itemElement = e.currentTarget; | ||
if (!isHTMLElement(itemElement)) | ||
return; | ||
if (isElementDisabled(itemElement)) { | ||
onItemLeave(e); | ||
return; | ||
} | ||
onMenuItemPointerMove(e); | ||
}), addEventListener(node, 'pointerleave', (e) => { | ||
onMenuItemPointerLeave(e); | ||
}), addEventListener(node, 'focusin', (e) => { | ||
const itemElement = e.currentTarget; | ||
if (!isHTMLElement(itemElement)) | ||
return; | ||
itemElement.setAttribute('data-highlighted', ''); | ||
}), addEventListener(node, 'focusout', (e) => { | ||
const itemElement = e.currentTarget; | ||
if (!isHTMLElement(itemElement)) | ||
return; | ||
itemElement.removeAttribute('data-highlighted'); | ||
})); | ||
return { | ||
destroy: unsub, | ||
}; | ||
}, | ||
}); | ||
const createMenuRadioGroup = (args = {}) => { | ||
const value = writable(args.value ?? null); | ||
const radioGroup = { | ||
role: 'group', | ||
'data-melt-part': 'radio-group', | ||
}; | ||
const radioItemDefaults = { | ||
disabled: false, | ||
}; | ||
const radioItem = { | ||
...derived([value], ([$value]) => { | ||
return (itemArgs) => { | ||
const { value: itemValue, disabled } = { ...radioItemDefaults, ...itemArgs }; | ||
const checked = $value === itemValue; | ||
return { | ||
disabled, | ||
role: 'menuitemradio', | ||
'data-state': checked ? 'checked' : 'unchecked', | ||
'aria-checked': checked, | ||
'data-disabled': disabled ? '' : undefined, | ||
'data-value': itemValue, | ||
'data-orientation': 'vertical', | ||
tabindex: -1, | ||
'data-melt-part': 'item', | ||
}; | ||
}; | ||
}), | ||
action: (node, params = {}) => { | ||
setMeltMenuAttribute(node); | ||
const { onSelect } = params; | ||
const unsub = executeCallbacks(addEventListener(node, 'pointerdown', (e) => { | ||
const itemElement = e.currentTarget; | ||
if (!isHTMLElement(itemElement)) | ||
return; | ||
const itemValue = node.dataset.value; | ||
const disabled = node.dataset.disabled; | ||
if (disabled || itemValue === undefined) { | ||
e.preventDefault(); | ||
return; | ||
} | ||
}), addEventListener(node, 'click', (e) => { | ||
const itemElement = e.currentTarget; | ||
if (!isHTMLElement(itemElement)) | ||
return; | ||
const itemValue = node.dataset.value; | ||
const disabled = node.dataset.disabled; | ||
if (disabled || itemValue === undefined) { | ||
e.preventDefault(); | ||
return; | ||
} | ||
if (e.defaultPrevented) { | ||
if (!isHTMLElement(itemElement)) | ||
return; | ||
handleRovingFocus(itemElement); | ||
return; | ||
} | ||
onSelect?.(e); | ||
if (e.defaultPrevented) | ||
return; | ||
value.set(itemValue); | ||
rootOpen.set(false); | ||
}), addEventListener(node, 'keydown', (e) => { | ||
onItemKeyDown(e); | ||
}), addEventListener(node, 'pointermove', (e) => { | ||
const itemElement = e.currentTarget; | ||
if (!isHTMLElement(itemElement)) | ||
return; | ||
const itemValue = node.dataset.value; | ||
const disabled = node.dataset.disabled; | ||
if (disabled || itemValue === undefined) { | ||
onItemLeave(e); | ||
return; | ||
} | ||
onMenuItemPointerMove(e); | ||
}), addEventListener(node, 'pointerleave', (e) => { | ||
onMenuItemPointerLeave(e); | ||
}), addEventListener(node, 'focusin', (e) => { | ||
const itemElement = e.currentTarget; | ||
if (!isHTMLElement(itemElement)) | ||
return; | ||
itemElement.setAttribute('data-highlighted', ''); | ||
}), addEventListener(node, 'focusout', (e) => { | ||
const itemElement = e.currentTarget; | ||
if (!isHTMLElement(itemElement)) | ||
return; | ||
itemElement.removeAttribute('data-highlighted'); | ||
})); | ||
return { | ||
destroy: unsub, | ||
}; | ||
}, | ||
}; | ||
const isChecked = derived(value, ($value) => { | ||
return (itemValue) => { | ||
return $value === itemValue; | ||
}; | ||
}); | ||
return { | ||
radioGroup, | ||
radioItem, | ||
isChecked, | ||
value, | ||
}; | ||
}; | ||
const { root: separator } = createSeparator({ | ||
orientation: 'horizontal', | ||
}); | ||
/* ------------------------------------------------------------------------------------------------- | ||
* SUBMENU | ||
* -----------------------------------------------------------------------------------------------*/ | ||
const subMenuDefaults = { | ||
...defaults, | ||
disabled: false, | ||
positioning: { | ||
placement: 'right-start', | ||
gutter: 8, | ||
}, | ||
}; | ||
const createSubMenu = (args) => { | ||
const withDefaults = { ...subMenuDefaults, ...args }; | ||
const subOptions = writable(withDefaults); | ||
const subOpen = writable(false); | ||
const subActiveTrigger = writable(null); | ||
const subOpenTimer = writable(null); | ||
const pointerGraceTimer = writable(0); | ||
const subIds = { | ||
menu: generateId(), | ||
trigger: generateId(), | ||
}; | ||
const subMenu = { | ||
...derived([subOpen], ([$subOpen]) => { | ||
return { | ||
role: 'menu', | ||
hidden: $subOpen ? undefined : true, | ||
style: styleToString({ | ||
display: $subOpen ? undefined : 'none', | ||
}), | ||
id: subIds.menu, | ||
'aria-labelledby': subIds.trigger, | ||
'data-melt-part': 'submenu', | ||
'data-melt-menu': '', | ||
'data-state': $subOpen ? 'open' : 'closed', | ||
tabindex: -1, | ||
}; | ||
}), | ||
action: (node) => { | ||
let unsubPopper = noop; | ||
const unsubDerived = effect([subOpen, subActiveTrigger, subOptions], ([$subOpen, $subActiveTrigger, $subOptions]) => { | ||
unsubPopper(); | ||
if ($subOpen && $subActiveTrigger) { | ||
tick().then(() => { | ||
const parentMenuEl = getParentMenu($subActiveTrigger); | ||
const popper = usePopper(node, { | ||
anchorElement: $subActiveTrigger, | ||
open: subOpen, | ||
options: { | ||
floating: $subOptions.positioning, | ||
portal: isHTMLElement(parentMenuEl) ? parentMenuEl : undefined, | ||
clickOutside: null, | ||
focusTrap: null, | ||
}, | ||
}); | ||
if (popper && popper.destroy) { | ||
unsubPopper = popper.destroy; | ||
} | ||
}); | ||
} | ||
}); | ||
const unsubEvents = executeCallbacks(addEventListener(node, 'keydown', (e) => { | ||
if (e.key === kbd.ESCAPE) { | ||
return; | ||
} | ||
// Submenu key events bubble through portals. | ||
// We only want the keys in this menu. | ||
const target = e.target; | ||
if (!isHTMLElement(target)) | ||
return; | ||
const menuElement = e.currentTarget; | ||
if (!isHTMLElement(menuElement)) | ||
return; | ||
const targetMeltMenuId = target.getAttribute('data-melt-menu-id'); | ||
if (!targetMeltMenuId) | ||
return; | ||
const isKeyDownInside = target.closest('[data-melt-menu]') === menuElement && | ||
targetMeltMenuId === menuElement.id; | ||
if (!isKeyDownInside) | ||
return; | ||
if (FIRST_LAST_KEYS.includes(e.key)) { | ||
// prevent events from bubbling | ||
e.stopImmediatePropagation(); | ||
handleMenuNavigation(e); | ||
return; | ||
} | ||
const isCloseKey = SUB_CLOSE_KEYS['ltr'].includes(e.key); | ||
const isModifierKey = e.ctrlKey || e.altKey || e.metaKey; | ||
const isCharacterKey = e.key.length === 1; | ||
// close the submenu if the user presses a close key | ||
if (isCloseKey) { | ||
const $subActiveTrigger = get(subActiveTrigger); | ||
e.preventDefault(); | ||
subOpen.update(() => { | ||
if ($subActiveTrigger) { | ||
handleRovingFocus($subActiveTrigger); | ||
} | ||
subActiveTrigger.set(null); | ||
return false; | ||
}); | ||
return; | ||
} | ||
/** | ||
* Menus should not be navigated using tab, so we prevent it. | ||
* @see https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_general_within | ||
*/ | ||
if (e.key === kbd.TAB) { | ||
e.preventDefault(); | ||
return; | ||
} | ||
if (!isModifierKey && isCharacterKey) { | ||
// typeahead logic | ||
handleTypeaheadSearch(e.key, getMenuItems(menuElement)); | ||
} | ||
}), addEventListener(node, 'pointermove', (e) => { | ||
onMenuPointerMove(e); | ||
}), addEventListener(node, 'focusout', (e) => { | ||
const $subActiveTrigger = get(subActiveTrigger); | ||
if (get(isUsingKeyboard)) { | ||
const target = e.target; | ||
if (!isHTMLElement(target)) | ||
return; | ||
const submenuElement = document.getElementById(subIds.menu); | ||
if (!isHTMLElement(submenuElement)) | ||
return; | ||
if (!submenuElement?.contains(target) && target !== $subActiveTrigger) { | ||
subOpen.set(false); | ||
subActiveTrigger.set(null); | ||
} | ||
} | ||
else { | ||
const menuElement = e.currentTarget; | ||
if (!isHTMLElement(menuElement)) | ||
return; | ||
const relatedTarget = e.relatedTarget; | ||
if (!isHTMLElement(relatedTarget)) | ||
return; | ||
if (!menuElement.contains(relatedTarget) && relatedTarget !== $subActiveTrigger) { | ||
subOpen.set(false); | ||
subActiveTrigger.set(null); | ||
} | ||
} | ||
})); | ||
return { | ||
destroy() { | ||
unsubDerived(); | ||
unsubPopper(); | ||
unsubEvents(); | ||
}, | ||
}; | ||
}, | ||
}; | ||
const subTrigger = { | ||
...derived([subOpen, subOptions], ([$subOpen, $subOptions]) => { | ||
return { | ||
role: 'menuitem', | ||
id: subIds.trigger, | ||
tabindex: -1, | ||
'aria-controls': subIds.menu, | ||
'aria-expanded': $subOpen, | ||
'data-state': $subOpen ? 'open' : 'closed', | ||
'data-disabled': $subOptions.disabled ? '' : undefined, | ||
'data-melt-part': 'subtrigger', | ||
'aria-haspopop': 'menu', | ||
}; | ||
}), | ||
action: (node) => { | ||
setMeltMenuAttribute(node); | ||
applyAttrsIfDisabled(node); | ||
const unsubTimer = () => { | ||
clearOpenTimer(subOpenTimer); | ||
window.clearTimeout(get(pointerGraceTimer)); | ||
pointerGraceIntent.set(null); | ||
}; | ||
const unsubEvents = executeCallbacks(addEventListener(node, 'click', (e) => { | ||
const triggerElement = e.currentTarget; | ||
if (!isHTMLElement(triggerElement)) | ||
return; | ||
if (isElementDisabled(triggerElement) || e.defaultPrevented) | ||
return; | ||
// Manually focus because iOS Safari doesn't always focus on click (e.g. buttons) | ||
handleRovingFocus(triggerElement); | ||
if (!get(subOpen)) { | ||
subOpen.update((prev) => { | ||
const isAlreadyOpen = prev; | ||
if (!isAlreadyOpen) { | ||
subActiveTrigger.set(triggerElement); | ||
return !prev; | ||
} | ||
return prev; | ||
}); | ||
} | ||
}), addEventListener(node, 'keydown', (e) => { | ||
const $typed = get(typed); | ||
const triggerElement = e.currentTarget; | ||
if (!isHTMLElement(triggerElement)) | ||
return; | ||
if (isElementDisabled(triggerElement)) | ||
return; | ||
const isTypingAhead = $typed.length > 0; | ||
if (isTypingAhead && e.key === kbd.SPACE) | ||
return; | ||
if (SUB_OPEN_KEYS['ltr'].includes(e.key)) { | ||
if (!get(subOpen)) { | ||
triggerElement.click(); | ||
e.preventDefault(); | ||
return; | ||
} | ||
const menuId = triggerElement.getAttribute('aria-controls'); | ||
if (!menuId) | ||
return; | ||
const menuElement = document.getElementById(menuId); | ||
if (!isHTMLElement(menuElement)) | ||
return; | ||
const firstItem = getMenuItems(menuElement)[0]; | ||
if (!isHTMLElement(firstItem)) | ||
return; | ||
handleRovingFocus(firstItem); | ||
} | ||
}), addEventListener(node, 'pointermove', (e) => { | ||
if (!isMouse(e)) | ||
return; | ||
onItemEnter(e); | ||
if (e.defaultPrevented) | ||
return; | ||
const triggerElement = e.currentTarget; | ||
if (!isHTMLElement(triggerElement)) | ||
return; | ||
handleRovingFocus(triggerElement); | ||
const openTimer = get(subOpenTimer); | ||
if (!get(subOpen) && !openTimer && !isElementDisabled(triggerElement)) { | ||
subOpenTimer.set(window.setTimeout(() => { | ||
subOpen.update(() => { | ||
subActiveTrigger.set(triggerElement); | ||
return true; | ||
}); | ||
clearOpenTimer(subOpenTimer); | ||
}, 100)); | ||
} | ||
}), addEventListener(node, 'pointerleave', (e) => { | ||
if (!isMouse(e)) | ||
return; | ||
clearOpenTimer(subOpenTimer); | ||
const submenuElement = document.getElementById(subIds.menu); | ||
const contentRect = submenuElement?.getBoundingClientRect(); | ||
if (contentRect) { | ||
const side = submenuElement?.dataset.side; | ||
const rightSide = side === 'right'; | ||
const bleed = rightSide ? -5 : +5; | ||
const contentNearEdge = contentRect[rightSide ? 'left' : 'right']; | ||
const contentFarEdge = contentRect[rightSide ? 'right' : 'left']; | ||
pointerGraceIntent.set({ | ||
area: [ | ||
// Apply a bleed on clientX to ensure that our exit point is | ||
// consistently within polygon bounds | ||
{ x: e.clientX + bleed, y: e.clientY }, | ||
{ x: contentNearEdge, y: contentRect.top }, | ||
{ x: contentFarEdge, y: contentRect.top }, | ||
{ x: contentFarEdge, y: contentRect.bottom }, | ||
{ x: contentNearEdge, y: contentRect.bottom }, | ||
], | ||
side, | ||
}); | ||
window.clearTimeout(get(pointerGraceTimer)); | ||
pointerGraceTimer.set(window.setTimeout(() => { | ||
pointerGraceIntent.set(null); | ||
}, 300)); | ||
} | ||
else { | ||
onTriggerLeave(e); | ||
if (e.defaultPrevented) | ||
return; | ||
// There's 100ms where the user may leave an item before the submenu was opened. | ||
pointerGraceIntent.set(null); | ||
} | ||
}), addEventListener(node, 'focusout', (e) => { | ||
const triggerElement = e.currentTarget; | ||
if (!isHTMLElement(triggerElement)) | ||
return; | ||
if (!isHTMLElement(triggerElement)) | ||
return; | ||
triggerElement.removeAttribute('data-highlighted'); | ||
const relatedTarget = e.relatedTarget; | ||
if (!isHTMLElement(relatedTarget)) | ||
return; | ||
const menuId = triggerElement.getAttribute('aria-controls'); | ||
if (!menuId) | ||
return; | ||
const menu = document.getElementById(menuId); | ||
if (isHTMLElement(menu) && !menu.contains(relatedTarget)) { | ||
subActiveTrigger.set(null); | ||
subOpen.set(false); | ||
} | ||
}), addEventListener(node, 'focusin', (e) => { | ||
const triggerElement = e.currentTarget; | ||
if (!isHTMLElement(triggerElement)) | ||
return; | ||
triggerElement.setAttribute('data-highlighted', ''); | ||
})); | ||
return { | ||
destroy() { | ||
unsubTimer(); | ||
unsubEvents(); | ||
}, | ||
}; | ||
}, | ||
}; | ||
const subArrow = derived(subOptions, ($subOptions) => ({ | ||
'data-arrow': true, | ||
style: styleToString({ | ||
position: 'absolute', | ||
width: `var(--arrow-size, ${$subOptions.arrowSize}px)`, | ||
height: `var(--arrow-size, ${$subOptions.arrowSize}px)`, | ||
}), | ||
})); | ||
/* ------------------------------------------------------------------------------------------------- | ||
* Sub Menu Effects | ||
* -----------------------------------------------------------------------------------------------*/ | ||
effect([rootOpen], ([$rootOpen]) => { | ||
if (!$rootOpen) { | ||
subActiveTrigger.set(null); | ||
subOpen.set(false); | ||
} | ||
}); | ||
effect([pointerGraceIntent], ([$pointerGraceIntent]) => { | ||
if (!isBrowser) | ||
return; | ||
if (!$pointerGraceIntent) { | ||
window.clearTimeout(get(pointerGraceTimer)); | ||
} | ||
}); | ||
effect([subOpen], ([$subOpen]) => { | ||
if (!isBrowser) | ||
return; | ||
sleep(1).then(() => { | ||
const menuElement = document.getElementById(subIds.menu); | ||
if (isHTMLElement(menuElement) && $subOpen && get(isUsingKeyboard)) { | ||
// Selector to get menu items belonging to menu | ||
const menuItems = getMenuItems(menuElement); | ||
if (get(isUsingKeyboard)) { | ||
isHTMLElement(menuItems[0]) ? handleRovingFocus(menuItems[0]) : undefined; | ||
} | ||
} | ||
}); | ||
}); | ||
return { | ||
subTrigger, | ||
subMenu, | ||
subOpen, | ||
subArrow, | ||
subOptions, | ||
}; | ||
}; | ||
/* ------------------------------------------------------------------------------------------------- | ||
* Root Effects | ||
* -----------------------------------------------------------------------------------------------*/ | ||
effect([rootOpen, rootActiveTrigger], ([$rootOpen, $rootActiveTrigger]) => { | ||
if (!isBrowser) | ||
return; | ||
if (!$rootOpen && $rootActiveTrigger) { | ||
handleRovingFocus($rootActiveTrigger); | ||
} | ||
sleep(1).then(() => { | ||
const menuElement = document.getElementById(rootIds.menu); | ||
if (isHTMLElement(menuElement) && $rootOpen && get(isUsingKeyboard)) { | ||
// Get menu items belonging to the root menu | ||
const menuItems = getMenuItems(menuElement); | ||
// Focus on first menu item | ||
isHTMLElement(menuItems[0]) ? handleRovingFocus(menuItems[0]) : undefined; | ||
} | ||
else if ($rootActiveTrigger) { | ||
// Focus on active trigger trigger | ||
handleRovingFocus($rootActiveTrigger); | ||
} | ||
else { | ||
const triggerElement = document.getElementById(rootIds.trigger); | ||
if (isHTMLElement(triggerElement)) { | ||
handleRovingFocus(triggerElement); | ||
} | ||
} | ||
}); | ||
}); | ||
onMount(() => { | ||
const handlePointer = () => isUsingKeyboard.set(false); | ||
const handleKeyDown = () => { | ||
isUsingKeyboard.set(true); | ||
document.addEventListener('pointerdown', handlePointer, { capture: true, once: true }); | ||
document.addEventListener('pointermove', handlePointer, { capture: true, once: true }); | ||
}; | ||
document.addEventListener('keydown', handleKeyDown, { capture: true }); | ||
const keydownListener = (e) => { | ||
if (e.key === kbd.ESCAPE) { | ||
rootOpen.set(false); | ||
return; | ||
} | ||
}; | ||
document.addEventListener('keydown', keydownListener); | ||
return () => { | ||
document.removeEventListener('keydown', handleKeyDown, { capture: true }); | ||
document.removeEventListener('pointerdown', handlePointer, { capture: true }); | ||
document.removeEventListener('pointermove', handlePointer, { capture: true }); | ||
document.removeEventListener('keydown', keydownListener); | ||
}; | ||
}); | ||
/* ------------------------------------------------------------------------------------------------- | ||
* Pointer Event Effects | ||
* -----------------------------------------------------------------------------------------------*/ | ||
function onItemEnter(e) { | ||
if (isPointerMovingToSubmenu(e)) { | ||
e.preventDefault(); | ||
} | ||
} | ||
function onItemLeave(e) { | ||
if (isPointerMovingToSubmenu(e)) { | ||
return; | ||
} | ||
const target = e.target; | ||
if (!isHTMLElement(target)) | ||
return; | ||
const parentMenuElement = getParentMenu(target); | ||
if (!isHTMLElement(parentMenuElement)) | ||
return; | ||
handleRovingFocus(parentMenuElement); | ||
} | ||
function onTriggerLeave(e) { | ||
if (isPointerMovingToSubmenu(e)) { | ||
e.preventDefault(); | ||
} | ||
} | ||
function onMenuPointerMove(e) { | ||
if (!isMouse(e)) | ||
return; | ||
const target = e.target; | ||
if (!isHTMLElement(target)) | ||
return; | ||
const currentTarget = e.currentTarget; | ||
if (!isHTMLElement(currentTarget)) | ||
return; | ||
const $lastPointerX = get(lastPointerX); | ||
const pointerXHasChanged = $lastPointerX !== e.clientX; | ||
// We don't use `e.movementX` for this check because Safari will | ||
// always return `0` on a pointer e. | ||
if (currentTarget.contains(target) && pointerXHasChanged) { | ||
const newDir = e.clientX > $lastPointerX ? 'right' : 'left'; | ||
pointerDir.set(newDir); | ||
lastPointerX.set(e.clientX); | ||
} | ||
} | ||
function onMenuItemPointerMove(e) { | ||
if (!isMouse(e)) | ||
return; | ||
onItemEnter(e); | ||
if (!e.defaultPrevented) { | ||
const currentTarget = e.currentTarget; | ||
if (!isHTMLElement(currentTarget)) | ||
return; | ||
// focus on the current menu item | ||
handleRovingFocus(currentTarget); | ||
} | ||
} | ||
function onMenuItemPointerLeave(e) { | ||
if (!isMouse(e)) | ||
return; | ||
onItemLeave(e); | ||
} | ||
/* ------------------------------------------------------------------------------------------------- | ||
* Helper Functions | ||
* -----------------------------------------------------------------------------------------------*/ | ||
function onItemKeyDown(e) { | ||
const $typed = get(typed); | ||
const isTypingAhead = $typed.length > 0; | ||
if (isTypingAhead && e.key === kbd.SPACE) { | ||
e.preventDefault(); | ||
return; | ||
} | ||
if (SELECTION_KEYS.includes(e.key)) { | ||
/** | ||
* We prevent default browser behaviour for selection keys as they should trigger | ||
* a selection only: | ||
* - prevents space from scrolling the page. | ||
* - if keydown causes focus to move, prevents keydown from firing on the new target. | ||
*/ | ||
e.preventDefault(); | ||
const itemElement = e.currentTarget; | ||
if (!isHTMLElement(itemElement)) | ||
return; | ||
itemElement.click(); | ||
} | ||
} | ||
function isIndeterminate(checked) { | ||
return checked === 'indeterminate'; | ||
} | ||
function getCheckedState(checked) { | ||
return isIndeterminate(checked) ? 'indeterminate' : checked ? 'checked' : 'unchecked'; | ||
} | ||
function applyAttrsIfDisabled(element) { | ||
if (!isBrowser) | ||
return; | ||
if (!isHTMLElement(element)) | ||
return; | ||
if (isElementDisabled(element)) { | ||
element.setAttribute('data-disabled', ''); | ||
element.setAttribute('aria-disabled', 'true'); | ||
} | ||
} | ||
function isPointerMovingToSubmenu(e) { | ||
return get(pointerMovingToSubmenu)(e); | ||
} | ||
/** | ||
* Check if the event is a mouse event | ||
* @param e The pointer event | ||
*/ | ||
function isMouse(e) { | ||
return e.pointerType === 'mouse'; | ||
} | ||
/** | ||
* Given a timer store, clear the timeout and set the store to null | ||
* @param openTimer The timer store | ||
*/ | ||
function clearOpenTimer(openTimer) { | ||
if (!isBrowser) | ||
return; | ||
const timer = get(openTimer); | ||
if (timer) { | ||
window.clearTimeout(timer); | ||
openTimer.set(null); | ||
} | ||
} | ||
/** | ||
* Keyboard event handler for menu navigation | ||
* @param e The keyboard event | ||
*/ | ||
function handleMenuNavigation(e) { | ||
e.preventDefault(); | ||
// currently focused menu item | ||
const currentFocusedItem = document.activeElement; | ||
if (!isHTMLElement(currentFocusedItem)) | ||
return; | ||
// menu element being navigated | ||
const currentTarget = e.currentTarget; | ||
if (!isHTMLElement(currentTarget)) | ||
return; | ||
// menu items of the current menu | ||
const menuItems = getMenuItems(currentTarget); | ||
if (!menuItems.length) | ||
return; | ||
const candidateNodes = menuItems.filter((item) => { | ||
if (item.hasAttribute('data-disabled')) { | ||
return false; | ||
} | ||
if (item.getAttribute('disabled') === 'true') { | ||
return false; | ||
} | ||
return true; | ||
}); | ||
// Index of the currently focused item in the candidate nodes array | ||
const currentIndex = candidateNodes.indexOf(currentFocusedItem); | ||
// Calculate the index of the next menu item | ||
let nextIndex; | ||
switch (e.key) { | ||
case kbd.ARROW_DOWN: | ||
nextIndex = currentIndex < candidateNodes.length - 1 ? currentIndex + 1 : currentIndex; | ||
break; | ||
case kbd.ARROW_UP: | ||
nextIndex = currentIndex > 0 ? currentIndex - 1 : 0; | ||
break; | ||
case kbd.HOME: | ||
nextIndex = 0; | ||
break; | ||
case kbd.END: | ||
nextIndex = candidateNodes.length - 1; | ||
break; | ||
default: | ||
return; | ||
} | ||
const nextFocusedItem = candidateNodes[nextIndex]; | ||
handleRovingFocus(nextFocusedItem); | ||
} | ||
/** | ||
* Get the parent menu element for a menu item. | ||
* @param element The menu item element | ||
*/ | ||
function getParentMenu(element) { | ||
return element.closest('[role="menu"]'); | ||
} | ||
/** | ||
* Set the `data-melt-menu-id` attribute on a menu item element. | ||
* @param element The menu item element | ||
*/ | ||
function setMeltMenuAttribute(element) { | ||
if (!element) | ||
return; | ||
const menuEl = element.closest('[data-melt-part="menu"], [data-melt-part="submenu"]'); | ||
if (!isHTMLElement(menuEl)) | ||
return; | ||
element.setAttribute('data-melt-menu-id', menuEl.id); | ||
} | ||
/** | ||
* Get the menu items for a given menu element. | ||
* This only selects menu items that are direct children of the menu element, | ||
* not menu items that are nested in submenus. | ||
* @param element The menu item element | ||
*/ | ||
function getMenuItems(menuElement) { | ||
return Array.from(menuElement.querySelectorAll(`[data-melt-menu-id="${menuElement.id}"]`)); | ||
} | ||
return { | ||
trigger: rootTrigger, | ||
menu: rootMenu, | ||
trigger, | ||
menu, | ||
open: rootOpen, | ||
item, | ||
checkboxItem, | ||
arrow: rootArrow, | ||
arrow, | ||
options: rootOptions, | ||
@@ -1082,27 +40,1 @@ createSubMenu, | ||
} | ||
/** | ||
* Determine if a point is inside of a polygon. | ||
* | ||
* @see https://github.com/substack/point-in-polygon | ||
*/ | ||
function isPointInPolygon(point, polygon) { | ||
const { x, y } = point; | ||
let inside = false; | ||
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { | ||
const xi = polygon[i].x; | ||
const yi = polygon[i].y; | ||
const xj = polygon[j].x; | ||
const yj = polygon[j].y; | ||
// prettier-ignore | ||
const intersect = ((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi); | ||
if (intersect) | ||
inside = !inside; | ||
} | ||
return inside; | ||
} | ||
function isPointerInGraceArea(e, area) { | ||
if (!area) | ||
return false; | ||
const cursorPos = { x: e.clientX, y: e.clientY }; | ||
return isPointInPolygon(cursorPos, area); | ||
} |
@@ -5,2 +5,3 @@ export * from './accordion'; | ||
export * from './collapsible'; | ||
export * from './context-menu'; | ||
export * from './dialog'; | ||
@@ -7,0 +8,0 @@ export * from './dropdown-menu'; |
@@ -5,2 +5,3 @@ export * from './accordion'; | ||
export * from './collapsible'; | ||
export * from './context-menu'; | ||
export * from './dialog'; | ||
@@ -7,0 +8,0 @@ export * from './dropdown-menu'; |
/// <reference types="svelte" /> | ||
import type { FloatingConfig } from '../../internal/actions'; | ||
/** | ||
* Features: | ||
* - [X] Click outside | ||
* - [X] Keyboard navigation | ||
* - [X] Focus management | ||
* - [ ] Detect overflow | ||
* - [ ] Same width as trigger | ||
* - [ ] A11y | ||
* - [X] Floating UI | ||
**/ | ||
export type CreateSelectArgs = { | ||
@@ -21,2 +11,4 @@ positioning?: FloatingConfig; | ||
name?: string; | ||
preventScroll?: boolean; | ||
loop?: boolean; | ||
}; | ||
@@ -34,19 +26,25 @@ export type OptionArgs = { | ||
subscribe(this: void, run: import("svelte/store").Subscriber<{ | ||
role: string; | ||
'aria-controls': string; | ||
'aria-expanded': boolean; | ||
'aria-required': boolean | undefined; | ||
'data-state': string; | ||
'data-disabled': boolean | undefined; | ||
disabled: boolean | undefined; | ||
id: string; | ||
readonly role: "combobox"; | ||
readonly 'aria-autocomplete': "none"; | ||
readonly 'aria-controls': string; | ||
readonly 'aria-expanded': boolean; | ||
readonly 'aria-required': boolean | undefined; | ||
readonly 'data-state': "open" | "closed"; | ||
readonly 'data-disabled': true | undefined; | ||
readonly 'data-melt-part': "trigger"; | ||
readonly disabled: boolean | undefined; | ||
readonly id: string; | ||
readonly tabindex: 0; | ||
}>, invalidate?: import("svelte/store").Invalidator<{ | ||
role: string; | ||
'aria-controls': string; | ||
'aria-expanded': boolean; | ||
'aria-required': boolean | undefined; | ||
'data-state': string; | ||
'data-disabled': boolean | undefined; | ||
disabled: boolean | undefined; | ||
id: string; | ||
readonly role: "combobox"; | ||
readonly 'aria-autocomplete': "none"; | ||
readonly 'aria-controls': string; | ||
readonly 'aria-expanded': boolean; | ||
readonly 'aria-required': boolean | undefined; | ||
readonly 'data-state': "open" | "closed"; | ||
readonly 'data-disabled': true | undefined; | ||
readonly 'data-melt-part': "trigger"; | ||
readonly disabled: boolean | undefined; | ||
readonly id: string; | ||
readonly tabindex: 0; | ||
}> | undefined): import("svelte/store").Unsubscriber; | ||
@@ -76,17 +74,17 @@ }; | ||
subscribe(this: void, run: import("svelte/store").Subscriber<(args: OptionArgs) => { | ||
role: string; | ||
'aria-selected': boolean; | ||
'data-selected': string | undefined; | ||
'data-value': unknown; | ||
'data-label': string | undefined; | ||
'data-disabled': string | undefined; | ||
tabindex: number; | ||
readonly role: "option"; | ||
readonly 'aria-selected': boolean; | ||
readonly 'data-selected': "" | undefined; | ||
readonly 'data-value': unknown; | ||
readonly 'data-label': string | undefined; | ||
readonly 'data-disabled': "" | undefined; | ||
readonly tabindex: -1; | ||
}>, invalidate?: import("svelte/store").Invalidator<(args: OptionArgs) => { | ||
role: string; | ||
'aria-selected': boolean; | ||
'data-selected': string | undefined; | ||
'data-value': unknown; | ||
'data-label': string | undefined; | ||
'data-disabled': string | undefined; | ||
tabindex: number; | ||
readonly role: "option"; | ||
readonly 'aria-selected': boolean; | ||
readonly 'data-selected': "" | undefined; | ||
readonly 'data-value': unknown; | ||
readonly 'data-label': string | undefined; | ||
readonly 'data-disabled': "" | undefined; | ||
readonly tabindex: -1; | ||
}> | undefined): import("svelte/store").Unsubscriber; | ||
@@ -113,2 +111,17 @@ }; | ||
}>; | ||
separator: import("svelte/store").Readable<{ | ||
role: string; | ||
'aria-orientation': "vertical" | undefined; | ||
'aria-hidden': boolean; | ||
'data-orientation': import("../../internal/types").Orientation; | ||
}>; | ||
createGroup: () => { | ||
group: { | ||
role: string; | ||
'aria-labelledby': string; | ||
}; | ||
label: { | ||
id: string; | ||
}; | ||
}; | ||
}; |
import { usePopper } from '../../internal/actions/popper'; | ||
import { addEventListener, createTypeaheadSearch, effect, executeCallbacks, generateId, isBrowser, kbd, noop, omit, styleToString, } from '../../internal/helpers'; | ||
import { addEventListener, createTypeaheadSearch, effect, executeCallbacks, generateId, handleRovingFocus, isBrowser, isHTMLElement, kbd, noop, omit, removeScroll, styleToString, } from '../../internal/helpers'; | ||
import { sleep } from '../../internal/helpers/sleep'; | ||
import { onMount, tick } from 'svelte'; | ||
import { derived, get, writable } from 'svelte/store'; | ||
import { createSeparator } from '../separator'; | ||
const SELECTION_KEYS = [kbd.ENTER, kbd.SPACE]; | ||
const FIRST_KEYS = [kbd.ARROW_DOWN, kbd.PAGE_UP, kbd.HOME]; | ||
const LAST_KEYS = [kbd.ARROW_UP, kbd.PAGE_DOWN, kbd.END]; | ||
const FIRST_LAST_KEYS = [...FIRST_KEYS, ...LAST_KEYS]; | ||
const defaults = { | ||
@@ -14,2 +19,4 @@ arrowSize: 8, | ||
}, | ||
preventScroll: true, | ||
loop: false, | ||
}; | ||
@@ -23,2 +30,18 @@ export function createSelect(args) { | ||
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. | ||
*/ | ||
const isUsingKeyboard = writable(false); | ||
const ids = { | ||
@@ -35,3 +58,3 @@ menu: generateId(), | ||
const selectedEl = menuEl.querySelector('[data-selected]'); | ||
if (!selectedEl) | ||
if (!isHTMLElement(selectedEl)) | ||
return; | ||
@@ -71,2 +94,32 @@ const dataLabel = selectedEl.getAttribute('data-label'); | ||
}); | ||
const unsubEventListeners = executeCallbacks(addEventListener(node, 'keydown', (e) => { | ||
const menuElement = e.currentTarget; | ||
if (!isHTMLElement(menuElement)) | ||
return; | ||
const target = e.target; | ||
if (!isHTMLElement(target)) | ||
return; | ||
const isModifierKey = e.ctrlKey || e.altKey || e.metaKey; | ||
const isCharacterKey = e.key.length === 1; | ||
if (e.key === kbd.TAB) { | ||
e.preventDefault(); | ||
activeTrigger.set(null); | ||
open.set(false); | ||
handleTabNavigation(e); | ||
} | ||
if (FIRST_LAST_KEYS.includes(e.key)) { | ||
e.preventDefault(); | ||
if (menuElement === target) { | ||
const selectedOption = getSelectedOption(menuElement); | ||
if (isHTMLElement(selectedOption)) { | ||
handleRovingFocus(selectedOption); | ||
return; | ||
} | ||
} | ||
handleMenuNavigation(e); | ||
} | ||
if (!isModifierKey && isCharacterKey) { | ||
handleTypeaheadSearch(e.key, getOptions(node)); | ||
} | ||
})); | ||
return { | ||
@@ -76,2 +129,3 @@ destroy() { | ||
unsubPopper(); | ||
unsubEventListeners(); | ||
}, | ||
@@ -85,2 +139,3 @@ }; | ||
role: 'combobox', | ||
'aria-autocomplete': 'none', | ||
'aria-controls': ids.menu, | ||
@@ -91,17 +146,25 @@ 'aria-expanded': $open, | ||
'data-disabled': $options.disabled ? true : undefined, | ||
'data-melt-part': 'trigger', | ||
disabled: $options.disabled, | ||
id: ids.trigger, | ||
tabindex: 0, | ||
}; | ||
}), | ||
action: (node) => { | ||
const unsub = executeCallbacks(addEventListener(node, 'click', (e) => { | ||
const unsub = executeCallbacks(addEventListener(node, 'pointerdown', (e) => { | ||
const $options = get(options); | ||
if ($options.disabled) | ||
if ($options.disabled) { | ||
e.preventDefault(); | ||
return; | ||
e.stopPropagation(); | ||
const triggerEl = e.currentTarget; | ||
} | ||
const $open = get(open); | ||
const triggerElement = e.currentTarget; | ||
if (!isHTMLElement(triggerElement)) | ||
return; | ||
open.update((prev) => { | ||
const isOpen = !prev; | ||
if (isOpen) { | ||
activeTrigger.set(triggerEl); | ||
nextFocusable.set(getNextFocusable(triggerElement)); | ||
prevFocusable.set(getPreviousFocusable(triggerElement)); | ||
activeTrigger.set(triggerElement); | ||
} | ||
@@ -113,11 +176,47 @@ else { | ||
}); | ||
}), addEventListener(node, 'mousedown', (e) => { | ||
e.preventDefault(); | ||
if (!$open) | ||
e.preventDefault(); | ||
}), addEventListener(node, 'keydown', (e) => { | ||
const $options = get(options); | ||
if ($options.disabled) | ||
const triggerElement = e.currentTarget; | ||
if (!isHTMLElement(triggerElement)) | ||
return; | ||
if ([kbd.ENTER, kbd.SPACE, kbd.ARROW_DOWN, kbd.ARROW_UP].includes(e.key)) { | ||
e.preventDefault(); | ||
open.set(true); | ||
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(triggerElement)); | ||
prevFocusable.set(getPreviousFocusable(triggerElement)); | ||
activeTrigger.set(triggerElement); | ||
} | ||
else { | ||
activeTrigger.set(null); | ||
} | ||
return isOpen; | ||
}); | ||
const menu = document.getElementById(ids.menu); | ||
if (!isHTMLElement(menu)) | ||
return; | ||
const selectedOption = menu.querySelector('[data-selected]'); | ||
if (isHTMLElement(selectedOption)) { | ||
handleRovingFocus(selectedOption); | ||
return; | ||
} | ||
const options = getOptions(menu); | ||
if (!options.length) | ||
return; | ||
const nextFocusedElement = options[0]; | ||
if (!isHTMLElement(nextFocusedElement)) | ||
return; | ||
handleRovingFocus(nextFocusedElement); | ||
} | ||
@@ -130,2 +229,19 @@ })); | ||
}; | ||
const { root: separator } = createSeparator({ | ||
decorative: true, | ||
}); | ||
const createGroup = () => { | ||
const groupId = generateId(); | ||
const group = { | ||
role: 'group', | ||
'aria-labelledby': groupId, | ||
}; | ||
const label = { | ||
id: groupId, | ||
}; | ||
return { | ||
group, | ||
label, | ||
}; | ||
}; | ||
const arrow = derived(options, ($options) => ({ | ||
@@ -149,3 +265,3 @@ 'data-arrow': true, | ||
'data-disabled': args.disabled ? '' : undefined, | ||
tabindex: 0, | ||
tabindex: -1, | ||
}; | ||
@@ -162,7 +278,21 @@ }; | ||
label: label ?? node.textContent ?? null, | ||
disabled: !disabled, | ||
disabled: disabled ? true : false, | ||
}; | ||
}; | ||
const unsub = executeCallbacks(addEventListener(node, 'click', () => { | ||
const unsub = executeCallbacks(addEventListener(node, 'pointerdown', (e) => { | ||
const args = getElArgs(); | ||
if (args.disabled) { | ||
e.preventDefault(); | ||
return; | ||
} | ||
}), addEventListener(node, 'click', (e) => { | ||
const itemElement = e.currentTarget; | ||
if (!isHTMLElement(itemElement)) | ||
return; | ||
const args = getElArgs(); | ||
if (args.disabled) { | ||
e.preventDefault(); | ||
return; | ||
} | ||
handleRovingFocus(itemElement); | ||
value.set(args.value); | ||
@@ -181,2 +311,3 @@ label.set(args.label); | ||
const args = getElArgs(); | ||
node.setAttribute('data-selected', ''); | ||
value.set(args.value); | ||
@@ -186,6 +317,32 @@ label.set(args.label); | ||
} | ||
}), addEventListener(node, 'mousemove', () => { | ||
node.focus(); | ||
}), addEventListener(node, 'mouseout', () => { | ||
node.blur(); | ||
}), addEventListener(node, 'pointermove', (e) => { | ||
const args = getElArgs(); | ||
if (args.disabled) { | ||
e.preventDefault(); | ||
return; | ||
} | ||
const itemElement = e.currentTarget; | ||
if (!isHTMLElement(itemElement)) | ||
return; | ||
if (args.disabled) { | ||
const menuElement = document.getElementById(ids.menu); | ||
if (!isHTMLElement(menuElement)) | ||
return; | ||
handleRovingFocus(menuElement); | ||
} | ||
onOptionPointerMove(e); | ||
}), addEventListener(node, 'pointerleave', (e) => { | ||
if (!isMouse(e)) | ||
return; | ||
onOptionLeave(); | ||
}), addEventListener(node, 'focusin', (e) => { | ||
const itemElement = e.currentTarget; | ||
if (!isHTMLElement(itemElement)) | ||
return; | ||
itemElement.setAttribute('data-highlighted', ''); | ||
}), addEventListener(node, 'focusout', (e) => { | ||
const itemElement = e.currentTarget; | ||
if (!isHTMLElement(itemElement)) | ||
return; | ||
itemElement.removeAttribute('data-highlighted'); | ||
})); | ||
@@ -197,24 +354,38 @@ return { | ||
}; | ||
const { typed, handleTypeaheadSearch } = createTypeaheadSearch({ | ||
onMatch: (element) => element.focus(), | ||
}); | ||
effect([open, activeTrigger, menu], ([$open, $activeTrigger]) => { | ||
const { typed, handleTypeaheadSearch } = createTypeaheadSearch(); | ||
effect([open, activeTrigger], ([$open, $activeTrigger]) => { | ||
const unsubs = []; | ||
if (!isBrowser) | ||
return; | ||
const menuEl = document.getElementById(ids.menu); | ||
if (menuEl && $open) { | ||
// Focus on selected option or first option | ||
const selectedOption = menuEl.querySelector('[data-selected]'); | ||
if (!selectedOption) { | ||
const firstOption = menuEl.querySelector('[role="option"]'); | ||
sleep(1).then(() => firstOption?.focus()); | ||
const $options = get(options); | ||
if ($open && $options.preventScroll) { | ||
unsubs.push(removeScroll()); | ||
} | ||
sleep(1).then(() => { | ||
const menuEl = document.getElementById(ids.menu); | ||
if (menuEl && $open && get(isUsingKeyboard)) { | ||
// Focus on selected option or first option | ||
const selectedOption = getSelectedOption(menuEl); | ||
if (!isHTMLElement(selectedOption)) { | ||
const firstOption = getFirstOption(menuEl); | ||
if (!isHTMLElement(firstOption)) | ||
return; | ||
handleRovingFocus(firstOption); | ||
} | ||
else { | ||
handleRovingFocus(selectedOption); | ||
} | ||
} | ||
else { | ||
sleep(1).then(() => selectedOption.focus()); | ||
else if (menuEl && $open) { | ||
// focus on the menu element | ||
handleRovingFocus(menuEl); | ||
} | ||
} | ||
else if (!$open && $activeTrigger && isBrowser) { | ||
// Hacky way to prevent the keydown event from triggering on the trigger | ||
sleep(1).then(() => $activeTrigger.focus()); | ||
} | ||
else if ($activeTrigger) { | ||
// Hacky way to prevent the keydown event from triggering on the trigger | ||
handleRovingFocus($activeTrigger); | ||
} | ||
}); | ||
return () => { | ||
unsubs.forEach((unsub) => unsub()); | ||
}; | ||
}); | ||
@@ -227,51 +398,23 @@ const isSelected = derived([value], ([$value]) => { | ||
onMount(() => { | ||
const handlePointer = () => isUsingKeyboard.set(false); | ||
const handleKeyDown = () => { | ||
isUsingKeyboard.set(true); | ||
document.addEventListener('pointerdown', handlePointer, { capture: true, once: true }); | ||
document.addEventListener('pointermove', handlePointer, { capture: true, once: true }); | ||
}; | ||
document.addEventListener('keydown', handleKeyDown, { capture: true }); | ||
const keydownListener = (e) => { | ||
const menuEl = document.getElementById(ids.menu); | ||
if (!menuEl || menuEl.hidden) | ||
return; | ||
if (e.key === kbd.ESCAPE) { | ||
open.set(false); | ||
activeTrigger.set(null); | ||
return; | ||
const $activeTrigger = get(activeTrigger); | ||
if (!$activeTrigger) | ||
return; | ||
handleRovingFocus($activeTrigger); | ||
} | ||
const allOptions = Array.from(menuEl.querySelectorAll('[role="option"]')); | ||
const focusedOption = allOptions.find((el) => el === document.activeElement); | ||
const focusedIndex = allOptions.indexOf(focusedOption); | ||
if (e.key === kbd.ARROW_DOWN) { | ||
e.preventDefault(); | ||
const nextIndex = focusedIndex + 1 > allOptions.length - 1 ? 0 : focusedIndex + 1; | ||
const nextOption = allOptions[nextIndex]; | ||
nextOption.focus(); | ||
return; | ||
} | ||
else if (e.key === kbd.ARROW_UP) { | ||
e.preventDefault(); | ||
const prevIndex = focusedIndex - 1 < 0 ? allOptions.length - 1 : focusedIndex - 1; | ||
const prevOption = allOptions[prevIndex]; | ||
prevOption.focus(); | ||
return; | ||
} | ||
else if (e.key === kbd.HOME) { | ||
e.preventDefault(); | ||
const firstOption = allOptions[0]; | ||
firstOption.focus(); | ||
return; | ||
} | ||
else if (e.key === kbd.END) { | ||
e.preventDefault(); | ||
const lastOption = allOptions[allOptions.length - 1]; | ||
lastOption.focus(); | ||
return; | ||
} | ||
/** | ||
* Handle typeahead search | ||
*/ | ||
const isCharacterKey = e.key.length === 1; | ||
const isModifierKey = e.ctrlKey || e.altKey || e.metaKey; | ||
if (!isModifierKey && isCharacterKey) { | ||
handleTypeaheadSearch(e.key, allOptions); | ||
} | ||
}; | ||
document.addEventListener('keydown', keydownListener); | ||
return () => { | ||
document.removeEventListener('keydown', handleKeyDown, { capture: true }); | ||
document.removeEventListener('pointerdown', handlePointer, { capture: true }); | ||
document.removeEventListener('pointermove', handlePointer, { capture: true }); | ||
document.removeEventListener('keydown', keydownListener); | ||
@@ -299,2 +442,110 @@ }; | ||
}); | ||
function getOptions(element) { | ||
return Array.from(element.querySelectorAll('[role="option"]')); | ||
} | ||
function isMouse(e) { | ||
return e.pointerType === 'mouse'; | ||
} | ||
function getFirstOption(menuElement) { | ||
return menuElement.querySelector('[role="option"]'); | ||
} | ||
function getSelectedOption(menuElement) { | ||
return menuElement.querySelector('[data-selected]'); | ||
} | ||
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; | ||
if (!isHTMLElement(currentFocusedItem)) | ||
return; | ||
// menu element being navigated | ||
const currentTarget = e.currentTarget; | ||
if (!isHTMLElement(currentTarget)) | ||
return; | ||
// menu items of the current menu | ||
const items = getOptions(currentTarget); | ||
if (!items.length) | ||
return; | ||
const candidateNodes = items.filter((opt) => { | ||
if (opt.hasAttribute('data-disabled')) { | ||
return false; | ||
} | ||
if (opt.getAttribute('disabled') === 'true') { | ||
return false; | ||
} | ||
return true; | ||
}); | ||
// Index of the currently focused item in the candidate nodes array | ||
const currentIndex = candidateNodes.indexOf(currentFocusedItem); | ||
// Calculate the index of the next menu item | ||
let nextIndex; | ||
const $options = get(options); | ||
const loop = $options.loop; | ||
switch (e.key) { | ||
case kbd.ARROW_DOWN: | ||
nextIndex = | ||
currentIndex < candidateNodes.length - 1 ? currentIndex + 1 : loop ? 0 : currentIndex; | ||
break; | ||
case kbd.ARROW_UP: | ||
nextIndex = currentIndex > 0 ? currentIndex - 1 : loop ? candidateNodes.length - 1 : 0; | ||
break; | ||
case kbd.HOME: | ||
nextIndex = 0; | ||
break; | ||
case kbd.END: | ||
nextIndex = candidateNodes.length - 1; | ||
break; | ||
default: | ||
return; | ||
} | ||
handleRovingFocus(candidateNodes[nextIndex]); | ||
} | ||
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); | ||
} | ||
} | ||
} | ||
function getNextFocusable(element) { | ||
const focusableElements = Array.from(document.querySelectorAll('a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])')); | ||
const currentIndex = focusableElements.indexOf(element); | ||
const nextIndex = currentIndex + 1; | ||
return nextIndex < focusableElements.length ? focusableElements[nextIndex] : null; | ||
} | ||
function getPreviousFocusable(element) { | ||
const focusableElements = Array.from(document.querySelectorAll('a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])')); | ||
const currentIndex = focusableElements.indexOf(element); | ||
const previousIndex = currentIndex - 1; | ||
return previousIndex >= 0 ? focusableElements[previousIndex] : null; | ||
} | ||
return { | ||
@@ -311,3 +562,5 @@ trigger, | ||
input, | ||
separator, | ||
createGroup, | ||
}; | ||
} |
@@ -11,3 +11,4 @@ import { type Writable } from 'svelte/store'; | ||
}; | ||
export declare function focusInput(id: string): void; | ||
export declare function focusInput(id: string, pos: 'start' | 'end'): void; | ||
export declare function getTagElements(me: HTMLElement, rootAttribute: string, tagAttribute: string): HTMLElement[]; | ||
export declare function deleteTagById(id: string, tags: Writable<Tag[]>): void; |
import { getElementByMeltId } from '../../internal/helpers'; | ||
import { get } from 'svelte/store'; | ||
export function focusInput(id) { | ||
export function focusInput(id, pos) { | ||
const inputEl = getElementByMeltId(id); | ||
if (inputEl) { | ||
inputEl.focus(); | ||
inputEl.setSelectionRange(inputEl.value.length, inputEl.value.length); // set caret at the end | ||
if (pos === 'start') | ||
inputEl.setSelectionRange(0, 0); | ||
else | ||
inputEl.setSelectionRange(inputEl.value.length, inputEl.value.length); | ||
} | ||
} | ||
export function getTagElements(me, rootAttribute, tagAttribute) { | ||
const rootEl = me.closest(`[data-melt-part="${rootAttribute}"]`); | ||
return Array.from(rootEl.querySelectorAll(`[data-melt-part="${tagAttribute}"]`)); | ||
} | ||
export function deleteTagById(id, tags) { | ||
@@ -11,0 +18,0 @@ const $tags = get(tags); |
@@ -19,3 +19,3 @@ /// <reference types="svelte" /> | ||
readonly 'data-melt-id': string; | ||
readonly 'data-melt-part': "tags-input"; | ||
readonly 'data-melt-part': string; | ||
readonly disabled: boolean | undefined; | ||
@@ -26,3 +26,3 @@ }>, invalidate?: import("svelte/store").Invalidator<{ | ||
readonly 'data-melt-id': string; | ||
readonly 'data-melt-part': "tags-input"; | ||
readonly 'data-melt-part': string; | ||
readonly disabled: boolean | undefined; | ||
@@ -54,5 +54,2 @@ }> | undefined): import("svelte/store").Unsubscriber; | ||
tag: { | ||
action: (node: HTMLElement) => { | ||
destroy: () => void; | ||
}; | ||
subscribe(this: void, run: import("svelte/store").Subscriber<(tag: TagArgs) => { | ||
@@ -59,0 +56,0 @@ role: string; |
import { addEventListener, executeCallbacks, generateId, getElementByMeltId, kbd, omit, } from '../../internal/helpers'; | ||
import { derived, get, writable } from 'svelte/store'; | ||
import { deleteTagById, focusInput } from './helpers'; | ||
import { deleteTagById, focusInput, getTagElements } from './helpers'; | ||
const defaults = { | ||
@@ -11,2 +11,8 @@ placeholder: 'Enter tags...', | ||
}; | ||
const dataMeltParts = { | ||
root: 'tags-input', | ||
input: 'tags-input-input', | ||
tag: 'tags-input-tag', | ||
deleteTrigger: 'tags-input-delete-trigger', | ||
}; | ||
export function createTagsInput(args) { | ||
@@ -42,3 +48,3 @@ const withDefaults = { ...defaults, ...args }; | ||
'data-melt-id': ids.root, | ||
'data-melt-part': 'tags-input', | ||
'data-melt-part': dataMeltParts['root'], | ||
disabled: $options.disabled, | ||
@@ -49,8 +55,17 @@ }; | ||
action: (node) => { | ||
const unsub = executeCallbacks(addEventListener(node, 'click', () => { | ||
// Focus on the input when the root is clicked | ||
selectedTag.set(null); | ||
const inputEl = getElementByMeltId(ids.input); | ||
if (inputEl) | ||
inputEl.focus(); | ||
const unsub = executeCallbacks(addEventListener(node, 'click', (e) => { | ||
const delegatedTarget = e.target.closest('[data-melt-part=tags-input-tag]'); | ||
if (delegatedTarget) { | ||
selectedTag.set({ | ||
id: delegatedTarget.getAttribute('data-tag-id') ?? '', | ||
value: delegatedTarget.getAttribute('data-tag-value') ?? '', | ||
}); | ||
} | ||
else { | ||
// Focus on the input when the root is clicked | ||
selectedTag.set(null); | ||
const inputEl = getElementByMeltId(ids.input); | ||
if (inputEl) | ||
inputEl.focus(); | ||
} | ||
})); | ||
@@ -68,3 +83,3 @@ return { | ||
'data-melt-id': ids.input, | ||
'data-melt-part': 'tags-input-input', | ||
'data-melt-part': dataMeltParts['input'], | ||
'data-disabled': $options.disabled, | ||
@@ -78,35 +93,142 @@ disabled: $options.disabled, | ||
const unsub = executeCallbacks(addEventListener(node, 'keydown', (e) => { | ||
// on any keydown, clear selected tag | ||
selectedTag.set(null); | ||
if (e.key === kbd.ENTER) { | ||
e.preventDefault(); | ||
const value = node.value; | ||
if (!value) | ||
return; | ||
// Do not add when tags are unique and this tag already exists | ||
const $options = get(options); | ||
const $tags = get(tags); | ||
if ($options.unique) { | ||
const index = $tags.findIndex((tag) => tag.value === value); | ||
if (index >= 0) { | ||
// Select the tag | ||
selectedTag.set($tags[index]); | ||
const $selectedTag = get(selectedTag); | ||
if ($selectedTag) { | ||
// Check if a character is entered into the input | ||
if (e.key.length === 1) { | ||
// A character is entered, set selectedTag to null | ||
selectedTag.set(null); | ||
} | ||
else if (e.key === kbd.ARROW_LEFT) { | ||
e.preventDefault(); | ||
const tagsEl = getTagElements(node, dataMeltParts['root'], dataMeltParts['tag']); | ||
const selectedIndex = tagsEl.findIndex((element) => element.getAttribute('data-tag-id') === $selectedTag.id); | ||
const prevIndex = selectedIndex - 1; | ||
if (prevIndex >= 0) { | ||
selectedTag.set({ | ||
id: tagsEl[prevIndex].getAttribute('data-tag-id') ?? '', | ||
value: tagsEl[prevIndex].getAttribute('data-tag-value') ?? '', | ||
}); | ||
} | ||
} | ||
else if (e.key === kbd.ARROW_RIGHT) { | ||
e.preventDefault(); | ||
const tagsEl = getTagElements(node, dataMeltParts['root'], dataMeltParts['tag']); | ||
const selectedIndex = tagsEl.findIndex((element) => element.getAttribute('data-tag-id') === $selectedTag.id); | ||
const nextIndex = selectedIndex + 1; | ||
if (nextIndex >= tagsEl.length) { | ||
selectedTag.set(null); | ||
focusInput(ids.input, 'start'); | ||
} | ||
else { | ||
selectedTag.set({ | ||
id: tagsEl[nextIndex].getAttribute('data-tag-id') ?? '', | ||
value: tagsEl[nextIndex].getAttribute('data-tag-value') ?? '', | ||
}); | ||
} | ||
} | ||
else if (e.key === kbd.HOME) { | ||
e.preventDefault(); | ||
const tagsEl = getTagElements(node, dataMeltParts['root'], dataMeltParts['tag']); | ||
if (tagsEl.length > 0) { | ||
selectedTag.set({ | ||
id: tagsEl[0].getAttribute('data-tag-id') ?? '', | ||
value: tagsEl[0].getAttribute('data-tag-value') ?? '', | ||
}); | ||
} | ||
} | ||
else if (e.key === kbd.END) { | ||
e.preventDefault(); | ||
selectedTag.set(null); | ||
focusInput(ids.input, 'end'); | ||
} | ||
else if (e.key === kbd.DELETE) { | ||
// Delete this tag and move to the next tag. If there is no next tag | ||
// focus on input | ||
e.preventDefault(); | ||
const prevSelectedId = $selectedTag.id; | ||
const tagsEl = getTagElements(node, dataMeltParts['root'], dataMeltParts['tag']); | ||
const selectedIndex = tagsEl.findIndex((element) => element.getAttribute('data-tag-id') === $selectedTag.id); | ||
const nextIndex = selectedIndex + 1; | ||
if (nextIndex >= tagsEl.length) { | ||
selectedTag.set(null); | ||
focusInput(ids.input, 'start'); | ||
} | ||
else { | ||
selectedTag.set({ | ||
id: tagsEl[nextIndex].getAttribute('data-tag-id') ?? '', | ||
value: tagsEl[nextIndex].getAttribute('data-tag-value') ?? '', | ||
}); | ||
} | ||
// Delete the previously selected tag | ||
deleteTagById(prevSelectedId, tags); | ||
} | ||
else if (e.key === kbd.BACKSPACE) { | ||
// Delete this tag and move to the previous tag. If there is no previous, | ||
// move to the next tag. If there is no next tag, focus on input | ||
e.preventDefault(); | ||
const prevSelectedId = $selectedTag.id; | ||
const tagsEl = getTagElements(node, dataMeltParts['root'], dataMeltParts['tag']); | ||
const selectedIndex = tagsEl.findIndex((element) => element.getAttribute('data-tag-id') === $selectedTag.id); | ||
const prevIndex = selectedIndex - 1; | ||
const nextIndex = selectedIndex + 1; | ||
if (prevIndex >= 0) { | ||
selectedTag.set({ | ||
id: tagsEl[prevIndex].getAttribute('data-tag-id') ?? '', | ||
value: tagsEl[prevIndex].getAttribute('data-tag-value') ?? '', | ||
}); | ||
} | ||
else { | ||
if (nextIndex >= tagsEl.length) { | ||
selectedTag.set(null); | ||
focusInput(ids.input, 'start'); | ||
} | ||
else { | ||
selectedTag.set({ | ||
id: tagsEl[nextIndex].getAttribute('data-tag-id') ?? '', | ||
value: tagsEl[nextIndex].getAttribute('data-tag-value') ?? '', | ||
}); | ||
} | ||
} | ||
// Delete the previously selected tag | ||
deleteTagById(prevSelectedId, tags); | ||
} | ||
} | ||
else { | ||
// ENTER | ||
if (e.key === kbd.ENTER) { | ||
e.preventDefault(); | ||
const value = node.value; | ||
if (!value) | ||
return; | ||
const $options = get(options); | ||
const $tags = get(tags); | ||
// Ignore unique | ||
if ($options.unique) { | ||
const index = $tags.findIndex((tag) => tag.value === value); | ||
if (index >= 0) { | ||
// Select the tag | ||
selectedTag.set($tags[index]); | ||
return; | ||
} | ||
} | ||
// Add tag | ||
tags.update((currentTags) => [ | ||
...currentTags, | ||
{ id: generateId(), value: node.value }, | ||
]); | ||
node.value = ''; | ||
} | ||
// Add tag | ||
tags.update((currentTags) => [...currentTags, { id: generateId(), value: node.value }]); | ||
node.value = ''; | ||
return; | ||
else if (node.selectionStart === 0 && | ||
(e.key === kbd.ARROW_LEFT || e.key === kbd.BACKSPACE)) { | ||
e.preventDefault(); | ||
const tagsEl = getTagElements(node, dataMeltParts['root'], dataMeltParts['tag']); | ||
const lastTag = tagsEl.at(-1); | ||
if (lastTag) { | ||
selectedTag.set({ | ||
id: lastTag.getAttribute('data-tag-id') ?? '', | ||
value: lastTag.getAttribute('data-tag-value') ?? '', | ||
}); | ||
} | ||
} | ||
} | ||
if (e.key === kbd.ARROW_LEFT || (e.key === kbd.BACKSPACE && node.selectionStart === 0)) { | ||
// Move to the last tag (if there is one) | ||
const el = e.currentTarget; | ||
const root = el.closest('[data-melt-part="tags-input"]'); | ||
const tags = Array.from(root.querySelectorAll('[data-melt-part="tags-input-tag"]')); | ||
// Go to the first tag | ||
e.preventDefault(); | ||
tags.at(-1)?.focus(); | ||
return; | ||
} | ||
})); | ||
@@ -127,3 +249,3 @@ return { | ||
role: 'tag', | ||
'data-melt-part': 'tags-input-tag', | ||
'data-melt-part': dataMeltParts['tag'], | ||
'aria-selected': selected, | ||
@@ -134,124 +256,6 @@ 'data-selected': selected ? '' : undefined, | ||
'data-disabled': disabled ? '' : undefined, | ||
tabindex: tag.disabled ? -1 : 0, | ||
tabindex: -1, | ||
}; | ||
}; | ||
}), | ||
// Action => use:tag.action | ||
action: (node) => { | ||
const getElArgs = () => { | ||
const value = node.getAttribute('data-tag-value') ?? ''; | ||
const id = node.getAttribute('data-tag-id') ?? ''; | ||
const disabled = node.hasAttribute('data-disabled'); | ||
return { | ||
value, | ||
id, | ||
disabled, | ||
}; | ||
}; | ||
const unsub = executeCallbacks(addEventListener(node, 'focus', (e) => { | ||
// Simulate a click on focus | ||
const el = e.currentTarget; | ||
el.click(); | ||
}), addEventListener(node, 'click', (e) => { | ||
e.stopPropagation(); | ||
const args = getElArgs(); | ||
// Do nothing when disabled | ||
if (args.disabled) | ||
return; | ||
const $selectedTag = get(selectedTag); | ||
if ($selectedTag?.id !== args.id) | ||
selectedTag.set({ id: args.id, value: args.value }); | ||
}), addEventListener(node, 'keydown', (e) => { | ||
const $selectedTag = get(selectedTag); | ||
const el = e.currentTarget; | ||
const rootEl = el.closest('[data-melt-part="tags-input"]'); | ||
const tagsEl = Array.from(rootEl.querySelectorAll('[data-melt-part="tags-input-tag"]')); | ||
const currentIndex = tagsEl.indexOf(el); | ||
const prevIndex = currentIndex - 1; | ||
const nextIndex = currentIndex + 1; | ||
if (e.key === kbd.ARROW_RIGHT) { | ||
// Go to the next tag. If this is the last tag, focus on input | ||
e.preventDefault(); | ||
if (nextIndex >= tagsEl.length) { | ||
selectedTag.set(null); | ||
focusInput(ids.input); | ||
} | ||
else { | ||
tagsEl[nextIndex].focus(); | ||
} | ||
} | ||
else if (e.key === kbd.ARROW_LEFT) { | ||
// Go to the previous tag | ||
e.preventDefault(); | ||
if (prevIndex >= 0) | ||
tagsEl[prevIndex].focus(); | ||
} | ||
else if (e.key === kbd.HOME) { | ||
// Go to the first tag | ||
e.preventDefault(); | ||
tagsEl[0].focus(); | ||
} | ||
else if (e.key === kbd.END) { | ||
// Focus on the input | ||
e.preventDefault(); | ||
selectedTag.set(null); | ||
focusInput(ids.input); | ||
} | ||
else if ($selectedTag !== null && e.key === kbd.DELETE) { | ||
// Delete this tag and move to the next tag. If there is no next tag | ||
// focus on input | ||
e.preventDefault(); | ||
if (nextIndex >= tagsEl.length) { | ||
// Focus on the input when the root is clicked | ||
selectedTag.set(null); | ||
focusInput(ids.input); | ||
} | ||
else { | ||
// Set the next tag as selected | ||
const value = tagsEl[nextIndex].getAttribute('data-tag-value') ?? ''; | ||
const id = tagsEl[nextIndex].getAttribute('data-tag-id') ?? ''; | ||
selectedTag.set({ id, value }); | ||
} | ||
// Delete the current tag | ||
const currentId = node.getAttribute('data-tag-id') ?? ''; | ||
deleteTagById(currentId, tags); | ||
} | ||
else if ($selectedTag !== null && e.key === kbd.BACKSPACE) { | ||
// Delete this tag and move to the previous tag. If there is no previous, | ||
// move to the next tag. If there is no next tag, focus on input | ||
e.preventDefault(); | ||
let previousEL = null; | ||
if (prevIndex >= 0) { | ||
const value = tagsEl[prevIndex].getAttribute('data-tag-value') ?? ''; | ||
const id = tagsEl[prevIndex].getAttribute('data-tag-id') ?? ''; | ||
selectedTag.set({ id, value }); | ||
previousEL = rootEl.querySelector(`[data-tag-id="${id}"]`); | ||
} | ||
else { | ||
const nextIndex = currentIndex + 1; | ||
if (nextIndex >= tagsEl.length) { | ||
// Focus on the input when the root is clicked | ||
selectedTag.set(null); | ||
focusInput(ids.input); | ||
} | ||
else { | ||
// Set the next tag as selected | ||
const value = tagsEl[nextIndex].getAttribute('data-tag-value') ?? ''; | ||
const id = tagsEl[nextIndex].getAttribute('data-tag-id') ?? ''; | ||
selectedTag.set({ id, value }); | ||
} | ||
} | ||
// Delete the current tag | ||
const currentId = node.getAttribute('data-tag-id') ?? ''; | ||
deleteTagById(currentId, tags); | ||
// Fix issue whereby when a tag is deleted the focus can sometimes | ||
// 'shift' | ||
if (previousEL) | ||
previousEL.focus(); | ||
} | ||
})); | ||
return { | ||
destroy: unsub, | ||
}; | ||
}, | ||
}; | ||
@@ -267,3 +271,3 @@ // Attributes and an action to apply each delete trigger | ||
role: 'tag-delete-trigger', | ||
'data-melt-part': 'tags-input-delete-trigger', | ||
'data-melt-part': dataMeltParts['deleteTrigger'], | ||
'aria-selected': selected, | ||
@@ -270,0 +274,0 @@ 'data-selected': selected ? '' : undefined, |
@@ -38,3 +38,3 @@ /// <reference types="svelte" /> | ||
readonly 'data-orientation': Orientation; | ||
readonly 'data-melt-part': "toggle-group"; | ||
readonly 'data-melt-toggle-group': ""; | ||
}>; | ||
@@ -44,3 +44,3 @@ item: { | ||
destroy: typeof noop; | ||
}; | ||
} | undefined; | ||
subscribe(this: void, run: import("svelte/store").Subscriber<(args: string | { | ||
@@ -58,5 +58,4 @@ value: string; | ||
readonly type: "button"; | ||
readonly 'data-melt-part': "toggle-group-item"; | ||
readonly 'data-melt-toggle-group-item': ""; | ||
readonly role: "radio" | undefined; | ||
readonly tabindex: 0 | -1; | ||
}>, invalidate?: import("svelte/store").Invalidator<(args: string | { | ||
@@ -74,5 +73,4 @@ value: string; | ||
readonly type: "button"; | ||
readonly 'data-melt-part': "toggle-group-item"; | ||
readonly 'data-melt-toggle-group-item': ""; | ||
readonly role: "radio" | undefined; | ||
readonly tabindex: 0 | -1; | ||
}> | undefined): import("svelte/store").Unsubscriber; | ||
@@ -79,0 +77,0 @@ }; |
@@ -1,2 +0,2 @@ | ||
import { addEventListener, executeCallbacks, kbd, noop, omit } from '../../internal/helpers'; | ||
import { addEventListener, executeCallbacks, handleRovingFocus, isHTMLElement, kbd, noop, omit, } from '../../internal/helpers'; | ||
import { getElemDirection } from '../../internal/helpers/locale'; | ||
@@ -31,3 +31,3 @@ import { derived, get, writable } from 'svelte/store'; | ||
'data-orientation': $options.orientation, | ||
'data-melt-part': 'toggle-group', | ||
'data-melt-toggle-group': '', | ||
}; | ||
@@ -42,3 +42,2 @@ }); | ||
const pressed = Array.isArray($value) ? $value.includes(itemValue) : $value === itemValue; | ||
const anyPressed = Array.isArray($value) ? $value.length > 0 : $value !== null; | ||
return { | ||
@@ -53,5 +52,4 @@ disabled, | ||
type: 'button', | ||
'data-melt-part': 'toggle-group-item', | ||
'data-melt-toggle-group-item': '', | ||
role: $options.type === 'single' ? 'radio' : undefined, | ||
tabindex: anyPressed ? (pressed ? 0 : -1) : 0, | ||
}; | ||
@@ -67,2 +65,14 @@ }; | ||
}; | ||
const parentGroup = node.closest('[data-melt-toggle-group]'); | ||
if (!isHTMLElement(parentGroup)) | ||
return; | ||
const items = Array.from(parentGroup.querySelectorAll('[data-melt-toggle-group-item]')); | ||
const $value = get(value); | ||
const anyPressed = Array.isArray($value) ? $value.length > 0 : $value !== null; | ||
if (!anyPressed && items[0] === node) { | ||
node.tabIndex = 0; | ||
} | ||
else { | ||
node.tabIndex = -1; | ||
} | ||
unsub = executeCallbacks(addEventListener(node, 'click', () => { | ||
@@ -83,4 +93,8 @@ const { value: itemValue, disabled } = getNodeProps(); | ||
const el = e.currentTarget; | ||
const root = el.closest('[data-melt-part="toggle-group"]'); | ||
const items = Array.from(root.querySelectorAll('[data-melt-part="toggle-group-item"]:not([data-disabled])')); | ||
if (!isHTMLElement(el)) | ||
return; | ||
const root = el.closest('[data-melt-toggle-group]'); | ||
if (!isHTMLElement(root)) | ||
return; | ||
const items = Array.from(root.querySelectorAll('[data-melt-toggle-group-item]:not([data-disabled])')); | ||
const currentIndex = items.indexOf(el); | ||
@@ -101,7 +115,7 @@ const dir = getElemDirection(el); | ||
if ($options.loop) { | ||
items[0].focus(); | ||
handleRovingFocus(items[0]); | ||
} | ||
} | ||
else { | ||
items[nextIndex].focus(); | ||
handleRovingFocus(items[nextIndex]); | ||
} | ||
@@ -114,7 +128,7 @@ } | ||
if ($options.loop) { | ||
items[items.length - 1].focus(); | ||
handleRovingFocus(items[items.length - 1]); | ||
} | ||
} | ||
else { | ||
items[prevIndex].focus(); | ||
handleRovingFocus(items[prevIndex]); | ||
} | ||
@@ -124,7 +138,7 @@ } | ||
e.preventDefault(); | ||
items[0].focus(); | ||
handleRovingFocus(items[0]); | ||
} | ||
else if (e.key === kbd.END) { | ||
e.preventDefault(); | ||
items[items.length - 1].focus(); | ||
handleRovingFocus(items[items.length - 1]); | ||
} | ||
@@ -131,0 +145,0 @@ })); |
@@ -7,8 +7,18 @@ /// <reference types="svelte" /> | ||
}; | ||
type SingleToolbarGroupRootArgs = { | ||
type?: 'single'; | ||
value?: string | null; | ||
}; | ||
type MultipleToolbarGroupRootProps = { | ||
type: 'multiple'; | ||
value?: string[]; | ||
}; | ||
export type CreateToolbarGroupArgs = (SingleToolbarGroupRootArgs | MultipleToolbarGroupRootProps) & { | ||
disabled?: boolean; | ||
}; | ||
export declare function createToolbar(args?: CreateToolbarArgs): { | ||
root: Readable<{ | ||
role: string; | ||
tabindex: number; | ||
'data-orientation': "horizontal" | "vertical"; | ||
'data-melt-part': string; | ||
'data-melt-toolbar': string; | ||
}>; | ||
@@ -22,13 +32,15 @@ options: import("svelte/store").Writable<{ | ||
readonly type: "button"; | ||
readonly 'data-melt-part': "toolbar-item"; | ||
readonly 'data-melt-toolbar-item': ""; | ||
readonly action: (node: HTMLElement) => { | ||
destroy: VoidFunction; | ||
}; | ||
readonly tabIndex: -1; | ||
}; | ||
link: { | ||
readonly role: "link"; | ||
readonly 'data-melt-part': "toolbar-item"; | ||
readonly 'data-melt-toolbar-item': ""; | ||
readonly action: (node: HTMLElement) => { | ||
destroy: VoidFunction; | ||
}; | ||
readonly tabIndex: -1; | ||
}; | ||
@@ -39,5 +51,57 @@ separator: Readable<{ | ||
readonly 'aria-orientation': "horizontal" | "vertical"; | ||
readonly 'data-melt-toolbar-separator': ""; | ||
}>; | ||
createToolbarGroup: (args?: CreateToolbarGroupArgs) => { | ||
options: import("svelte/store").Writable<Omit<{ | ||
type: "single"; | ||
value: string | null; | ||
disabled: boolean; | ||
} | { | ||
type: "multiple"; | ||
value: string[] | null; | ||
disabled: boolean; | ||
}, "value">>; | ||
value: import("svelte/store").Writable<string | string[] | null>; | ||
root: Readable<{ | ||
readonly role: "group"; | ||
readonly 'data-orientation': "horizontal" | "vertical"; | ||
readonly 'data-melt-toolbar-group': ""; | ||
}>; | ||
item: { | ||
action: (node: HTMLElement) => { | ||
destroy: () => void; | ||
} | undefined; | ||
subscribe(this: void, run: import("svelte/store").Subscriber<(args: string | { | ||
value: string; | ||
disabled?: boolean | undefined; | ||
}) => { | ||
readonly disabled: boolean; | ||
readonly pressed: boolean; | ||
readonly 'data-orientation': "horizontal" | "vertical"; | ||
readonly 'data-disabled': true | undefined; | ||
readonly 'data-value': string; | ||
readonly 'data-state': "on" | "off"; | ||
readonly 'aria-pressed': boolean; | ||
readonly type: "button"; | ||
readonly role: "radio" | undefined; | ||
readonly 'data-melt-toolbar-item': ""; | ||
}>, invalidate?: import("svelte/store").Invalidator<(args: string | { | ||
value: string; | ||
disabled?: boolean | undefined; | ||
}) => { | ||
readonly disabled: boolean; | ||
readonly pressed: boolean; | ||
readonly 'data-orientation': "horizontal" | "vertical"; | ||
readonly 'data-disabled': true | undefined; | ||
readonly 'data-value': string; | ||
readonly 'data-state': "on" | "off"; | ||
readonly 'aria-pressed': boolean; | ||
readonly type: "button"; | ||
readonly role: "radio" | undefined; | ||
readonly 'data-melt-toolbar-item': ""; | ||
}> | undefined): import("svelte/store").Unsubscriber; | ||
}; | ||
isPressed: Readable<(itemValue: string) => boolean>; | ||
}; | ||
}; | ||
export { createToolbarGroup } from './group'; | ||
export declare const getKeydownHandler: (options: Readable<Pick<CreateToolbarArgs, 'orientation' | 'loop'>>) => (e: KeyboardEvent) => void; | ||
export {}; |
@@ -1,2 +0,2 @@ | ||
import { addEventListener, hiddenAction, kbd } from '../../internal/helpers'; | ||
import { addEventListener, executeCallbacks, handleRovingFocus, hiddenAction, isHTMLElement, kbd, omit, } from '../../internal/helpers'; | ||
import { derived, get, writable } from 'svelte/store'; | ||
@@ -9,9 +9,8 @@ const defaults = { | ||
const withDefaults = { ...defaults, ...args }; | ||
const options = writable({ ...withDefaults }); | ||
const root = derived(options, ($options) => { | ||
const toolbarOptions = writable({ ...withDefaults }); | ||
const root = derived(toolbarOptions, ($toolbarOptions) => { | ||
return { | ||
role: 'toolbar', | ||
tabindex: 0, | ||
'data-orientation': $options.orientation, | ||
'data-melt-part': 'toolbar', | ||
'data-orientation': $toolbarOptions.orientation, | ||
'data-melt-toolbar': '', | ||
}; | ||
@@ -22,5 +21,5 @@ }); | ||
type: 'button', | ||
'data-melt-part': 'toolbar-item', | ||
'data-melt-toolbar-item': '', | ||
action: (node) => { | ||
const unsub = addEventListener(node, 'keydown', getKeydownHandler(options)); | ||
const unsub = addEventListener(node, 'keydown', getKeydownHandler(toolbarOptions)); | ||
return { | ||
@@ -30,8 +29,9 @@ destroy: unsub, | ||
}, | ||
tabIndex: -1, | ||
}); | ||
const link = hiddenAction({ | ||
role: 'link', | ||
'data-melt-part': 'toolbar-item', | ||
'data-melt-toolbar-item': '', | ||
action: (node) => { | ||
const unsub = addEventListener(node, 'keydown', getKeydownHandler(options)); | ||
const unsub = addEventListener(node, 'keydown', getKeydownHandler(toolbarOptions)); | ||
return { | ||
@@ -41,20 +41,120 @@ destroy: unsub, | ||
}, | ||
tabIndex: -1, | ||
}); | ||
const separator = derived(options, ($options) => { | ||
const separator = derived(toolbarOptions, ($toolbarOptions) => { | ||
return { | ||
role: 'separator', | ||
'data-orientation': $options.orientation === 'horizontal' ? 'vertical' : 'horizontal', | ||
'aria-orientation': $options.orientation === 'horizontal' ? 'vertical' : 'horizontal', | ||
'data-orientation': $toolbarOptions.orientation === 'horizontal' ? 'vertical' : 'horizontal', | ||
'aria-orientation': $toolbarOptions.orientation === 'horizontal' ? 'vertical' : 'horizontal', | ||
'data-melt-toolbar-separator': '', | ||
}; | ||
}); | ||
const groupDefaults = { | ||
type: 'single', | ||
disabled: false, | ||
value: null, | ||
}; | ||
function createToolbarGroup(args = {}) { | ||
const groupWithDefaults = { ...groupDefaults, ...args }; | ||
const groupOptions = writable(omit(groupWithDefaults, 'value')); | ||
const value = writable(groupWithDefaults.value); | ||
groupOptions.subscribe((o) => { | ||
value.update((v) => { | ||
if (o.type === 'single' && Array.isArray(v)) { | ||
return null; | ||
} | ||
if (o.type === 'multiple' && !Array.isArray(v)) { | ||
return v === null ? [] : [v]; | ||
} | ||
return v; | ||
}); | ||
}); | ||
const root = derived(toolbarOptions, ($toolbarOptions) => { | ||
return { | ||
role: 'group', | ||
'data-orientation': $toolbarOptions.orientation, | ||
'data-melt-toolbar-group': '', | ||
}; | ||
}); | ||
const item = { | ||
...derived([groupOptions, value, toolbarOptions], ([$groupOptions, $value, $toolbarOptions]) => { | ||
return (args) => { | ||
const itemValue = typeof args === 'string' ? args : args.value; | ||
const argDisabled = typeof args === 'string' ? false : !!args.disabled; | ||
const disabled = $groupOptions.disabled || argDisabled; | ||
const pressed = Array.isArray($value) | ||
? $value.includes(itemValue) | ||
: $value === itemValue; | ||
return { | ||
disabled, | ||
pressed, | ||
'data-orientation': $toolbarOptions.orientation, | ||
'data-disabled': disabled ? true : undefined, | ||
'data-value': itemValue, | ||
'data-state': pressed ? 'on' : 'off', | ||
'aria-pressed': pressed, | ||
type: 'button', | ||
role: $groupOptions.type === 'single' ? 'radio' : undefined, | ||
'data-melt-toolbar-item': '', | ||
}; | ||
}; | ||
}), | ||
action: (node) => { | ||
const getNodeProps = () => { | ||
const itemValue = node.dataset.value; | ||
const disabled = node.dataset.disabled === 'true'; | ||
return { value: itemValue, disabled }; | ||
}; | ||
const parentToolbar = node.closest('[data-melt-toolbar]'); | ||
if (!parentToolbar) | ||
return; | ||
const items = getToolbarItems(parentToolbar); | ||
if (items[0] === node) { | ||
node.tabIndex = 0; | ||
} | ||
else { | ||
node.tabIndex = -1; | ||
} | ||
const unsub = executeCallbacks(addEventListener(node, 'click', () => { | ||
const { value: itemValue, disabled } = getNodeProps(); | ||
if (itemValue === undefined || disabled) | ||
return; | ||
value.update((v) => { | ||
if (Array.isArray(v)) { | ||
return v.includes(itemValue) ? v.filter((i) => i !== itemValue) : [...v, itemValue]; | ||
} | ||
return v === itemValue ? null : itemValue; | ||
}); | ||
}), addEventListener(node, 'keydown', getKeydownHandler(toolbarOptions))); | ||
return { | ||
destroy: unsub, | ||
}; | ||
}, | ||
}; | ||
const isPressed = derived(value, ($value) => { | ||
return (itemValue) => { | ||
return Array.isArray($value) ? $value.includes(itemValue) : $value === itemValue; | ||
}; | ||
}); | ||
return { | ||
options: groupOptions, | ||
value, | ||
root, | ||
item, | ||
isPressed, | ||
}; | ||
} | ||
return { | ||
root, | ||
options, | ||
options: toolbarOptions, | ||
button, | ||
link, | ||
separator, | ||
createToolbarGroup, | ||
}; | ||
} | ||
export { createToolbarGroup } from './group'; | ||
export const getKeydownHandler = (options) => (e) => { | ||
function getToolbarItems(element) { | ||
return Array.from(element.querySelectorAll('[data-melt-toolbar-item]')); | ||
} | ||
const getKeydownHandler = (options) => (e) => { | ||
const $options = get(options); | ||
@@ -71,4 +171,8 @@ const dir = 'ltr'; | ||
const el = e.currentTarget; | ||
const root = el.closest('[data-melt-part="toolbar"]'); | ||
const items = Array.from(root.querySelectorAll('[data-melt-part="toolbar-item"]')); | ||
if (!isHTMLElement(el)) | ||
return; | ||
const root = el.closest('[data-melt-toolbar]'); | ||
if (!isHTMLElement(root)) | ||
return; | ||
const items = Array.from(root.querySelectorAll('[data-melt-toolbar-item]')); | ||
const currentIndex = items.indexOf(el); | ||
@@ -80,7 +184,7 @@ if (e.key === nextKey) { | ||
if ($options.loop) { | ||
items[0].focus(); | ||
handleRovingFocus(items[0]); | ||
} | ||
} | ||
else { | ||
items[nextIndex].focus(); | ||
handleRovingFocus(items[nextIndex]); | ||
} | ||
@@ -93,7 +197,7 @@ } | ||
if ($options.loop) { | ||
items[items.length - 1].focus(); | ||
handleRovingFocus(items[items.length - 1]); | ||
} | ||
} | ||
else { | ||
items[prevIndex].focus(); | ||
handleRovingFocus(items[prevIndex]); | ||
} | ||
@@ -103,8 +207,8 @@ } | ||
e.preventDefault(); | ||
items[0].focus(); | ||
handleRovingFocus(items[0]); | ||
} | ||
else if (e.key === kbd.END) { | ||
e.preventDefault(); | ||
items[items.length - 1].focus(); | ||
handleRovingFocus(items[items.length - 1]); | ||
} | ||
}; |
import type { FloatingConfig } from './floating.types'; | ||
import { noop } from '../../helpers'; | ||
export declare function useFloating(reference: HTMLElement | undefined, floating: HTMLElement | undefined, opts?: FloatingConfig): { | ||
import type { VirtualElement } from '@floating-ui/core'; | ||
export declare function useFloating(reference: HTMLElement | VirtualElement | undefined, floating: HTMLElement | undefined, opts?: FloatingConfig): { | ||
destroy: typeof noop; | ||
}; |
import { createFocusTrap, useClickOutside, useFloating, usePortal } from '..'; | ||
import { addEventListener, executeCallbacks, kbd, noop, } from '../../helpers'; | ||
import { addEventListener, executeCallbacks, kbd, noop, isHTMLElement, } from '../../helpers'; | ||
const defaultConfig = { | ||
@@ -42,3 +42,3 @@ floating: {}, | ||
return; | ||
if (!anchorElement?.contains(e.target)) { | ||
if (isHTMLElement(anchorElement) && !anchorElement.contains(e.target)) { | ||
open.set(false); | ||
@@ -45,0 +45,0 @@ anchorElement.focus(); |
import type { ClickOutsideConfig, FloatingConfig, FocusTrapConfig, PortalConfig } from '..'; | ||
import type { VirtualElement } from '@floating-ui/core'; | ||
import type { Writable } from 'svelte/store'; | ||
@@ -10,5 +11,5 @@ export type PopperConfig = { | ||
export type PopperArgs = { | ||
anchorElement: HTMLElement; | ||
anchorElement: Element | VirtualElement; | ||
open: Writable<boolean>; | ||
options?: PopperConfig; | ||
}; |
@@ -15,1 +15,7 @@ /** | ||
export declare function last<T>(array: T[]): T | undefined; | ||
/** | ||
* Wraps an array around itself at a given start index | ||
* Example: `wrapArray(['a', 'b', 'c', 'd'], 2) === ['c', 'd', 'a', 'b']` | ||
* Reference: https://github.com/radix-ui/primitives | ||
*/ | ||
export declare function wrapArray<T>(array: T[], startIndex: number): T[]; |
@@ -27,1 +27,9 @@ /** | ||
} | ||
/** | ||
* Wraps an array around itself at a given start index | ||
* Example: `wrapArray(['a', 'b', 'c', 'd'], 2) === ['c', 'd', 'a', 'b']` | ||
* Reference: https://github.com/radix-ui/primitives | ||
*/ | ||
export function wrapArray(array, startIndex) { | ||
return array.map((_, index) => array[(startIndex + index) % array.length]); | ||
} |
@@ -8,1 +8,3 @@ /** | ||
export declare function handleRovingFocus(nextElement: HTMLElement): void; | ||
export declare function getNextFocusable(currentElement: HTMLElement): HTMLElement | null; | ||
export declare function getPreviousFocusable(currentElement: HTMLElement): HTMLElement | null; |
@@ -21,1 +21,13 @@ import { isBrowser, isHTMLElement } from './is'; | ||
} | ||
export function getNextFocusable(currentElement) { | ||
const focusableElements = Array.from(document.querySelectorAll('a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])')); | ||
const currentIndex = focusableElements.indexOf(currentElement); | ||
const nextIndex = currentIndex + 1; | ||
return nextIndex < focusableElements.length ? focusableElements[nextIndex] : null; | ||
} | ||
export function getPreviousFocusable(currentElement) { | ||
const focusableElements = Array.from(document.querySelectorAll('a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])')); | ||
const currentIndex = focusableElements.indexOf(currentElement); | ||
const previousIndex = currentIndex - 1; | ||
return previousIndex >= 0 ? focusableElements[previousIndex] : null; | ||
} |
import { get, writable } from 'svelte/store'; | ||
import { debounce } from './debounce'; | ||
import { handleRovingFocus } from './rovingFocus'; | ||
import { isHTMLElement } from './is'; | ||
import { wrapArray } from './array'; | ||
/** | ||
@@ -19,2 +21,3 @@ * Default options for the typeahead search. | ||
const handleTypeaheadSearch = (key, items) => { | ||
const currentItem = document.activeElement; | ||
const $typed = get(typed); | ||
@@ -26,7 +29,23 @@ if (!Array.isArray($typed)) { | ||
typed.update(() => $typed); | ||
const typedString = $typed.join(''); | ||
const matchingOption = items.find((item) => item.innerText.toLowerCase().startsWith(typedString)); | ||
if (matchingOption) { | ||
withDefaults.onMatch(matchingOption); | ||
const candidateItems = items.filter((item) => { | ||
if (item.hasAttribute('disabled')) { | ||
return false; | ||
} | ||
if (item.hasAttribute('data-disabled')) { | ||
return false; | ||
} | ||
return true; | ||
}); | ||
const isRepeated = $typed.length > 1 && $typed.every((char) => char === $typed[0]); | ||
const normalizeSearch = isRepeated ? $typed[0] : $typed.join(''); | ||
const currentItemIndex = currentItem ? candidateItems.indexOf(currentItem) : -1; | ||
let wrappedItems = wrapArray(candidateItems, Math.max(currentItemIndex, 0)); | ||
const excludeCurrentItem = normalizeSearch.length === 1; | ||
if (excludeCurrentItem) { | ||
wrappedItems = wrappedItems.filter((v) => v !== currentItem); | ||
} | ||
const nextItem = wrappedItems.find((item) => item.innerText.toLowerCase().startsWith(normalizeSearch.toLowerCase())); | ||
if (isHTMLElement(nextItem) && nextItem !== currentItem) { | ||
withDefaults.onMatch(nextItem); | ||
} | ||
resetTyped(); | ||
@@ -33,0 +52,0 @@ }; |
{ | ||
"name": "@melt-ui/svelte", | ||
"version": "0.15.0", | ||
"version": "0.16.0", | ||
"license": "MIT", | ||
@@ -5,0 +5,0 @@ "exports": { |
@@ -80,5 +80,5 @@ <h1 align="center"> | ||
| ComboBox | | | ||
| Context Menu | | | ||
| Context Menu | ✅ | | ||
| Dialog | ✅ | | ||
| Dropdown Menu | | | ||
| Dropdown Menu | ✅ | | ||
| Hover Card | | | ||
@@ -99,3 +99,3 @@ | Label | ✅ | | ||
| Tabs | ✅ | | ||
| Tags Input | | | ||
| Tags Input | ✅ | | ||
| Toast | | | ||
@@ -108,3 +108,3 @@ | Toggle | ✅ | | ||
**Progress:** 19/30+ | ||
**Progress:** 21/30+ | ||
@@ -111,0 +111,0 @@ ## Similar projects |
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
300484
127
7617