UNPKG

@medalsocial/meda

Version:

Shared Meda UI shell and runtime package.

142 lines (141 loc) 7.46 kB
'use client'; import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; import { Command, CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandShortcut, } from '../components/ui/command.js'; import { useMedaShell } from './shell-provider.js'; // --------------------------------------------------------------------------- // Context // --------------------------------------------------------------------------- export const CommandRegistryContext = createContext(null); // --------------------------------------------------------------------------- // matchesHotkey — strict modifier matching // // 'mod+k' → matches Cmd+K on macOS, Ctrl+K on Windows/Linux // 'mod+shift+k' → requires modifier + shift + key; no other modifiers // 'mod+alt+k' → requires modifier + alt + key; no other modifiers // // Modifiers not listed in `spec` MUST NOT be pressed. // --------------------------------------------------------------------------- export function matchesHotkey(e, spec) { const parts = spec.toLowerCase().split('+'); /* v8 ignore next — ?? '' fallback: split('+') always returns at least one element */ const key = parts.at(-1) ?? ''; const mods = parts.slice(0, -1); const wantMod = mods.includes('mod'); const wantShift = mods.includes('shift'); const wantAlt = mods.includes('alt'); const hasMod = e.metaKey || e.ctrlKey; return (e.key.toLowerCase() === key && (wantMod ? hasMod : !hasMod) && (wantShift ? e.shiftKey : !e.shiftKey) && (wantAlt ? e.altKey : !e.altKey)); } export function CommandPalette({ children }) { const ctx = useMedaShell(); const { open, setOpen } = ctx.commandPalette; const hotkey = ctx.commandPaletteHotkey; const [commands, setCommands] = useState([]); const [groups, setGroups] = useState([]); // ------------------------------------------------------------------------- // Registry methods // ------------------------------------------------------------------------- const register = useCallback((cmds) => { setCommands((prev) => { const ids = new Set(cmds.map((c) => c.id)); return [...prev.filter((c) => !ids.has(c.id)), ...cmds]; }); }, []); const unregister = useCallback((ids) => { setCommands((prev) => prev.filter((c) => !ids.includes(c.id))); }, []); const registerGroup = useCallback((group) => { setGroups((prev) => [...prev.filter((g) => g.id !== group.id), group]); }, []); const unregisterGroup = useCallback((id) => { setGroups((prev) => prev.filter((g) => g.id !== id)); }, []); const registry = useMemo(() => ({ register, unregister, registerGroup, unregisterGroup }), [register, unregister, registerGroup, unregisterGroup]); // ------------------------------------------------------------------------- // Hotkey listener // ------------------------------------------------------------------------- useEffect(() => { const onKey = (e) => { if (matchesHotkey(e, hotkey)) { e.preventDefault(); setOpen(true); } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [hotkey, setOpen]); // ------------------------------------------------------------------------- // Group + sort commands // ------------------------------------------------------------------------- const grouped = useMemo(() => { const byGroup = new Map(); for (const cmd of commands) { const list = byGroup.get(cmd.group) ?? []; list.push(cmd); byGroup.set(cmd.group, list); } const orderedGroupIds = [...byGroup.keys()].sort((a, b) => { const ga = groups.find((g) => g.id === a); const gb = groups.find((g) => g.id === b); const pa = ga?.priority ?? 100; const pb = gb?.priority ?? 100; if (pa !== pb) return pa - pb; const la = ga?.label ?? a; const lb = gb?.label ?? b; return la.localeCompare(lb); }); return orderedGroupIds.map((groupId) => { const def = groups.find((g) => g.id === groupId); return { id: groupId, label: def?.label ?? groupId, /* v8 ignore next — ?? []: groupId always exists in byGroup since it comes from byGroup.keys() */ commands: (byGroup.get(groupId) ?? []).sort((a, b) => a.label.localeCompare(b.label)), }; }); }, [commands, groups]); // ------------------------------------------------------------------------- // Handlers // ------------------------------------------------------------------------- const handleSelect = (cmd) => { setOpen(false); void cmd.run(); }; // ------------------------------------------------------------------------- // Render // ------------------------------------------------------------------------- return (_jsxs(CommandRegistryContext.Provider, { value: registry, children: [children, _jsx(CommandDialog, { open: open, onOpenChange: setOpen, children: _jsxs(Command, { children: [_jsx(CommandInput, { placeholder: "Search commands\u2026" }), _jsxs(CommandList, { children: [_jsx(CommandEmpty, { children: "No results." }), grouped.map((group) => (_jsx(CommandGroup, { heading: group.label, children: group.commands.map((cmd) => { const Icon = cmd.icon; const shortcut = cmd.shortcut ?? cmd.hotkey; return (_jsxs(CommandItem, { value: `${group.label} ${cmd.label}`, onSelect: () => handleSelect(cmd), children: [Icon && _jsx(Icon, { size: 16, "aria-hidden": "true" }), _jsx("span", { children: cmd.label }), shortcut && _jsx(CommandShortcut, { children: shortcut })] }, cmd.id)); }) }, group.id)))] })] }) })] })); } // --------------------------------------------------------------------------- // useCommands — registers on mount, unregisters on unmount // --------------------------------------------------------------------------- export function useCommands(commands) { const registry = useContext(CommandRegistryContext); if (!registry) throw new Error('useCommands must be used inside <CommandPalette>'); useEffect(() => { registry.register(commands); return () => registry.unregister(commands.map((c) => c.id)); }, [commands, registry.register, registry.unregister]); } // --------------------------------------------------------------------------- // useCommandGroup — registers group metadata for ordering // --------------------------------------------------------------------------- export function useCommandGroup(group) { const registry = useContext(CommandRegistryContext); if (!registry) throw new Error('useCommandGroup must be used inside <CommandPalette>'); useEffect(() => { registry.registerGroup(group); return () => registry.unregisterGroup(group.id); }, [group, registry.registerGroup, registry.unregisterGroup]); }