UNPKG

@melt-ui/svelte

Version:
532 lines (531 loc) 22 kB
import { usePopper } from '../../internal/actions/index.js'; import { FIRST_LAST_KEYS, SELECTION_KEYS, 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, escapeBehavior: 'close', preventScroll: false, }; export const menubarIdParts = ['menubar']; export function createMenubar(props) { const withDefaults = { ...defaults, ...props }; const options = toWritableStores(omit(withDefaults, 'ids')); const { loop, escapeBehavior, 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, escapeBehavior: 'close', closeOnOutsideClick: true, portal: 'body', forceVisible: false, defaultOpen: false, typeahead: true, closeFocus: undefined, disableFocusFirstItem: false, closeOnItemClick: true, onOutsideClick: undefined, preventTextSelectionOverflow: true, }; const createMenu = (props) => { const withDefaults = { ...menuDefaults, ...props }; const rootOpen = withGet(writable(false)); const rootActiveTrigger = withGet(writable(null)); // options const options = toWritableStores(withDefaults); const { positioning, portal, forceVisible, closeOnOutsideClick, onOutsideClick, preventTextSelectionOverflow, } = 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, rootOpen, rootActiveTrigger, portal, m.ids.menu, m.ids.trigger, ids.menubar, ], returned: ([$isVisible, $rootOpen, $rootActiveTrigger, $portal, $menuId, $triggerId, $menubarId,]) => { return { role: 'menu', hidden: $isVisible ? undefined : true, style: $isVisible ? undefined : styleToString({ display: 'none' }), id: $menuId, 'aria-labelledby': $triggerId, 'data-state': $rootOpen && $rootActiveTrigger ? '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(() => { unsubPopper(); unsubPopper = 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(''); }, }, escapeKeydown: { behaviorType: escapeBehavior, handler: () => activeMenu.set(''), }, preventTextSelectionOverflow: { enabled: preventTextSelectionOverflow }, }, }).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); } /** * Submenu key events bubble through portals and * we only care about key events that happen inside this menu. */ const isKeyDownInside = target.closest('[role="menu"]') === menuEl; 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(); rootActiveTrigger.set(null); rootOpen.set(false); handleTabNavigation(e, nextFocusable, prevFocusable); } /** * Check for typeahead search and handle it. */ 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, }; }; /* -------- */ /* Helpers */ /* -------- */ /** * KBD Navigation handler for moving between menus in the menu bar. */ function handleCrossMenuNavigation(e) { if (!isBrowser) return; e.preventDefault(); // menu element being navigated 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; // prevent navigation when opening a submenu if (isNextKey && targetIsSubTrigger) return; // prevent navigation when closing a submenu if (isPrevKey && isKeyDownInsideSubMenu) return; // Index of the currently focused item in the candidate nodes array 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)); } /** * Keyboard event handler for menubar navigation. * @param e The keyboard event */ function handleMenubarNavigation(e) { e.preventDefault(); // currently focused menu item const currentFocusedItem = document.activeElement; // menu element being navigated const currentTarget = e.currentTarget; if (!isHTMLElement(currentTarget) || !isHTMLElement(currentFocusedItem)) return; // menu items of the current menu 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; }); // 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 $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]); } /* --------------------*/ /* Lifecycle & Effects */ /* --------------------*/ 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; /** * Submenu key events bubble through portals and * we only care about key events that happen inside this menu. */ const isTargetTrigger = target.hasAttribute('data-melt-menubar-trigger'); if (!isTargetTrigger) return; if (MENUBAR_NAV_KEYS.includes(e.key)) { handleMenubarNavigation(e); } })); return () => { unsubEvents(); }; }); const unsubs = []; effect([activeMenu, preventScroll], ([$activeMenu, $preventScroll]) => { if (!isBrowser || !$preventScroll) return; /** * To prevent adding/removing the scroll as we cycle through * menus (which causes horrible performance issues), we only * want to remove the scroll when the first menu is opened, and * remove it when no menus are open. */ 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, }; }