@haruhimemoe/ui 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -1
- package/README.md +143 -33
- package/dist/components/basics/Disclosure.d.ts +1 -1
- package/dist/components/basics/Disclosure.js +2 -2
- package/dist/components/filters/RangeSlider.d.ts +1 -1
- package/dist/components/filters/RangeSlider.js +6 -6
- package/dist/components/filters/chipStyles.d.ts +2 -2
- package/dist/components/filters/chipStyles.js +3 -2
- package/dist/components/forms/CharCounter.d.ts +4 -2
- package/dist/components/forms/CharCounter.js +5 -3
- package/dist/components/forms/Checkbox.d.ts +1 -1
- package/dist/components/forms/Checkbox.js +3 -1
- package/dist/components/forms/FieldFrame.d.ts +1 -1
- package/dist/components/forms/FieldFrame.js +1 -1
- package/dist/components/forms/RadioGroup.d.ts +1 -1
- package/dist/components/forms/RadioGroup.js +4 -2
- package/dist/components/forms/ReportDisclosure.d.ts +1 -1
- package/dist/components/forms/ReportDisclosure.js +10 -6
- package/dist/components/forms/fieldStyles.d.ts +5 -6
- package/dist/components/forms/fieldStyles.js +6 -7
- package/dist/components/osu/BeatmapStats.d.ts +1 -1
- package/dist/components/osu/BeatmapStats.js +2 -1
- package/dist/components/osu/starColors.d.ts +14 -4
- package/dist/components/osu/starColors.js +39 -2
- package/dist/components/palette/CommandPalette.d.ts +21 -0
- package/dist/components/palette/CommandPalette.js +315 -0
- package/dist/components/palette/CommandPaletteButton.d.ts +21 -0
- package/dist/components/palette/CommandPaletteButton.js +26 -0
- package/dist/components/palette/PaletteFooter.d.ts +21 -0
- package/dist/components/palette/PaletteFooter.js +10 -0
- package/dist/components/palette/PaletteInput.d.ts +30 -0
- package/dist/components/palette/PaletteInput.js +23 -0
- package/dist/components/palette/PaletteList.d.ts +29 -0
- package/dist/components/palette/PaletteList.js +44 -0
- package/dist/components/palette/PaletteRow.d.ts +30 -0
- package/dist/components/palette/PaletteRow.js +33 -0
- package/dist/components/palette/calc.d.ts +28 -0
- package/dist/components/palette/calc.js +226 -0
- package/dist/components/palette/fuzzy.d.ts +51 -0
- package/dist/components/palette/fuzzy.js +138 -0
- package/dist/components/palette/hotkeys.d.ts +57 -0
- package/dist/components/palette/hotkeys.js +111 -0
- package/dist/components/palette/paletteEvents.d.ts +22 -0
- package/dist/components/palette/paletteEvents.js +22 -0
- package/dist/components/palette/platform.d.ts +13 -0
- package/dist/components/palette/platform.js +21 -0
- package/dist/components/palette/recents.d.ts +48 -0
- package/dist/components/palette/recents.js +85 -0
- package/dist/components/palette/rows.d.ts +63 -0
- package/dist/components/palette/rows.js +136 -0
- package/dist/components/palette/siteCommands.d.ts +38 -0
- package/dist/components/palette/siteCommands.js +152 -0
- package/dist/components/palette/store.d.ts +94 -0
- package/dist/components/palette/store.js +135 -0
- package/dist/components/palette/types.d.ts +102 -0
- package/dist/components/palette/types.js +11 -0
- package/dist/components/palette/useProviderSearch.d.ts +28 -0
- package/dist/components/palette/useProviderSearch.js +68 -0
- package/dist/components/shell/HeaderMenu.d.ts +1 -1
- package/dist/components/shell/HeaderMenu.js +2 -2
- package/dist/components/shell/NavItem.d.ts +1 -1
- package/dist/components/shell/NavItem.js +2 -2
- package/dist/components/shell/SiteFooter.d.ts +9 -4
- package/dist/components/shell/SiteFooter.js +4 -2
- package/dist/components/tables/Table.d.ts +5 -3
- package/dist/components/tables/Table.js +15 -2
- package/dist/index.d.ts +8 -1
- package/dist/index.js +8 -1
- package/dist/theme.css +4 -4
- package/package.json +8 -3
|
@@ -0,0 +1,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;
|
|
@@ -0,0 +1,226 @@
|
|
|
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
|
+
const CONSTANTS = { pi: Math.PI, e: Math.E };
|
|
12
|
+
const FUNCTIONS = {
|
|
13
|
+
sqrt: ([x]) => (x === undefined ? undefined : Math.sqrt(x)),
|
|
14
|
+
abs: ([x]) => (x === undefined ? undefined : Math.abs(x)),
|
|
15
|
+
round: ([x]) => (x === undefined ? undefined : Math.round(x)),
|
|
16
|
+
floor: ([x]) => (x === undefined ? undefined : Math.floor(x)),
|
|
17
|
+
ceil: ([x]) => (x === undefined ? undefined : Math.ceil(x)),
|
|
18
|
+
min: (args) => (args.length === 0 ? undefined : Math.min(...args)),
|
|
19
|
+
max: (args) => (args.length === 0 ? undefined : Math.max(...args)),
|
|
20
|
+
};
|
|
21
|
+
const SUFFIX = { k: 1e3, m: 1e6 };
|
|
22
|
+
const NUMBER = /^(\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?([km])?/i;
|
|
23
|
+
const IDENT = /^[a-z]+/i;
|
|
24
|
+
const tokenize = (expression) => {
|
|
25
|
+
const tokens = [];
|
|
26
|
+
let rest = expression.trim();
|
|
27
|
+
while (rest.length > 0) {
|
|
28
|
+
const space = /^\s+/.exec(rest);
|
|
29
|
+
if (space) {
|
|
30
|
+
rest = rest.slice(space[0].length);
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
const num = NUMBER.exec(rest);
|
|
34
|
+
const ident = num ? null : IDENT.exec(rest);
|
|
35
|
+
if (num) {
|
|
36
|
+
const value = Number(num[0].replace(/[km]$/i, "")) * (SUFFIX[(num[2] ?? "").toLowerCase()] ?? 1);
|
|
37
|
+
tokens.push({ kind: "num", value });
|
|
38
|
+
rest = rest.slice(num[0].length);
|
|
39
|
+
}
|
|
40
|
+
else if (ident) {
|
|
41
|
+
const name = ident[0].toLowerCase();
|
|
42
|
+
rest = rest.slice(ident[0].length);
|
|
43
|
+
if (name in CONSTANTS)
|
|
44
|
+
tokens.push({ kind: "num", value: CONSTANTS[name] });
|
|
45
|
+
else if (name in FUNCTIONS)
|
|
46
|
+
tokens.push({ kind: "fn", value: name });
|
|
47
|
+
else
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
const ch = rest[0];
|
|
52
|
+
rest = rest.slice(1);
|
|
53
|
+
if (ch === "(" || ch === ")" || ch === ",")
|
|
54
|
+
tokens.push({ kind: ch });
|
|
55
|
+
else if ("+-*/%^".includes(ch)) {
|
|
56
|
+
const previous = tokens[tokens.length - 1];
|
|
57
|
+
const unary = ch === "-" &&
|
|
58
|
+
(!previous || previous.kind === "op" || previous.kind === "(" || previous.kind === ",");
|
|
59
|
+
tokens.push({
|
|
60
|
+
kind: "op",
|
|
61
|
+
value: unary ? "neg" : ch,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
else
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return tokens;
|
|
69
|
+
};
|
|
70
|
+
const PRECEDENCE = {
|
|
71
|
+
"+": 1,
|
|
72
|
+
"-": 1,
|
|
73
|
+
"*": 2,
|
|
74
|
+
"/": 2,
|
|
75
|
+
"%": 2,
|
|
76
|
+
"^": 4,
|
|
77
|
+
neg: 3,
|
|
78
|
+
};
|
|
79
|
+
const RIGHT = new Set(["^", "neg"]);
|
|
80
|
+
const apply = (op, values) => {
|
|
81
|
+
if (op === "neg") {
|
|
82
|
+
const x = values.pop();
|
|
83
|
+
if (x === undefined)
|
|
84
|
+
return false;
|
|
85
|
+
values.push(-x);
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
const b = values.pop();
|
|
89
|
+
const a = values.pop();
|
|
90
|
+
if (a === undefined || b === undefined)
|
|
91
|
+
return false;
|
|
92
|
+
const result = op === "+"
|
|
93
|
+
? a + b
|
|
94
|
+
: op === "-"
|
|
95
|
+
? a - b
|
|
96
|
+
: op === "*"
|
|
97
|
+
? a * b
|
|
98
|
+
: op === "/"
|
|
99
|
+
? a / b
|
|
100
|
+
: op === "%"
|
|
101
|
+
? a % b
|
|
102
|
+
: a ** b;
|
|
103
|
+
values.push(result);
|
|
104
|
+
return true;
|
|
105
|
+
};
|
|
106
|
+
/**
|
|
107
|
+
* @function evaluate
|
|
108
|
+
* @param expression {string} what was typed, e.g. "1.5k * (2 + sqrt(9))"
|
|
109
|
+
* @returns {number | null} the value, or null when it doesn't parse or isn't finite
|
|
110
|
+
*/
|
|
111
|
+
export function evaluate(expression) {
|
|
112
|
+
const tokens = tokenize(expression);
|
|
113
|
+
if (!tokens || tokens.length === 0)
|
|
114
|
+
return null;
|
|
115
|
+
const values = [];
|
|
116
|
+
const ops = [];
|
|
117
|
+
const argc = [];
|
|
118
|
+
let expectValue = true;
|
|
119
|
+
const popOps = (until) => {
|
|
120
|
+
while (ops.length > 0) {
|
|
121
|
+
const top = ops[ops.length - 1];
|
|
122
|
+
if (until(top))
|
|
123
|
+
return true;
|
|
124
|
+
ops.pop();
|
|
125
|
+
if (top.kind !== "op" || !apply(top.value, values))
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
return false;
|
|
129
|
+
};
|
|
130
|
+
for (const [i, token] of tokens.entries()) {
|
|
131
|
+
const next = tokens[i + 1];
|
|
132
|
+
if (token.kind === "num") {
|
|
133
|
+
if (!expectValue)
|
|
134
|
+
return null;
|
|
135
|
+
values.push(token.value);
|
|
136
|
+
expectValue = false;
|
|
137
|
+
}
|
|
138
|
+
else if (token.kind === "fn") {
|
|
139
|
+
if (!expectValue || next?.kind !== "(")
|
|
140
|
+
return null;
|
|
141
|
+
ops.push({ kind: "(", fn: token.value });
|
|
142
|
+
argc.push(next === undefined ? 0 : tokens[i + 2]?.kind === ")" ? 0 : 1);
|
|
143
|
+
tokens.splice(i + 1, 1);
|
|
144
|
+
}
|
|
145
|
+
else if (token.kind === "(") {
|
|
146
|
+
if (!expectValue)
|
|
147
|
+
return null;
|
|
148
|
+
ops.push(token);
|
|
149
|
+
argc.push(0);
|
|
150
|
+
expectValue = true;
|
|
151
|
+
}
|
|
152
|
+
else if (token.kind === ",") {
|
|
153
|
+
if (expectValue)
|
|
154
|
+
return null;
|
|
155
|
+
if (!popOps((top) => top.kind === "("))
|
|
156
|
+
return null;
|
|
157
|
+
const top = ops[ops.length - 1];
|
|
158
|
+
if (top?.kind !== "(" || !top.fn)
|
|
159
|
+
return null;
|
|
160
|
+
argc[argc.length - 1] = (argc[argc.length - 1] ?? 0) + 1;
|
|
161
|
+
expectValue = true;
|
|
162
|
+
}
|
|
163
|
+
else if (token.kind === ")") {
|
|
164
|
+
if (expectValue && ops[ops.length - 1]?.kind !== "(")
|
|
165
|
+
return null;
|
|
166
|
+
if (!popOps((top) => top.kind === "("))
|
|
167
|
+
return null;
|
|
168
|
+
const open = ops.pop();
|
|
169
|
+
const count = argc.pop() ?? 0;
|
|
170
|
+
if (open?.kind === "(" && open.fn) {
|
|
171
|
+
const n = expectValue ? count : count;
|
|
172
|
+
const args = values.splice(values.length - n, n);
|
|
173
|
+
if (args.length !== n)
|
|
174
|
+
return null;
|
|
175
|
+
const result = FUNCTIONS[open.fn]?.(args);
|
|
176
|
+
if (result === undefined)
|
|
177
|
+
return null;
|
|
178
|
+
values.push(result);
|
|
179
|
+
}
|
|
180
|
+
else if (expectValue)
|
|
181
|
+
return null;
|
|
182
|
+
expectValue = false;
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
const op = token.value;
|
|
186
|
+
if (op === "neg") {
|
|
187
|
+
if (!expectValue)
|
|
188
|
+
return null;
|
|
189
|
+
ops.push(token);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (expectValue)
|
|
193
|
+
return null;
|
|
194
|
+
const p = PRECEDENCE[op];
|
|
195
|
+
popOps((top) => {
|
|
196
|
+
if (top.kind !== "op")
|
|
197
|
+
return true;
|
|
198
|
+
const topP = PRECEDENCE[top.value];
|
|
199
|
+
return topP < p || (topP === p && RIGHT.has(op));
|
|
200
|
+
});
|
|
201
|
+
ops.push(token);
|
|
202
|
+
expectValue = true;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (expectValue)
|
|
206
|
+
return null;
|
|
207
|
+
if (popOps((top) => top.kind === "("))
|
|
208
|
+
return null;
|
|
209
|
+
const result = values.length === 1 ? values[0] : undefined;
|
|
210
|
+
return result !== undefined && Number.isFinite(result) ? result : null;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* @function formatResult
|
|
214
|
+
* @param value {number} a result
|
|
215
|
+
* @returns {string} up to 10 significant digits, trailing zeros dropped, "-0" as "0"
|
|
216
|
+
*/
|
|
217
|
+
export function formatResult(value) {
|
|
218
|
+
const text = String(Number(value.toPrecision(10)));
|
|
219
|
+
return text === "-0" ? "0" : text;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* @function isBareNumber
|
|
223
|
+
* @param expression {string} what was typed
|
|
224
|
+
* @returns {boolean} true for just a number ("2", " -2.5 "), when the palette shows no "= 2" row
|
|
225
|
+
*/
|
|
226
|
+
export const isBareNumber = (expression) => /^\s*-?(\d+\.?\d*|\.\d+)\s*$/.test(expression);
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/palette/fuzzy.ts
|
|
3
|
+
* @desc The palette's scorer, pure. A query matches a text when its characters appear in order
|
|
4
|
+
* (case-insensitive). Each matched character scores 10 at a word start, 8 when it follows
|
|
5
|
+
* the previous match, 4 otherwise, minus one per skipped character; a query that prefixes
|
|
6
|
+
* the text adds 20. A command's score is the best of its title, keywords (0.7), subtitle
|
|
7
|
+
* (0.5) and group (0.3). A cased letter or digit may only match at a word start or right
|
|
8
|
+
* after the previous match, so "cpu" lands on "Copy page URL"'s initials and "go" never
|
|
9
|
+
* finds "Sign out"; punctuation and uncased scripts (kanji, kana) match anywhere, since
|
|
10
|
+
* they have no word starts. Backtracking, left to right, memoized.
|
|
11
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
12
|
+
* @created Sat Oct 3, 2026
|
|
13
|
+
* @modified Sat Oct 3, 2026
|
|
14
|
+
*/
|
|
15
|
+
import type { Command } from "./types.js";
|
|
16
|
+
/** A match: its score and the matched [start, end) spans in the text, for highlighting. */
|
|
17
|
+
export type FuzzyMatch = {
|
|
18
|
+
score: number;
|
|
19
|
+
ranges: readonly (readonly [number, number])[];
|
|
20
|
+
};
|
|
21
|
+
/** A command with its score and the title's matched spans (empty when another field matched). */
|
|
22
|
+
export type Scored = {
|
|
23
|
+
command: Command;
|
|
24
|
+
score: number;
|
|
25
|
+
ranges: FuzzyMatch["ranges"];
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* @function fuzzyScore
|
|
29
|
+
* @param query {string} what was typed; surrounding spaces are ignored
|
|
30
|
+
* @param text {string} a title, keyword or heading
|
|
31
|
+
* @returns {FuzzyMatch | null} the score and matched spans, or null when the query isn't a
|
|
32
|
+
* subsequence of the text. An empty query scores 0 with no spans.
|
|
33
|
+
*/
|
|
34
|
+
export declare function fuzzyScore(query: string, text: string): FuzzyMatch | null;
|
|
35
|
+
/**
|
|
36
|
+
* @function scoreCommand
|
|
37
|
+
* @param query {string} what was typed
|
|
38
|
+
* @param command {Command} a row
|
|
39
|
+
* @returns {Scored | null} the best weighted field match, with title spans only when the title
|
|
40
|
+
* itself matched; null when no field matches
|
|
41
|
+
*/
|
|
42
|
+
export declare function scoreCommand(query: string, command: Command): Scored | null;
|
|
43
|
+
/**
|
|
44
|
+
* @function rankResults
|
|
45
|
+
* @param query {string} what was typed
|
|
46
|
+
* @param commands {readonly Command[]} the rows to search
|
|
47
|
+
* @param boost {(id: string) => number} a tie-breaker per id (recents count)
|
|
48
|
+
* @returns {Scored[]} the matching rows, best first; ties go to the higher boost, then to
|
|
49
|
+
* declaration order
|
|
50
|
+
*/
|
|
51
|
+
export declare function rankResults(query: string, commands: readonly Command[], boost: (id: string) => number): Scored[];
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/palette/fuzzy.ts
|
|
3
|
+
* @desc The palette's scorer, pure. A query matches a text when its characters appear in order
|
|
4
|
+
* (case-insensitive). Each matched character scores 10 at a word start, 8 when it follows
|
|
5
|
+
* the previous match, 4 otherwise, minus one per skipped character; a query that prefixes
|
|
6
|
+
* the text adds 20. A command's score is the best of its title, keywords (0.7), subtitle
|
|
7
|
+
* (0.5) and group (0.3). A cased letter or digit may only match at a word start or right
|
|
8
|
+
* after the previous match, so "cpu" lands on "Copy page URL"'s initials and "go" never
|
|
9
|
+
* finds "Sign out"; punctuation and uncased scripts (kanji, kana) match anywhere, since
|
|
10
|
+
* they have no word starts. Backtracking, left to right, memoized.
|
|
11
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
12
|
+
* @created Sat Oct 3, 2026
|
|
13
|
+
* @modified Sat Oct 3, 2026
|
|
14
|
+
*/
|
|
15
|
+
const WORD_BREAK = /[\s\-_/.]/;
|
|
16
|
+
const isWordStart = (text, at) => {
|
|
17
|
+
if (at === 0)
|
|
18
|
+
return true;
|
|
19
|
+
const before = text[at - 1] ?? "";
|
|
20
|
+
const here = text[at] ?? "";
|
|
21
|
+
if (WORD_BREAK.test(before))
|
|
22
|
+
return true;
|
|
23
|
+
return before === before.toLowerCase() && here !== here.toLowerCase();
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* A character the word-start rule applies to: a digit or a letter with case. Scripts without
|
|
27
|
+
* case (kanji, kana, hangul) have no spaces to start words at, so they may match anywhere.
|
|
28
|
+
*/
|
|
29
|
+
const isStrict = (ch) => /\p{N}/u.test(ch) || (/\p{L}/u.test(ch) && ch.toLowerCase() !== ch.toUpperCase());
|
|
30
|
+
/**
|
|
31
|
+
* The positions where the query's characters match, found left to right with backtracking,
|
|
32
|
+
* memoized on (index, from, consecutive) so a repetitive query stays polynomial. A cased letter
|
|
33
|
+
* or a digit may only match at a word start or right after the previous match (so "go" never
|
|
34
|
+
* lands on the g inside "Sign out"); punctuation and uncased letters may match anywhere. Null
|
|
35
|
+
* when the query can't be placed.
|
|
36
|
+
*/
|
|
37
|
+
const place = (chars, i, from, prev, text, t, memo) => {
|
|
38
|
+
const ch = chars[i];
|
|
39
|
+
if (ch === undefined)
|
|
40
|
+
return [];
|
|
41
|
+
const key = `${i}:${from}:${prev === from - 1 ? 1 : 0}`;
|
|
42
|
+
const seen = memo.get(key);
|
|
43
|
+
if (seen !== undefined)
|
|
44
|
+
return seen;
|
|
45
|
+
let found = null;
|
|
46
|
+
for (let at = t.indexOf(ch, from); at >= 0; at = t.indexOf(ch, at + 1)) {
|
|
47
|
+
const allowed = !isStrict(ch) || (prev >= 0 && at === prev + 1) || isWordStart(text, at);
|
|
48
|
+
if (!allowed)
|
|
49
|
+
continue;
|
|
50
|
+
const rest = place(chars, i + 1, at + 1, at, text, t, memo);
|
|
51
|
+
if (rest) {
|
|
52
|
+
found = [at, ...rest];
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
memo.set(key, found);
|
|
57
|
+
return found;
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* @function fuzzyScore
|
|
61
|
+
* @param query {string} what was typed; surrounding spaces are ignored
|
|
62
|
+
* @param text {string} a title, keyword or heading
|
|
63
|
+
* @returns {FuzzyMatch | null} the score and matched spans, or null when the query isn't a
|
|
64
|
+
* subsequence of the text. An empty query scores 0 with no spans.
|
|
65
|
+
*/
|
|
66
|
+
export function fuzzyScore(query, text) {
|
|
67
|
+
const q = query.trim().toLowerCase();
|
|
68
|
+
if (q.length === 0)
|
|
69
|
+
return { score: 0, ranges: [] };
|
|
70
|
+
const t = text.toLowerCase();
|
|
71
|
+
const positions = place([...q], 0, 0, -1, text, t, new Map());
|
|
72
|
+
if (!positions)
|
|
73
|
+
return null;
|
|
74
|
+
let score = 0;
|
|
75
|
+
let prev = -1;
|
|
76
|
+
const ranges = [];
|
|
77
|
+
for (const at of positions) {
|
|
78
|
+
if (prev >= 0 && at === prev + 1)
|
|
79
|
+
score += 8;
|
|
80
|
+
else if (isWordStart(text, at))
|
|
81
|
+
score += 10;
|
|
82
|
+
else
|
|
83
|
+
score += 4;
|
|
84
|
+
if (prev >= 0)
|
|
85
|
+
score -= Math.max(0, at - prev - 1);
|
|
86
|
+
const last = ranges[ranges.length - 1];
|
|
87
|
+
if (last && last[1] === at)
|
|
88
|
+
last[1] = at + 1;
|
|
89
|
+
else
|
|
90
|
+
ranges.push([at, at + 1]);
|
|
91
|
+
prev = at;
|
|
92
|
+
}
|
|
93
|
+
if (t.startsWith(q))
|
|
94
|
+
score += 20;
|
|
95
|
+
return { score, ranges };
|
|
96
|
+
}
|
|
97
|
+
const WEIGHTS = { keyword: 0.7, subtitle: 0.5, group: 0.3 };
|
|
98
|
+
/**
|
|
99
|
+
* @function scoreCommand
|
|
100
|
+
* @param query {string} what was typed
|
|
101
|
+
* @param command {Command} a row
|
|
102
|
+
* @returns {Scored | null} the best weighted field match, with title spans only when the title
|
|
103
|
+
* itself matched; null when no field matches
|
|
104
|
+
*/
|
|
105
|
+
export function scoreCommand(query, command) {
|
|
106
|
+
const title = fuzzyScore(query, command.title);
|
|
107
|
+
let best = title ? { score: title.score, ranges: title.ranges } : null;
|
|
108
|
+
const consider = (text, weight) => {
|
|
109
|
+
if (!text)
|
|
110
|
+
return;
|
|
111
|
+
const match = fuzzyScore(query, text);
|
|
112
|
+
if (match && (!best || match.score * weight > best.score)) {
|
|
113
|
+
best = { score: match.score * weight, ranges: [] };
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
for (const keyword of command.keywords ?? [])
|
|
117
|
+
consider(keyword, WEIGHTS.keyword);
|
|
118
|
+
consider(command.subtitle, WEIGHTS.subtitle);
|
|
119
|
+
consider(command.group, WEIGHTS.group);
|
|
120
|
+
return best ? { command, ...best } : null;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* @function rankResults
|
|
124
|
+
* @param query {string} what was typed
|
|
125
|
+
* @param commands {readonly Command[]} the rows to search
|
|
126
|
+
* @param boost {(id: string) => number} a tie-breaker per id (recents count)
|
|
127
|
+
* @returns {Scored[]} the matching rows, best first; ties go to the higher boost, then to
|
|
128
|
+
* declaration order
|
|
129
|
+
*/
|
|
130
|
+
export function rankResults(query, commands, boost) {
|
|
131
|
+
return commands
|
|
132
|
+
.map((command, index) => ({ scored: scoreCommand(query, command), index }))
|
|
133
|
+
.filter((entry) => entry.scored !== null)
|
|
134
|
+
.sort((a, b) => b.scored.score - a.scored.score ||
|
|
135
|
+
boost(b.scored.command.id) - boost(a.scored.command.id) ||
|
|
136
|
+
a.index - b.index)
|
|
137
|
+
.map((entry) => entry.scored);
|
|
138
|
+
}
|