Huge News!Announcing our $40M Series B led by Abstract Ventures.Learn More
Socket
Sign inDemoInstall
Socket

@melt-ui/svelte

Package Overview
Dependencies
Maintainers
1
Versions
195
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@melt-ui/svelte - npm Package Compare versions

Comparing version 0.15.0 to 0.16.0

dist/builders/context-menu/index.d.ts

68

dist/builders/dropdown-menu/index.d.ts
/// <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

SocketSocket SOC 2 Logo

Product

  • Package Alerts
  • Integrations
  • Docs
  • Pricing
  • FAQ
  • Roadmap
  • Changelog

Packages

npm

Stay in touch

Get open source security insights delivered straight into your inbox.


  • Terms
  • Privacy
  • Security

Made with ⚡️ by Socket Inc