@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
@@ -7,7 +7,7 @@
7
7
  * native radios do. Controlled (`value`) or uncontrolled (`defaultValue`).
8
8
  * @author David @dvhsh (https://dvh.sh)
9
9
  * @created Mon Sep 28, 2026
10
- * @modified Mon Sep 28, 2026
10
+ * @modified Sat Oct 3, 2026
11
11
  */
12
12
  import { type ComponentProps, type ReactNode } from "react";
13
13
  /** One radio: its value, its label, an optional inline hint, and whether it can be picked. */
@@ -7,7 +7,7 @@
7
7
  * native radios do. Controlled (`value`) or uncontrolled (`defaultValue`).
8
8
  * @author David @dvhsh (https://dvh.sh)
9
9
  * @created Mon Sep 28, 2026
10
- * @modified Mon Sep 28, 2026
10
+ * @modified Sat Oct 3, 2026
11
11
  */
12
12
  "use client";
13
13
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
@@ -27,6 +27,8 @@ export function RadioGroup({ label, options, name, value, defaultValue, onChange
27
27
  const optionId = `${id}-${i}`;
28
28
  return (_jsxs("label", { className: "flex items-start gap-2 text-sm", children: [_jsx("input", { type: "radio", id: optionId, name: name ?? id, value: option.value, ...(value === undefined
29
29
  ? { defaultChecked: option.value === defaultValue }
30
- : { checked: option.value === value }), disabled: option.disabled, required: required, "aria-invalid": error ? true : undefined, "aria-labelledby": `${optionId}-label`, "aria-describedby": option.hint ? hintId(optionId) : undefined, onChange: () => onChange?.(option.value), className: "mt-1 accent-h1" }), _jsxs("span", { children: [_jsx("span", { id: `${optionId}-label`, className: "font-bold text-c1", children: option.label }), option.hint ? (_jsxs("span", { className: "text-c3", children: [" · ", _jsx("span", { id: hintId(optionId), children: option.hint })] })) : null] })] }, option.value));
30
+ : { checked: option.value === value }), disabled: option.disabled, required: required, "aria-labelledby": `${optionId}-label`, "aria-describedby": option.hint ? hintId(optionId) : undefined, onChange: () => onChange?.(option.value),
31
+ // 24px, the WCAG 2.2 target size; -mt-0.5 centers it on the first text-sm line.
32
+ className: "-mt-0.5 size-6 shrink-0 accent-h1" }), _jsxs("span", { children: [_jsx("span", { id: `${optionId}-label`, className: "font-bold text-c1", children: option.label }), option.hint ? (_jsxs("span", { className: "text-c3", children: [" · ", _jsx("span", { id: hintId(optionId), children: option.hint })] })) : null] })] }, option.value));
31
33
  }), hint ? (_jsx("div", { id: hintId(id), className: "text-c4 text-xs", children: hint })) : null, _jsx(FieldError, { id: id, error: error })] }));
32
34
  }
@@ -6,7 +6,7 @@
6
6
  * at a time; a throw reads as `failedMessage`. Moved from bb.haruhime.moe (ReportForm).
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 ReactNode } from "react";
12
12
  /** What `onSubmit` says: done (with what to say) or not (with the error). */
@@ -6,11 +6,11 @@
6
6
  * at a time; a throw reads as `failedMessage`. Moved from bb.haruhime.moe (ReportForm).
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";
13
- import { useId, useRef, useState } from "react";
13
+ import { useEffect, useId, useRef, useState } from "react";
14
14
  import { cx } from "../../utils/cx.js";
15
15
  import { Button } from "../basics/Button.js";
16
16
  import { Disclosure } from "../basics/Disclosure.js";
@@ -27,9 +27,13 @@ export function ReportDisclosure({ onSubmit, summary = "Report", label = "What's
27
27
  const [error, setError] = useState(null);
28
28
  const [done, setDone] = useState(null);
29
29
  const running = useRef(false);
30
- if (done !== null) {
31
- return (_jsx("p", { role: "status", className: cx("text-c2 text-sm", className), children: done }));
32
- }
30
+ const status = useRef(null);
31
+ // The form goes away with the button that had focus, so focus lands on the outcome instead
32
+ // of falling to the body.
33
+ useEffect(() => {
34
+ if (done !== null)
35
+ status.current?.focus();
36
+ }, [done]);
33
37
  const send = async (event) => {
34
38
  event.preventDefault();
35
39
  if (running.current)
@@ -50,5 +54,5 @@ export function ReportDisclosure({ onSubmit, summary = "Report", label = "What's
50
54
  running.current = false;
51
55
  setPending(false);
52
56
  };
53
- return (_jsx(Disclosure, { summary: summary, className: className, children: _jsxs("form", { className: "flex flex-col gap-2", onSubmit: send, children: [_jsx(Textarea, { id: `${id}-reason`, label: label, hint: hint, error: error ?? undefined, value: reason, rows: rows, minLength: minLength, maxLength: maxLength, required: true, onChange: (event) => setReason(event.currentTarget.value) }), _jsx(Button, { type: "submit", variant: "secondary", disabled: pending, className: "self-start", children: pending ? pendingLabel : submitLabel })] }) }));
57
+ return (_jsxs("div", { className: cx("flex flex-col gap-2", className), children: [done === null ? (_jsx(Disclosure, { summary: summary, children: _jsxs("form", { className: "flex flex-col gap-2", onSubmit: send, children: [_jsx(Textarea, { id: `${id}-reason`, label: label, hint: hint, error: error ?? undefined, value: reason, rows: rows, minLength: minLength, maxLength: maxLength, required: true, onChange: (event) => setReason(event.currentTarget.value) }), _jsx(Button, { type: "submit", variant: "secondary", disabled: pending, className: "self-start", children: pending ? pendingLabel : submitLabel })] }) })) : null, _jsx("p", { ref: status, role: "status", tabIndex: -1, className: done === null ? "sr-only" : "text-c2 text-sm", children: done })] }));
54
58
  }
@@ -1,13 +1,12 @@
1
1
  /**
2
2
  * @file src/components/forms/fieldStyles.ts
3
- * @desc Shared classes for inputs, selects, and textareas (osu!-web dark fields). Focus shows as
4
- * an h1 border. On an invalid field it also gets an h1 ring, since rose to pink alone is
5
- * hard to see. `outline-hidden` (not `outline-none`) leaves a transparent outline that
6
- * forced-colors mode paints, so focus shows there too. Also the one label look that fields,
7
- * groups and filter rows share.
3
+ * * @desc Shared field classes for TextInput, Select, Textarea and RangeBox (the packs look), and
4
+ * the label class the fieldsets share. Focus is the theme's 2px h1 outline, the same ring
5
+ * every control gets, plus an h1 border; an invalid field keeps its rose border until it
6
+ * takes focus, when the h1 border and ring take over so focus stays visible.
8
7
  * @author David @dvhsh (https://dvh.sh)
9
8
  * @created Wed Sep 23, 2026
10
- * @modified Mon Sep 28, 2026
9
+ * @modified Sat Oct 3, 2026
11
10
  */
12
11
  /** A field or group label: bold c3 at text-sm (internal). */
13
12
  export declare const FIELD_LABEL = "font-bold text-c3 text-sm";
@@ -1,16 +1,15 @@
1
1
  /**
2
2
  * @file src/components/forms/fieldStyles.ts
3
- * @desc Shared classes for inputs, selects, and textareas (osu!-web dark fields). Focus shows as
4
- * an h1 border. On an invalid field it also gets an h1 ring, since rose to pink alone is
5
- * hard to see. `outline-hidden` (not `outline-none`) leaves a transparent outline that
6
- * forced-colors mode paints, so focus shows there too. Also the one label look that fields,
7
- * groups and filter rows share.
3
+ * * @desc Shared field classes for TextInput, Select, Textarea and RangeBox (the packs look), and
4
+ * the label class the fieldsets share. Focus is the theme's 2px h1 outline, the same ring
5
+ * every control gets, plus an h1 border; an invalid field keeps its rose border until it
6
+ * takes focus, when the h1 border and ring take over so focus stays visible.
8
7
  * @author David @dvhsh (https://dvh.sh)
9
8
  * @created Wed Sep 23, 2026
10
- * @modified Mon Sep 28, 2026
9
+ * @modified Sat Oct 3, 2026
11
10
  */
12
11
  import { cx } from "../../utils/cx.js";
13
- const FIELD = "w-full rounded-md border border-b3 bg-b6 px-3 py-2 text-c1 text-sm placeholder:text-c4 focus-visible:border-h1 focus-visible:outline-hidden disabled:opacity-50 aria-invalid:border-rose-400 aria-invalid:focus-visible:border-h1 aria-invalid:focus-visible:ring-1 aria-invalid:focus-visible:ring-h1";
12
+ const FIELD = "w-full rounded-md border border-b3 bg-b6 px-3 py-2 text-c1 text-sm placeholder:text-c4 focus-visible:border-h1 disabled:opacity-50 aria-invalid:border-rose-400 aria-invalid:focus-visible:border-h1";
14
13
  /** A field or group label: bold c3 at text-sm (internal). */
15
14
  export const FIELD_LABEL = "font-bold text-c3 text-sm";
16
15
  /**
@@ -5,7 +5,7 @@
5
5
  * not finite doesn't show. The short names are `<abbr>`s with the full name as a title.
6
6
  * @author David @dvhsh (https://dvh.sh)
7
7
  * @created Mon Sep 28, 2026
8
- * @modified Mon Sep 28, 2026
8
+ * @modified Sat Oct 3, 2026
9
9
  */
10
10
  import type { ComponentProps, ReactNode } from "react";
11
11
  /** The stats BeatmapStats shows, in order. */
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { cx } from "../../utils/cx.js";
3
- const abbr = (short, full) => _jsx("abbr", { title: full, children: short });
3
+ // The short form shows (and titles a tooltip); screen readers get the full name instead.
4
+ const abbr = (short, full) => (_jsxs("abbr", { title: full, children: [_jsx("span", { "aria-hidden": "true", children: short }), _jsx("span", { className: "sr-only", children: full })] }));
4
5
  const LABELS = {
5
6
  cs: abbr("CS", "Circle size"),
6
7
  ar: abbr("AR", "Approach rate"),
@@ -5,8 +5,9 @@
5
5
  * They are the same at every --hue, like the wordmark's brand colors.
6
6
  * @author David @dvhsh (https://dvh.sh)
7
7
  * @created Mon Sep 28, 2026
8
- * @modified Mon Sep 28, 2026
8
+ * @modified Sat Oct 3, 2026
9
9
  */
10
+ type Rgb = readonly [number, number, number];
10
11
  /**
11
12
  * @function starRatingColor
12
13
  * @param stars {number} a star rating
@@ -14,10 +15,19 @@
14
15
  * straight blend between the two stops around it otherwise
15
16
  */
16
17
  export declare const starRatingColor: (stars: number) => string;
18
+ /**
19
+ * @function contrastRatio
20
+ * @param a {Rgb} one color
21
+ * @param b {Rgb} the other
22
+ * @returns {number} the WCAG contrast ratio between them, 1 to 21
23
+ */
24
+ export declare const contrastRatio: (a: Rgb, b: Rgb) => number;
17
25
  /**
18
26
  * @function starRatingTextColor
19
- * @param stars {number} a star rating
20
- * @returns {string} text that reads on `starRatingColor(stars)`: black up to 6.5, then osu!'s pale
21
- * yellow on the dark end of the spectrum
27
+ * @param stars {number} the rating
28
+ * @returns {string} the text color on {@link starRatingColor}'s pill: osu!'s black up to the
29
+ * dark end, its pale gold from there, white only where neither clears 4.5:1 (the violet
30
+ * band around 6.5 to 7 stars)
22
31
  */
23
32
  export declare const starRatingTextColor: (stars: number) => string;
33
+ export {};
@@ -5,7 +5,7 @@
5
5
  * They are the same at every --hue, like the wordmark's brand colors.
6
6
  * @author David @dvhsh (https://dvh.sh)
7
7
  * @created Mon Sep 28, 2026
8
- * @modified Mon Sep 28, 2026
8
+ * @modified Sat Oct 3, 2026
9
9
  */
10
10
  const STOPS = [
11
11
  [0.1, [66, 144, 251]],
@@ -45,4 +45,41 @@ export const starRatingColor = (stars) => {
45
45
  * @returns {string} text that reads on `starRatingColor(stars)`: black up to 6.5, then osu!'s pale
46
46
  * yellow on the dark end of the spectrum
47
47
  */
48
- export const starRatingTextColor = (stars) => stars >= 6.5 ? css([255, 217, 102]) : css([0, 0, 0]);
48
+ const BLACK = [0, 0, 0];
49
+ const GOLD = [255, 217, 102];
50
+ const WHITE = [255, 255, 255];
51
+ const luminance = ([r, g, b]) => {
52
+ const channel = (v) => {
53
+ const c = v / 255;
54
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
55
+ };
56
+ return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
57
+ };
58
+ /**
59
+ * @function contrastRatio
60
+ * @param a {Rgb} one color
61
+ * @param b {Rgb} the other
62
+ * @returns {number} the WCAG contrast ratio between them, 1 to 21
63
+ */
64
+ export const contrastRatio = (a, b) => {
65
+ const [light, dark] = [luminance(a), luminance(b)].sort((x, y) => y - x);
66
+ return (light + 0.05) / (dark + 0.05);
67
+ };
68
+ const parse = (color) => (color.match(/\d+/g) ?? ["0", "0", "0"]).slice(0, 3).map(Number);
69
+ /**
70
+ * @function starRatingTextColor
71
+ * @param stars {number} the rating
72
+ * @returns {string} the text color on {@link starRatingColor}'s pill: osu!'s black up to the
73
+ * dark end, its pale gold from there, white only where neither clears 4.5:1 (the violet
74
+ * band around 6.5 to 7 stars)
75
+ */
76
+ export const starRatingTextColor = (stars) => {
77
+ const pill = parse(starRatingColor(stars));
78
+ if (stars < 6.5 && contrastRatio(BLACK, pill) >= 4.5)
79
+ return css(BLACK);
80
+ if (contrastRatio(GOLD, pill) >= 4.5)
81
+ return css(GOLD);
82
+ if (contrastRatio(BLACK, pill) >= 4.5)
83
+ return css(BLACK);
84
+ return css(WHITE);
85
+ };
@@ -0,0 +1,21 @@
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
+ import type { CommandPaletteProps } from "./types.js";
14
+ export type { CommandPaletteProps } from "./types.js";
15
+ /**
16
+ * @function CommandPalette
17
+ * @param props {CommandPaletteProps} commands, root providers, storage key, hotkey, placeholder,
18
+ * label, calculator and recents switches, panel classes
19
+ * @returns {JSX.Element} the dialog, empty until opened
20
+ */
21
+ export declare function CommandPalette({ commands, providers, storageKey, hotkey, placeholder, label, calculator, recents: recentsOn, className, }: CommandPaletteProps): import("react").JSX.Element;
@@ -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;