@stridge/noctis 1.0.0-beta.5 → 1.0.0-beta.6
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/dist/components/breadcrumb/breadcrumb.d.ts +163 -0
- package/dist/components/breadcrumb/breadcrumb.js +152 -0
- package/dist/components/breadcrumb/breadcrumb.props.d.ts +59 -0
- package/dist/components/breadcrumb/breadcrumb.props.js +68 -0
- package/dist/components/breadcrumb/breadcrumb.slots.d.ts +16 -0
- package/dist/components/breadcrumb/breadcrumb.slots.js +32 -0
- package/dist/components/breadcrumb/breadcrumb.types.d.ts +9 -0
- package/dist/components/breadcrumb/index.d.ts +3 -0
- package/dist/components/command/command-listbox.js +174 -0
- package/dist/components/command/command-rank.d.ts +40 -0
- package/dist/components/command/command-rank.js +61 -0
- package/dist/components/command/command-score.d.ts +25 -0
- package/dist/components/command/command-score.js +85 -0
- package/dist/components/command/command.context.d.ts +17 -0
- package/dist/components/command/command.context.js +13 -0
- package/dist/components/command/command.d.ts +396 -0
- package/dist/components/command/command.js +471 -0
- package/dist/components/command/command.props.d.ts +91 -0
- package/dist/components/command/command.props.js +94 -0
- package/dist/components/command/command.slots.d.ts +23 -0
- package/dist/components/command/command.slots.js +60 -0
- package/dist/components/command/index.d.ts +6 -0
- package/dist/components/command/use-command-ranking.d.ts +37 -0
- package/dist/components/command/use-command-ranking.js +127 -0
- package/dist/components/search-dialog/parts/root.js +1 -1
- package/dist/components/skeleton/index.d.ts +3 -0
- package/dist/components/skeleton/skeleton.context.js +12 -0
- package/dist/components/skeleton/skeleton.d.ts +157 -0
- package/dist/components/skeleton/skeleton.js +130 -0
- package/dist/components/skeleton/skeleton.props.d.ts +47 -0
- package/dist/components/skeleton/skeleton.props.js +57 -0
- package/dist/components/skeleton/skeleton.slots.d.ts +15 -0
- package/dist/components/skeleton/skeleton.slots.js +28 -0
- package/dist/components/skeleton/skeleton.types.d.ts +13 -0
- package/dist/components/surface/surface.d.ts +1 -1
- package/dist/index.d.ts +15 -3
- package/dist/index.js +13 -4
- package/dist/primitives/index.d.ts +1 -1
- package/dist/primitives/index.js +2 -2
- package/dist/props.d.ts +37 -34
- package/dist/props.js +37 -34
- package/dist/styles.css +715 -0
- package/package.json +4 -4
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { createContext, use, useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
|
|
3
|
+
//#region src/components/command/command-listbox.tsx
|
|
4
|
+
/**
|
|
5
|
+
* The command palette's own combobox/listbox engine. Rather than delegate the keyboard model to a
|
|
6
|
+
* generic autocomplete primitive (and then fight it for command-bar details), Noctis owns it directly:
|
|
7
|
+
* the top result auto-highlights on every keystroke, arrow navigation has a smart key-repeat loop
|
|
8
|
+
* (holding a key stops at the end; a fresh press wraps), and Enter activates the highlighted row. Focus
|
|
9
|
+
* stays on the input throughout — a virtual cursor (`aria-activedescendant`) moves the active row, the
|
|
10
|
+
* WAI-ARIA editable-combobox pattern.
|
|
11
|
+
*/
|
|
12
|
+
/** Enabled command rows, in DOM (render) order — the unit the navigation and auto-highlight act on. */
|
|
13
|
+
const OPTION_SELECTOR = "[data-slot=\"noctis-command-item\"]:not([aria-disabled=\"true\"])";
|
|
14
|
+
/**
|
|
15
|
+
* Resolve a keydown to a navigation intent, matching the reference's bindings:
|
|
16
|
+
* ↓/↑ step; ⌘/Alt+↓/↑ jump to the ends; Home/End to the ends; PageDown/PageUp by a page; Ctrl-N/J down and
|
|
17
|
+
* Ctrl-P/K up (emacs/vim); Enter activates. Returns `null` for anything else so typing falls through.
|
|
18
|
+
*/
|
|
19
|
+
function navIntentFor(event) {
|
|
20
|
+
const jump = event.metaKey || event.altKey;
|
|
21
|
+
switch (event.key) {
|
|
22
|
+
case "Enter": return "activate";
|
|
23
|
+
case "ArrowDown": return jump ? "last" : "down";
|
|
24
|
+
case "ArrowUp": return jump ? "first" : "up";
|
|
25
|
+
case "Home": return "first";
|
|
26
|
+
case "End": return "last";
|
|
27
|
+
case "PageDown": return "pageDown";
|
|
28
|
+
case "PageUp": return "pageUp";
|
|
29
|
+
default: break;
|
|
30
|
+
}
|
|
31
|
+
if (event.ctrlKey && !event.metaKey && !event.altKey) {
|
|
32
|
+
const key = event.key.toLowerCase();
|
|
33
|
+
if (key === "n" || key === "j") return "down";
|
|
34
|
+
if (key === "p" || key === "k") return "up";
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
const CommandListboxContext = createContext(null);
|
|
39
|
+
/** Read the listbox context, throwing a named error when a part renders outside the palette. */
|
|
40
|
+
function useCommandListbox(part) {
|
|
41
|
+
const context = use(CommandListboxContext);
|
|
42
|
+
if (context === null) throw new Error(`Command.${part} must be rendered inside <Command.Root> or <Command.Dialog>.`);
|
|
43
|
+
return context;
|
|
44
|
+
}
|
|
45
|
+
const CommandListboxProvider = CommandListboxContext.Provider;
|
|
46
|
+
/** A row's stable id plus whether it is the active (highlighted) row and a setter to claim the highlight. */
|
|
47
|
+
function useCommandOption() {
|
|
48
|
+
const id = useId();
|
|
49
|
+
const { activeId, setActiveId } = useCommandListbox("Item");
|
|
50
|
+
const setActive = useCallback(() => setActiveId(id), [id, setActiveId]);
|
|
51
|
+
return {
|
|
52
|
+
id,
|
|
53
|
+
active: id === activeId,
|
|
54
|
+
setActive
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/** Lets a `Command.GroupLabel` lend its id to its `Command.Group`'s `aria-labelledby` (only when present). */
|
|
58
|
+
/** A `Command.GroupLabel` rendered outside any group registers into this no-op, so the call is always safe. */
|
|
59
|
+
const noopRegisterGroupLabel = () => {};
|
|
60
|
+
const CommandGroupContext = createContext(noopRegisterGroupLabel);
|
|
61
|
+
const CommandGroupProvider = CommandGroupContext.Provider;
|
|
62
|
+
/** Register a group label's id with its enclosing group, so the group is `aria-labelledby` it while mounted. */
|
|
63
|
+
function useRegisterGroupLabel(id) {
|
|
64
|
+
const register = use(CommandGroupContext);
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
register(id);
|
|
67
|
+
return () => register(void 0);
|
|
68
|
+
}, [id, register]);
|
|
69
|
+
}
|
|
70
|
+
/** The group's own state: the id of its label (if a `Command.GroupLabel` is rendered) for `aria-labelledby`. */
|
|
71
|
+
function useCommandGroup() {
|
|
72
|
+
const [labelId, setLabelId] = useState(void 0);
|
|
73
|
+
return {
|
|
74
|
+
labelId,
|
|
75
|
+
setLabelId
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/** Owns the active-row state, the auto-highlight, and the keyboard navigation that the input delegates to. */
|
|
79
|
+
function useCommandListboxState({ query, setQuery, loop, viewKey }) {
|
|
80
|
+
const listId = useId();
|
|
81
|
+
const listRef = useRef(null);
|
|
82
|
+
const [activeId, setActiveIdState] = useState(void 0);
|
|
83
|
+
const activeIdRef = useRef(void 0);
|
|
84
|
+
const setActiveId = useCallback((id) => {
|
|
85
|
+
activeIdRef.current = id;
|
|
86
|
+
setActiveIdState(id);
|
|
87
|
+
}, []);
|
|
88
|
+
const options = useCallback(() => listRef.current ? Array.from(listRef.current.querySelectorAll(OPTION_SELECTOR)) : [], []);
|
|
89
|
+
const prevViewRef = useRef(null);
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
const list = listRef.current;
|
|
92
|
+
if (!list) return;
|
|
93
|
+
const opts = Array.from(list.querySelectorAll(OPTION_SELECTOR));
|
|
94
|
+
const viewChanged = prevViewRef.current !== viewKey;
|
|
95
|
+
prevViewRef.current = viewKey;
|
|
96
|
+
if (opts.length === 0) {
|
|
97
|
+
if (activeId !== void 0) setActiveId(void 0);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const stillValid = activeId !== void 0 && opts.some((option) => option.id === activeId);
|
|
101
|
+
if (viewChanged || !stillValid) {
|
|
102
|
+
setActiveId(opts[0].id);
|
|
103
|
+
if (viewChanged) list.scrollTop = 0;
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
const onInputKeyDown = useCallback((event) => {
|
|
107
|
+
const intent = navIntentFor(event);
|
|
108
|
+
if (intent === null) return;
|
|
109
|
+
const opts = options();
|
|
110
|
+
if (opts.length === 0) return;
|
|
111
|
+
const current = opts.findIndex((option) => option.id === activeIdRef.current);
|
|
112
|
+
const last = opts.length - 1;
|
|
113
|
+
if (intent === "activate") {
|
|
114
|
+
event.preventDefault();
|
|
115
|
+
/* v8 ignore next -- the guard's false branch (Enter with nothing highlighted) is unreachable */
|
|
116
|
+
if (current >= 0) opts[current].click();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const list = listRef.current;
|
|
120
|
+
/* v8 ignore next -- jsdom reports offsetHeight 0, so only the `|| 1` guard runs (real rows measure > 0) */
|
|
121
|
+
const rowHeight = opts[0].offsetHeight || 1;
|
|
122
|
+
const page = Math.max(1, Math.floor(list.clientHeight / rowHeight) - 1);
|
|
123
|
+
const from = Math.max(current, 0);
|
|
124
|
+
let next;
|
|
125
|
+
switch (intent) {
|
|
126
|
+
case "down":
|
|
127
|
+
next = current >= last ? loop && !event.repeat ? 0 : last : from + 1;
|
|
128
|
+
break;
|
|
129
|
+
case "up":
|
|
130
|
+
next = current <= 0 ? loop && !event.repeat ? last : 0 : current - 1;
|
|
131
|
+
break;
|
|
132
|
+
case "first":
|
|
133
|
+
next = 0;
|
|
134
|
+
break;
|
|
135
|
+
case "last":
|
|
136
|
+
next = last;
|
|
137
|
+
break;
|
|
138
|
+
case "pageDown":
|
|
139
|
+
next = Math.min(from + page, last);
|
|
140
|
+
break;
|
|
141
|
+
default:
|
|
142
|
+
next = Math.max(from - page, 0);
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
event.preventDefault();
|
|
146
|
+
const target = opts[next];
|
|
147
|
+
setActiveId(target.id);
|
|
148
|
+
if (next === 0) list.scrollTop = 0;
|
|
149
|
+
else if (next === last) list.scrollTop = list.scrollHeight;
|
|
150
|
+
else target.scrollIntoView({ block: "nearest" });
|
|
151
|
+
}, [
|
|
152
|
+
loop,
|
|
153
|
+
options,
|
|
154
|
+
setActiveId
|
|
155
|
+
]);
|
|
156
|
+
return useMemo(() => ({
|
|
157
|
+
query,
|
|
158
|
+
setQuery,
|
|
159
|
+
listId,
|
|
160
|
+
activeId,
|
|
161
|
+
listRef,
|
|
162
|
+
setActiveId,
|
|
163
|
+
onInputKeyDown
|
|
164
|
+
}), [
|
|
165
|
+
query,
|
|
166
|
+
setQuery,
|
|
167
|
+
listId,
|
|
168
|
+
activeId,
|
|
169
|
+
setActiveId,
|
|
170
|
+
onInputKeyDown
|
|
171
|
+
]);
|
|
172
|
+
}
|
|
173
|
+
//#endregion
|
|
174
|
+
export { CommandGroupProvider, CommandListboxProvider, useCommandGroup, useCommandListbox, useCommandListboxState, useCommandOption, useRegisterGroupLabel };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
//#region src/components/command/command-rank.d.ts
|
|
2
|
+
/** A candidate the ranker can score. Carry whatever else you need on the same object; only these are read. */
|
|
3
|
+
interface RankableItem {
|
|
4
|
+
/** A stable, unique value/id for the item (used as the React key and the selection value). */
|
|
5
|
+
value: string;
|
|
6
|
+
/** The text the query is scored against (the row's label). */
|
|
7
|
+
label: string;
|
|
8
|
+
/** Hidden aliases the row should also answer to (e.g. `["new", "add"]`), folded into the score. */
|
|
9
|
+
keywords?: readonly string[];
|
|
10
|
+
/**
|
|
11
|
+
* A `[0, 1]` usage/recency boost blended into the textual score (frecency). The consumer owns and
|
|
12
|
+
* persists this; the ranker only reads it. A boost only re-orders items that already match — it
|
|
13
|
+
* never resurrects a non-matching row.
|
|
14
|
+
*/
|
|
15
|
+
boost?: number;
|
|
16
|
+
}
|
|
17
|
+
/** Options for {@link rankItems}. */
|
|
18
|
+
interface RankOptions<T extends RankableItem> {
|
|
19
|
+
/**
|
|
20
|
+
* Items whose textual score is at or below this are dropped. `0` keeps any subsequence match.
|
|
21
|
+
* @default 0
|
|
22
|
+
*/
|
|
23
|
+
threshold?: number;
|
|
24
|
+
/**
|
|
25
|
+
* Override the textual scorer. Receives the item and the query, returns `[0, 1]`. Defaults to
|
|
26
|
+
* {@link commandScore} over the label + keywords.
|
|
27
|
+
*/
|
|
28
|
+
score?: (item: T, query: string) => number;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Rank `items` against `query`: score each (blending its `boost`), drop everything at or below
|
|
32
|
+
* `threshold`, and return the survivors sorted strongest-first. An empty/whitespace query returns the
|
|
33
|
+
* items unchanged (the caller's own order), so the palette shows its full list at rest.
|
|
34
|
+
*
|
|
35
|
+
* The sort is stable, so equally-scored items keep their input order — let the caller pre-order by
|
|
36
|
+
* section/priority and ties resolve predictably.
|
|
37
|
+
*/
|
|
38
|
+
declare function rankItems<T extends RankableItem>(items: readonly T[], query: string, options?: RankOptions<T>): T[];
|
|
39
|
+
//#endregion
|
|
40
|
+
export { RankOptions, RankableItem, rankItems };
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { commandScore } from "./command-score.js";
|
|
2
|
+
//#region src/components/command/command-rank.ts
|
|
3
|
+
/**
|
|
4
|
+
* Ranking layer over {@link commandScore}: turn a flat list of candidates and a query into the ordered,
|
|
5
|
+
* filtered list a palette renders. This is the seam where the consumer's frecency (a per-item usage
|
|
6
|
+
* `boost`) is blended into the textual score, and where a custom scorer can be dropped in. Pure and
|
|
7
|
+
* synchronous — the default path used everywhere; the Worker offload (`useCommandRanking`) wraps this
|
|
8
|
+
* same function for large sets.
|
|
9
|
+
*/
|
|
10
|
+
const defaultScore = (item, query) => commandScore(item.label, query, item.keywords);
|
|
11
|
+
/**
|
|
12
|
+
* Rank `items` against `query`: score each (blending its `boost`), drop everything at or below
|
|
13
|
+
* `threshold`, and return the survivors sorted strongest-first. An empty/whitespace query returns the
|
|
14
|
+
* items unchanged (the caller's own order), so the palette shows its full list at rest.
|
|
15
|
+
*
|
|
16
|
+
* The sort is stable, so equally-scored items keep their input order — let the caller pre-order by
|
|
17
|
+
* section/priority and ties resolve predictably.
|
|
18
|
+
*/
|
|
19
|
+
function rankItems(items, query, options = {}) {
|
|
20
|
+
const trimmed = query.trim();
|
|
21
|
+
if (trimmed === "") return items.slice();
|
|
22
|
+
const { threshold = 0, score = defaultScore } = options;
|
|
23
|
+
const scored = [];
|
|
24
|
+
items.forEach((item, order) => {
|
|
25
|
+
const base = score(item, trimmed);
|
|
26
|
+
if (base <= threshold) return;
|
|
27
|
+
const sortScore = base * (1 + (item.boost ?? 0));
|
|
28
|
+
scored.push({
|
|
29
|
+
item,
|
|
30
|
+
sortScore,
|
|
31
|
+
order
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
scored.sort((a, b) => b.sortScore - a.sortScore || a.order - b.order);
|
|
35
|
+
return scored.map((entry) => entry.item);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The default ranking reduced to just the surviving `value`s, strongest-first — the unit of work the
|
|
39
|
+
* Web Worker performs (it can't pass rich objects or a custom scorer across the boundary). Mirrors
|
|
40
|
+
* {@link rankItems} with the default scorer, and is **self-contained** (it references only
|
|
41
|
+
* {@link commandScore}) so it can be serialised into the Worker via `toString()`. The hook maps the
|
|
42
|
+
* returned values back to its full items.
|
|
43
|
+
*/
|
|
44
|
+
function rankValues(items, query, threshold = 0) {
|
|
45
|
+
const trimmed = query.trim();
|
|
46
|
+
if (trimmed === "") return items.map((item) => item.value);
|
|
47
|
+
const scored = [];
|
|
48
|
+
items.forEach((item, order) => {
|
|
49
|
+
const base = commandScore(item.label, trimmed, item.keywords);
|
|
50
|
+
if (base <= threshold) return;
|
|
51
|
+
scored.push({
|
|
52
|
+
value: item.value,
|
|
53
|
+
sortScore: base * (1 + (item.boost ?? 0)),
|
|
54
|
+
order
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
scored.sort((a, b) => b.sortScore - a.sortScore || a.order - b.order);
|
|
58
|
+
return scored.map((entry) => entry.value);
|
|
59
|
+
}
|
|
60
|
+
//#endregion
|
|
61
|
+
export { rankItems, rankValues };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
//#region src/components/command/command-score.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* A fuzzy subsequence scorer for command palettes — the ranking brain `Command` ships by default. It
|
|
4
|
+
* rewards the matches a person reading a palette expects to float to the top: a contiguous prefix beats
|
|
5
|
+
* a gapped one, a match at a word boundary (after a space, `-`, `_`, `/`, `.`, …) beats one mid-word,
|
|
6
|
+
* an exact-case hit edges out a case-fold, and a single transposed pair still scores. The result is a
|
|
7
|
+
* number in `[0, 1]` — `0` means "no subsequence match", `1` means "the query is the whole string".
|
|
8
|
+
*
|
|
9
|
+
* The algorithm is the well-known open command-palette scorer (the same family editor and command-bar
|
|
10
|
+
* UIs use): a memoized recursive search over every way the query can thread through the candidate,
|
|
11
|
+
* keeping the best-scoring threading. Authored clean-room from the documented algorithm; the constants
|
|
12
|
+
* are the canonical weights.
|
|
13
|
+
*
|
|
14
|
+
* The whole function is intentionally **self-contained** (every constant and the recursion live in its
|
|
15
|
+
* own scope, no outer-scope references) so it can be serialised verbatim into the ranking Web Worker via
|
|
16
|
+
* `Function.prototype.toString()` — the worker reuses this exact, tested implementation rather than a
|
|
17
|
+
* copy. Keep it free of module-level references for that reason.
|
|
18
|
+
*
|
|
19
|
+
* @param candidate The text shown on the row (its label).
|
|
20
|
+
* @param query The current input value.
|
|
21
|
+
* @param keywords Hidden aliases appended to the candidate before scoring (e.g. `["new", "add"]`).
|
|
22
|
+
*/
|
|
23
|
+
declare function commandScore(candidate: string, query: string, keywords?: readonly string[]): number;
|
|
24
|
+
//#endregion
|
|
25
|
+
export { commandScore };
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
//#region src/components/command/command-score.ts
|
|
2
|
+
/**
|
|
3
|
+
* A fuzzy subsequence scorer for command palettes — the ranking brain `Command` ships by default. It
|
|
4
|
+
* rewards the matches a person reading a palette expects to float to the top: a contiguous prefix beats
|
|
5
|
+
* a gapped one, a match at a word boundary (after a space, `-`, `_`, `/`, `.`, …) beats one mid-word,
|
|
6
|
+
* an exact-case hit edges out a case-fold, and a single transposed pair still scores. The result is a
|
|
7
|
+
* number in `[0, 1]` — `0` means "no subsequence match", `1` means "the query is the whole string".
|
|
8
|
+
*
|
|
9
|
+
* The algorithm is the well-known open command-palette scorer (the same family editor and command-bar
|
|
10
|
+
* UIs use): a memoized recursive search over every way the query can thread through the candidate,
|
|
11
|
+
* keeping the best-scoring threading. Authored clean-room from the documented algorithm; the constants
|
|
12
|
+
* are the canonical weights.
|
|
13
|
+
*
|
|
14
|
+
* The whole function is intentionally **self-contained** (every constant and the recursion live in its
|
|
15
|
+
* own scope, no outer-scope references) so it can be serialised verbatim into the ranking Web Worker via
|
|
16
|
+
* `Function.prototype.toString()` — the worker reuses this exact, tested implementation rather than a
|
|
17
|
+
* copy. Keep it free of module-level references for that reason.
|
|
18
|
+
*
|
|
19
|
+
* @param candidate The text shown on the row (its label).
|
|
20
|
+
* @param query The current input value.
|
|
21
|
+
* @param keywords Hidden aliases appended to the candidate before scoring (e.g. `["new", "add"]`).
|
|
22
|
+
*/
|
|
23
|
+
function commandScore(candidate, query, keywords) {
|
|
24
|
+
/** A contiguous continuation of the match — the strongest signal. */
|
|
25
|
+
const SCORE_CONTINUE_MATCH = 1;
|
|
26
|
+
/** Jumping to the start of a new space-delimited word. */
|
|
27
|
+
const SCORE_SPACE_WORD_JUMP = .9;
|
|
28
|
+
/** Jumping to the start of a new word delimited by a non-space separator (`-`, `_`, `/`, …). */
|
|
29
|
+
const SCORE_NON_SPACE_WORD_JUMP = .8;
|
|
30
|
+
/** Jumping to a character mid-word (the weakest jump). */
|
|
31
|
+
const SCORE_CHARACTER_JUMP = .17;
|
|
32
|
+
/** A transposed pair (e.g. "recieve" matching "receive"). */
|
|
33
|
+
const SCORE_TRANSPOSITION = .1;
|
|
34
|
+
/** Multiplicative penalty per skipped character, so tighter matches win. */
|
|
35
|
+
const PENALTY_SKIPPED = .999;
|
|
36
|
+
/** Penalty when the matched character's case differs from the query's. */
|
|
37
|
+
const PENALTY_CASE_MISMATCH = .9999;
|
|
38
|
+
/** Penalty when the query is exhausted but the candidate still has trailing characters. */
|
|
39
|
+
const PENALTY_NOT_COMPLETE = .99;
|
|
40
|
+
const IS_GAP = /[\\/_+.#"@[({&]/;
|
|
41
|
+
const COUNT_GAP = /[\\/_+.#"@[({&]/g;
|
|
42
|
+
const IS_SPACE = /[\s-]/;
|
|
43
|
+
const COUNT_SPACE = /[\s-]/g;
|
|
44
|
+
const fold = (value) => value.toLowerCase().replace(COUNT_SPACE, " ");
|
|
45
|
+
const scoreInner = (cand, q, lc, lq, ci, qi, memo) => {
|
|
46
|
+
if (qi === q.length) return ci === cand.length ? SCORE_CONTINUE_MATCH : PENALTY_NOT_COMPLETE;
|
|
47
|
+
const key = `${ci},${qi}`;
|
|
48
|
+
const cached = memo[key];
|
|
49
|
+
if (cached !== void 0) return cached;
|
|
50
|
+
const queryChar = lq.charAt(qi);
|
|
51
|
+
let index = lc.indexOf(queryChar, ci);
|
|
52
|
+
let high = 0;
|
|
53
|
+
while (index >= 0) {
|
|
54
|
+
let score = scoreInner(cand, q, lc, lq, index + 1, qi + 1, memo);
|
|
55
|
+
if (score > high) {
|
|
56
|
+
if (index === ci) score *= SCORE_CONTINUE_MATCH;
|
|
57
|
+
else if (IS_GAP.test(cand.charAt(index - 1))) {
|
|
58
|
+
score *= SCORE_NON_SPACE_WORD_JUMP;
|
|
59
|
+
const gaps = cand.slice(ci, index - 1).match(COUNT_GAP);
|
|
60
|
+
if (gaps && ci > 0) score *= PENALTY_SKIPPED ** gaps.length;
|
|
61
|
+
} else if (IS_SPACE.test(cand.charAt(index - 1))) {
|
|
62
|
+
score *= SCORE_SPACE_WORD_JUMP;
|
|
63
|
+
const spaces = cand.slice(ci, index - 1).match(COUNT_SPACE);
|
|
64
|
+
if (spaces && ci > 0) score *= PENALTY_SKIPPED ** spaces.length;
|
|
65
|
+
} else {
|
|
66
|
+
score *= SCORE_CHARACTER_JUMP;
|
|
67
|
+
if (ci > 0) score *= PENALTY_SKIPPED ** (index - ci);
|
|
68
|
+
}
|
|
69
|
+
if (cand.charAt(index) !== q.charAt(qi)) score *= PENALTY_CASE_MISMATCH;
|
|
70
|
+
}
|
|
71
|
+
if (score < SCORE_TRANSPOSITION && lc.charAt(index - 1) === lq.charAt(qi + 1) || lq.charAt(qi + 1) === lq.charAt(qi) && lc.charAt(index - 1) !== lq.charAt(qi)) {
|
|
72
|
+
const transposed = scoreInner(cand, q, lc, lq, index + 1, qi + 2, memo);
|
|
73
|
+
score = Math.max(score, transposed * SCORE_TRANSPOSITION);
|
|
74
|
+
}
|
|
75
|
+
if (score > high) high = score;
|
|
76
|
+
index = lc.indexOf(queryChar, index + 1);
|
|
77
|
+
}
|
|
78
|
+
memo[key] = high;
|
|
79
|
+
return high;
|
|
80
|
+
};
|
|
81
|
+
const haystack = keywords && keywords.length > 0 ? `${candidate} ${keywords.join(" ")}` : candidate;
|
|
82
|
+
return scoreInner(haystack, query, fold(haystack), fold(query), 0, 0, {});
|
|
83
|
+
}
|
|
84
|
+
//#endregion
|
|
85
|
+
export { commandScore };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
//#region src/components/command/command.context.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* One entry in the drill-in stack. Selecting a parent command pushes a page (the list swaps to its
|
|
6
|
+
* children, the query clears, a breadcrumb segment appears); Backspace on the empty query — or clicking
|
|
7
|
+
* an earlier segment — pops back. The consumer owns the stack (`pages` + `onPagesChange` on
|
|
8
|
+
* `Command.Root`) so it can load children lazily.
|
|
9
|
+
*/
|
|
10
|
+
interface CommandPage {
|
|
11
|
+
/** A stable id for the page (used as the React key and for navigation). */
|
|
12
|
+
id: string;
|
|
13
|
+
/** The breadcrumb label rendered for this page. */
|
|
14
|
+
label: ReactNode;
|
|
15
|
+
}
|
|
16
|
+
//#endregion
|
|
17
|
+
export { CommandPage };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { createContext, use } from "react";
|
|
3
|
+
//#region src/components/command/command.context.ts
|
|
4
|
+
const CommandContext = createContext(null);
|
|
5
|
+
const CommandProvider = CommandContext.Provider;
|
|
6
|
+
/** Read the palette context, throwing a named error when a part renders outside `Command.Root`. */
|
|
7
|
+
function useCommandContext(part) {
|
|
8
|
+
const context = use(CommandContext);
|
|
9
|
+
if (context === null) throw new Error(`Command.${part} must be rendered inside <Command.Root> or <Command.Dialog>.`);
|
|
10
|
+
return context;
|
|
11
|
+
}
|
|
12
|
+
//#endregion
|
|
13
|
+
export { CommandProvider, useCommandContext };
|