|
import { usePopper } from '../../internal/actions/index.js'; |
|
import { FIRST_LAST_KEYS, SELECTION_KEYS, addEventListener, addHighlight, addMeltEventListener, makeElement, createElHelpers, derivedVisible, effect, executeCallbacks, generateIds, getNextFocusable, getPortalDestination, getPreviousFocusable, handleRovingFocus, isBrowser, isElement, isHTMLElement, kbd, noop, omit, removeHighlight, removeScroll, styleToString, toWritableStores, portalAttr, } from '../../internal/helpers/index.js'; |
|
import { safeOnDestroy, safeOnMount } from '../../internal/helpers/lifecycle.js'; |
|
import { tick } from 'svelte'; |
|
import { writable } from 'svelte/store'; |
|
import { applyAttrsIfDisabled, createMenuBuilder, getMenuItems, handleMenuNavigation, handleTabNavigation, } from '../menu/index.js'; |
|
import { withGet } from '../../internal/helpers/withGet.js'; |
|
const MENUBAR_NAV_KEYS = [kbd.ARROW_LEFT, kbd.ARROW_RIGHT, kbd.HOME, kbd.END]; |
|
const { name } = createElHelpers('menubar'); |
|
const defaults = { |
|
loop: true, |
|
closeOnEscape: true, |
|
preventScroll: false, |
|
}; |
|
export const menubarIdParts = ['menubar']; |
|
export function createMenubar(props) { |
|
const withDefaults = { ...defaults, ...props }; |
|
const options = toWritableStores(omit(withDefaults, 'ids')); |
|
const { loop, closeOnEscape, preventScroll } = options; |
|
const activeMenu = withGet(writable('')); |
|
const nextFocusable = withGet(writable(null)); |
|
const prevFocusable = withGet(writable(null)); |
|
const lastFocusedMenuTrigger = withGet(writable(null)); |
|
const closeTimer = withGet(writable(0)); |
|
let scrollRemoved = false; |
|
const ids = toWritableStores({ ...generateIds(menubarIdParts), ...withDefaults.ids }); |
|
const menubar = makeElement(name(), { |
|
stores: [ids.menubar], |
|
returned([$menubarId]) { |
|
return { |
|
role: 'menubar', |
|
'data-melt-menubar': '', |
|
'data-orientation': 'horizontal', |
|
id: $menubarId, |
|
}; |
|
}, |
|
action: (node) => { |
|
const menuTriggers = Array.from(node.querySelectorAll('[data-melt-menubar-trigger]')); |
|
if (!isHTMLElement(menuTriggers[0])) |
|
return {}; |
|
menuTriggers[0].tabIndex = 0; |
|
return { |
|
destroy: noop, |
|
}; |
|
}, |
|
}); |
|
const menuDefaults = { |
|
positioning: { |
|
placement: 'bottom-start', |
|
}, |
|
arrowSize: 8, |
|
dir: 'ltr', |
|
loop: false, |
|
closeOnEscape: true, |
|
closeOnOutsideClick: true, |
|
portal: undefined, |
|
forceVisible: false, |
|
defaultOpen: false, |
|
typeahead: true, |
|
closeFocus: undefined, |
|
disableFocusFirstItem: false, |
|
closeOnItemClick: true, |
|
onOutsideClick: undefined, |
|
}; |
|
const createMenu = (props) => { |
|
const withDefaults = { ...menuDefaults, ...props }; |
|
const rootOpen = withGet(writable(false)); |
|
const rootActiveTrigger = withGet(writable(null)); |
|
|
|
const options = toWritableStores(withDefaults); |
|
const { positioning, portal, forceVisible, closeOnOutsideClick, onOutsideClick } = options; |
|
const m = createMenuBuilder({ |
|
rootOptions: { ...options, preventScroll }, |
|
rootOpen: withGet(rootOpen), |
|
rootActiveTrigger: withGet(rootActiveTrigger), |
|
nextFocusable: withGet(nextFocusable), |
|
prevFocusable: withGet(prevFocusable), |
|
selector: 'menubar-menu', |
|
removeScroll: false, |
|
}); |
|
const isVisible = derivedVisible({ |
|
open: rootOpen, |
|
forceVisible, |
|
activeTrigger: rootActiveTrigger, |
|
}); |
|
const menu = makeElement(name('menu'), { |
|
stores: [isVisible, portal, m.ids.menu, m.ids.trigger, ids.menubar], |
|
returned: ([$isVisible, $portal, $menuId, $triggerId, $menubarId]) => { |
|
return { |
|
role: 'menu', |
|
hidden: $isVisible ? undefined : true, |
|
style: styleToString({ |
|
display: $isVisible ? undefined : 'none', |
|
}), |
|
id: $menuId, |
|
'aria-labelledby': $triggerId, |
|
'data-state': $isVisible ? 'open' : 'closed', |
|
'data-melt-scope': $menubarId, |
|
'data-portal': portalAttr($portal), |
|
tabindex: -1, |
|
}; |
|
}, |
|
action: (node) => { |
|
let unsubPopper = noop; |
|
const unsubDerived = effect([rootOpen, rootActiveTrigger, positioning, portal, closeOnOutsideClick], ([$rootOpen, $rootActiveTrigger, $positioning, $portal, $closeOnOutsideClick]) => { |
|
unsubPopper(); |
|
if (!($rootOpen && $rootActiveTrigger)) |
|
return; |
|
tick().then(() => { |
|
const popper = usePopper(node, { |
|
anchorElement: $rootActiveTrigger, |
|
open: rootOpen, |
|
options: { |
|
floating: $positioning, |
|
portal: getPortalDestination(node, $portal), |
|
modal: { |
|
closeOnInteractOutside: $closeOnOutsideClick, |
|
shouldCloseOnInteractOutside: (e) => { |
|
onOutsideClick.get()?.(e); |
|
if (e.defaultPrevented) |
|
return false; |
|
const target = e.target; |
|
const menubarEl = document.getElementById(ids.menubar.get()); |
|
if (!menubarEl || !isElement(target)) |
|
return true; |
|
if (menubarEl.contains(target)) |
|
return false; |
|
return true; |
|
}, |
|
onClose: () => { |
|
activeMenu.set(''); |
|
}, |
|
open: $rootOpen, |
|
}, |
|
}, |
|
}); |
|
if (popper && popper.destroy) { |
|
unsubPopper = popper.destroy; |
|
} |
|
}); |
|
}); |
|
const unsubEvents = executeCallbacks(addMeltEventListener(node, 'keydown', (e) => { |
|
const target = e.target; |
|
const menuEl = e.currentTarget; |
|
if (!isHTMLElement(menuEl) || !isHTMLElement(target)) |
|
return; |
|
if (MENUBAR_NAV_KEYS.includes(e.key)) { |
|
handleCrossMenuNavigation(e); |
|
} |
|
|
|
|
|
|
|
|
|
const isKeyDownInside = target.closest('[role="menu"]') === menuEl; |
|
if (!isKeyDownInside) |
|
return; |
|
if (FIRST_LAST_KEYS.includes(e.key)) { |
|
handleMenuNavigation(e); |
|
} |
|
|
|
|
|
|
|
|
|
if (e.key === kbd.TAB) { |
|
e.preventDefault(); |
|
rootActiveTrigger.set(null); |
|
rootOpen.set(false); |
|
handleTabNavigation(e, nextFocusable, prevFocusable); |
|
} |
|
|
|
|
|
|
|
const isCharacterKey = e.key.length === 1; |
|
const isModifierKey = e.ctrlKey || e.altKey || e.metaKey; |
|
if (!isModifierKey && isCharacterKey) { |
|
m.helpers.handleTypeaheadSearch(e.key, getMenuItems(menuEl)); |
|
} |
|
})); |
|
return { |
|
destroy() { |
|
unsubDerived(); |
|
unsubEvents(); |
|
unsubPopper(); |
|
}, |
|
}; |
|
}, |
|
}); |
|
const trigger = makeElement(name('trigger'), { |
|
stores: [rootOpen, m.ids.menu, m.ids.trigger], |
|
returned: ([$rootOpen, $menuId, $triggerId]) => { |
|
return { |
|
'aria-controls': $menuId, |
|
'aria-expanded': $rootOpen, |
|
'data-state': $rootOpen ? 'open' : 'closed', |
|
id: $triggerId, |
|
'aria-haspopup': 'menu', |
|
'data-orientation': 'horizontal', |
|
role: 'menuitem', |
|
}; |
|
}, |
|
action: (node) => { |
|
applyAttrsIfDisabled(node); |
|
const menubarEl = document.getElementById(ids.menubar.get()); |
|
if (!menubarEl) |
|
return {}; |
|
const menubarTriggers = Array.from(menubarEl.querySelectorAll('[data-melt-menubar-trigger]')); |
|
if (!menubarTriggers.length) |
|
return {}; |
|
const unsubEffect = effect([lastFocusedMenuTrigger], ([$lastFocusedMenuTrigger]) => { |
|
if (!$lastFocusedMenuTrigger && menubarTriggers[0] === node) { |
|
node.tabIndex = 0; |
|
} |
|
else if ($lastFocusedMenuTrigger === node) { |
|
node.tabIndex = 0; |
|
} |
|
else { |
|
node.tabIndex = -1; |
|
} |
|
}); |
|
if (menubarTriggers[0] === node) { |
|
node.tabIndex = 0; |
|
} |
|
else { |
|
node.tabIndex = -1; |
|
} |
|
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) { |
|
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]); |
|
} |
|
}), addMeltEventListener(node, 'pointerenter', (e) => { |
|
const triggerEl = e.currentTarget; |
|
if (!isHTMLElement(triggerEl)) |
|
return; |
|
const $activeMenu = activeMenu.get(); |
|
const $rootOpen = rootOpen.get(); |
|
if ($activeMenu && !$rootOpen) { |
|
rootOpen.set(true); |
|
activeMenu.set(m.ids.menu.get()); |
|
rootActiveTrigger.set(triggerEl); |
|
} |
|
})); |
|
return { |
|
destroy() { |
|
unsub(); |
|
unsubEffect(); |
|
}, |
|
}; |
|
}, |
|
}); |
|
function handleOpen(triggerEl) { |
|
rootOpen.update((prev) => { |
|
const isOpen = !prev; |
|
if (isOpen) { |
|
nextFocusable.set(getNextFocusable(triggerEl)); |
|
prevFocusable.set(getPreviousFocusable(triggerEl)); |
|
rootActiveTrigger.set(triggerEl); |
|
activeMenu.set(m.ids.menu.get()); |
|
} |
|
else { |
|
rootActiveTrigger.set(null); |
|
} |
|
return isOpen; |
|
}); |
|
} |
|
effect([activeMenu], ([$activeMenu]) => { |
|
if (!isBrowser) |
|
return; |
|
if ($activeMenu === m.ids.menu.get()) { |
|
if (rootOpen.get()) |
|
return; |
|
const triggerEl = document.getElementById(m.ids.trigger.get()); |
|
if (!triggerEl) |
|
return; |
|
rootActiveTrigger.set(triggerEl); |
|
addHighlight(triggerEl); |
|
rootOpen.set(true); |
|
return; |
|
} |
|
if ($activeMenu !== m.ids.menu.get()) { |
|
if (!isBrowser) |
|
return; |
|
if (rootOpen.get()) { |
|
const triggerEl = document.getElementById(m.ids.trigger.get()); |
|
if (!triggerEl) |
|
return; |
|
rootActiveTrigger.set(null); |
|
rootOpen.set(false); |
|
removeHighlight(triggerEl); |
|
} |
|
return; |
|
} |
|
}); |
|
effect([rootOpen], ([$rootOpen]) => { |
|
if (!isBrowser) |
|
return; |
|
const triggerEl = document.getElementById(m.ids.trigger.get()); |
|
if (!triggerEl) |
|
return; |
|
if (!$rootOpen && activeMenu.get() === m.ids.menu.get()) { |
|
rootActiveTrigger.set(null); |
|
activeMenu.set(''); |
|
removeHighlight(triggerEl); |
|
return; |
|
} |
|
if ($rootOpen) { |
|
lastFocusedMenuTrigger.set(triggerEl); |
|
addHighlight(triggerEl); |
|
} |
|
}); |
|
safeOnMount(() => { |
|
if (!isBrowser) |
|
return; |
|
const triggerEl = document.getElementById(m.ids.trigger.get()); |
|
if (isHTMLElement(triggerEl) && rootOpen.get()) { |
|
rootActiveTrigger.set(triggerEl); |
|
} |
|
}); |
|
return { |
|
ids: m.ids, |
|
elements: { |
|
...m.elements, |
|
menu, |
|
trigger, |
|
}, |
|
builders: m.builders, |
|
states: m.states, |
|
options, |
|
}; |
|
}; |
|
|
|
|
|
|
|
|
|
|
|
|
|
function handleCrossMenuNavigation(e) { |
|
if (!isBrowser) |
|
return; |
|
e.preventDefault(); |
|
|
|
const currentTarget = e.currentTarget; |
|
const target = e.target; |
|
if (!isHTMLElement(target) || !isHTMLElement(currentTarget)) |
|
return; |
|
const targetIsSubTrigger = target.hasAttribute('data-melt-menubar-menu-subtrigger'); |
|
const isKeyDownInsideSubMenu = target.closest('[role="menu"]') !== currentTarget; |
|
const prevMenuKey = kbd.ARROW_LEFT; |
|
const isPrevKey = e.key === prevMenuKey; |
|
const isNextKey = !isPrevKey; |
|
|
|
if (isNextKey && targetIsSubTrigger) |
|
return; |
|
|
|
if (isPrevKey && isKeyDownInsideSubMenu) |
|
return; |
|
|
|
const menubarEl = document.getElementById(ids.menubar.get()); |
|
if (!isHTMLElement(menubarEl)) |
|
return; |
|
const triggers = getMenuTriggers(menubarEl); |
|
const currTriggerId = currentTarget.getAttribute('aria-labelledby'); |
|
const currIndex = triggers.findIndex((trigger) => trigger.id === currTriggerId); |
|
let nextIndex; |
|
switch (e.key) { |
|
case kbd.ARROW_RIGHT: |
|
nextIndex = currIndex < triggers.length - 1 ? currIndex + 1 : 0; |
|
break; |
|
case kbd.ARROW_LEFT: |
|
nextIndex = currIndex > 0 ? currIndex - 1 : triggers.length - 1; |
|
break; |
|
case kbd.HOME: |
|
nextIndex = 0; |
|
break; |
|
case kbd.END: |
|
nextIndex = triggers.length - 1; |
|
break; |
|
default: |
|
return; |
|
} |
|
const nextFocusedTrigger = triggers[nextIndex]; |
|
const menuId = nextFocusedTrigger.getAttribute('aria-controls'); |
|
menuId && activeMenu.set(menuId); |
|
} |
|
function getMenuTriggers(el) { |
|
const menuEl = el.closest('[role="menubar"]'); |
|
if (!isHTMLElement(menuEl)) |
|
return []; |
|
return Array.from(menuEl.querySelectorAll('[data-melt-menubar-trigger]')).filter((el) => isHTMLElement(el)); |
|
} |
|
|
|
|
|
|
|
|
|
function handleMenubarNavigation(e) { |
|
e.preventDefault(); |
|
|
|
const currentFocusedItem = document.activeElement; |
|
|
|
const currentTarget = e.currentTarget; |
|
if (!isHTMLElement(currentTarget) || !isHTMLElement(currentFocusedItem)) |
|
return; |
|
|
|
const menuTriggers = getMenuTriggers(currentTarget); |
|
if (!menuTriggers.length) |
|
return; |
|
const candidateNodes = menuTriggers.filter((item) => { |
|
if (item.hasAttribute('data-disabled')) { |
|
return false; |
|
} |
|
if (item.getAttribute('disabled') === 'true') { |
|
return false; |
|
} |
|
return true; |
|
}); |
|
|
|
const currentIndex = candidateNodes.indexOf(currentFocusedItem); |
|
|
|
let nextIndex; |
|
const $loop = loop.get(); |
|
switch (e.key) { |
|
case kbd.ARROW_RIGHT: |
|
nextIndex = |
|
currentIndex < candidateNodes.length - 1 ? currentIndex + 1 : $loop ? 0 : currentIndex; |
|
break; |
|
case kbd.ARROW_LEFT: |
|
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]); |
|
} |
|
|
|
|
|
|
|
safeOnMount(() => { |
|
if (!isBrowser) |
|
return; |
|
const menubarEl = document.getElementById(ids.menubar.get()); |
|
if (!menubarEl) |
|
return; |
|
const unsubEvents = executeCallbacks(addMeltEventListener(menubarEl, 'keydown', (e) => { |
|
const target = e.target; |
|
const menuEl = e.currentTarget; |
|
if (!isHTMLElement(menuEl) || !isHTMLElement(target)) |
|
return; |
|
|
|
|
|
|
|
|
|
const isTargetTrigger = target.hasAttribute('data-melt-menubar-trigger'); |
|
if (!isTargetTrigger) |
|
return; |
|
if (MENUBAR_NAV_KEYS.includes(e.key)) { |
|
handleMenubarNavigation(e); |
|
} |
|
}), addEventListener(document, 'keydown', (e) => { |
|
if (closeOnEscape.get() && e.key === kbd.ESCAPE) { |
|
window.clearTimeout(closeTimer.get()); |
|
activeMenu.set(''); |
|
} |
|
})); |
|
return () => { |
|
unsubEvents(); |
|
}; |
|
}); |
|
const unsubs = []; |
|
effect([activeMenu, preventScroll], ([$activeMenu, $preventScroll]) => { |
|
if (!isBrowser || !$preventScroll) |
|
return; |
|
|
|
|
|
|
|
|
|
|
|
|
|
if (!$activeMenu) { |
|
unsubs.forEach((unsub) => unsub()); |
|
scrollRemoved = false; |
|
} |
|
else if (!scrollRemoved) { |
|
unsubs.push(removeScroll()); |
|
scrollRemoved = true; |
|
} |
|
}); |
|
safeOnDestroy(() => { |
|
unsubs.forEach((unsub) => unsub()); |
|
}); |
|
return { |
|
ids, |
|
elements: { |
|
menubar, |
|
}, |
|
builders: { |
|
createMenu, |
|
}, |
|
options, |
|
}; |
|
} |
|
|