@haruhimemoe/ui 0.7.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 +16 -1
- package/README.md +114 -11
- package/dist/components/basics/Disclosure.d.ts +1 -1
- package/dist/components/basics/Disclosure.js +2 -2
- package/dist/components/forms/Checkbox.d.ts +1 -1
- package/dist/components/forms/Checkbox.js +3 -1
- package/dist/components/forms/RadioGroup.js +3 -1
- 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/index.d.ts +8 -1
- package/dist/index.js +8 -1
- package/package.json +8 -3
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/palette/CommandPalette.tsx
|
|
3
|
+
* @desc The command palette: a native <dialog> opened by mod+k, `openCommandPalette()` or a
|
|
4
|
+
* command shortcut, with a combobox over the rows the store and row builder produce.
|
|
5
|
+
* Enter runs a command, pushes its page, or starts collecting its args; Escape and
|
|
6
|
+
* Backspace on an empty input go back a level, then close. Providers search as you type.
|
|
7
|
+
* Recents are read on open and written on every run. The calculator row copies its result.
|
|
8
|
+
* Mount once, inside a client component, since commands carry functions.
|
|
9
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
10
|
+
* @created Sat Oct 3, 2026
|
|
11
|
+
* @modified Sat Oct 3, 2026
|
|
12
|
+
*/
|
|
13
|
+
"use client";
|
|
14
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
15
|
+
import { usePathname, useRouter } from "next/navigation.js";
|
|
16
|
+
import { useCallback, useEffect, useId, useMemo, useReducer, useRef, useState, } from "react";
|
|
17
|
+
import { cx } from "../../utils/cx.js";
|
|
18
|
+
import { useLatestStatus } from "../actions/useLatestStatus.js";
|
|
19
|
+
import { isEditableTarget, matchesCombo, parseShortcut } from "./hotkeys.js";
|
|
20
|
+
import { PaletteFooter } from "./PaletteFooter.js";
|
|
21
|
+
import { PaletteInput } from "./PaletteInput.js";
|
|
22
|
+
import { PaletteList } from "./PaletteList.js";
|
|
23
|
+
import { PALETTE_EVENT } from "./paletteEvents.js";
|
|
24
|
+
import { isMac } from "./platform.js";
|
|
25
|
+
import { boostFrom, readRecents, recentIds, recordRecent } from "./recents.js";
|
|
26
|
+
import { buildRows, CALC_ID, optionAt, optionCount } from "./rows.js";
|
|
27
|
+
import { initialState, reduce, topFrame } from "./store.js";
|
|
28
|
+
import { useProviderSearch } from "./useProviderSearch.js";
|
|
29
|
+
const NONE = [];
|
|
30
|
+
const CHORD_MS = 800;
|
|
31
|
+
const DIALOG = "m-0 h-dvh max-h-none w-full max-w-none bg-transparent p-0 text-c1 backdrop:bg-b6/70 backdrop:backdrop-blur-sm sm:h-auto sm:pt-[12vh]";
|
|
32
|
+
const PANEL = "mx-auto flex h-full max-h-dvh w-full flex-col overflow-hidden border-b3 bg-b6 shadow-2xl sm:h-auto sm:max-h-[70vh] sm:max-w-xl sm:rounded-xl sm:border";
|
|
33
|
+
/**
|
|
34
|
+
* @function CommandPalette
|
|
35
|
+
* @param props {CommandPaletteProps} commands, root providers, storage key, hotkey, placeholder,
|
|
36
|
+
* label, calculator and recents switches, panel classes
|
|
37
|
+
* @returns {JSX.Element} the dialog, empty until opened
|
|
38
|
+
*/
|
|
39
|
+
export function CommandPalette({ commands, providers = NONE, storageKey = "default", hotkey = "mod+k", placeholder = "Search commands…", label = "Command palette", calculator = true, recents: recentsOn = true, className, }) {
|
|
40
|
+
const router = useRouter();
|
|
41
|
+
const pathname = usePathname() ?? "";
|
|
42
|
+
const [state, dispatch] = useReducer(reduce, undefined, initialState);
|
|
43
|
+
const [recents, setRecents] = useState({});
|
|
44
|
+
const [mac, setMac] = useState(false);
|
|
45
|
+
const dialog = useRef(null);
|
|
46
|
+
const input = useRef(null);
|
|
47
|
+
const opener = useRef(null);
|
|
48
|
+
const chord = useRef(null);
|
|
49
|
+
// Where the pointer last was over a row. Chrome fires a pointermove at the same spot after the
|
|
50
|
+
// list scrolls under a still cursor; only a real move may take the active row from the keys.
|
|
51
|
+
const pointer = useRef(null);
|
|
52
|
+
const { status, start, settle } = useLatestStatus();
|
|
53
|
+
// The copy status reads until the query changes or the palette reopens, then the count again.
|
|
54
|
+
const [statusShown, setStatusShown] = useState(false);
|
|
55
|
+
const baseId = useId();
|
|
56
|
+
const listId = `${baseId}-list`;
|
|
57
|
+
const optionId = useCallback((index) => `${baseId}-option-${index}`, [baseId]);
|
|
58
|
+
useEffect(() => setMac(isMac()), []);
|
|
59
|
+
const root = useMemo(() => ({ title: label, placeholder, commands, providers }), [label, placeholder, commands, providers]);
|
|
60
|
+
const open = useCallback((page) => {
|
|
61
|
+
// Opening onto a page while already open keeps the original opener.
|
|
62
|
+
if (!state.open)
|
|
63
|
+
opener.current = document.activeElement;
|
|
64
|
+
if (recentsOn)
|
|
65
|
+
setRecents(readRecents(storageKey));
|
|
66
|
+
setStatusShown(false);
|
|
67
|
+
dispatch({ type: "open", root, page });
|
|
68
|
+
}, [root, recentsOn, storageKey, state.open]);
|
|
69
|
+
const close = useCallback(() => dispatch({ type: "close" }), []);
|
|
70
|
+
const ctx = useMemo(() => ({
|
|
71
|
+
navigate: (href) => router.push(href),
|
|
72
|
+
close,
|
|
73
|
+
push: (page) => (state.open ? dispatch({ type: "push", page }) : open(page)),
|
|
74
|
+
copy: async (text) => {
|
|
75
|
+
const run = start();
|
|
76
|
+
setStatusShown(true);
|
|
77
|
+
try {
|
|
78
|
+
await navigator.clipboard.writeText(text);
|
|
79
|
+
settle(run, "Copied");
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
settle(run, "Couldn't copy");
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
pathname,
|
|
86
|
+
commands,
|
|
87
|
+
}), [router, close, state.open, open, start, settle, pathname, commands]);
|
|
88
|
+
const frame = topFrame(state);
|
|
89
|
+
const depth = state.stack.length - 1;
|
|
90
|
+
const isRoot = depth === 0;
|
|
91
|
+
const query = state.arg ? state.arg.query : (frame?.query ?? "");
|
|
92
|
+
const rows = useMemo(() => frame
|
|
93
|
+
? buildRows({
|
|
94
|
+
query: frame.query,
|
|
95
|
+
commands: frame.page.commands ?? [],
|
|
96
|
+
providers: frame.page.providers ?? NONE,
|
|
97
|
+
providerResults: frame.providers,
|
|
98
|
+
recentIds: isRoot && recentsOn ? recentIds(recents) : [],
|
|
99
|
+
boost: boostFrom(recents),
|
|
100
|
+
isRoot,
|
|
101
|
+
calculator,
|
|
102
|
+
arg: state.arg,
|
|
103
|
+
ctx,
|
|
104
|
+
})
|
|
105
|
+
: [], [frame, isRoot, recentsOn, recents, calculator, state.arg, ctx]);
|
|
106
|
+
const count = optionCount(rows);
|
|
107
|
+
const active = state.arg ? state.arg.active : (frame?.active ?? 0);
|
|
108
|
+
useProviderSearch({
|
|
109
|
+
providers: frame?.page.providers ?? NONE,
|
|
110
|
+
query: frame?.query ?? "",
|
|
111
|
+
depth,
|
|
112
|
+
dispatch,
|
|
113
|
+
enabled: state.open && state.arg === null && frame !== undefined,
|
|
114
|
+
});
|
|
115
|
+
// The dialog follows `open`: show it modally, focus the input, lock the page's scroll; on
|
|
116
|
+
// close, hand focus back to whatever had it.
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
const element = dialog.current;
|
|
119
|
+
if (!element)
|
|
120
|
+
return;
|
|
121
|
+
if (state.open) {
|
|
122
|
+
if (!element.open)
|
|
123
|
+
element.showModal();
|
|
124
|
+
input.current?.focus();
|
|
125
|
+
document.documentElement.style.overflow = "hidden";
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (element.open)
|
|
129
|
+
element.close();
|
|
130
|
+
document.documentElement.style.overflow = "";
|
|
131
|
+
if (opener.current instanceof HTMLElement)
|
|
132
|
+
opener.current.focus();
|
|
133
|
+
opener.current = null;
|
|
134
|
+
}, [state.open]);
|
|
135
|
+
useEffect(() => () => {
|
|
136
|
+
document.documentElement.style.overflow = "";
|
|
137
|
+
}, []);
|
|
138
|
+
const remember = (id) => {
|
|
139
|
+
if (recentsOn && id !== CALC_ID)
|
|
140
|
+
setRecents(recordRecent(storageKey, id));
|
|
141
|
+
};
|
|
142
|
+
const runCommand = (command, args = {}) => {
|
|
143
|
+
remember(command.id);
|
|
144
|
+
try {
|
|
145
|
+
const result = command.run?.(ctx, args);
|
|
146
|
+
if (result instanceof Promise)
|
|
147
|
+
result.catch((error) => console.error(`CommandPalette: "${command.id}" failed`, error));
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
console.error(`CommandPalette: "${command.id}" failed`, error);
|
|
151
|
+
}
|
|
152
|
+
if (command.closeOnRun !== false)
|
|
153
|
+
close();
|
|
154
|
+
};
|
|
155
|
+
const acceptArg = (value) => {
|
|
156
|
+
const arg = state.arg;
|
|
157
|
+
const spec = arg?.command.args?.[arg.index];
|
|
158
|
+
if (!arg || !spec)
|
|
159
|
+
return;
|
|
160
|
+
const trimmed = value.trim();
|
|
161
|
+
if (spec.type !== "choice" && trimmed === "")
|
|
162
|
+
return dispatch({ type: "argError", message: "Required" });
|
|
163
|
+
if (spec.type === "number" && Number.isNaN(Number(trimmed)))
|
|
164
|
+
return dispatch({ type: "argError", message: "Enter a number" });
|
|
165
|
+
const message = spec.validate?.(trimmed);
|
|
166
|
+
if (message)
|
|
167
|
+
return dispatch({ type: "argError", message });
|
|
168
|
+
dispatch({ type: "argAccept", value: trimmed });
|
|
169
|
+
if (arg.index + 1 >= (arg.command.args?.length ?? 0))
|
|
170
|
+
runCommand(arg.command, { ...arg.values, [spec.name]: trimmed });
|
|
171
|
+
};
|
|
172
|
+
const select = (command) => {
|
|
173
|
+
if (state.arg)
|
|
174
|
+
return acceptArg(command.id);
|
|
175
|
+
if (command.page) {
|
|
176
|
+
remember(command.id);
|
|
177
|
+
return dispatch({ type: "push", page: command.page });
|
|
178
|
+
}
|
|
179
|
+
if (command.args && command.args.length > 0)
|
|
180
|
+
return dispatch({ type: "beginArgs", command });
|
|
181
|
+
runCommand(command);
|
|
182
|
+
};
|
|
183
|
+
const fire = (command) => {
|
|
184
|
+
try {
|
|
185
|
+
if (command.when && !command.when(ctx))
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (command.page)
|
|
192
|
+
return open(command.page);
|
|
193
|
+
if (command.args && command.args.length > 0) {
|
|
194
|
+
open();
|
|
195
|
+
return dispatch({ type: "beginArgs", command });
|
|
196
|
+
}
|
|
197
|
+
runCommand(command);
|
|
198
|
+
};
|
|
199
|
+
const back = () => {
|
|
200
|
+
if (state.arg || depth > 0)
|
|
201
|
+
dispatch({ type: "pop" });
|
|
202
|
+
else
|
|
203
|
+
close();
|
|
204
|
+
};
|
|
205
|
+
const onKeyDown = (event) => {
|
|
206
|
+
const spec = state.arg?.command.args?.[state.arg.index];
|
|
207
|
+
switch (event.key) {
|
|
208
|
+
case "ArrowDown":
|
|
209
|
+
case "ArrowUp":
|
|
210
|
+
event.preventDefault();
|
|
211
|
+
return dispatch({ type: "move", delta: event.key === "ArrowDown" ? 1 : -1, count });
|
|
212
|
+
case "Home":
|
|
213
|
+
case "End":
|
|
214
|
+
if (count === 0)
|
|
215
|
+
return;
|
|
216
|
+
event.preventDefault();
|
|
217
|
+
return dispatch({ type: "move", delta: event.key === "Home" ? "home" : "end", count });
|
|
218
|
+
case "Enter": {
|
|
219
|
+
event.preventDefault();
|
|
220
|
+
if (state.arg && spec && spec.type !== "choice")
|
|
221
|
+
return acceptArg(state.arg.query);
|
|
222
|
+
const option = optionAt(rows, active);
|
|
223
|
+
return option ? select(option.command) : undefined;
|
|
224
|
+
}
|
|
225
|
+
case "Escape":
|
|
226
|
+
event.preventDefault();
|
|
227
|
+
return back();
|
|
228
|
+
case "Backspace":
|
|
229
|
+
if (query !== "" || (!state.arg && depth === 0))
|
|
230
|
+
return;
|
|
231
|
+
event.preventDefault();
|
|
232
|
+
return dispatch({ type: "pop" });
|
|
233
|
+
case "Tab":
|
|
234
|
+
return event.preventDefault();
|
|
235
|
+
default:
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
// Global keys: the opening hotkey anywhere; command shortcuts (combos and chords) only while
|
|
240
|
+
// closed and outside editable targets.
|
|
241
|
+
useEffect(() => {
|
|
242
|
+
const toggle = parseShortcut(hotkey);
|
|
243
|
+
const shortcuts = commands.flatMap((command) => {
|
|
244
|
+
const shortcut = command.shortcut ? parseShortcut(command.shortcut) : null;
|
|
245
|
+
return shortcut ? [{ command, shortcut }] : [];
|
|
246
|
+
});
|
|
247
|
+
const onKey = (event) => {
|
|
248
|
+
if (toggle?.kind === "combo" && matchesCombo(event, toggle.combo, mac)) {
|
|
249
|
+
event.preventDefault();
|
|
250
|
+
return state.open ? close() : open();
|
|
251
|
+
}
|
|
252
|
+
if (state.open || isEditableTarget(event.target))
|
|
253
|
+
return;
|
|
254
|
+
const bare = !event.metaKey && !event.ctrlKey && !event.altKey && event.key.length === 1;
|
|
255
|
+
const key = event.key.toLowerCase();
|
|
256
|
+
const pending = chord.current;
|
|
257
|
+
chord.current = null;
|
|
258
|
+
for (const { command, shortcut } of shortcuts) {
|
|
259
|
+
if (shortcut.kind === "combo") {
|
|
260
|
+
if (!matchesCombo(event, shortcut.combo, mac))
|
|
261
|
+
continue;
|
|
262
|
+
event.preventDefault();
|
|
263
|
+
return fire(command);
|
|
264
|
+
}
|
|
265
|
+
if (!bare)
|
|
266
|
+
continue;
|
|
267
|
+
if (pending &&
|
|
268
|
+
pending.key === shortcut.keys[0] &&
|
|
269
|
+
key === shortcut.keys[1] &&
|
|
270
|
+
Date.now() - pending.at < CHORD_MS) {
|
|
271
|
+
event.preventDefault();
|
|
272
|
+
return fire(command);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (bare &&
|
|
276
|
+
shortcuts.some((entry) => entry.shortcut.kind === "chord" && entry.shortcut.keys[0] === key)) {
|
|
277
|
+
chord.current = { key, at: Date.now() };
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
window.addEventListener("keydown", onKey);
|
|
281
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
282
|
+
});
|
|
283
|
+
useEffect(() => {
|
|
284
|
+
const onOpen = (event) => open(event.detail?.page);
|
|
285
|
+
window.addEventListener(PALETTE_EVENT, onOpen);
|
|
286
|
+
return () => window.removeEventListener(PALETTE_EVENT, onOpen);
|
|
287
|
+
}, [open]);
|
|
288
|
+
const argSpec = state.arg?.command.args?.[state.arg.index];
|
|
289
|
+
const crumbs = state.arg
|
|
290
|
+
? [
|
|
291
|
+
state.arg.command.title,
|
|
292
|
+
...(state.arg.command.args ?? [])
|
|
293
|
+
.slice(0, state.arg.index)
|
|
294
|
+
.map((a) => state.arg?.values[a.name] ?? ""),
|
|
295
|
+
]
|
|
296
|
+
: state.stack.slice(1).map((f) => f.page.title);
|
|
297
|
+
return (_jsx("dialog", { ref: dialog, "data-palette": "", "aria-label": label, className: DIALOG, onCancel: (event) => {
|
|
298
|
+
event.preventDefault();
|
|
299
|
+
back();
|
|
300
|
+
}, onKeyDown: onKeyDown,
|
|
301
|
+
// The browser can close the dialog itself (a back gesture, a close watcher): follow it.
|
|
302
|
+
onClose: close, onClick: (event) => {
|
|
303
|
+
if (event.target === event.currentTarget)
|
|
304
|
+
close();
|
|
305
|
+
}, children: state.open && frame ? (_jsxs("div", { className: cx(PANEL, className), children: [_jsx(PaletteInput, { ref: input, value: query, placeholder: argSpec ? argSpec.label : (frame.page.placeholder ?? "Search…"), label: label, crumbs: crumbs, listId: listId, activeId: count > 0 ? optionId(active) : undefined, error: state.arg?.error, onChange: (value) => {
|
|
306
|
+
setStatusShown(false);
|
|
307
|
+
dispatch({ type: "query", value });
|
|
308
|
+
} }), _jsx(PaletteList, { id: listId, rows: rows, active: active, mac: mac, optionId: optionId, busy: Object.values(frame.providers).some((result) => result.state === "loading"), onActivate: (index, x, y) => {
|
|
309
|
+
const last = pointer.current;
|
|
310
|
+
if (last && last.x === x && last.y === y)
|
|
311
|
+
return;
|
|
312
|
+
pointer.current = { x, y };
|
|
313
|
+
dispatch({ type: "active", index, count });
|
|
314
|
+
}, onSelect: select }), _jsx(PaletteFooter, { count: count, nested: depth > 0 || state.arg !== null, status: statusShown ? (status?.result ?? null) : null, statusRun: statusShown ? (status?.run ?? null) : null })] })) : null }));
|
|
315
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/palette/CommandPaletteButton.tsx
|
|
3
|
+
* @desc A header button that opens the mounted CommandPalette: a magnifier, optional text, and
|
|
4
|
+
* the hotkey hint ("Ctrl K", or "⌘K" once a Mac is detected after mount). Goes anywhere;
|
|
5
|
+
* it only dispatches the palette event.
|
|
6
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
7
|
+
* @created Sat Oct 3, 2026
|
|
8
|
+
* @modified Sat Oct 3, 2026
|
|
9
|
+
*/
|
|
10
|
+
import { type ButtonProps } from "../basics/Button.js";
|
|
11
|
+
/** Every Button prop except onClick, plus the accessible name. */
|
|
12
|
+
export type CommandPaletteButtonProps = Omit<ButtonProps, "onClick"> & {
|
|
13
|
+
/** The accessible name. Default "Open command palette". */
|
|
14
|
+
label?: string | undefined;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* @function CommandPaletteButton
|
|
18
|
+
* @param props {CommandPaletteButtonProps} label, children (text beside the icon) and Button props
|
|
19
|
+
* @returns {JSX.Element} a ghost Button
|
|
20
|
+
*/
|
|
21
|
+
export declare function CommandPaletteButton({ label, children, variant, ...props }: CommandPaletteButtonProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/palette/CommandPaletteButton.tsx
|
|
3
|
+
* @desc A header button that opens the mounted CommandPalette: a magnifier, optional text, and
|
|
4
|
+
* the hotkey hint ("Ctrl K", or "⌘K" once a Mac is detected after mount). Goes anywhere;
|
|
5
|
+
* it only dispatches the palette event.
|
|
6
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
7
|
+
* @created Sat Oct 3, 2026
|
|
8
|
+
* @modified Sat Oct 3, 2026
|
|
9
|
+
*/
|
|
10
|
+
"use client";
|
|
11
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
12
|
+
import { useEffect, useState } from "react";
|
|
13
|
+
import { Button } from "../basics/Button.js";
|
|
14
|
+
import { openCommandPalette } from "./paletteEvents.js";
|
|
15
|
+
import { isMac } from "./platform.js";
|
|
16
|
+
const KBD = "rounded border border-b3 bg-b5 px-1.5 font-sans text-c3 text-xs";
|
|
17
|
+
/**
|
|
18
|
+
* @function CommandPaletteButton
|
|
19
|
+
* @param props {CommandPaletteButtonProps} label, children (text beside the icon) and Button props
|
|
20
|
+
* @returns {JSX.Element} a ghost Button
|
|
21
|
+
*/
|
|
22
|
+
export function CommandPaletteButton({ label = "Open command palette", children, variant = "ghost", ...props }) {
|
|
23
|
+
const [mac, setMac] = useState(false);
|
|
24
|
+
useEffect(() => setMac(isMac()), []);
|
|
25
|
+
return (_jsxs(Button, { type: "button", variant: variant, "aria-label": children === undefined || children === null ? label : undefined, onClick: () => openCommandPalette(), ...props, children: [_jsxs("svg", { "aria-hidden": "true", viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: "2", className: "size-4", children: [_jsx("circle", { cx: "8.5", cy: "8.5", r: "5.5" }), _jsx("path", { d: "m13 13 4 4", strokeLinecap: "round" })] }), children, _jsx("kbd", { "aria-hidden": "true", className: KBD, children: mac ? "⌘K" : "Ctrl K" })] }));
|
|
26
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/palette/PaletteFooter.tsx
|
|
3
|
+
* @desc The palette's bottom line (internal): the live region that announces the result count
|
|
4
|
+
* or the latest copy status, and the key hints (hidden on phones). No hooks, no cx.
|
|
5
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
6
|
+
* @created Sat Oct 3, 2026
|
|
7
|
+
* @modified Sat Oct 3, 2026
|
|
8
|
+
*/
|
|
9
|
+
/** The option count, whether Backspace goes back, and the latest status (keyed by its run). */
|
|
10
|
+
export type PaletteFooterProps = {
|
|
11
|
+
count: number;
|
|
12
|
+
nested: boolean;
|
|
13
|
+
status: string | null;
|
|
14
|
+
statusRun: number | null;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* @function PaletteFooter
|
|
18
|
+
* @param props {PaletteFooterProps} count, nested, status and its run
|
|
19
|
+
* @returns {JSX.Element} the footer with its `<output>` live region
|
|
20
|
+
*/
|
|
21
|
+
export declare function PaletteFooter({ count, nested, status, statusRun }: PaletteFooterProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
const KBD = "rounded border border-b3 bg-b5 px-1 font-sans text-c3";
|
|
3
|
+
/**
|
|
4
|
+
* @function PaletteFooter
|
|
5
|
+
* @param props {PaletteFooterProps} count, nested, status and its run
|
|
6
|
+
* @returns {JSX.Element} the footer with its `<output>` live region
|
|
7
|
+
*/
|
|
8
|
+
export function PaletteFooter({ count, nested, status, statusRun }) {
|
|
9
|
+
return (_jsxs("div", { className: "flex items-center justify-between gap-4 border-b4 border-t px-4 py-2 text-c4 text-xs", children: [_jsx("output", { "aria-live": "polite", className: "truncate", children: statusRun === null ? (`${count} ${count === 1 ? "result" : "results"}`) : (_jsx("span", { children: status }, statusRun)) }), _jsxs("span", { className: "hidden shrink-0 gap-3 sm:flex", children: [_jsxs("span", { children: [_jsx("kbd", { className: KBD, children: "\u2191" }), " ", _jsx("kbd", { className: KBD, children: "\u2193" }), " navigate"] }), _jsxs("span", { children: [_jsx("kbd", { className: KBD, children: "\u21B5" }), " select"] }), nested ? (_jsxs("span", { children: [_jsx("kbd", { className: KBD, children: "\u232B" }), " back"] })) : null, _jsxs("span", { children: [_jsx("kbd", { className: KBD, children: "esc" }), " ", nested ? "back" : "close"] })] })] }));
|
|
10
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/palette/PaletteInput.tsx
|
|
3
|
+
* @desc The palette's top line (internal): breadcrumb chips for nested pages or the argument
|
|
4
|
+
* being collected, the combobox input, and the validation message under it. The input is
|
|
5
|
+
* the dialog's one focusable thing, so its row shows focus: the bottom border turns h1 on
|
|
6
|
+
* focus-visible, the cue 0.7.0's fields use. The error line is a status region that is
|
|
7
|
+
* always mounted, so a message that appears is announced.
|
|
8
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
9
|
+
* @created Sat Oct 3, 2026
|
|
10
|
+
* @modified Sat Oct 3, 2026
|
|
11
|
+
*/
|
|
12
|
+
import { type Ref } from "react";
|
|
13
|
+
/** The input's state and ids. */
|
|
14
|
+
export type PaletteInputProps = {
|
|
15
|
+
ref: Ref<HTMLInputElement>;
|
|
16
|
+
value: string;
|
|
17
|
+
placeholder: string;
|
|
18
|
+
label: string;
|
|
19
|
+
crumbs: readonly string[];
|
|
20
|
+
listId: string;
|
|
21
|
+
activeId: string | undefined;
|
|
22
|
+
error: string | undefined;
|
|
23
|
+
onChange: (value: string) => void;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* @function PaletteInput
|
|
27
|
+
* @param props {PaletteInputProps} value, placeholder, name, crumbs, list and active ids, error
|
|
28
|
+
* @returns {JSX.Element} the crumbs, the combobox and its error line
|
|
29
|
+
*/
|
|
30
|
+
export declare function PaletteInput({ ref, value, placeholder, label, crumbs, listId, activeId, error, onChange, }: PaletteInputProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/palette/PaletteInput.tsx
|
|
3
|
+
* @desc The palette's top line (internal): breadcrumb chips for nested pages or the argument
|
|
4
|
+
* being collected, the combobox input, and the validation message under it. The input is
|
|
5
|
+
* the dialog's one focusable thing, so its row shows focus: the bottom border turns h1 on
|
|
6
|
+
* focus-visible, the cue 0.7.0's fields use. The error line is a status region that is
|
|
7
|
+
* always mounted, so a message that appears is announced.
|
|
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 { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
14
|
+
import { useId } from "react";
|
|
15
|
+
/**
|
|
16
|
+
* @function PaletteInput
|
|
17
|
+
* @param props {PaletteInputProps} value, placeholder, name, crumbs, list and active ids, error
|
|
18
|
+
* @returns {JSX.Element} the crumbs, the combobox and its error line
|
|
19
|
+
*/
|
|
20
|
+
export function PaletteInput({ ref, value, placeholder, label, crumbs, listId, activeId, error, onChange, }) {
|
|
21
|
+
const errorId = `${useId()}-error`;
|
|
22
|
+
return (_jsxs("div", { className: "border-b4 border-b has-[input:focus-visible]:border-h1", children: [_jsxs("div", { className: "flex items-center gap-2 px-4 py-3", children: [crumbs.map((crumb, index) => (_jsx("span", { className: "shrink-0 rounded bg-b4 px-2 py-0.5 text-c3 text-xs", children: crumb }, index))), _jsx("input", { ref: ref, type: "text", role: "combobox", "aria-expanded": "true", "aria-controls": listId, "aria-activedescendant": activeId, "aria-autocomplete": "list", "aria-label": label, "aria-invalid": error ? true : undefined, "aria-describedby": errorId, autoComplete: "off", spellCheck: false, value: value, placeholder: placeholder, onChange: (event) => onChange(event.target.value), className: "min-w-0 flex-1 bg-transparent text-base text-c1 outline-hidden placeholder:text-c4 sm:text-lg" })] }), _jsx("p", { id: errorId, role: "status", className: error ? "px-4 pb-2 text-rose-300 text-xs" : "sr-only", children: error })] }));
|
|
23
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/palette/PaletteList.tsx
|
|
3
|
+
* @desc The palette's listbox (internal): option rows under group headings, hint rows as plain
|
|
4
|
+
* text, the active row scrolled into view. Hover makes a row active; click selects it.
|
|
5
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
6
|
+
* @created Sat Oct 3, 2026
|
|
7
|
+
* @modified Sat Oct 3, 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { Row } from "./rows.js";
|
|
10
|
+
import type { Command } from "./types.js";
|
|
11
|
+
/** The rows, which option is active, how to id an option, and the handlers. */
|
|
12
|
+
export type PaletteListProps = {
|
|
13
|
+
id: string;
|
|
14
|
+
rows: readonly Row[];
|
|
15
|
+
active: number;
|
|
16
|
+
mac: boolean;
|
|
17
|
+
optionId: (index: number) => string;
|
|
18
|
+
/** A provider is searching: the listbox is aria-busy. */
|
|
19
|
+
busy?: boolean | undefined;
|
|
20
|
+
/** The pointer is over a row, at this position. */
|
|
21
|
+
onActivate: (index: number, x: number, y: number) => void;
|
|
22
|
+
onSelect: (command: Command) => void;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* @function PaletteList
|
|
26
|
+
* @param props {PaletteListProps} rows, the active index, option ids and handlers
|
|
27
|
+
* @returns {JSX.Element} the `role="listbox"`
|
|
28
|
+
*/
|
|
29
|
+
export declare function PaletteList({ id, rows, active, mac, optionId, busy, onActivate, onSelect, }: PaletteListProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/palette/PaletteList.tsx
|
|
3
|
+
* @desc The palette's listbox (internal): option rows under group headings, hint rows as plain
|
|
4
|
+
* text, the active row scrolled into view. Hover makes a row active; click selects it.
|
|
5
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
6
|
+
* @created Sat Oct 3, 2026
|
|
7
|
+
* @modified Sat Oct 3, 2026
|
|
8
|
+
*/
|
|
9
|
+
"use client";
|
|
10
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
11
|
+
import { useEffect } from "react";
|
|
12
|
+
import { PaletteRow } from "./PaletteRow.js";
|
|
13
|
+
const segments = (rows) => {
|
|
14
|
+
const out = [];
|
|
15
|
+
let index = 0;
|
|
16
|
+
for (const row of rows) {
|
|
17
|
+
if (row.kind === "hint") {
|
|
18
|
+
out.push({ kind: "hint", row });
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
const last = out[out.length - 1];
|
|
22
|
+
const item = { row, index: index++ };
|
|
23
|
+
if (last?.kind === "group" && last.group === row.group)
|
|
24
|
+
last.items.push(item);
|
|
25
|
+
else
|
|
26
|
+
out.push({ kind: "group", group: row.group, items: [item] });
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* @function PaletteList
|
|
32
|
+
* @param props {PaletteListProps} rows, the active index, option ids and handlers
|
|
33
|
+
* @returns {JSX.Element} the `role="listbox"`
|
|
34
|
+
*/
|
|
35
|
+
export function PaletteList({ id, rows, active, mac, optionId, busy, onActivate, onSelect, }) {
|
|
36
|
+
// Runs for a new list too (its first row changes), so it scrolls back up when active stays 0.
|
|
37
|
+
const first = rows[0]?.id;
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (first === undefined)
|
|
40
|
+
return;
|
|
41
|
+
document.getElementById(optionId(active))?.scrollIntoView?.({ block: "nearest" });
|
|
42
|
+
}, [active, optionId, first]);
|
|
43
|
+
return (_jsx("div", { id: id, role: "listbox", "aria-label": "Results", "aria-busy": busy ? true : undefined, className: "min-h-0 flex-1 overflow-y-auto p-2", children: segments(rows).map((segment) => segment.kind === "hint" ? (_jsx("div", { role: "option", "aria-disabled": "true", "aria-selected": "false", className: "px-3 py-2 text-c4 text-sm", children: segment.row.text }, segment.row.id)) : (_jsxs("div", { role: "group", "aria-labelledby": `${id}-${segment.items[0]?.index}-h`, children: [_jsx("div", { id: `${id}-${segment.items[0]?.index}-h`, className: "px-3 pt-3 pb-1 font-bold text-c3 text-xs", children: segment.group }), segment.items.map(({ row, index }) => (_jsx(PaletteRow, { id: optionId(index), command: row.command, ranges: row.ranges, active: index === active, mac: mac, onHover: (x, y) => onActivate(index, x, y), onSelect: () => onSelect(row.command) }, row.id)))] }, `${segment.group}:${segment.items[0]?.row.id}`))) }));
|
|
44
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/palette/PaletteRow.tsx
|
|
3
|
+
* @desc One option in the palette's list (internal): icon slot, the title with its matched
|
|
4
|
+
* characters marked, the subtitle, and the shortcut as kbd parts. Finished class strings,
|
|
5
|
+
* no hooks: the list sets aria-selected and the pointer handlers. Keyboard handling lives
|
|
6
|
+
* on the dialog, so this isn't focusable (aria-activedescendant names it).
|
|
7
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
8
|
+
* @created Sat Oct 3, 2026
|
|
9
|
+
* @modified Sat Oct 3, 2026
|
|
10
|
+
*/
|
|
11
|
+
import type { FuzzyMatch } from "./fuzzy.js";
|
|
12
|
+
import type { Command } from "./types.js";
|
|
13
|
+
/** The row's id (for aria-activedescendant), its command and spans, whether it's active. */
|
|
14
|
+
export type PaletteRowProps = {
|
|
15
|
+
id: string;
|
|
16
|
+
command: Command;
|
|
17
|
+
ranges: FuzzyMatch["ranges"];
|
|
18
|
+
active: boolean;
|
|
19
|
+
mac: boolean;
|
|
20
|
+
/** The pointer moved over the row; gets the pointer's position, so a stale move can be told. */
|
|
21
|
+
onHover: (x: number, y: number) => void;
|
|
22
|
+
onSelect: () => void;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* @function PaletteRow
|
|
26
|
+
* @param props {PaletteRowProps} the option's id, command, matched spans, active state, platform
|
|
27
|
+
* and pointer handlers
|
|
28
|
+
* @returns {JSX.Element} a `role="option"` row
|
|
29
|
+
*/
|
|
30
|
+
export declare function PaletteRow({ id, command, ranges, active, mac, onHover, onSelect, }: PaletteRowProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { displayShortcut, parseShortcut } from "./hotkeys.js";
|
|
3
|
+
// The active row shows its state with an h1 edge, not background alone: bg-b4 on b6 is only
|
|
4
|
+
// 1.5:1. min-h-9 keeps every row a 36px target.
|
|
5
|
+
const ROW = "flex min-h-9 cursor-default items-center gap-3 rounded-lg border-l-2 border-transparent px-3 py-2";
|
|
6
|
+
const ACTIVE = `${ROW} border-h1 bg-b4 forced-colors:border-[Highlight] forced-colors:bg-[Highlight] forced-colors:text-[HighlightText]`;
|
|
7
|
+
const KBD = "rounded border border-b3 bg-b5 px-1.5 font-sans text-c3 text-xs";
|
|
8
|
+
const highlighted = (title, ranges) => {
|
|
9
|
+
const parts = [];
|
|
10
|
+
let at = 0;
|
|
11
|
+
for (const [start, end] of ranges) {
|
|
12
|
+
if (start > at)
|
|
13
|
+
parts.push(title.slice(at, start));
|
|
14
|
+
parts.push(_jsx("mark", { className: "bg-transparent text-h1", children: title.slice(start, end) }, start));
|
|
15
|
+
at = end;
|
|
16
|
+
}
|
|
17
|
+
if (at < title.length)
|
|
18
|
+
parts.push(title.slice(at));
|
|
19
|
+
return parts;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* @function PaletteRow
|
|
23
|
+
* @param props {PaletteRowProps} the option's id, command, matched spans, active state, platform
|
|
24
|
+
* and pointer handlers
|
|
25
|
+
* @returns {JSX.Element} a `role="option"` row
|
|
26
|
+
*/
|
|
27
|
+
export function PaletteRow({ id, command, ranges, active, mac, onHover, onSelect, }) {
|
|
28
|
+
const shortcut = command.shortcut ? parseShortcut(command.shortcut) : null;
|
|
29
|
+
return (_jsxs("div", { id: id, role: "option", "aria-selected": active, className: active ? ACTIVE : ROW, onPointerMove: (event) => onHover(event.clientX, event.clientY),
|
|
30
|
+
// Keep focus in the input: a click on a row must not blur it, so the next prompt
|
|
31
|
+
// (an argument, a nested page) can be typed straight away.
|
|
32
|
+
onPointerDown: (event) => event.preventDefault(), onClick: onSelect, children: [command.icon ? (_jsx("span", { className: "flex size-5 shrink-0 items-center justify-center text-c3", children: command.icon })) : null, _jsxs("span", { className: "flex min-w-0 flex-1 flex-col", children: [_jsx("span", { className: "truncate font-bold text-c1 text-sm", children: highlighted(command.title, ranges) }), command.subtitle ? (_jsx("span", { className: "truncate text-c4 text-xs", children: command.subtitle })) : null] }), shortcut ? (_jsx("span", { className: "flex shrink-0 gap-1", children: displayShortcut(shortcut, mac).map((part, index) => (_jsx("kbd", { className: KBD, children: part }, index))) })) : null] }));
|
|
33
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/palette/calc.ts
|
|
3
|
+
* @desc The palette's calculator, pure and without eval: a tokenizer and the shunting-yard
|
|
4
|
+
* algorithm over + - * / % ^, unary minus, parentheses, k and m number suffixes, pi and e,
|
|
5
|
+
* and sqrt, abs, round, floor, ceil, min and max. Anything it can't parse, and any result
|
|
6
|
+
* that isn't finite (division by zero), is null.
|
|
7
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
8
|
+
* @created Sat Oct 3, 2026
|
|
9
|
+
* @modified Sat Oct 3, 2026
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* @function evaluate
|
|
13
|
+
* @param expression {string} what was typed, e.g. "1.5k * (2 + sqrt(9))"
|
|
14
|
+
* @returns {number | null} the value, or null when it doesn't parse or isn't finite
|
|
15
|
+
*/
|
|
16
|
+
export declare function evaluate(expression: string): number | null;
|
|
17
|
+
/**
|
|
18
|
+
* @function formatResult
|
|
19
|
+
* @param value {number} a result
|
|
20
|
+
* @returns {string} up to 10 significant digits, trailing zeros dropped, "-0" as "0"
|
|
21
|
+
*/
|
|
22
|
+
export declare function formatResult(value: number): string;
|
|
23
|
+
/**
|
|
24
|
+
* @function isBareNumber
|
|
25
|
+
* @param expression {string} what was typed
|
|
26
|
+
* @returns {boolean} true for just a number ("2", " -2.5 "), when the palette shows no "= 2" row
|
|
27
|
+
*/
|
|
28
|
+
export declare const isBareNumber: (expression: string) => boolean;
|