import { usePopper } from '../../internal/actions/index.js'; import { FIRST_LAST_KEYS, addMeltEventListener, makeElement, createElHelpers, derivedVisible, effect, executeCallbacks, getNextFocusable, getPortalDestination, getPreviousFocusable, isHTMLElement, kbd, noop, omit, overridable, styleToString, toWritableStores, withGet, portalAttr, } from '../../internal/helpers/index.js'; import { tick } from 'svelte'; import { derived, writable } from 'svelte/store'; import { applyAttrsIfDisabled, clearTimerStore, createMenuBuilder, getMenuItems, handleMenuNavigation, handleTabNavigation, setMeltMenuAttribute, } from '../menu/index.js'; const defaults = { arrowSize: 8, positioning: { placement: 'bottom-start', }, preventScroll: true, closeOnEscape: true, closeOnOutsideClick: true, portal: undefined, loop: false, dir: 'ltr', defaultOpen: false, forceVisible: false, typeahead: true, disableFocusFirstItem: true, closeFocus: undefined, closeOnItemClick: true, onOutsideClick: undefined, }; const { name, selector } = createElHelpers('context-menu'); export function createContextMenu(props) { const withDefaults = { ...defaults, ...props }; const rootOptions = toWritableStores(omit(withDefaults, 'ids')); const { positioning, closeOnOutsideClick, portal, forceVisible, closeOnEscape, loop } = rootOptions; const openWritable = withDefaults.open ?? writable(withDefaults.defaultOpen); const rootOpen = overridable(openWritable, withDefaults?.onOpenChange); const rootActiveTrigger = writable(null); const nextFocusable = withGet.writable(null); const prevFocusable = withGet.writable(null); const { elements, builders, ids, options, helpers, states } = createMenuBuilder({ rootOpen, rootOptions, rootActiveTrigger: withGet(rootActiveTrigger), nextFocusable: withGet(nextFocusable), prevFocusable: withGet(prevFocusable), selector: 'context-menu', removeScroll: true, ids: withDefaults.ids, }); const { handleTypeaheadSearch } = helpers; const point = writable(null); const virtual = withGet(derived([point], ([$point]) => { if ($point === null) return null; return { getBoundingClientRect: () => DOMRect.fromRect({ width: 0, height: 0, ...$point, }), }; })); const longPressTimer = withGet.writable(0); function handleClickOutside(e) { rootOptions.onOutsideClick.get()?.(e); if (e.defaultPrevented) return false; const target = e.target; if (!(target instanceof Element)) return false; const isClickInsideTrigger = target.closest(`[data-id="${ids.trigger.get()}"]`) !== null; if (!isClickInsideTrigger || isLeftClick(e)) { return true; } return false; } const isVisible = derivedVisible({ open: rootOpen, forceVisible, activeTrigger: rootActiveTrigger, }); const menu = makeElement(name(), { stores: [isVisible, portal, ids.menu, ids.trigger], returned: ([$isVisible, $portal, $menuId, $triggerId]) => { // We only want to render the menu when it's open and has an active trigger. return { role: 'menu', hidden: $isVisible ? undefined : true, style: styleToString({ display: $isVisible ? undefined : 'none', }), id: $menuId, 'aria-labelledby': $triggerId, '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 $virtual = virtual.get(); const popper = usePopper(node, { anchorElement: $virtual ? $virtual : $rootActiveTrigger, open: rootOpen, options: { floating: $positioning, modal: { closeOnInteractOutside: $closeOnOutsideClick, onClose: () => { rootOpen.set(false); }, shouldCloseOnInteractOutside: handleClickOutside, open: $isVisible, }, portal: getPortalDestination(node, $portal), escapeKeydown: $closeOnEscape ? undefined : null, }, }); if (!popper || !popper.destroy) return; unsubPopper = popper.destroy; }); }); const unsubEvents = executeCallbacks(addMeltEventListener(node, 'keydown', (e) => { const target = e.target; const menuEl = e.currentTarget; if (!isHTMLElement(target) || !isHTMLElement(menuEl)) return; /** * 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, loop.get()); } /** * 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(); rootOpen.set(false); handleTabNavigation(e, nextFocusable, prevFocusable); 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(menuEl)); } })); return { destroy() { unsubDerived(); unsubEvents(); unsubPopper(); }, }; }, }); const trigger = makeElement(name('trigger'), { stores: [rootOpen, ids.trigger], returned: ([$rootOpen, $triggerId]) => { return { 'data-state': $rootOpen ? 'open' : 'closed', id: $triggerId, style: styleToString({ WebkitTouchCallout: 'none', }), 'data-id': $triggerId, }; }, action: (node) => { applyAttrsIfDisabled(node); const handleOpen = (e) => { point.set({ x: e.clientX, y: e.clientY, }); nextFocusable.set(getNextFocusable(node)); prevFocusable.set(getPreviousFocusable(node)); rootActiveTrigger.set(node); rootOpen.set(true); }; const unsubTimer = () => { clearTimerStore(longPressTimer); }; const unsub = executeCallbacks(addMeltEventListener(node, 'contextmenu', (e) => { /** * Clear the long press because some platforms already * fire a contextmenu event on long press. */ clearTimerStore(longPressTimer); handleOpen(e); e.preventDefault(); }), addMeltEventListener(node, 'pointerdown', (e) => { if (!isTouchOrPen(e)) return; // Clear the long press in case there's multiple touchpoints clearTimerStore(longPressTimer); longPressTimer.set(window.setTimeout(() => handleOpen(e), 700)); }), addMeltEventListener(node, 'pointermove', (e) => { if (!isTouchOrPen(e)) return; clearTimerStore(longPressTimer); }), addMeltEventListener(node, 'pointercancel', (e) => { if (!isTouchOrPen(e)) return; clearTimerStore(longPressTimer); }), addMeltEventListener(node, 'pointerup', (e) => { if (!isTouchOrPen(e)) return; clearTimerStore(longPressTimer); })); return { destroy() { unsubTimer(); unsub(); }, }; }, }); return { ids, elements: { ...elements, menu, trigger, }, states, builders, options, }; } /** * Check if the event is a touch or pen event * @param e The pointer event */ function isTouchOrPen(e) { return e.pointerType !== 'mouse'; } export function isLeftClick(event) { if ('button' in event) { return event.button === 0 && event.ctrlKey === false && event.metaKey === false; } return true; }