@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
|
@@ -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
|
|
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
|
|
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-
|
|
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
|
|
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
|
|
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
|
-
|
|
31
|
-
|
|
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 (
|
|
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
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
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
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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}
|
|
20
|
-
* @returns {string} text
|
|
21
|
-
*
|
|
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
|
|
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
|
-
|
|
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;
|