234 lines
		
	
	
		
			6.6 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			234 lines
		
	
	
		
			6.6 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| 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";
 | |
| import { IS_BROWSER } from "$fresh/runtime.ts";
 | |
| const KMenuEntry = (
 | |
|   { entry, activeIndex, index }: {
 | |
|     entry: MenuEntry;
 | |
|     activeIndex: Signal<number>;
 | |
|     index: number;
 | |
|   },
 | |
| ) => {
 | |
|   return (
 | |
|     <div
 | |
|       onClick={() => 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}
 | |
|     </div>
 | |
|   );
 | |
| };
 | |
| 
 | |
| 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<HTMLDivElement>(null);
 | |
|   const input = useRef<HTMLInputElement>(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) => {
 | |
|     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 (input.current) {
 | |
|         input.current.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();
 | |
|     }
 | |
|   }, IS_BROWSER ? document?.body : undefined);
 | |
| 
 | |
|   return (
 | |
|     <div
 | |
|       class={`${visible.value ? "opacity-100" : "opacity-0"} pointer-events-${
 | |
|         visible.value ? "auto" : "none"
 | |
|       } transition grid place-items-center w-full h-full fixed top-0 left-0 z-50`}
 | |
|       style={{ background: "#141217ee" }}
 | |
|     >
 | |
|       <div
 | |
|         class={`relative w-1/2 max-h-64 max-w-[400px] rounded-2xl shadow-2xl nnoisy-gradient overflow-hidden after:opacity-10 bg-gray-700`}
 | |
|         style={{ background: "#2B2930", color: "#818181" }}
 | |
|       >
 | |
|         <div
 | |
|           class={`grid h-12 text-gray-400 ${
 | |
|             activeState.value !== "loading" && "border-b"
 | |
|           } border-gray-500 `}
 | |
|           style={{
 | |
|             gridTemplateColumns: activeState.value !== "loading"
 | |
|               ? "auto 1fr"
 | |
|               : "1fr",
 | |
|           }}
 | |
|         >
 | |
|           {(activeState.value === "normal" || activeState.value === "input") &&
 | |
|             (
 | |
|               <>
 | |
|                 <div class="grid place-items-center border-r border-gray-500">
 | |
|                   <span class="text-white mx-4">
 | |
|                     {activeMenu.title}
 | |
|                   </span>
 | |
|                 </div>
 | |
|                 <input
 | |
|                   ref={input}
 | |
|                   onInput={() => {
 | |
|                     commandInput.value = input.current?.value || "";
 | |
|                   }}
 | |
|                   style={{ outline: "none !important" }}
 | |
|                   placeholder="Command"
 | |
|                   class="bg-transparent color pl-4 outline-none"
 | |
|                 />
 | |
|               </>
 | |
|             )}
 | |
|           {activeState.value === "loading" && (
 | |
|             <div class="py-3 px-4 flex items-center gap-2">
 | |
|               <icons.IconLoader2 class="animate-spin w-4 h-4" />
 | |
|               {loadingText.value || "Loading..."}
 | |
|             </div>
 | |
|           )}
 | |
|         </div>
 | |
|         {activeState.value === "normal" &&
 | |
|           (
 | |
|             <div
 | |
|               class=""
 | |
|               style={{ maxHeight: "12rem", overflowY: "auto" }}
 | |
|               ref={containerRef}
 | |
|             >
 | |
|               {entries?.length === 0 && (
 | |
|                 <div class="text-gray-400 px-4 py-2">
 | |
|                   No Entries
 | |
|                 </div>
 | |
|               )}
 | |
|               {entries.map(
 | |
|                 (k, index) => {
 | |
|                   return (
 | |
|                     <KMenuEntry
 | |
|                       entry={k}
 | |
|                       activeIndex={activeIndex}
 | |
|                       index={index}
 | |
|                     />
 | |
|                   );
 | |
|                 },
 | |
|               )}
 | |
|             </div>
 | |
|           )}
 | |
|       </div>
 | |
|     </div>
 | |
|   );
 | |
| };
 |