diff --git a/apps/frontend/src/app/layout.tsx b/apps/frontend/src/app/layout.tsx index 59d439d..caa5270 100644 --- a/apps/frontend/src/app/layout.tsx +++ b/apps/frontend/src/app/layout.tsx @@ -3,7 +3,7 @@ import { Show, onCleanup, onMount } from "solid-js"; import ToastRegion from "@/components/toast-region"; import { Banner } from "@/components/ui/banner"; -import Commandline from "@/features/commandline/components/commandline"; +import { Commandline } from "@/features/commandline/components/commandline"; import { themeManager } from "@/features/content/themes/manager"; import { Navbar } from "@/components/navbar"; import { Footer } from "@/components/footer"; diff --git a/apps/frontend/src/features/commandline/components/commandline-input.tsx b/apps/frontend/src/features/commandline/components/commandline-input.tsx deleted file mode 100644 index e9fee66..0000000 --- a/apps/frontend/src/features/commandline/components/commandline-input.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Search } from "lucide-solid"; - -type CommandlineInputProps = { - value: string; - placeholder: string; - inputRef: (element: HTMLInputElement) => void; - onInput: (value: string) => void; -}; - -function CommandlineInput(props: CommandlineInputProps) { - return ( -
- - props.onInput(event.currentTarget.value)} - /> -
- ); -} - -export default CommandlineInput; diff --git a/apps/frontend/src/features/commandline/components/commandline.tsx b/apps/frontend/src/features/commandline/components/commandline.tsx index f772809..990a1b8 100644 --- a/apps/frontend/src/features/commandline/components/commandline.tsx +++ b/apps/frontend/src/features/commandline/components/commandline.tsx @@ -3,12 +3,10 @@ import { createMemo, createSignal, onCleanup, - onMount, Show, } from "solid-js"; -import { Command } from "lucide-solid"; +import { Command, Search } from "lucide-solid"; -import CommandlineInput from "@/features/commandline/components/commandline-input"; import CommandlineList from "@/features/commandline/components/commandline-list"; import { createCommandlineRegistry } from "@/features/commandline/registry"; import type { @@ -18,178 +16,151 @@ import type { import { filterCommands, getScopeLabel } from "@/features/commandline/utils"; import { themeManager } from "@/features/content/themes/manager"; -function Commandline() { - let searchInputRef: HTMLInputElement | undefined; +export function Commandline() { + const cmd = createCommandlineController(); + + createEffect(() => { + if (cmd.scope() === "themes" && cmd.isOpen()) { + const item = cmd.visibleItems()[cmd.selectedIndex()]; + + if (item && item.id.startsWith("theme-")) { + themeManager.preview(item.id.replace("theme-", "") as any); + } + + onCleanup(() => themeManager.reset()); + return; + } + + if (cmd.isOpen()) { + themeManager.reset(); + } + }); + + return ( + <> + + + + + +
+
+
event.stopPropagation()} + > + + + cmd.selectCurrent(item)} + /> +
+
+
+
+ + ); +} + +function createCommandlineController() { const [isOpen, setIsOpen] = createSignal(false); const [query, setQuery] = createSignal(""); - const [selectedIndex, setSelectedIndex] = createSignal(0); const [scope, setScope] = createSignal("root"); + const [selectedIndex, setSelectedIndex] = createSignal(0); const [interactionType, setInteractionType] = createSignal< "keyboard" | "mouse" >("keyboard"); - const registry = createMemo(() => createCommandlineRegistry(setScope)); + let inputRef: HTMLInputElement | undefined; - const itemsForScope = createMemo(() => registry()[scope()]); + const registry = createMemo(() => createCommandlineRegistry(setScope)); const visibleItems = createMemo(() => - filterCommands(itemsForScope(), query()), + filterCommands(registry()[scope()], query()), ); const isNestedScope = createMemo(() => scope() !== "root"); - const inputPlaceholder = createMemo(() => + + const placeholder = createMemo(() => isNestedScope() - ? `${getScopeLabel(scope() as Exclude).toLowerCase()}...` + ? `${getScopeLabel( + scope() as Exclude, + ).toLowerCase()}...` : "Search...", ); - const focusInput = () => { - queueMicrotask(() => searchInputRef?.focus()); - }; - - const resetInteractionState = () => { - setQuery(""); - setSelectedIndex(0); - }; - - const stepBack = () => { - if (query()) { - resetInteractionState(); - return; - } - - if (isNestedScope()) { - setScope("root"); - setSelectedIndex(0); - return; - } - - setIsOpen(false); - }; - - const handleSelectItem = (item: CommandlineItem) => { - const previousScope = scope(); - item.onSelect(); - - if (previousScope === scope()) { - setIsOpen(false); - return; - } - - resetInteractionState(); - focusInput(); - }; - createEffect(() => { - if (scope() === "themes" && isOpen()) { - const item = visibleItems()[selectedIndex()]; - - if (item && item.id.startsWith("theme-")) { - themeManager.preview(item.id.replace("theme-", "") as any); - } - - return; - } + const maxIndex = visibleItems().length - 1; - if (isOpen()) { - themeManager.reset(); + if (selectedIndex() > maxIndex) { + setSelectedIndex(Math.max(maxIndex, 0)); } }); createEffect(() => { - if (!isOpen()) { - resetInteractionState(); - setScope("root"); - themeManager.reset(); - return; + if (isOpen()) { + scope(); + inputRef?.focus(); } - - resetInteractionState(); - setScope("root"); - focusInput(); }); createEffect(() => { - const maxIndex = visibleItems().length - 1; - - if (selectedIndex() > maxIndex) { - setSelectedIndex(Math.max(maxIndex, 0)); - } - }); - - onMount(() => { - const handleCommandlineShortcut = (event: KeyboardEvent) => { - if ( - !(event.ctrlKey || event.metaKey) || - event.key.toLowerCase() !== "k" - ) { + const handleKeyDown = (event: KeyboardEvent) => { + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") { + event.preventDefault(); + toggle(); return; } - event.preventDefault(); - setIsOpen((current) => !current); - }; - - window.addEventListener("keydown", handleCommandlineShortcut); - - onCleanup(() => { - window.removeEventListener("keydown", handleCommandlineShortcut); - }); - }); - - createEffect(() => { - if (!isOpen()) { - return; - } - - const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { event.preventDefault(); - stepBack(); + goBack(); return; } + if (!isOpen()) return; + if (event.key === "Backspace" && query() === "" && isNestedScope()) { event.preventDefault(); - stepBack(); + goBack(); return; } if (event.key === "ArrowDown") { event.preventDefault(); - setInteractionType("keyboard"); - setSelectedIndex((current) => { - if (visibleItems().length === 0) { - return 0; - } - - return (current + 1) % visibleItems().length; - }); + moveDown(); return; } if (event.key === "ArrowUp") { event.preventDefault(); - setInteractionType("keyboard"); - setSelectedIndex((current) => { - if (visibleItems().length === 0) { - return 0; - } - - return (current - 1 + visibleItems().length) % visibleItems().length; - }); + moveUp(); return; } if (event.key === "Enter") { event.preventDefault(); - const item = visibleItems()[selectedIndex()]; - - if (!item) { - return; - } - - handleSelectItem(item); + selectCurrent(); } }; @@ -200,58 +171,153 @@ function Commandline() { }); }); - return ( - <> - - - + // + // private helpers + // - -
setIsOpen(false)} - > -
-
event.stopPropagation()} - > - { - searchInputRef = element; - }} - value={query()} - placeholder={inputPlaceholder()} - onInput={(value) => { - setQuery(value); - setSelectedIndex(0); - }} - /> + const resetSearch = () => { + setQuery(""); + setSelectedIndex(0); + }; - { - setSelectedIndex(index); - setInteractionType("mouse"); - }} - onSelectItem={handleSelectItem} - /> -
-
-
-
- - ); + const selectItem = (item: CommandlineItem) => { + const previousScope = scope(); + + item.onSelect(); + + if (previousScope === scope()) { + close(); + return; + } + + resetSearch(); + }; + + const moveSelection = (direction: 1 | -1) => { + const items = visibleItems(); + + if (items.length === 0) { + setSelectedIndex(0); + return; + } + + setSelectedIndex((current) => { + return (current + direction + items.length) % items.length; + }); + }; + + // + // public api + // + + const open = () => { + setIsOpen(true); + }; + + const close = () => { + resetSearch(); + setScope("root"); + setIsOpen(false); + }; + + const toggle = () => { + setIsOpen((current) => !current); + }; + + const updateQuery = (value: string) => { + setQuery(value); + setSelectedIndex(0); + }; + + const moveUp = () => { + setInteractionType("keyboard"); + moveSelection(-1); + }; + + const moveDown = () => { + setInteractionType("keyboard"); + moveSelection(1); + }; + + const hoverItem = (index: number) => { + setInteractionType("mouse"); + setSelectedIndex(index); + }; + + const selectCurrent = (item?: CommandlineItem) => { + const target = item ?? visibleItems()[selectedIndex()]; + + if (!target) return; + + selectItem(target); + }; + + const goBack = () => { + if (query()) { + resetSearch(); + return; + } + + if (isNestedScope()) { + setScope("root"); + setSelectedIndex(0); + return; + } + + close(); + }; + + const setInputRef = (el: HTMLInputElement) => { + inputRef = el; + }; + + return { + // state + isOpen, + query, + scope, + selectedIndex, + interactionType, + + // derived + visibleItems, + placeholder, + isNestedScope, + + // actions + open, + close, + toggle, + updateQuery, + moveUp, + moveDown, + hoverItem, + selectCurrent, + goBack, + + // refs + setInputRef, + }; } -export default Commandline; +type CommandlineInputProps = { + value: string; + placeholder: string; + onInput: (value: string) => void; + ref?: (el: HTMLInputElement) => void; +}; + +function CommandlineInput(props: CommandlineInputProps) { + return ( +
+ + props.onInput(event.currentTarget.value)} + /> +
+ ); +}