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)}
+ />
+
+ );
+}