@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.
- package/CHANGELOG.md +29 -1
- package/README.md +143 -33
- package/dist/components/basics/Disclosure.d.ts +1 -1
- package/dist/components/basics/Disclosure.js +2 -2
- package/dist/components/filters/RangeSlider.d.ts +1 -1
- package/dist/components/filters/RangeSlider.js +6 -6
- package/dist/components/filters/chipStyles.d.ts +2 -2
- package/dist/components/filters/chipStyles.js +3 -2
- package/dist/components/forms/CharCounter.d.ts +4 -2
- package/dist/components/forms/CharCounter.js +5 -3
- package/dist/components/forms/Checkbox.d.ts +1 -1
- package/dist/components/forms/Checkbox.js +3 -1
- package/dist/components/forms/FieldFrame.d.ts +1 -1
- package/dist/components/forms/FieldFrame.js +1 -1
- package/dist/components/forms/RadioGroup.d.ts +1 -1
- package/dist/components/forms/RadioGroup.js +4 -2
- package/dist/components/forms/ReportDisclosure.d.ts +1 -1
- package/dist/components/forms/ReportDisclosure.js +10 -6
- package/dist/components/forms/fieldStyles.d.ts +5 -6
- package/dist/components/forms/fieldStyles.js +6 -7
- package/dist/components/osu/BeatmapStats.d.ts +1 -1
- package/dist/components/osu/BeatmapStats.js +2 -1
- package/dist/components/osu/starColors.d.ts +14 -4
- package/dist/components/osu/starColors.js +39 -2
- package/dist/components/palette/CommandPalette.d.ts +21 -0
- package/dist/components/palette/CommandPalette.js +315 -0
- package/dist/components/palette/CommandPaletteButton.d.ts +21 -0
- package/dist/components/palette/CommandPaletteButton.js +26 -0
- package/dist/components/palette/PaletteFooter.d.ts +21 -0
- package/dist/components/palette/PaletteFooter.js +10 -0
- package/dist/components/palette/PaletteInput.d.ts +30 -0
- package/dist/components/palette/PaletteInput.js +23 -0
- package/dist/components/palette/PaletteList.d.ts +29 -0
- package/dist/components/palette/PaletteList.js +44 -0
- package/dist/components/palette/PaletteRow.d.ts +30 -0
- package/dist/components/palette/PaletteRow.js +33 -0
- package/dist/components/palette/calc.d.ts +28 -0
- package/dist/components/palette/calc.js +226 -0
- package/dist/components/palette/fuzzy.d.ts +51 -0
- package/dist/components/palette/fuzzy.js +138 -0
- package/dist/components/palette/hotkeys.d.ts +57 -0
- package/dist/components/palette/hotkeys.js +111 -0
- package/dist/components/palette/paletteEvents.d.ts +22 -0
- package/dist/components/palette/paletteEvents.js +22 -0
- package/dist/components/palette/platform.d.ts +13 -0
- package/dist/components/palette/platform.js +21 -0
- package/dist/components/palette/recents.d.ts +48 -0
- package/dist/components/palette/recents.js +85 -0
- package/dist/components/palette/rows.d.ts +63 -0
- package/dist/components/palette/rows.js +136 -0
- package/dist/components/palette/siteCommands.d.ts +38 -0
- package/dist/components/palette/siteCommands.js +152 -0
- package/dist/components/palette/store.d.ts +94 -0
- package/dist/components/palette/store.js +135 -0
- package/dist/components/palette/types.d.ts +102 -0
- package/dist/components/palette/types.js +11 -0
- package/dist/components/palette/useProviderSearch.d.ts +28 -0
- package/dist/components/palette/useProviderSearch.js +68 -0
- package/dist/components/shell/HeaderMenu.d.ts +1 -1
- package/dist/components/shell/HeaderMenu.js +2 -2
- package/dist/components/shell/NavItem.d.ts +1 -1
- package/dist/components/shell/NavItem.js +2 -2
- package/dist/components/shell/SiteFooter.d.ts +9 -4
- package/dist/components/shell/SiteFooter.js +4 -2
- package/dist/components/tables/Table.d.ts +5 -3
- package/dist/components/tables/Table.js +15 -2
- package/dist/index.d.ts +8 -1
- package/dist/index.js +8 -1
- package/dist/theme.css +4 -4
- 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
|
|
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
|
|
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
|
|
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
|
|
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", {
|
|
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
|
}
|