import { Signal, useSignal } from "@preact/signals"; import { useRef } from "preact/hooks"; import { useEventListener } from "@lib/hooks/useEventListener.ts"; import { menus } from "@islands/KMenu/commands.ts"; import { MenuEntry } from "@islands/KMenu/types.ts"; import * as icons from "@components/icons.tsx"; const KMenuEntry = ( { entry, activeIndex, index }: { entry: MenuEntry; activeIndex: Signal; index: number; }, ) => { return (
activeIndex.value = index} class={`px-4 py-2 flex items-center gap-1 ${ activeIndex.value === index ? "bg-gray-100 text-gray-900" : "text-gray-400" }`} > {entry?.icon && icons[entry.icon]({ class: "w-4 h-4 mr-1" })} {entry.title}
); }; export const KMenu = ( { type = "main", context }: { context: unknown; type: keyof typeof menus }, ) => { const activeMenuType = useSignal(type); const activeMenu = menus[activeMenuType.value || "main"]; const activeState = useSignal<"normal" | "loading" | "error" | "input">( "normal", ); const loadingText = useSignal(""); const activeIndex = useSignal(-1); const containerRef = useRef(null); const input = useRef(null); const commandInput = useSignal(""); const visible = useSignal(false); if (visible.value === false) { setTimeout(() => { activeMenuType.value = "main"; activeState.value = "normal"; if (input.current) { input.current.value = ""; } commandInput.value = ""; }, 100); } const entries = activeMenu?.entries?.filter((entry) => { const entryVisible = typeof entry["visible"] === "boolean" ? entry.visible : typeof entry["visible"] === "function" ? entry.visible?.(context) : true; const search = commandInput?.value?.toLowerCase(); const searchMatches = entry.title.toLowerCase().includes(search) || entry.meta?.toLowerCase()?.includes(search); return entryVisible && searchMatches; }); if (entries.length === 1) { activeIndex.value = 0; } function activateEntry(menuEntry?: MenuEntry) { if (!menuEntry) return; menuEntry.cb({ activeMenu: activeMenuType, loadingText, menus, activeState, commandInput, visible, }, context); } const container = containerRef.current; if (container) { const selectedItem = container.children[activeIndex.value]; if (selectedItem) { const itemPosition = selectedItem.getBoundingClientRect(); const containerPosition = container.getBoundingClientRect(); // Check if the selected item is above the visible area if (itemPosition.top < containerPosition.top) { container.scrollTop -= containerPosition.top - itemPosition.top; } // Check if the selected item is below the visible area else if (itemPosition.bottom > containerPosition.bottom) { container.scrollTop += itemPosition.bottom - containerPosition.bottom; } } } useEventListener("keydown", (ev: KeyboardEvent) => { console.log("kMenu", { key: ev.key }); if (ev.key === "k") { if (ev?.target?.nodeName == "INPUT") { return; } if (!visible.value) { ev.preventDefault(); ev.stopPropagation(); } visible.value = !visible.value; } if (ev.key === "ArrowDown") { const index = activeIndex.value; if (index + 1 >= entries.length) { activeIndex.value = 0; } else { activeIndex.value += 1; } } if (ev.key === "Enter") { activateEntry(entries[activeIndex.value]); } if (ev.key === "ArrowUp") { const index = activeIndex.value; if (index - 1 < 0) { activeIndex.value = entries.length - 1; } else { activeIndex.value -= 1; } } if (ev.key === "Escape") { visible.value = false; if (input.current) { input.current.value = ""; } } if (visible.value) { input.current?.focus(); } else { input.current?.blur(); } }, document?.body); return (
{(activeState.value === "normal" || activeState.value === "input") && ( <>
{activeMenu.title}
{ commandInput.value = input.current?.value || ""; }} style={{ outline: "none !important" }} placeholder="Command" class="bg-transparent color pl-4 outline outline outline-2 outline-offset-2" /> )} {activeState.value === "loading" && (
{loadingText.value || "Loading..."}
)}
{activeState.value === "normal" && (
{entries?.length === 0 && (
No Entries
)} {entries.map( (k, index) => { return ( ); }, )}
)}
); };