Refactor journal input command parsing into a dedicated module and add a `CompletionsDropdown` component to provide real-time suggestions for commands (/roll, /spark, /link, /stat) and their arguments.
115 lines
3.9 KiB
TypeScript
115 lines
3.9 KiB
TypeScript
/**
|
|
* CompletionsDropdown — autocomplete popover for journal command input.
|
|
*/
|
|
|
|
import { Component, For, Show, createEffect, onMount, onCleanup } from "solid-js";
|
|
import type { CompletionItem } from "./command-parser";
|
|
import type { CompletionsState } from "./completions";
|
|
|
|
export interface CompletionsDropdownProps {
|
|
show: boolean;
|
|
items: CompletionItem[];
|
|
selectedIdx: number;
|
|
onSelect: (item: CompletionItem) => void;
|
|
onHover: (idx: number) => void;
|
|
onClose: () => void;
|
|
state: CompletionsState;
|
|
}
|
|
|
|
export const CompletionsDropdown: Component<CompletionsDropdownProps> = (
|
|
props,
|
|
) => {
|
|
let containerRef!: HTMLDivElement;
|
|
|
|
// Scroll selected into view
|
|
createEffect(() => {
|
|
const idx = props.selectedIdx;
|
|
if (!props.show || !containerRef) return;
|
|
const el = containerRef.querySelector(`[data-comp-idx="${idx}"]`);
|
|
if (el) el.scrollIntoView({ block: "nearest" });
|
|
});
|
|
|
|
// Click outside
|
|
onMount(() => {
|
|
const handler = (e: MouseEvent) => {
|
|
if (containerRef && !containerRef.contains(e.target as Node)) {
|
|
props.onClose();
|
|
}
|
|
};
|
|
document.addEventListener("mousedown", handler);
|
|
onCleanup(() => document.removeEventListener("mousedown", handler));
|
|
});
|
|
|
|
return (
|
|
<Show when={props.show}>
|
|
<Show
|
|
when={props.state.status === "loaded" || props.state.status === "empty"}
|
|
fallback={
|
|
<Show
|
|
when={props.state.status === "loading"}
|
|
fallback={
|
|
<div
|
|
ref={containerRef}
|
|
class="absolute bottom-full left-0 right-0 bg-white border border-red-200 rounded-t shadow-lg mb-0.5 z-50 px-3 py-2 text-xs text-red-500"
|
|
>
|
|
{(props.state as any).message || "无法加载补全数据"}
|
|
</div>
|
|
}
|
|
>
|
|
<div
|
|
ref={containerRef}
|
|
class="absolute bottom-full left-0 right-0 bg-white border border-gray-200 rounded-t shadow-lg mb-0.5 z-50 px-3 py-2 text-xs text-gray-400 italic"
|
|
>
|
|
正在加载补全数据…
|
|
</div>
|
|
</Show>
|
|
}
|
|
>
|
|
<Show when={props.items.length > 0}>
|
|
<div
|
|
ref={containerRef}
|
|
class="absolute bottom-full left-0 right-0 bg-white border border-gray-200 rounded-t shadow-lg mb-0.5 max-h-40 overflow-y-auto z-50"
|
|
>
|
|
<For each={props.items}>
|
|
{(item, idx) => (
|
|
<div
|
|
data-comp-idx={idx()}
|
|
class={`flex items-center gap-2 px-3 py-1.5 cursor-pointer text-sm ${
|
|
item.kind === "no-results"
|
|
? "text-gray-400 italic cursor-default"
|
|
: idx() === props.selectedIdx
|
|
? "bg-blue-50 text-blue-700"
|
|
: "hover:bg-gray-100 text-gray-700"
|
|
}`}
|
|
onClick={() => props.onSelect(item)}
|
|
onMouseEnter={() =>
|
|
item.kind !== "no-results" && props.onHover(idx())
|
|
}
|
|
>
|
|
<Show
|
|
when={item.kind !== "no-results"}
|
|
fallback={
|
|
<span class="text-xs text-gray-300 shrink-0">—</span>
|
|
}
|
|
>
|
|
<span
|
|
class={`text-xs px-1 rounded shrink-0 ${
|
|
item.kind === "command"
|
|
? "bg-purple-100 text-purple-700"
|
|
: "bg-gray-100 text-gray-500"
|
|
}`}
|
|
>
|
|
{item.kind === "command" ? "cmd" : "val"}
|
|
</span>
|
|
</Show>
|
|
<span class="font-mono truncate flex-1">{item.label}</span>
|
|
</div>
|
|
)}
|
|
</For>
|
|
</div>
|
|
</Show>
|
|
</Show>
|
|
</Show>
|
|
);
|
|
};
|