|
import { createSeparator } from '../index.js'; |
|
import { useEscapeKeydown, usePopper, usePortal } from '../../internal/actions/index.js'; |
|
import { FIRST_LAST_KEYS, SELECTION_KEYS, addEventListener, addHighlight, addMeltEventListener, makeElement, createElHelpers, createTypeaheadSearch, derivedVisible, disabledAttr, effect, executeCallbacks, generateIds, getNextFocusable, getPortalDestination, getPreviousFocusable, handleFocus, handleRovingFocus, isBrowser, isElementDisabled, isHTMLElement, kbd, noop, omit, overridable, removeHighlight, removeScroll, sleep, styleToString, toWritableStores, portalAttr, } from '../../internal/helpers/index.js'; |
|
import { tick } from 'svelte'; |
|
import { derived, writable } from 'svelte/store'; |
|
import { safeOnMount } from '../../internal/helpers/lifecycle.js'; |
|
import { withGet } from '../../internal/helpers/withGet.js'; |
|
export const SUB_OPEN_KEYS = { |
|
ltr: [...SELECTION_KEYS, kbd.ARROW_RIGHT], |
|
rtl: [...SELECTION_KEYS, kbd.ARROW_LEFT], |
|
}; |
|
export const SUB_CLOSE_KEYS = { |
|
ltr: [kbd.ARROW_LEFT], |
|
rtl: [kbd.ARROW_RIGHT], |
|
}; |
|
export const menuIdParts = ['menu', 'trigger']; |
|
const defaults = { |
|
arrowSize: 8, |
|
positioning: { |
|
placement: 'bottom', |
|
}, |
|
preventScroll: true, |
|
closeOnEscape: true, |
|
closeOnOutsideClick: true, |
|
portal: undefined, |
|
loop: false, |
|
dir: 'ltr', |
|
defaultOpen: false, |
|
typeahead: true, |
|
closeOnItemClick: true, |
|
onOutsideClick: undefined, |
|
}; |
|
export function createMenuBuilder(opts) { |
|
const { name, selector } = createElHelpers(opts.selector); |
|
const { preventScroll, arrowSize, positioning, closeOnEscape, closeOnOutsideClick, portal, forceVisible, typeahead, loop, closeFocus, disableFocusFirstItem, closeOnItemClick, onOutsideClick, } = opts.rootOptions; |
|
const rootOpen = opts.rootOpen; |
|
const rootActiveTrigger = opts.rootActiveTrigger; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const nextFocusable = opts.nextFocusable; |
|
const prevFocusable = opts.prevFocusable; |
|
|
|
|
|
|
|
|
|
|
|
const isUsingKeyboard = withGet.writable(false); |
|
|
|
|
|
|
|
|
|
|
|
const lastPointerX = withGet(writable(0)); |
|
const pointerGraceIntent = withGet(writable(null)); |
|
const pointerDir = withGet(writable('right')); |
|
|
|
|
|
|
|
const currentFocusedItem = withGet(writable(null)); |
|
const pointerMovingToSubmenu = withGet(derived([pointerDir, pointerGraceIntent], ([$pointerDir, $pointerGraceIntent]) => { |
|
return (e) => { |
|
const isMovingTowards = $pointerDir === $pointerGraceIntent?.side; |
|
return isMovingTowards && isPointerInGraceArea(e, $pointerGraceIntent?.area); |
|
}; |
|
})); |
|
const { typed, handleTypeaheadSearch } = createTypeaheadSearch(); |
|
const rootIds = toWritableStores({ ...generateIds(menuIdParts), ...opts.ids }); |
|
const isVisible = derivedVisible({ |
|
open: rootOpen, |
|
forceVisible, |
|
activeTrigger: rootActiveTrigger, |
|
}); |
|
const rootMenu = makeElement(name(), { |
|
stores: [isVisible, portal, rootIds.menu, rootIds.trigger], |
|
returned: ([$isVisible, $portal, $rootMenuId, $rootTriggerId]) => { |
|
return { |
|
role: 'menu', |
|
hidden: $isVisible ? undefined : true, |
|
style: styleToString({ |
|
display: $isVisible ? undefined : 'none', |
|
}), |
|
id: $rootMenuId, |
|
'aria-labelledby': $rootTriggerId, |
|
'data-state': $isVisible ? 'open' : 'closed', |
|
'data-portal': portalAttr($portal), |
|
tabindex: -1, |
|
}; |
|
}, |
|
action: (node) => { |
|
let unsubPopper = noop; |
|
const unsubDerived = effect([isVisible, rootActiveTrigger, positioning, closeOnOutsideClick, portal, closeOnEscape], ([$isVisible, $rootActiveTrigger, $positioning, $closeOnOutsideClick, $portal, $closeOnEscape,]) => { |
|
unsubPopper(); |
|
if (!$isVisible || !$rootActiveTrigger) |
|
return; |
|
tick().then(() => { |
|
setMeltMenuAttribute(node, selector); |
|
const popper = usePopper(node, { |
|
anchorElement: $rootActiveTrigger, |
|
open: rootOpen, |
|
options: { |
|
floating: $positioning, |
|
modal: { |
|
closeOnInteractOutside: $closeOnOutsideClick, |
|
shouldCloseOnInteractOutside: (e) => { |
|
onOutsideClick.get()?.(e); |
|
if (e.defaultPrevented) |
|
return false; |
|
if (isHTMLElement($rootActiveTrigger) && |
|
$rootActiveTrigger.contains(e.target)) { |
|
return false; |
|
} |
|
return true; |
|
}, |
|
onClose: () => { |
|
rootOpen.set(false); |
|
$rootActiveTrigger.focus(); |
|
}, |
|
open: $isVisible, |
|
}, |
|
portal: getPortalDestination(node, $portal), |
|
escapeKeydown: $closeOnEscape ? undefined : null, |
|
}, |
|
}); |
|
if (popper && popper.destroy) { |
|
unsubPopper = popper.destroy; |
|
} |
|
}); |
|
}); |
|
const unsubEvents = executeCallbacks(addMeltEventListener(node, 'keydown', (e) => { |
|
const target = e.target; |
|
const menuEl = e.currentTarget; |
|
if (!isHTMLElement(target) || !isHTMLElement(menuEl)) |
|
return; |
|
|
|
|
|
|
|
|
|
const isKeyDownInside = target.closest('[role="menu"]') === menuEl; |
|
if (!isKeyDownInside) |
|
return; |
|
if (FIRST_LAST_KEYS.includes(e.key)) { |
|
handleMenuNavigation(e, loop.get() ?? false); |
|
} |
|
|
|
|
|
|
|
|
|
if (e.key === kbd.TAB) { |
|
e.preventDefault(); |
|
rootOpen.set(false); |
|
handleTabNavigation(e, nextFocusable, prevFocusable); |
|
return; |
|
} |
|
|
|
|
|
|
|
const isCharacterKey = e.key.length === 1; |
|
const isModifierKey = e.ctrlKey || e.altKey || e.metaKey; |
|
if (!isModifierKey && isCharacterKey && typeahead.get() === true) { |
|
handleTypeaheadSearch(e.key, getMenuItems(menuEl)); |
|
} |
|
})); |
|
return { |
|
destroy() { |
|
unsubDerived(); |
|
unsubEvents(); |
|
unsubPopper(); |
|
}, |
|
}; |
|
}, |
|
}); |
|
const rootTrigger = makeElement(name('trigger'), { |
|
stores: [rootOpen, rootIds.menu, rootIds.trigger], |
|
returned: ([$rootOpen, $rootMenuId, $rootTriggerId]) => { |
|
return { |
|
'aria-controls': $rootMenuId, |
|
'aria-expanded': $rootOpen, |
|
'data-state': $rootOpen ? 'open' : 'closed', |
|
id: $rootTriggerId, |
|
tabindex: 0, |
|
}; |
|
}, |
|
action: (node) => { |
|
applyAttrsIfDisabled(node); |
|
rootActiveTrigger.update((p) => { |
|
if (p) |
|
return p; |
|
return node; |
|
}); |
|
const unsub = executeCallbacks(addMeltEventListener(node, 'click', (e) => { |
|
const $rootOpen = rootOpen.get(); |
|
const triggerEl = e.currentTarget; |
|
if (!isHTMLElement(triggerEl)) |
|
return; |
|
handleOpen(triggerEl); |
|
if (!$rootOpen) |
|
e.preventDefault(); |
|
}), addMeltEventListener(node, 'keydown', (e) => { |
|
const triggerEl = e.currentTarget; |
|
if (!isHTMLElement(triggerEl)) |
|
return; |
|
if (!(SELECTION_KEYS.includes(e.key) || e.key === kbd.ARROW_DOWN)) |
|
return; |
|
e.preventDefault(); |
|
handleOpen(triggerEl); |
|
const menuId = triggerEl.getAttribute('aria-controls'); |
|
if (!menuId) |
|
return; |
|
const menu = document.getElementById(menuId); |
|
if (!menu) |
|
return; |
|
const menuItems = getMenuItems(menu); |
|
if (!menuItems.length) |
|
return; |
|
handleRovingFocus(menuItems[0]); |
|
})); |
|
return { |
|
destroy: unsub, |
|
}; |
|
}, |
|
}); |
|
const rootArrow = makeElement(name('arrow'), { |
|
stores: arrowSize, |
|
returned: ($arrowSize) => ({ |
|
'data-arrow': true, |
|
style: styleToString({ |
|
position: 'absolute', |
|
width: `var(--arrow-size, ${$arrowSize}px)`, |
|
height: `var(--arrow-size, ${$arrowSize}px)`, |
|
}), |
|
}), |
|
}); |
|
const overlay = makeElement(name('overlay'), { |
|
stores: [isVisible], |
|
returned: ([$isVisible]) => { |
|
return { |
|
hidden: $isVisible ? undefined : true, |
|
tabindex: -1, |
|
style: styleToString({ |
|
display: $isVisible ? undefined : 'none', |
|
}), |
|
'aria-hidden': 'true', |
|
'data-state': stateAttr($isVisible), |
|
}; |
|
}, |
|
action: (node) => { |
|
let unsubEscapeKeydown = noop; |
|
if (closeOnEscape.get()) { |
|
const escapeKeydown = useEscapeKeydown(node, { |
|
handler: () => { |
|
rootOpen.set(false); |
|
const $rootActiveTrigger = rootActiveTrigger.get(); |
|
if ($rootActiveTrigger) |
|
$rootActiveTrigger.focus(); |
|
}, |
|
}); |
|
if (escapeKeydown && escapeKeydown.destroy) { |
|
unsubEscapeKeydown = escapeKeydown.destroy; |
|
} |
|
} |
|
const unsubPortal = effect([portal], ([$portal]) => { |
|
if ($portal === null) |
|
return noop; |
|
const portalDestination = getPortalDestination(node, $portal); |
|
if (portalDestination === null) |
|
return noop; |
|
const portalAction = usePortal(node, portalDestination); |
|
if (portalAction && portalAction.destroy) { |
|
return portalAction.destroy; |
|
} |
|
else { |
|
return noop; |
|
} |
|
}); |
|
return { |
|
destroy() { |
|
unsubEscapeKeydown(); |
|
unsubPortal(); |
|
}, |
|
}; |
|
}, |
|
}); |
|
const item = makeElement(name('item'), { |
|
returned: () => { |
|
return { |
|
role: 'menuitem', |
|
tabindex: -1, |
|
'data-orientation': 'vertical', |
|
}; |
|
}, |
|
action: (node) => { |
|
setMeltMenuAttribute(node, selector); |
|
applyAttrsIfDisabled(node); |
|
const unsub = executeCallbacks(addMeltEventListener(node, 'pointerdown', (e) => { |
|
const itemEl = e.currentTarget; |
|
if (!isHTMLElement(itemEl)) |
|
return; |
|
if (isElementDisabled(itemEl)) { |
|
e.preventDefault(); |
|
return; |
|
} |
|
}), addMeltEventListener(node, 'click', (e) => { |
|
const itemEl = e.currentTarget; |
|
if (!isHTMLElement(itemEl)) |
|
return; |
|
if (isElementDisabled(itemEl)) { |
|
e.preventDefault(); |
|
return; |
|
} |
|
if (e.defaultPrevented) { |
|
handleRovingFocus(itemEl); |
|
return; |
|
} |
|
if (closeOnItemClick.get()) { |
|
|
|
sleep(1).then(() => { |
|
rootOpen.set(false); |
|
}); |
|
} |
|
}), addMeltEventListener(node, 'keydown', (e) => { |
|
onItemKeyDown(e); |
|
}), addMeltEventListener(node, 'pointermove', (e) => { |
|
onMenuItemPointerMove(e); |
|
}), addMeltEventListener(node, 'pointerleave', (e) => { |
|
onMenuItemPointerLeave(e); |
|
}), addMeltEventListener(node, 'focusin', (e) => { |
|
onItemFocusIn(e); |
|
}), addMeltEventListener(node, 'focusout', (e) => { |
|
onItemFocusOut(e); |
|
})); |
|
return { |
|
destroy: unsub, |
|
}; |
|
}, |
|
}); |
|
const group = makeElement(name('group'), { |
|
returned: () => { |
|
return (groupId) => ({ |
|
role: 'group', |
|
'aria-labelledby': groupId, |
|
}); |
|
}, |
|
}); |
|
const groupLabel = makeElement(name('group-label'), { |
|
returned: () => { |
|
return (groupId) => ({ |
|
id: groupId, |
|
}); |
|
}, |
|
}); |
|
const checkboxItemDefaults = { |
|
defaultChecked: false, |
|
disabled: false, |
|
}; |
|
const createCheckboxItem = (props) => { |
|
const withDefaults = { ...checkboxItemDefaults, ...props }; |
|
const checkedWritable = withDefaults.checked ?? writable(withDefaults.defaultChecked ?? null); |
|
const checked = overridable(checkedWritable, withDefaults.onCheckedChange); |
|
const disabled = writable(withDefaults.disabled); |
|
const checkboxItem = makeElement(name('checkbox-item'), { |
|
stores: [checked, disabled], |
|
returned: ([$checked, $disabled]) => { |
|
return { |
|
role: 'menuitemcheckbox', |
|
tabindex: -1, |
|
'data-orientation': 'vertical', |
|
'aria-checked': isIndeterminate($checked) ? 'mixed' : $checked ? 'true' : 'false', |
|
'data-disabled': disabledAttr($disabled), |
|
'data-state': getCheckedState($checked), |
|
}; |
|
}, |
|
action: (node) => { |
|
setMeltMenuAttribute(node, selector); |
|
applyAttrsIfDisabled(node); |
|
const unsub = executeCallbacks(addMeltEventListener(node, 'pointerdown', (e) => { |
|
const itemEl = e.currentTarget; |
|
if (!isHTMLElement(itemEl)) |
|
return; |
|
if (isElementDisabled(itemEl)) { |
|
e.preventDefault(); |
|
return; |
|
} |
|
}), addMeltEventListener(node, 'click', (e) => { |
|
const itemEl = e.currentTarget; |
|
if (!isHTMLElement(itemEl)) |
|
return; |
|
if (isElementDisabled(itemEl)) { |
|
e.preventDefault(); |
|
return; |
|
} |
|
if (e.defaultPrevented) { |
|
handleRovingFocus(itemEl); |
|
return; |
|
} |
|
checked.update((prev) => { |
|
if (isIndeterminate(prev)) |
|
return true; |
|
return !prev; |
|
}); |
|
if (closeOnItemClick.get()) { |
|
|
|
|
|
|
|
|
|
tick().then(() => { |
|
rootOpen.set(false); |
|
}); |
|
} |
|
}), addMeltEventListener(node, 'keydown', (e) => { |
|
onItemKeyDown(e); |
|
}), addMeltEventListener(node, 'pointermove', (e) => { |
|
const itemEl = e.currentTarget; |
|
if (!isHTMLElement(itemEl)) |
|
return; |
|
if (isElementDisabled(itemEl)) { |
|
onItemLeave(e); |
|
return; |
|
} |
|
onMenuItemPointerMove(e, itemEl); |
|
}), addMeltEventListener(node, 'pointerleave', (e) => { |
|
onMenuItemPointerLeave(e); |
|
}), addMeltEventListener(node, 'focusin', (e) => { |
|
onItemFocusIn(e); |
|
}), addMeltEventListener(node, 'focusout', (e) => { |
|
onItemFocusOut(e); |
|
})); |
|
return { |
|
destroy: unsub, |
|
}; |
|
}, |
|
}); |
|
const isChecked = derived(checked, ($checked) => $checked === true); |
|
const _isIndeterminate = derived(checked, ($checked) => $checked === 'indeterminate'); |
|
return { |
|
elements: { |
|
checkboxItem, |
|
}, |
|
states: { |
|
checked, |
|
}, |
|
helpers: { |
|
isChecked, |
|
isIndeterminate: _isIndeterminate, |
|
}, |
|
options: { |
|
disabled, |
|
}, |
|
}; |
|
}; |
|
const createMenuRadioGroup = (args = {}) => { |
|
const valueWritable = args.value ?? writable(args.defaultValue ?? null); |
|
const value = overridable(valueWritable, args.onValueChange); |
|
const radioGroup = makeElement(name('radio-group'), { |
|
returned: () => ({ |
|
role: 'group', |
|
}), |
|
}); |
|
const radioItemDefaults = { |
|
disabled: false, |
|
}; |
|
const radioItem = makeElement(name('radio-item'), { |
|
stores: [value], |
|
returned: ([$value]) => { |
|
return (itemProps) => { |
|
const { value: itemValue, disabled } = { ...radioItemDefaults, ...itemProps }; |
|
const checked = $value === itemValue; |
|
return { |
|
disabled, |
|
role: 'menuitemradio', |
|
'data-state': checked ? 'checked' : 'unchecked', |
|
'aria-checked': checked, |
|
'data-disabled': disabledAttr(disabled), |
|
'data-value': itemValue, |
|
'data-orientation': 'vertical', |
|
tabindex: -1, |
|
}; |
|
}; |
|
}, |
|
action: (node) => { |
|
setMeltMenuAttribute(node, selector); |
|
const unsub = executeCallbacks(addMeltEventListener(node, 'pointerdown', (e) => { |
|
const itemEl = e.currentTarget; |
|
if (!isHTMLElement(itemEl)) |
|
return; |
|
const itemValue = node.dataset.value; |
|
const disabled = node.dataset.disabled; |
|
if (disabled || itemValue === undefined) { |
|
e.preventDefault(); |
|
return; |
|
} |
|
}), addMeltEventListener(node, 'click', (e) => { |
|
const itemEl = e.currentTarget; |
|
if (!isHTMLElement(itemEl)) |
|
return; |
|
const itemValue = node.dataset.value; |
|
const disabled = node.dataset.disabled; |
|
if (disabled || itemValue === undefined) { |
|
e.preventDefault(); |
|
return; |
|
} |
|
if (e.defaultPrevented) { |
|
if (!isHTMLElement(itemEl)) |
|
return; |
|
handleRovingFocus(itemEl); |
|
return; |
|
} |
|
value.set(itemValue); |
|
if (closeOnItemClick.get()) { |
|
|
|
|
|
|
|
|
|
tick().then(() => { |
|
rootOpen.set(false); |
|
}); |
|
} |
|
}), addMeltEventListener(node, 'keydown', (e) => { |
|
onItemKeyDown(e); |
|
}), addMeltEventListener(node, 'pointermove', (e) => { |
|
const itemEl = e.currentTarget; |
|
if (!isHTMLElement(itemEl)) |
|
return; |
|
const itemValue = node.dataset.value; |
|
const disabled = node.dataset.disabled; |
|
if (disabled || itemValue === undefined) { |
|
onItemLeave(e); |
|
return; |
|
} |
|
onMenuItemPointerMove(e, itemEl); |
|
}), addMeltEventListener(node, 'pointerleave', (e) => { |
|
onMenuItemPointerLeave(e); |
|
}), addMeltEventListener(node, 'focusin', (e) => { |
|
onItemFocusIn(e); |
|
}), addMeltEventListener(node, 'focusout', (e) => { |
|
onItemFocusOut(e); |
|
})); |
|
return { |
|
destroy: unsub, |
|
}; |
|
}, |
|
}); |
|
const isChecked = derived(value, ($value) => { |
|
return (itemValue) => { |
|
return $value === itemValue; |
|
}; |
|
}); |
|
return { |
|
elements: { |
|
radioGroup, |
|
radioItem, |
|
}, |
|
states: { |
|
value, |
|
}, |
|
helpers: { |
|
isChecked, |
|
}, |
|
}; |
|
}; |
|
const { elements: { root: separator }, } = createSeparator({ |
|
orientation: 'horizontal', |
|
}); |
|
|
|
|
|
|
|
const subMenuDefaults = { |
|
...defaults, |
|
disabled: false, |
|
positioning: { |
|
placement: 'right-start', |
|
gutter: 8, |
|
}, |
|
}; |
|
const createSubmenu = (args) => { |
|
const withDefaults = { ...subMenuDefaults, ...args }; |
|
const subOpenWritable = withDefaults.open ?? writable(false); |
|
const subOpen = overridable(subOpenWritable, withDefaults?.onOpenChange); |
|
|
|
const options = toWritableStores(omit(withDefaults, 'ids')); |
|
const { positioning, arrowSize, disabled } = options; |
|
const subActiveTrigger = withGet(writable(null)); |
|
const subOpenTimer = withGet(writable(null)); |
|
const pointerGraceTimer = withGet(writable(0)); |
|
const subIds = toWritableStores({ ...generateIds(menuIdParts), ...withDefaults.ids }); |
|
safeOnMount(() => { |
|
|
|
|
|
|
|
|
|
const subTrigger = document.getElementById(subIds.trigger.get()); |
|
if (subTrigger) { |
|
subActiveTrigger.set(subTrigger); |
|
} |
|
}); |
|
const subIsVisible = derivedVisible({ |
|
open: subOpen, |
|
forceVisible, |
|
activeTrigger: subActiveTrigger, |
|
}); |
|
const subMenu = makeElement(name('submenu'), { |
|
stores: [subIsVisible, subIds.menu, subIds.trigger], |
|
returned: ([$subIsVisible, $subMenuId, $subTriggerId]) => { |
|
return { |
|
role: 'menu', |
|
hidden: $subIsVisible ? undefined : true, |
|
style: styleToString({ |
|
display: $subIsVisible ? undefined : 'none', |
|
}), |
|
id: $subMenuId, |
|
'aria-labelledby': $subTriggerId, |
|
'data-state': $subIsVisible ? 'open' : 'closed', |
|
|
|
|
|
'data-id': $subMenuId, |
|
tabindex: -1, |
|
}; |
|
}, |
|
action: (node) => { |
|
let unsubPopper = noop; |
|
const unsubDerived = effect([subIsVisible, positioning], ([$subIsVisible, $positioning]) => { |
|
unsubPopper(); |
|
if (!$subIsVisible) |
|
return; |
|
const activeTrigger = subActiveTrigger.get(); |
|
if (!activeTrigger) |
|
return; |
|
tick().then(() => { |
|
const parentMenuEl = getParentMenu(activeTrigger); |
|
const popper = usePopper(node, { |
|
anchorElement: activeTrigger, |
|
open: subOpen, |
|
options: { |
|
floating: $positioning, |
|
portal: isHTMLElement(parentMenuEl) ? parentMenuEl : undefined, |
|
modal: null, |
|
focusTrap: null, |
|
escapeKeydown: null, |
|
}, |
|
}); |
|
if (popper && popper.destroy) { |
|
unsubPopper = popper.destroy; |
|
} |
|
}); |
|
}); |
|
const unsubEvents = executeCallbacks(addMeltEventListener(node, 'keydown', (e) => { |
|
if (e.key === kbd.ESCAPE) { |
|
return; |
|
} |
|
|
|
|
|
const target = e.target; |
|
const menuEl = e.currentTarget; |
|
if (!isHTMLElement(target) || !isHTMLElement(menuEl)) |
|
return; |
|
const isKeyDownInside = target.closest('[role="menu"]') === menuEl; |
|
if (!isKeyDownInside) |
|
return; |
|
if (FIRST_LAST_KEYS.includes(e.key)) { |
|
|
|
e.stopImmediatePropagation(); |
|
handleMenuNavigation(e, loop.get() ?? false); |
|
return; |
|
} |
|
const isCloseKey = SUB_CLOSE_KEYS['ltr'].includes(e.key); |
|
const isModifierKey = e.ctrlKey || e.altKey || e.metaKey; |
|
const isCharacterKey = e.key.length === 1; |
|
|
|
if (isCloseKey) { |
|
const $subActiveTrigger = subActiveTrigger.get(); |
|
e.preventDefault(); |
|
subOpen.update(() => { |
|
if ($subActiveTrigger) { |
|
handleRovingFocus($subActiveTrigger); |
|
} |
|
return false; |
|
}); |
|
return; |
|
} |
|
|
|
|
|
|
|
|
|
if (e.key === kbd.TAB) { |
|
e.preventDefault(); |
|
rootOpen.set(false); |
|
handleTabNavigation(e, nextFocusable, prevFocusable); |
|
return; |
|
} |
|
if (!isModifierKey && isCharacterKey && typeahead.get() === true) { |
|
|
|
handleTypeaheadSearch(e.key, getMenuItems(menuEl)); |
|
} |
|
}), addMeltEventListener(node, 'pointermove', (e) => { |
|
onMenuPointerMove(e); |
|
}), addMeltEventListener(node, 'focusout', (e) => { |
|
const $subActiveTrigger = subActiveTrigger.get(); |
|
if (isUsingKeyboard.get()) { |
|
const target = e.target; |
|
const submenuEl = document.getElementById(subIds.menu.get()); |
|
if (!isHTMLElement(submenuEl) || !isHTMLElement(target)) |
|
return; |
|
if (!submenuEl.contains(target) && target !== $subActiveTrigger) { |
|
subOpen.set(false); |
|
} |
|
} |
|
else { |
|
const menuEl = e.currentTarget; |
|
const relatedTarget = e.relatedTarget; |
|
if (!isHTMLElement(relatedTarget) || !isHTMLElement(menuEl)) |
|
return; |
|
if (!menuEl.contains(relatedTarget) && relatedTarget !== $subActiveTrigger) { |
|
subOpen.set(false); |
|
} |
|
} |
|
})); |
|
return { |
|
destroy() { |
|
unsubDerived(); |
|
unsubPopper(); |
|
unsubEvents(); |
|
}, |
|
}; |
|
}, |
|
}); |
|
const subTrigger = makeElement(name('subtrigger'), { |
|
stores: [subOpen, disabled, subIds.menu, subIds.trigger], |
|
returned: ([$subOpen, $disabled, $subMenuId, $subTriggerId]) => { |
|
return { |
|
role: 'menuitem', |
|
id: $subTriggerId, |
|
tabindex: -1, |
|
'aria-controls': $subMenuId, |
|
'aria-expanded': $subOpen, |
|
'data-state': $subOpen ? 'open' : 'closed', |
|
'data-disabled': disabledAttr($disabled), |
|
'aria-haspopop': 'menu', |
|
}; |
|
}, |
|
action: (node) => { |
|
setMeltMenuAttribute(node, selector); |
|
applyAttrsIfDisabled(node); |
|
subActiveTrigger.update((p) => { |
|
if (p) |
|
return p; |
|
return node; |
|
}); |
|
const unsubTimer = () => { |
|
clearTimerStore(subOpenTimer); |
|
window.clearTimeout(pointerGraceTimer.get()); |
|
pointerGraceIntent.set(null); |
|
}; |
|
const unsubEvents = executeCallbacks(addMeltEventListener(node, 'click', (e) => { |
|
if (e.defaultPrevented) |
|
return; |
|
const triggerEl = e.currentTarget; |
|
if (!isHTMLElement(triggerEl) || isElementDisabled(triggerEl)) |
|
return; |
|
|
|
handleRovingFocus(triggerEl); |
|
if (!subOpen.get()) { |
|
subOpen.update((prev) => { |
|
const isAlreadyOpen = prev; |
|
if (!isAlreadyOpen) { |
|
subActiveTrigger.set(triggerEl); |
|
return !prev; |
|
} |
|
return prev; |
|
}); |
|
} |
|
}), addMeltEventListener(node, 'keydown', (e) => { |
|
const $typed = typed.get(); |
|
const triggerEl = e.currentTarget; |
|
if (!isHTMLElement(triggerEl) || isElementDisabled(triggerEl)) |
|
return; |
|
const isTypingAhead = $typed.length > 0; |
|
if (isTypingAhead && e.key === kbd.SPACE) |
|
return; |
|
if (SUB_OPEN_KEYS['ltr'].includes(e.key)) { |
|
if (!subOpen.get()) { |
|
triggerEl.click(); |
|
e.preventDefault(); |
|
return; |
|
} |
|
const menuId = triggerEl.getAttribute('aria-controls'); |
|
if (!menuId) |
|
return; |
|
const menuEl = document.getElementById(menuId); |
|
if (!isHTMLElement(menuEl)) |
|
return; |
|
const firstItem = getMenuItems(menuEl)[0]; |
|
handleRovingFocus(firstItem); |
|
} |
|
}), addMeltEventListener(node, 'pointermove', (e) => { |
|
if (!isMouse(e)) |
|
return; |
|
onItemEnter(e); |
|
if (e.defaultPrevented) |
|
return; |
|
const triggerEl = e.currentTarget; |
|
if (!isHTMLElement(triggerEl)) |
|
return; |
|
if (!isFocusWithinSubmenu(subIds.menu.get())) { |
|
handleRovingFocus(triggerEl); |
|
} |
|
const openTimer = subOpenTimer.get(); |
|
if (!subOpen.get() && !openTimer && !isElementDisabled(triggerEl)) { |
|
subOpenTimer.set(window.setTimeout(() => { |
|
subOpen.update(() => { |
|
subActiveTrigger.set(triggerEl); |
|
return true; |
|
}); |
|
clearTimerStore(subOpenTimer); |
|
}, 100)); |
|
} |
|
}), addMeltEventListener(node, 'pointerleave', (e) => { |
|
if (!isMouse(e)) |
|
return; |
|
clearTimerStore(subOpenTimer); |
|
const submenuEl = document.getElementById(subIds.menu.get()); |
|
const contentRect = submenuEl?.getBoundingClientRect(); |
|
if (contentRect) { |
|
const side = submenuEl?.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: [ |
|
|
|
|
|
{ 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(pointerGraceTimer.get()); |
|
pointerGraceTimer.set(window.setTimeout(() => { |
|
pointerGraceIntent.set(null); |
|
}, 300)); |
|
} |
|
else { |
|
onTriggerLeave(e); |
|
if (e.defaultPrevented) |
|
return; |
|
|
|
pointerGraceIntent.set(null); |
|
} |
|
}), addMeltEventListener(node, 'focusout', (e) => { |
|
const triggerEl = e.currentTarget; |
|
if (!isHTMLElement(triggerEl)) |
|
return; |
|
removeHighlight(triggerEl); |
|
const relatedTarget = e.relatedTarget; |
|
if (!isHTMLElement(relatedTarget)) |
|
return; |
|
const menuId = triggerEl.getAttribute('aria-controls'); |
|
if (!menuId) |
|
return; |
|
const menu = document.getElementById(menuId); |
|
if (menu && !menu.contains(relatedTarget)) { |
|
subOpen.set(false); |
|
} |
|
}), addMeltEventListener(node, 'focusin', (e) => { |
|
onItemFocusIn(e); |
|
})); |
|
return { |
|
destroy() { |
|
unsubTimer(); |
|
unsubEvents(); |
|
}, |
|
}; |
|
}, |
|
}); |
|
const subArrow = makeElement(name('subarrow'), { |
|
stores: arrowSize, |
|
returned: ($arrowSize) => ({ |
|
'data-arrow': true, |
|
style: styleToString({ |
|
position: 'absolute', |
|
width: `var(--arrow-size, ${$arrowSize}px)`, |
|
height: `var(--arrow-size, ${$arrowSize}px)`, |
|
}), |
|
}), |
|
}); |
|
|
|
|
|
|
|
effect([rootOpen], ([$rootOpen]) => { |
|
if (!$rootOpen) { |
|
subActiveTrigger.set(null); |
|
subOpen.set(false); |
|
} |
|
}); |
|
effect([pointerGraceIntent], ([$pointerGraceIntent]) => { |
|
if (!isBrowser || $pointerGraceIntent) |
|
return; |
|
window.clearTimeout(pointerGraceTimer.get()); |
|
}); |
|
effect([subOpen], ([$subOpen]) => { |
|
if (!isBrowser) |
|
return; |
|
if ($subOpen && isUsingKeyboard.get()) { |
|
sleep(1).then(() => { |
|
const menuEl = document.getElementById(subIds.menu.get()); |
|
if (!menuEl) |
|
return; |
|
const menuItems = getMenuItems(menuEl); |
|
if (!menuItems.length) |
|
return; |
|
handleRovingFocus(menuItems[0]); |
|
}); |
|
} |
|
if (!$subOpen) { |
|
const focusedItem = currentFocusedItem.get(); |
|
const subTriggerEl = document.getElementById(subIds.trigger.get()); |
|
if (focusedItem) { |
|
sleep(1).then(() => { |
|
const menuEl = document.getElementById(subIds.menu.get()); |
|
if (!menuEl) |
|
return; |
|
if (menuEl.contains(focusedItem)) { |
|
removeHighlight(focusedItem); |
|
} |
|
}); |
|
} |
|
if (!subTriggerEl || document.activeElement === subTriggerEl) |
|
return; |
|
removeHighlight(subTriggerEl); |
|
} |
|
}); |
|
return { |
|
ids: subIds, |
|
elements: { |
|
subTrigger, |
|
subMenu, |
|
subArrow, |
|
}, |
|
states: { |
|
subOpen, |
|
}, |
|
options, |
|
}; |
|
}; |
|
safeOnMount(() => { |
|
|
|
|
|
|
|
|
|
|
|
const triggerEl = document.getElementById(rootIds.trigger.get()); |
|
if (isHTMLElement(triggerEl) && rootOpen.get()) { |
|
rootActiveTrigger.set(triggerEl); |
|
} |
|
const unsubs = []; |
|
const handlePointer = () => isUsingKeyboard.set(false); |
|
const handleKeyDown = () => { |
|
isUsingKeyboard.set(true); |
|
unsubs.push(executeCallbacks(addEventListener(document, 'pointerdown', handlePointer, { capture: true, once: true }), addEventListener(document, 'pointermove', handlePointer, { capture: true, once: true }))); |
|
}; |
|
const keydownListener = (e) => { |
|
if (e.key === kbd.ESCAPE && closeOnEscape.get()) { |
|
rootOpen.set(false); |
|
return; |
|
} |
|
}; |
|
unsubs.push(addEventListener(document, 'keydown', handleKeyDown, { capture: true })); |
|
unsubs.push(addEventListener(document, 'keydown', keydownListener)); |
|
return () => { |
|
unsubs.forEach((unsub) => unsub()); |
|
}; |
|
}); |
|
|
|
|
|
|
|
effect([rootOpen, currentFocusedItem], ([$rootOpen, $currentFocusedItem]) => { |
|
if (!$rootOpen && $currentFocusedItem) { |
|
removeHighlight($currentFocusedItem); |
|
} |
|
}); |
|
effect([rootOpen], ([$rootOpen]) => { |
|
if (!isBrowser) |
|
return; |
|
if (!$rootOpen) { |
|
const $rootActiveTrigger = rootActiveTrigger.get(); |
|
if (!$rootActiveTrigger) |
|
return; |
|
const $closeFocus = closeFocus.get(); |
|
if (!$rootOpen && $rootActiveTrigger) { |
|
handleFocus({ prop: $closeFocus, defaultEl: $rootActiveTrigger }); |
|
} |
|
} |
|
}); |
|
effect([rootOpen, preventScroll], ([$rootOpen, $preventScroll]) => { |
|
if (!isBrowser) |
|
return; |
|
const unsubs = []; |
|
if (opts.removeScroll && $rootOpen && $preventScroll) { |
|
unsubs.push(removeScroll()); |
|
} |
|
|
|
|
|
sleep(1).then(() => { |
|
const menuEl = document.getElementById(rootIds.menu.get()); |
|
if (menuEl && $rootOpen && isUsingKeyboard.get()) { |
|
if (disableFocusFirstItem.get()) { |
|
handleRovingFocus(menuEl); |
|
return; |
|
} |
|
|
|
const menuItems = getMenuItems(menuEl); |
|
if (!menuItems.length) |
|
return; |
|
|
|
handleRovingFocus(menuItems[0]); |
|
} |
|
}); |
|
return () => { |
|
unsubs.forEach((unsub) => unsub()); |
|
}; |
|
}); |
|
effect(rootOpen, ($rootOpen) => { |
|
if (!isBrowser) |
|
return; |
|
const handlePointer = () => isUsingKeyboard.set(false); |
|
const handleKeyDown = (e) => { |
|
isUsingKeyboard.set(true); |
|
if (e.key === kbd.ESCAPE && $rootOpen && closeOnEscape.get()) { |
|
rootOpen.set(false); |
|
return; |
|
} |
|
}; |
|
return executeCallbacks(addEventListener(document, 'pointerdown', handlePointer, { capture: true, once: true }), addEventListener(document, 'pointermove', handlePointer, { capture: true, once: true }), addEventListener(document, 'keydown', handleKeyDown, { capture: true })); |
|
}); |
|
function handleOpen(triggerEl) { |
|
rootOpen.update((prev) => { |
|
const isOpen = !prev; |
|
if (isOpen) { |
|
nextFocusable.set(getNextFocusable(triggerEl)); |
|
prevFocusable.set(getPreviousFocusable(triggerEl)); |
|
rootActiveTrigger.set(triggerEl); |
|
} |
|
return isOpen; |
|
}); |
|
} |
|
|
|
|
|
|
|
function onItemFocusIn(e) { |
|
const itemEl = e.currentTarget; |
|
if (!isHTMLElement(itemEl)) |
|
return; |
|
const $currentFocusedItem = currentFocusedItem.get(); |
|
if ($currentFocusedItem) { |
|
removeHighlight($currentFocusedItem); |
|
} |
|
addHighlight(itemEl); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
currentFocusedItem.set(itemEl); |
|
} |
|
|
|
|
|
|
|
function onItemFocusOut(e) { |
|
const itemEl = e.currentTarget; |
|
if (!isHTMLElement(itemEl)) |
|
return; |
|
removeHighlight(itemEl); |
|
} |
|
function onItemEnter(e) { |
|
if (isPointerMovingToSubmenu(e)) { |
|
e.preventDefault(); |
|
} |
|
} |
|
function onItemLeave(e) { |
|
if (isPointerMovingToSubmenu(e)) { |
|
return; |
|
} |
|
const target = e.target; |
|
if (!isHTMLElement(target)) |
|
return; |
|
const parentMenuEl = getParentMenu(target); |
|
if (!parentMenuEl) |
|
return; |
|
handleRovingFocus(parentMenuEl); |
|
} |
|
function onTriggerLeave(e) { |
|
if (isPointerMovingToSubmenu(e)) { |
|
e.preventDefault(); |
|
} |
|
} |
|
function onMenuPointerMove(e) { |
|
if (!isMouse(e)) |
|
return; |
|
const target = e.target; |
|
const currentTarget = e.currentTarget; |
|
if (!isHTMLElement(currentTarget) || !isHTMLElement(target)) |
|
return; |
|
const $lastPointerX = lastPointerX.get(); |
|
const pointerXHasChanged = $lastPointerX !== e.clientX; |
|
|
|
|
|
if (currentTarget.contains(target) && pointerXHasChanged) { |
|
const newDir = e.clientX > $lastPointerX ? 'right' : 'left'; |
|
pointerDir.set(newDir); |
|
lastPointerX.set(e.clientX); |
|
} |
|
} |
|
function onMenuItemPointerMove(e, currTarget = null) { |
|
if (!isMouse(e)) |
|
return; |
|
onItemEnter(e); |
|
if (e.defaultPrevented) |
|
return; |
|
|
|
if (currTarget) { |
|
handleRovingFocus(currTarget); |
|
return; |
|
} |
|
|
|
const currentTarget = e.currentTarget; |
|
if (!isHTMLElement(currentTarget)) |
|
return; |
|
|
|
handleRovingFocus(currentTarget); |
|
} |
|
function onMenuItemPointerLeave(e) { |
|
if (!isMouse(e)) |
|
return; |
|
onItemLeave(e); |
|
} |
|
|
|
|
|
|
|
function onItemKeyDown(e) { |
|
const $typed = typed.get(); |
|
const isTypingAhead = $typed.length > 0; |
|
if (isTypingAhead && e.key === kbd.SPACE) { |
|
e.preventDefault(); |
|
return; |
|
} |
|
if (SELECTION_KEYS.includes(e.key)) { |
|
|
|
|
|
|
|
|
|
|
|
|
|
e.preventDefault(); |
|
const itemEl = e.currentTarget; |
|
if (!isHTMLElement(itemEl)) |
|
return; |
|
itemEl.click(); |
|
} |
|
} |
|
function isIndeterminate(checked) { |
|
return checked === 'indeterminate'; |
|
} |
|
function getCheckedState(checked) { |
|
return isIndeterminate(checked) ? 'indeterminate' : checked ? 'checked' : 'unchecked'; |
|
} |
|
function isPointerMovingToSubmenu(e) { |
|
return pointerMovingToSubmenu.get()(e); |
|
} |
|
|
|
|
|
|
|
|
|
function getParentMenu(element) { |
|
const parentMenuEl = element.closest('[role="menu"]'); |
|
if (!isHTMLElement(parentMenuEl)) |
|
return null; |
|
return parentMenuEl; |
|
} |
|
return { |
|
elements: { |
|
trigger: rootTrigger, |
|
menu: rootMenu, |
|
overlay, |
|
item, |
|
group, |
|
groupLabel, |
|
arrow: rootArrow, |
|
separator, |
|
}, |
|
builders: { |
|
createCheckboxItem, |
|
createSubmenu, |
|
createMenuRadioGroup, |
|
}, |
|
states: { |
|
open: rootOpen, |
|
}, |
|
helpers: { |
|
handleTypeaheadSearch, |
|
}, |
|
ids: rootIds, |
|
options: opts.rootOptions, |
|
}; |
|
} |
|
export function handleTabNavigation(e, nextFocusable, prevFocusable) { |
|
if (e.shiftKey) { |
|
const $prevFocusable = prevFocusable.get(); |
|
if ($prevFocusable) { |
|
e.preventDefault(); |
|
sleep(1).then(() => $prevFocusable.focus()); |
|
prevFocusable.set(null); |
|
} |
|
} |
|
else { |
|
const $nextFocusable = nextFocusable.get(); |
|
if ($nextFocusable) { |
|
e.preventDefault(); |
|
sleep(1).then(() => $nextFocusable.focus()); |
|
nextFocusable.set(null); |
|
} |
|
} |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
export function getMenuItems(menuElement) { |
|
return Array.from(menuElement.querySelectorAll(`[data-melt-menu-id="${menuElement.id}"]`)).filter((item) => isHTMLElement(item)); |
|
} |
|
export function applyAttrsIfDisabled(element) { |
|
if (!element || !isElementDisabled(element)) |
|
return; |
|
element.setAttribute('data-disabled', ''); |
|
element.setAttribute('aria-disabled', 'true'); |
|
} |
|
|
|
|
|
|
|
|
|
export function clearTimerStore(timerStore) { |
|
if (!isBrowser) |
|
return; |
|
const timer = timerStore.get(); |
|
if (timer) { |
|
window.clearTimeout(timer); |
|
timerStore.set(null); |
|
} |
|
} |
|
|
|
|
|
|
|
|
|
function isMouse(e) { |
|
return e.pointerType === 'mouse'; |
|
} |
|
|
|
|
|
|
|
|
|
export function setMeltMenuAttribute(element, selector) { |
|
if (!element) |
|
return; |
|
const menuEl = element.closest(`${selector()}, ${selector('submenu')}`); |
|
if (!isHTMLElement(menuEl)) |
|
return; |
|
element.setAttribute('data-melt-menu-id', menuEl.id); |
|
} |
|
|
|
|
|
|
|
|
|
export function handleMenuNavigation(e, loop) { |
|
e.preventDefault(); |
|
|
|
const currentFocusedItem = document.activeElement; |
|
|
|
const currentTarget = e.currentTarget; |
|
if (!isHTMLElement(currentFocusedItem) || !isHTMLElement(currentTarget)) |
|
return; |
|
|
|
const menuItems = getMenuItems(currentTarget); |
|
if (!menuItems.length) |
|
return; |
|
const candidateNodes = menuItems.filter((item) => { |
|
if (item.hasAttribute('data-disabled') || item.getAttribute('disabled') === 'true') { |
|
return false; |
|
} |
|
return true; |
|
}); |
|
|
|
const currentIndex = candidateNodes.indexOf(currentFocusedItem); |
|
|
|
let nextIndex; |
|
switch (e.key) { |
|
case kbd.ARROW_DOWN: |
|
if (loop) { |
|
nextIndex = currentIndex < candidateNodes.length - 1 ? currentIndex + 1 : 0; |
|
} |
|
else { |
|
nextIndex = currentIndex < candidateNodes.length - 1 ? currentIndex + 1 : currentIndex; |
|
} |
|
break; |
|
case kbd.ARROW_UP: |
|
if (loop) { |
|
nextIndex = currentIndex > 0 ? currentIndex - 1 : candidateNodes.length - 1; |
|
} |
|
else { |
|
nextIndex = |
|
currentIndex < 0 ? candidateNodes.length - 1 : currentIndex > 0 ? currentIndex - 1 : 0; |
|
} |
|
break; |
|
case kbd.HOME: |
|
nextIndex = 0; |
|
break; |
|
case kbd.END: |
|
nextIndex = candidateNodes.length - 1; |
|
break; |
|
default: |
|
return; |
|
} |
|
handleRovingFocus(candidateNodes[nextIndex]); |
|
} |
|
function isPointerInGraceArea(e, area) { |
|
if (!area) |
|
return false; |
|
const cursorPos = { x: e.clientX, y: e.clientY }; |
|
return isPointInPolygon(cursorPos, area); |
|
} |
|
|
|
|
|
|
|
|
|
|
|
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; |
|
|
|
const intersect = ((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi); |
|
if (intersect) |
|
inside = !inside; |
|
} |
|
return inside; |
|
} |
|
function isFocusWithinSubmenu(submenuId) { |
|
const activeEl = document.activeElement; |
|
if (!isHTMLElement(activeEl)) |
|
return false; |
|
|
|
|
|
const submenuEl = activeEl.closest(`[data-id="${submenuId}"]`); |
|
return isHTMLElement(submenuEl); |
|
} |
|
function stateAttr(open) { |
|
return open ? 'open' : 'closed'; |
|
} |
|
|