@haruhimemoe/ui 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +29 -1
  2. package/README.md +143 -33
  3. package/dist/components/basics/Disclosure.d.ts +1 -1
  4. package/dist/components/basics/Disclosure.js +2 -2
  5. package/dist/components/filters/RangeSlider.d.ts +1 -1
  6. package/dist/components/filters/RangeSlider.js +6 -6
  7. package/dist/components/filters/chipStyles.d.ts +2 -2
  8. package/dist/components/filters/chipStyles.js +3 -2
  9. package/dist/components/forms/CharCounter.d.ts +4 -2
  10. package/dist/components/forms/CharCounter.js +5 -3
  11. package/dist/components/forms/Checkbox.d.ts +1 -1
  12. package/dist/components/forms/Checkbox.js +3 -1
  13. package/dist/components/forms/FieldFrame.d.ts +1 -1
  14. package/dist/components/forms/FieldFrame.js +1 -1
  15. package/dist/components/forms/RadioGroup.d.ts +1 -1
  16. package/dist/components/forms/RadioGroup.js +4 -2
  17. package/dist/components/forms/ReportDisclosure.d.ts +1 -1
  18. package/dist/components/forms/ReportDisclosure.js +10 -6
  19. package/dist/components/forms/fieldStyles.d.ts +5 -6
  20. package/dist/components/forms/fieldStyles.js +6 -7
  21. package/dist/components/osu/BeatmapStats.d.ts +1 -1
  22. package/dist/components/osu/BeatmapStats.js +2 -1
  23. package/dist/components/osu/starColors.d.ts +14 -4
  24. package/dist/components/osu/starColors.js +39 -2
  25. package/dist/components/palette/CommandPalette.d.ts +21 -0
  26. package/dist/components/palette/CommandPalette.js +315 -0
  27. package/dist/components/palette/CommandPaletteButton.d.ts +21 -0
  28. package/dist/components/palette/CommandPaletteButton.js +26 -0
  29. package/dist/components/palette/PaletteFooter.d.ts +21 -0
  30. package/dist/components/palette/PaletteFooter.js +10 -0
  31. package/dist/components/palette/PaletteInput.d.ts +30 -0
  32. package/dist/components/palette/PaletteInput.js +23 -0
  33. package/dist/components/palette/PaletteList.d.ts +29 -0
  34. package/dist/components/palette/PaletteList.js +44 -0
  35. package/dist/components/palette/PaletteRow.d.ts +30 -0
  36. package/dist/components/palette/PaletteRow.js +33 -0
  37. package/dist/components/palette/calc.d.ts +28 -0
  38. package/dist/components/palette/calc.js +226 -0
  39. package/dist/components/palette/fuzzy.d.ts +51 -0
  40. package/dist/components/palette/fuzzy.js +138 -0
  41. package/dist/components/palette/hotkeys.d.ts +57 -0
  42. package/dist/components/palette/hotkeys.js +111 -0
  43. package/dist/components/palette/paletteEvents.d.ts +22 -0
  44. package/dist/components/palette/paletteEvents.js +22 -0
  45. package/dist/components/palette/platform.d.ts +13 -0
  46. package/dist/components/palette/platform.js +21 -0
  47. package/dist/components/palette/recents.d.ts +48 -0
  48. package/dist/components/palette/recents.js +85 -0
  49. package/dist/components/palette/rows.d.ts +63 -0
  50. package/dist/components/palette/rows.js +136 -0
  51. package/dist/components/palette/siteCommands.d.ts +38 -0
  52. package/dist/components/palette/siteCommands.js +152 -0
  53. package/dist/components/palette/store.d.ts +94 -0
  54. package/dist/components/palette/store.js +135 -0
  55. package/dist/components/palette/types.d.ts +102 -0
  56. package/dist/components/palette/types.js +11 -0
  57. package/dist/components/palette/useProviderSearch.d.ts +28 -0
  58. package/dist/components/palette/useProviderSearch.js +68 -0
  59. package/dist/components/shell/HeaderMenu.d.ts +1 -1
  60. package/dist/components/shell/HeaderMenu.js +2 -2
  61. package/dist/components/shell/NavItem.d.ts +1 -1
  62. package/dist/components/shell/NavItem.js +2 -2
  63. package/dist/components/shell/SiteFooter.d.ts +9 -4
  64. package/dist/components/shell/SiteFooter.js +4 -2
  65. package/dist/components/tables/Table.d.ts +5 -3
  66. package/dist/components/tables/Table.js +15 -2
  67. package/dist/index.d.ts +8 -1
  68. package/dist/index.js +8 -1
  69. package/dist/theme.css +4 -4
  70. package/package.json +8 -3
@@ -0,0 +1,152 @@
1
+ /**
2
+ * @file src/components/palette/siteCommands.ts
3
+ * @desc The default command set every tool gets: "Go to <page>" from the nav items, "Open
4
+ * <tool>" from HARUHIME_TOOLS, page actions (copy URL, back, top, reload, GitHub), account
5
+ * (sign in, my account, sign out by signedIn), and help (the shortcuts page, report a bug).
6
+ * Ids are stable ("site.copy-url") so recents survive relabeling. Client-only: it opens
7
+ * tabs and scrolls.
8
+ * @author David @dvhsh (https://dvh.sh)
9
+ * @created Sat Oct 3, 2026
10
+ * @modified Sat Oct 3, 2026
11
+ */
12
+ "use client";
13
+ import { isExternalHref } from "../../utils/href.js";
14
+ import { HARUHIME_TOOLS } from "../shell/haruhimeTools.js";
15
+ const slug = (label) => label
16
+ .toLowerCase()
17
+ .replace(/[^a-z0-9]+/g, "-")
18
+ .replace(/^-|-$/g, "") || "page";
19
+ const openTab = (href) => {
20
+ window.open(href, "_blank", "noopener");
21
+ };
22
+ const go = (href, ctx) => isExternalHref(href) ? openTab(href) : ctx.navigate(href);
23
+ const reducedMotion = () => typeof window.matchMedia === "function" &&
24
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches;
25
+ const navigate = (options) => {
26
+ const pages = (options.pages ?? []).flatMap((item) => {
27
+ const href = item.href;
28
+ if (!href)
29
+ return [];
30
+ return [
31
+ {
32
+ id: `site.go.${slug(item.label)}`,
33
+ title: `Go to ${item.label}`,
34
+ group: "Navigate",
35
+ keywords: [href],
36
+ run: (ctx) => go(href, ctx),
37
+ },
38
+ ];
39
+ });
40
+ if (options.tools === false)
41
+ return pages;
42
+ const tools = HARUHIME_TOOLS.filter((tool) => tool.id !== options.tools).map((tool) => ({
43
+ id: `site.tool.${tool.id}`,
44
+ title: `Open ${tool.name}`,
45
+ subtitle: tool.blurb,
46
+ group: "Navigate",
47
+ run: () => openTab(tool.href),
48
+ }));
49
+ tools.push({
50
+ id: "site.tool.home",
51
+ title: "Open haruhime.moe",
52
+ subtitle: "all tools",
53
+ group: "Navigate",
54
+ run: () => openTab("https://www.haruhime.moe"),
55
+ });
56
+ return [...pages, ...tools];
57
+ };
58
+ const page = (options) => {
59
+ const list = [
60
+ {
61
+ id: "site.copy-url",
62
+ title: "Copy page URL",
63
+ group: "Page",
64
+ shortcut: "mod+shift+c",
65
+ closeOnRun: false,
66
+ run: (ctx) => ctx.copy(location.href),
67
+ },
68
+ { id: "site.back", title: "Go back", group: "Page", run: () => history.back() },
69
+ {
70
+ id: "site.top",
71
+ title: "Scroll to top",
72
+ group: "Page",
73
+ run: () => window.scrollTo({ top: 0, behavior: reducedMotion() ? "auto" : "smooth" }),
74
+ },
75
+ { id: "site.reload", title: "Reload page", group: "Page", run: () => location.reload() },
76
+ ];
77
+ if (options.repo)
78
+ list.push({
79
+ id: "site.github",
80
+ title: "Open on GitHub",
81
+ group: "Page",
82
+ keywords: ["source", "repo"],
83
+ run: () => openTab(options.repo),
84
+ });
85
+ return list;
86
+ };
87
+ const account = (options) => {
88
+ const acct = options.account;
89
+ if (!acct)
90
+ return [];
91
+ const list = [
92
+ {
93
+ id: "site.sign-in",
94
+ title: "Sign in",
95
+ group: "Account",
96
+ when: () => !acct.signedIn,
97
+ run: (ctx) => ctx.navigate(acct.signInHref),
98
+ },
99
+ ];
100
+ if (acct.accountHref)
101
+ list.push({
102
+ id: "site.account",
103
+ title: "My account",
104
+ group: "Account",
105
+ when: () => acct.signedIn,
106
+ run: (ctx) => ctx.navigate(acct.accountHref),
107
+ });
108
+ if (acct.signOutHref)
109
+ list.push({
110
+ id: "site.sign-out",
111
+ title: "Sign out",
112
+ group: "Account",
113
+ when: () => acct.signedIn,
114
+ run: (ctx) => ctx.navigate(acct.signOutHref),
115
+ });
116
+ return list;
117
+ };
118
+ const help = (options) => {
119
+ const list = [
120
+ {
121
+ id: "site.shortcuts",
122
+ title: "Keyboard shortcuts",
123
+ group: "Help",
124
+ shortcut: "?",
125
+ closeOnRun: false,
126
+ run: (ctx) => ctx.push({
127
+ title: "Keyboard shortcuts",
128
+ placeholder: "Filter shortcuts…",
129
+ commands: ctx.commands.filter((c) => c.shortcut && c.id !== "site.shortcuts"),
130
+ }),
131
+ },
132
+ ];
133
+ if (options.repo)
134
+ list.push({
135
+ id: "site.report",
136
+ title: "Report a bug",
137
+ group: "Help",
138
+ keywords: ["issue", "feedback"],
139
+ run: () => openTab(`${options.repo}/issues/new`),
140
+ });
141
+ return list;
142
+ };
143
+ const BUILDERS = { navigate, page, account, help };
144
+ /**
145
+ * @function siteCommands
146
+ * @param options {SiteCommandsOptions} pages, tools, repo, account and which groups to include
147
+ * @returns {Command[]} the defaults in order: Navigate, Page, Account, Help
148
+ */
149
+ export function siteCommands(options = {}) {
150
+ const include = options.include ?? ["navigate", "page", "account", "help"];
151
+ return include.flatMap((group) => BUILDERS[group](options));
152
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * @file src/components/palette/store.ts
3
+ * @desc The palette's state and reducer, pure: open or closed, a stack of page frames (each with
4
+ * its own query, active row and provider results, so popping back restores where you
5
+ * were), and the argument prompt in progress. Every key the palette handles is an action
6
+ * here, so the rules test without a DOM. CommandPalette owns it through useReducer.
7
+ * @author David @dvhsh (https://dvh.sh)
8
+ * @created Sat Oct 3, 2026
9
+ * @modified Sat Oct 3, 2026
10
+ */
11
+ import type { Command, Page } from "./types.js";
12
+ /** One provider's rows on a frame and where its search is. */
13
+ export type ProviderResult = {
14
+ state: "loading" | "done" | "error";
15
+ rows: readonly Command[];
16
+ };
17
+ /** One page on the stack. */
18
+ export type Frame = {
19
+ page: Page;
20
+ query: string;
21
+ active: number;
22
+ providers: Record<string, ProviderResult>;
23
+ };
24
+ /** The argument prompt in progress. */
25
+ export type ArgState = {
26
+ command: Command;
27
+ index: number;
28
+ values: Record<string, string>;
29
+ query: string;
30
+ active: number;
31
+ error?: string | undefined;
32
+ };
33
+ export type PaletteState = {
34
+ open: boolean;
35
+ stack: Frame[];
36
+ arg: ArgState | null;
37
+ };
38
+ export type PaletteAction = {
39
+ type: "open";
40
+ root: Page;
41
+ page?: Page | undefined;
42
+ } | {
43
+ type: "close";
44
+ } | {
45
+ type: "query";
46
+ value: string;
47
+ } | {
48
+ type: "active";
49
+ index: number;
50
+ count: number;
51
+ } | {
52
+ type: "move";
53
+ delta: Delta;
54
+ count: number;
55
+ } | {
56
+ type: "push";
57
+ page: Page;
58
+ } | {
59
+ type: "pop";
60
+ } | {
61
+ type: "beginArgs";
62
+ command: Command;
63
+ } | {
64
+ type: "argAccept";
65
+ value: string;
66
+ } | {
67
+ type: "argError";
68
+ message: string;
69
+ } | {
70
+ type: "provided";
71
+ depth: number;
72
+ providerId: string;
73
+ result: ProviderResult;
74
+ };
75
+ /**
76
+ * @function initialState
77
+ * @returns {PaletteState} closed, empty
78
+ */
79
+ export declare const initialState: () => PaletteState;
80
+ /**
81
+ * @function topFrame
82
+ * @param state {PaletteState} the state
83
+ * @returns {Frame | undefined} the page on top, undefined while closed
84
+ */
85
+ export declare const topFrame: (state: PaletteState) => Frame | undefined;
86
+ type Delta = 1 | -1 | "home" | "end";
87
+ /**
88
+ * @function reduce
89
+ * @param state {PaletteState} the state before
90
+ * @param action {PaletteAction} what happened
91
+ * @returns {PaletteState} the state after; the same object when nothing changes
92
+ */
93
+ export declare function reduce(state: PaletteState, action: PaletteAction): PaletteState;
94
+ export {};
@@ -0,0 +1,135 @@
1
+ /**
2
+ * @file src/components/palette/store.ts
3
+ * @desc The palette's state and reducer, pure: open or closed, a stack of page frames (each with
4
+ * its own query, active row and provider results, so popping back restores where you
5
+ * were), and the argument prompt in progress. Every key the palette handles is an action
6
+ * here, so the rules test without a DOM. CommandPalette owns it through useReducer.
7
+ * @author David @dvhsh (https://dvh.sh)
8
+ * @created Sat Oct 3, 2026
9
+ * @modified Sat Oct 3, 2026
10
+ */
11
+ const frame = (page) => ({ page, query: "", active: 0, providers: {} });
12
+ /**
13
+ * @function initialState
14
+ * @returns {PaletteState} closed, empty
15
+ */
16
+ export const initialState = () => ({ open: false, stack: [], arg: null });
17
+ /**
18
+ * @function topFrame
19
+ * @param state {PaletteState} the state
20
+ * @returns {Frame | undefined} the page on top, undefined while closed
21
+ */
22
+ export const topFrame = (state) => state.stack[state.stack.length - 1];
23
+ const clamp = (index, count) => count === 0 ? 0 : Math.min(Math.max(index, 0), count - 1);
24
+ const moved = (active, delta, count) => {
25
+ if (count === 0)
26
+ return 0;
27
+ if (delta === "home")
28
+ return 0;
29
+ if (delta === "end")
30
+ return count - 1;
31
+ return (active + delta + count) % count;
32
+ };
33
+ const withTop = (state, patch) => {
34
+ const top = topFrame(state);
35
+ if (!top)
36
+ return state;
37
+ return { ...state, stack: [...state.stack.slice(0, -1), { ...top, ...patch }] };
38
+ };
39
+ const withArg = (state, patch) => state.arg ? { ...state, arg: { ...state.arg, ...patch } } : state;
40
+ /**
41
+ * @function reduce
42
+ * @param state {PaletteState} the state before
43
+ * @param action {PaletteAction} what happened
44
+ * @returns {PaletteState} the state after; the same object when nothing changes
45
+ */
46
+ export function reduce(state, action) {
47
+ switch (action.type) {
48
+ case "open": {
49
+ const stack = [frame(action.root)];
50
+ if (action.page)
51
+ stack.push(frame(action.page));
52
+ return { open: true, stack, arg: null };
53
+ }
54
+ case "close":
55
+ return state.open ? { ...state, open: false } : state;
56
+ case "query":
57
+ return state.arg
58
+ ? withArg(state, { query: action.value, active: 0, error: undefined })
59
+ : withTop(state, { query: action.value, active: 0 });
60
+ case "active": {
61
+ const index = clamp(action.index, action.count);
62
+ const current = state.arg ? state.arg.active : topFrame(state)?.active;
63
+ if (index === current)
64
+ return state;
65
+ return state.arg ? withArg(state, { active: index }) : withTop(state, { active: index });
66
+ }
67
+ case "move": {
68
+ const current = state.arg ? state.arg.active : (topFrame(state)?.active ?? 0);
69
+ const active = moved(current, action.delta, action.count);
70
+ return state.arg ? withArg(state, { active }) : withTop(state, { active });
71
+ }
72
+ case "push":
73
+ return { ...state, stack: [...state.stack, frame(action.page)], arg: null };
74
+ case "pop": {
75
+ if (state.arg) {
76
+ if (state.arg.index === 0)
77
+ return { ...state, arg: null };
78
+ const spec = state.arg.command.args?.[state.arg.index - 1];
79
+ const values = { ...state.arg.values };
80
+ if (spec)
81
+ delete values[spec.name];
82
+ return withArg(state, {
83
+ index: state.arg.index - 1,
84
+ values,
85
+ query: "",
86
+ active: 0,
87
+ error: undefined,
88
+ });
89
+ }
90
+ if (state.stack.length <= 1)
91
+ return state;
92
+ return { ...state, stack: state.stack.slice(0, -1) };
93
+ }
94
+ case "beginArgs":
95
+ return {
96
+ ...state,
97
+ arg: { command: action.command, index: 0, values: {}, query: "", active: 0 },
98
+ };
99
+ case "argAccept": {
100
+ if (!state.arg)
101
+ return state;
102
+ const specs = state.arg.command.args ?? [];
103
+ const spec = specs[state.arg.index];
104
+ if (!spec)
105
+ return { ...state, arg: null };
106
+ const values = { ...state.arg.values, [spec.name]: action.value };
107
+ if (state.arg.index + 1 >= specs.length)
108
+ return { ...state, arg: null };
109
+ return withArg(state, {
110
+ index: state.arg.index + 1,
111
+ values,
112
+ query: "",
113
+ active: 0,
114
+ error: undefined,
115
+ });
116
+ }
117
+ case "argError":
118
+ return withArg(state, { error: action.message });
119
+ case "provided": {
120
+ const target = state.stack[action.depth];
121
+ if (!target)
122
+ return state;
123
+ // A search that starts keeps the previous rows on screen until the new ones land.
124
+ const previous = target.providers[action.providerId];
125
+ const result = action.result.state === "loading" && previous
126
+ ? { ...action.result, rows: previous.rows }
127
+ : action.result;
128
+ const updated = {
129
+ ...target,
130
+ providers: { ...target.providers, [action.providerId]: result },
131
+ };
132
+ return { ...state, stack: state.stack.map((f, i) => (i === action.depth ? updated : f)) };
133
+ }
134
+ }
135
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * @file src/components/palette/types.ts
3
+ * @desc The command palette's data: a Command (leaf with `run`, or nested with `page`, with
4
+ * optional `args` collected first), a Page (static commands and async providers), a
5
+ * Provider (search as you type), an ArgSpec (one prompt), the PaletteContext a command
6
+ * runs with, and CommandPalette's props. Plain types, server-safe.
7
+ * @author David @dvhsh (https://dvh.sh)
8
+ * @created Sat Oct 3, 2026
9
+ * @modified Sat Oct 3, 2026
10
+ */
11
+ import type { ReactNode } from "react";
12
+ /** One row of a "choice" argument. */
13
+ export type ArgChoice = {
14
+ value: string;
15
+ label: string;
16
+ subtitle?: string | undefined;
17
+ };
18
+ /** One prompt collected before a command runs. */
19
+ export type ArgSpec = {
20
+ name: string;
21
+ /** The input's placeholder while collecting it. */
22
+ label: string;
23
+ type: "text" | "number" | "choice";
24
+ /** For "choice": the rows to pick from, fuzzy-filtered by the input. */
25
+ choices?: readonly ArgChoice[] | undefined;
26
+ /** Returns a message to block Enter. */
27
+ validate?: ((value: string) => string | undefined) | undefined;
28
+ };
29
+ /** Rows searched as the query changes. `search` must honor the signal. */
30
+ export type Provider = {
31
+ id: string;
32
+ /** Group heading for its rows. Default: the page title. */
33
+ group?: string | undefined;
34
+ /** Characters before the first search. Default 2. */
35
+ minLength?: number | undefined;
36
+ /** Debounce in ms. Default 200. */
37
+ debounceMs?: number | undefined;
38
+ search: (query: string, signal: AbortSignal) => Promise<readonly Command[]>;
39
+ };
40
+ /** A list the palette can show: static rows, async rows, or both. */
41
+ export type Page = {
42
+ title: string;
43
+ /** Input placeholder. Default "Search…". */
44
+ placeholder?: string | undefined;
45
+ commands?: readonly Command[] | undefined;
46
+ providers?: readonly Provider[] | undefined;
47
+ };
48
+ /** What a command runs with. */
49
+ export type PaletteContext = {
50
+ /** `router.push`. */
51
+ navigate: (href: string) => void;
52
+ close: () => void;
53
+ /** Push a page; opens the palette first when it's closed. */
54
+ push: (page: Page) => void;
55
+ /** Writes to the clipboard and announces "Copied" (or the failure). */
56
+ copy: (text: string) => Promise<void>;
57
+ /** `usePathname()`, "" outside the router. */
58
+ pathname: string;
59
+ /** Every root command, after `when`. */
60
+ commands: readonly Command[];
61
+ };
62
+ /** One row. Exactly one of `run` and `page` is set. */
63
+ export type Command = {
64
+ /** Unique in the app. Recents are stored by it. */
65
+ id: string;
66
+ title: string;
67
+ subtitle?: string | undefined;
68
+ icon?: ReactNode;
69
+ /** Matched at a lower weight than the title. */
70
+ keywords?: readonly string[] | undefined;
71
+ /** Heading the row sits under. Default "Commands". */
72
+ group?: string | undefined;
73
+ /** "mod+shift+c", "?" or a chord "g p". Shown as kbd and active while the palette is mounted. */
74
+ shortcut?: string | undefined;
75
+ /** Hidden when false. Read on every render of the list. */
76
+ when?: ((ctx: PaletteContext) => boolean) | undefined;
77
+ run?: ((ctx: PaletteContext, args: Record<string, string>) => void | Promise<void>) | undefined;
78
+ page?: Page | undefined;
79
+ args?: readonly ArgSpec[] | undefined;
80
+ /** Close after `run`. Default true. */
81
+ closeOnRun?: boolean | undefined;
82
+ };
83
+ /** CommandPalette's props. */
84
+ export type CommandPaletteProps = {
85
+ commands: readonly Command[];
86
+ /** Searched at the root once the query reaches each one's minLength. */
87
+ providers?: readonly Provider[] | undefined;
88
+ /** Namespaces recents in localStorage. Default "default". */
89
+ storageKey?: string | undefined;
90
+ /** Default "mod+k". */
91
+ hotkey?: string | undefined;
92
+ /** Root placeholder. Default "Search commands…". */
93
+ placeholder?: string | undefined;
94
+ /** The dialog's accessible name. Default "Command palette". */
95
+ label?: string | undefined;
96
+ /** Default true. */
97
+ calculator?: boolean | undefined;
98
+ /** Default true. */
99
+ recents?: boolean | undefined;
100
+ /** Classes for the panel. */
101
+ className?: string | undefined;
102
+ };
@@ -0,0 +1,11 @@
1
+ /**
2
+ * @file src/components/palette/types.ts
3
+ * @desc The command palette's data: a Command (leaf with `run`, or nested with `page`, with
4
+ * optional `args` collected first), a Page (static commands and async providers), a
5
+ * Provider (search as you type), an ArgSpec (one prompt), the PaletteContext a command
6
+ * runs with, and CommandPalette's props. Plain types, server-safe.
7
+ * @author David @dvhsh (https://dvh.sh)
8
+ * @created Sat Oct 3, 2026
9
+ * @modified Sat Oct 3, 2026
10
+ */
11
+ export {};
@@ -0,0 +1,28 @@
1
+ /**
2
+ * @file src/components/palette/useProviderSearch.ts
3
+ * @desc Runs a frame's providers as the query changes: each one waits for its minLength,
4
+ * debounces, and gets an AbortSignal that fires when a newer query starts, the frame goes
5
+ * away, or the palette closes. A superseded search's result is dropped even if it
6
+ * resolves. Results go to the store by frame depth and provider id.
7
+ * @author David @dvhsh (https://dvh.sh)
8
+ * @created Sat Oct 3, 2026
9
+ * @modified Sat Oct 3, 2026
10
+ */
11
+ import type { PaletteAction } from "./store.js";
12
+ import type { Provider } from "./types.js";
13
+ /** What the hook needs. */
14
+ export type ProviderSearchArgs = {
15
+ providers: readonly Provider[];
16
+ query: string;
17
+ /** The frame's index in the stack, so results land on it even after a push. */
18
+ depth: number;
19
+ dispatch: (action: PaletteAction) => void;
20
+ /** False while the palette is closed, or the frame isn't on top. */
21
+ enabled: boolean;
22
+ };
23
+ /**
24
+ * @function useProviderSearch
25
+ * @param args {ProviderSearchArgs} providers, the query, the frame depth, dispatch and enabled
26
+ * @returns {void}
27
+ */
28
+ export declare function useProviderSearch({ providers, query, depth, dispatch, enabled, }: ProviderSearchArgs): void;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * @file src/components/palette/useProviderSearch.ts
3
+ * @desc Runs a frame's providers as the query changes: each one waits for its minLength,
4
+ * debounces, and gets an AbortSignal that fires when a newer query starts, the frame goes
5
+ * away, or the palette closes. A superseded search's result is dropped even if it
6
+ * resolves. Results go to the store by frame depth and provider id.
7
+ * @author David @dvhsh (https://dvh.sh)
8
+ * @created Sat Oct 3, 2026
9
+ * @modified Sat Oct 3, 2026
10
+ */
11
+ "use client";
12
+ import { useEffect } from "react";
13
+ const DEFAULT_DEBOUNCE = 200;
14
+ const DEFAULT_MIN = 2;
15
+ /**
16
+ * @function useProviderSearch
17
+ * @param args {ProviderSearchArgs} providers, the query, the frame depth, dispatch and enabled
18
+ * @returns {void}
19
+ */
20
+ export function useProviderSearch({ providers, query, depth, dispatch, enabled, }) {
21
+ useEffect(() => {
22
+ if (!enabled)
23
+ return;
24
+ const trimmed = query.trim();
25
+ const controllers = [];
26
+ const timers = [];
27
+ for (const provider of providers) {
28
+ if (trimmed.length < (provider.minLength ?? DEFAULT_MIN))
29
+ continue;
30
+ const controller = new AbortController();
31
+ controllers.push(controller);
32
+ dispatch({
33
+ type: "provided",
34
+ depth,
35
+ providerId: provider.id,
36
+ result: { state: "loading", rows: [] },
37
+ });
38
+ timers.push(setTimeout(() => {
39
+ provider.search(trimmed, controller.signal).then((rows) => {
40
+ if (controller.signal.aborted)
41
+ return;
42
+ dispatch({
43
+ type: "provided",
44
+ depth,
45
+ providerId: provider.id,
46
+ result: { state: "done", rows },
47
+ });
48
+ }, (error) => {
49
+ if (controller.signal.aborted)
50
+ return;
51
+ console.error(`CommandPalette: provider "${provider.id}" failed`, error);
52
+ dispatch({
53
+ type: "provided",
54
+ depth,
55
+ providerId: provider.id,
56
+ result: { state: "error", rows: [] },
57
+ });
58
+ });
59
+ }, provider.debounceMs ?? DEFAULT_DEBOUNCE));
60
+ }
61
+ return () => {
62
+ for (const timer of timers)
63
+ clearTimeout(timer);
64
+ for (const controller of controllers)
65
+ controller.abort();
66
+ };
67
+ }, [providers, query, depth, dispatch, enabled]);
68
+ }
@@ -6,7 +6,7 @@
6
6
  * a click on a link, or focus leaving it closes it too.
7
7
  * @author David @dvhsh (https://dvh.sh)
8
8
  * @created Mon Sep 28, 2026
9
- * @modified Mon Sep 28, 2026
9
+ * @modified Sat Oct 3, 2026
10
10
  */
11
11
  import { type ComponentProps, type ReactNode } from "react";
12
12
  /** One link in the panel. */
@@ -6,7 +6,7 @@
6
6
  * a click on a link, or focus leaving it closes it too.
7
7
  * @author David @dvhsh (https://dvh.sh)
8
8
  * @created Mon Sep 28, 2026
9
- * @modified Mon Sep 28, 2026
9
+ * @modified Sat Oct 3, 2026
10
10
  */
11
11
  "use client";
12
12
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
@@ -54,5 +54,5 @@ export function HeaderMenu({ label, items = [], children, align = "end", buttonL
54
54
  box?.removeEventListener("focusout", onFocusOut);
55
55
  };
56
56
  }, [open]);
57
- return (_jsxs("div", { ref: wrapper, className: cx("relative", className), ...props, children: [_jsx("button", { ref: button, type: "button", "aria-expanded": open, "aria-controls": panelId, "aria-label": buttonLabel, onClick: () => setOpen(!open), className: cx("flex items-center gap-2 font-bold text-c1 text-sm transition-colors hover:text-h1", buttonClassName), children: label }), _jsxs("div", { id: panelId, hidden: !open, className: cx("absolute z-10 mt-2 w-44 flex-col gap-1 rounded-lg border border-b3 bg-b6 p-2", open && "flex", align === "end" ? "right-0" : "left-0"), children: [items.length > 0 ? (_jsx("ul", { className: "flex flex-col gap-1", children: items.map((item) => (_jsx("li", { children: _jsx(AutoLink, { href: item.href, className: ITEM, onClick: () => setOpen(false), children: item.label }) }, item.href))) })) : null, children] })] }));
57
+ return (_jsxs("div", { ref: wrapper, className: cx("relative", className), ...props, children: [_jsx("button", { ref: button, type: "button", "aria-expanded": open, "aria-controls": panelId, "aria-label": buttonLabel, onClick: () => setOpen(!open), className: cx("flex min-h-6 items-center gap-2 font-bold text-c1 text-sm transition-colors hover:text-h1", buttonClassName), children: label }), _jsxs("div", { id: panelId, hidden: !open, className: cx("absolute z-10 mt-2 w-44 flex-col gap-1 rounded-lg border border-b3 bg-b6 p-2", open && "flex", align === "end" ? "right-0" : "left-0"), children: [items.length > 0 ? (_jsx("ul", { className: "flex flex-col gap-1", children: items.map((item) => (_jsx("li", { children: _jsx(AutoLink, { href: item.href, className: ITEM, onClick: () => setOpen(false), children: item.label }) }, item.href))) })) : null, children] })] }));
58
58
  }
@@ -5,7 +5,7 @@
5
5
  * browser, so both lists print the same markup.
6
6
  * @author David @dvhsh (https://dvh.sh)
7
7
  * @created Thu Sep 24, 2026
8
- * @modified Mon Sep 28, 2026
8
+ * @modified Sat Oct 3, 2026
9
9
  */
10
10
  import type { SiteLinkItem } from "./links.js";
11
11
  /** One nav entry, its aria-current and the finished classes for its link. */
@@ -6,7 +6,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
6
6
  * browser, so both lists print the same markup.
7
7
  * @author David @dvhsh (https://dvh.sh)
8
8
  * @created Thu Sep 24, 2026
9
- * @modified Mon Sep 28, 2026
9
+ * @modified Sat Oct 3, 2026
10
10
  */
11
11
  import { AutoLink } from "../basics/AutoLink.js";
12
12
  import { LinkNote } from "./LinkNote.js";
@@ -17,7 +17,7 @@ import { LinkNote } from "./LinkNote.js";
17
17
  */
18
18
  export function NavItem({ item, current, linkClassName }) {
19
19
  if (!item.href) {
20
- return (_jsx("li", { children: _jsxs("span", { "aria-disabled": "true", className: "text-c4", children: [item.label, _jsx(LinkNote, { note: item.note })] }) }));
20
+ return (_jsx("li", { children: _jsxs("span", { className: "text-c4", children: [item.label, _jsx(LinkNote, { note: item.note })] }) }));
21
21
  }
22
22
  return (_jsx("li", { children: _jsx(AutoLink, { href: item.href, "aria-current": current, className: linkClassName, children: item.label }) }));
23
23
  }