UNPKG

@grail-ui/svelte

Version:

[![NPM](https://img.shields.io/npm/v/@grail-ui/svelte)](https://www.npmjs.com/package/@grail-ui/svelte) [![minified](https://img.shields.io/bundlephobia/min/@grail-ui/svelte)](https://bundlephobia.com/package/@grail-ui/svelte) [![minified + zipped](https:

190 lines (189 loc) 6.78 kB
import { tick } from 'svelte'; import { derived, get, readable, writable } from 'svelte/store'; import { addEventListener } from '../eventListener/eventListener.js'; import { usePortal } from '../portal/portal.js'; import { getPlacement } from '../floating/placement.js'; import { useClickOutside } from '../clickOutside/clickOutside.js'; import { listKeyManager } from '../keyManager/listKeyManager.js'; import { chain } from '../util/chain.js'; import { uniqueId } from '../util/id.js'; import { DOWN_ARROW, ENTER, ESCAPE, SPACE, TAB, UP_ARROW } from '../util/keyboard.js'; import { noop } from '../util/noop.js'; import { writableEffect } from '../util/store.js'; const getMenuItems = (parent) => Array.from(parent.querySelectorAll('[role=menuitem]')); export const createMenu = ({ positioning = {}, open = false, portal = null, onOpenChange, ariaLabel = 'Menu', onSelect, } = {}) => { const id = uniqueId('menu'); const getTrigger = () => document.getElementById(id); const open$ = writableEffect(open, async (isOpen) => { if (isOpen) { await tick(); get(overlayElement$)?.focus(); } onOpenChange?.(isOpen); }); let cleanup = noop; const triggerAttrs = derived(open$, (open) => ({ id, 'aria-haspopup': 'true', 'aria-expanded': `${open}`, })); const menuAttrs = readable({ role: 'menu', 'aria-label': ariaLabel, tabindex: '-1' }); const separatorAttrs = readable({ role: 'separator', 'aria-orientation': 'horizontal', tabindex: '-1', }); const items = writable([]); const skipPredicate = (item) => 'disabled' in item.dataset; const keyManager = listKeyManager({ items, typeahead: true, homeAndEnd: true, wrap: false, vertical: true, skipPredicate, tabOut: () => hide(), onActivate: (item) => item.focus(), }); const itemAttrs = derived(keyManager.activeItem, ($activeItem) => { return function (attrs) { const { id: _id, label } = typeof attrs === 'string' ? { id: attrs, label: attrs } : attrs; const itemId = `${id}_item_${_id}`; return { role: 'menuitem', id: itemId, tabindex: $activeItem?.id === itemId ? '0' : '-1', 'data-item-id': _id, 'data-label': label, }; }; }); async function setupPlacement(triggerElement, overlayElement) { cleanup(); cleanup = getPlacement(triggerElement, overlayElement, { placement: 'bottom', ...positioning, }); } const overlayElement$ = writable(null); const useTrigger = (element) => { async function openMenuWithArrow(e) { e.preventDefault(); open$.set(true); await tick(); e.key === DOWN_ARROW ? keyManager.setFirstItemActive() : keyManager.setLastItemActive(); } const handleKeyDown = async (e) => { switch (e.key) { case SPACE: case ENTER: e.preventDefault(); toggle(); break; case ESCAPE: e.preventDefault(); hide(false); break; case TAB: hide(false); break; case DOWN_ARROW: case UP_ARROW: openMenuWithArrow(e); break; } }; const removeEvents = chain(addEventListener(element, 'click', toggle), addEventListener(element, 'keydown', handleKeyDown)); const unsubscribe = derived([open$, overlayElement$], (values) => values).subscribe(([$open, $overlayElement]) => { if ($open && $overlayElement) { setupPlacement(element, $overlayElement); items.set(getMenuItems($overlayElement)); } else { keyManager.setActiveItem(-1); cleanup(); } }); return { destroy() { keyManager.destroy(); cleanup(); removeEvents(); unsubscribe(); hide(); }, }; }; function handleSelectFromEvent(event) { if (!onSelect) return; const { target } = event; const menuItem = target instanceof Element && target.closest('[role=menuitem]'); if (menuItem instanceof HTMLElement) { event.preventDefault(); if (!skipPredicate(menuItem)) { const shouldClose = onSelect(menuItem.getAttribute('data-item-id')); if (shouldClose !== false) { hide(true); } } } } const useMenu = (element) => { const portalAction = portal ? usePortal(element, { target: portal }) : undefined; overlayElement$.set(element); const clickOutsideAction = useClickOutside(element, { enabled: open$, handler: (e) => { if (!e.defaultPrevented && !e.target.closest(`#${id}`)) { hide(); } }, }); const handleKeyDown = (event) => { if (event.key === ESCAPE) { hide(true); return; } else if (event.key === ENTER) { handleSelectFromEvent(event); } keyManager.onKeydown(event); }; const handleClick = (event) => { handleSelectFromEvent(event); }; const removeEvents = chain(addEventListener(element, 'keydown', handleKeyDown), addEventListener(element, 'click', handleClick), addEventListener(element, 'pointerover', (e) => { const target = e.target; if (target instanceof Element && target.getAttribute('role') === 'menuitem') { keyManager.setActiveItem(e.target); } })); return { destroy() { removeEvents(); clickOutsideAction?.destroy?.(); portalAction?.destroy?.(); overlayElement$.set(null); }, }; }; const hide = (returnFocus = false) => { open$.set(false); if (returnFocus) { getTrigger()?.focus(); } }; const toggle = () => { open$.update((value) => !value); }; return { useTrigger, triggerAttrs, useMenu, menuAttrs, itemAttrs, separatorAttrs, open: open$, }; };