@kurajs/ctrlk 0.0.1 → 0.0.2
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/README.md +5 -1
- package/dist/core.js +61 -11
- package/dist/types.d.ts +18 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -71,7 +71,11 @@ Pass `renderItem` to fully control row markup, or `injectStyles: false` to ship
|
|
|
71
71
|
## API
|
|
72
72
|
|
|
73
73
|
- `createCtrlk(options) → Ctrlk` — headless controller. Options: `search` | `items` + `filter`,
|
|
74
|
-
`debounce`, `empty`, `
|
|
74
|
+
`debounce`, `empty`, `loop` (wrap arrow nav, default true), `shouldFilter` (static mode, default
|
|
75
|
+
true), `value` (initial highlight), `onSelect`, `onValueChange`, `onOpenChange`.
|
|
76
|
+
Controller methods: `open`/`close`/`toggle`, `setQuery`, `move`/`setActive`, `setValue` (by id),
|
|
77
|
+
`setItems` (swap the static pool), `select`, `subscribe`, `getState`, `destroy`. State exposes a
|
|
78
|
+
cmdk-style `value` (the active item id) alongside `activeIndex`.
|
|
75
79
|
- `mountCtrlk(ctrl, opts) → { destroy }` — default DOM. Opts: `trigger`, `hotkey`, `labels`,
|
|
76
80
|
`tokensOf`, `renderItem`, `injectStyles`, `platform`, `ariaLabel`, `target`.
|
|
77
81
|
- `platformHotkeyLabel()` → `"⌘K"` on macOS, `"Ctrl K"` elsewhere (for a trigger hint).
|
package/dist/core.js
CHANGED
|
@@ -44,11 +44,14 @@ function bucket(items) {
|
|
|
44
44
|
*/
|
|
45
45
|
export function createCtrlk(options = {}) {
|
|
46
46
|
const debounceMs = options.debounce ?? DEFAULT_DEBOUNCE;
|
|
47
|
+
const loop = options.loop ?? true; // wrap arrow nav around the ends (false → clamp)
|
|
47
48
|
const subs = new Set();
|
|
48
|
-
let
|
|
49
|
+
let staticItems = options.items ?? []; // the static-mode pool (swappable via setItems)
|
|
50
|
+
let state = { open: false, query: "", loading: false, items: [], groups: [], activeIndex: -1, value: null, error: null };
|
|
49
51
|
let timer = null;
|
|
50
52
|
let ac = null;
|
|
51
53
|
let runSeq = 0; // monotonic guard: only the most recent async run may apply its result
|
|
54
|
+
let pendingValue = options.value ?? null; // initial highlight, honored on first results
|
|
52
55
|
const emit = () => { const snap = state; for (const fn of [...subs])
|
|
53
56
|
fn(snap); };
|
|
54
57
|
const set = (patch) => { state = { ...state, ...patch }; emit(); };
|
|
@@ -56,24 +59,50 @@ export function createCtrlk(options = {}) {
|
|
|
56
59
|
clearTimeout(timer);
|
|
57
60
|
timer = null;
|
|
58
61
|
} };
|
|
59
|
-
|
|
62
|
+
// Set the highlighted row by index, deriving `value` (its id) and firing onValueChange on change.
|
|
63
|
+
const commitActive = (index) => {
|
|
64
|
+
const value = index >= 0 ? state.items[index]?.id ?? null : null;
|
|
65
|
+
const changed = value !== state.value;
|
|
66
|
+
set({ activeIndex: index, value });
|
|
67
|
+
if (changed)
|
|
68
|
+
options.onValueChange?.(value);
|
|
69
|
+
};
|
|
70
|
+
// Replace the result set. `resetActive` (query-driven runs) highlights the first row — except an
|
|
71
|
+
// initial `value` is honored once; otherwise the current highlight is kept if it survived (so a
|
|
72
|
+
// host-driven setItems doesn't yank the user's selection).
|
|
73
|
+
const applyItems = (items, resetActive) => {
|
|
74
|
+
const want = resetActive ? pendingValue : state.value ?? pendingValue;
|
|
75
|
+
pendingValue = null;
|
|
76
|
+
let index = want != null ? items.findIndex((it) => it.id === want) : -1;
|
|
77
|
+
if (index < 0)
|
|
78
|
+
index = items.length ? 0 : -1;
|
|
79
|
+
const value = index >= 0 ? items[index].id : null;
|
|
80
|
+
const changed = value !== state.value;
|
|
81
|
+
state = { ...state, items, groups: bucket(items), activeIndex: index, value, loading: false, error: null };
|
|
82
|
+
emit();
|
|
83
|
+
if (changed)
|
|
84
|
+
options.onValueChange?.(value);
|
|
85
|
+
};
|
|
60
86
|
const runStatic = (q) => {
|
|
61
|
-
|
|
87
|
+
if (options.shouldFilter === false) {
|
|
88
|
+
applyItems(staticItems.slice(), true);
|
|
89
|
+
return;
|
|
90
|
+
} // already ranked
|
|
62
91
|
if (!q.trim()) {
|
|
63
|
-
applyItems((options.empty ??
|
|
92
|
+
applyItems((options.empty ?? staticItems).slice(), true);
|
|
64
93
|
return;
|
|
65
94
|
}
|
|
66
95
|
const filter = options.filter ?? defaultFilter;
|
|
67
|
-
applyItems(
|
|
96
|
+
applyItems(staticItems.map((it) => ({ it, s: filter(it, q) }))
|
|
68
97
|
.filter((x) => x.s > 0)
|
|
69
98
|
.sort((a, b) => b.s - a.s)
|
|
70
|
-
.map((x) => x.it));
|
|
99
|
+
.map((x) => x.it), true);
|
|
71
100
|
};
|
|
72
101
|
const runAsync = async (q) => {
|
|
73
102
|
// Empty query never hits the network — show the configured suggestions (or nothing).
|
|
74
103
|
if (!q.trim()) {
|
|
75
104
|
ac?.abort();
|
|
76
|
-
applyItems((options.empty ?? []).slice());
|
|
105
|
+
applyItems((options.empty ?? []).slice(), true);
|
|
77
106
|
return;
|
|
78
107
|
}
|
|
79
108
|
const seq = ++runSeq;
|
|
@@ -84,7 +113,7 @@ export function createCtrlk(options = {}) {
|
|
|
84
113
|
try {
|
|
85
114
|
const items = await options.search(q, controller.signal);
|
|
86
115
|
if (seq === runSeq)
|
|
87
|
-
applyItems(items); // ignore out-of-order/stale resolves
|
|
116
|
+
applyItems(items, true); // ignore out-of-order/stale resolves
|
|
88
117
|
}
|
|
89
118
|
catch (err) {
|
|
90
119
|
if (seq === runSeq && !controller.signal.aborted)
|
|
@@ -105,6 +134,7 @@ export function createCtrlk(options = {}) {
|
|
|
105
134
|
};
|
|
106
135
|
const open = () => { if (!state.open) {
|
|
107
136
|
set({ open: true });
|
|
137
|
+
options.onOpenChange?.(true);
|
|
108
138
|
schedule(state.query);
|
|
109
139
|
} };
|
|
110
140
|
// Closing invalidates any in-flight async run (bump the seq) so a late resolve/reject can't
|
|
@@ -114,6 +144,7 @@ export function createCtrlk(options = {}) {
|
|
|
114
144
|
runSeq++;
|
|
115
145
|
ac?.abort();
|
|
116
146
|
set({ open: false, loading: false });
|
|
147
|
+
options.onOpenChange?.(false);
|
|
117
148
|
} };
|
|
118
149
|
return {
|
|
119
150
|
getState: () => state,
|
|
@@ -129,13 +160,32 @@ export function createCtrlk(options = {}) {
|
|
|
129
160
|
const n = state.items.length;
|
|
130
161
|
if (!n)
|
|
131
162
|
return;
|
|
132
|
-
|
|
133
|
-
|
|
163
|
+
let i;
|
|
164
|
+
if (state.activeIndex < 0)
|
|
165
|
+
i = delta > 0 ? 0 : n - 1;
|
|
166
|
+
else if (loop)
|
|
167
|
+
i = (state.activeIndex + delta + n) % n;
|
|
168
|
+
else
|
|
169
|
+
i = Math.max(0, Math.min(n - 1, state.activeIndex + delta));
|
|
170
|
+
commitActive(i);
|
|
134
171
|
},
|
|
135
172
|
setActive(index) {
|
|
136
173
|
const n = state.items.length;
|
|
137
174
|
if (n)
|
|
138
|
-
|
|
175
|
+
commitActive(Math.max(0, Math.min(n - 1, index)));
|
|
176
|
+
},
|
|
177
|
+
setValue(id) {
|
|
178
|
+
if (id == null) {
|
|
179
|
+
commitActive(-1);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const i = state.items.findIndex((it) => it.id === id);
|
|
183
|
+
if (i >= 0)
|
|
184
|
+
commitActive(i);
|
|
185
|
+
},
|
|
186
|
+
setItems(items) {
|
|
187
|
+
staticItems = items;
|
|
188
|
+
applyItems(items, false); // host-supplied (e.g. externally filtered) → keep the active row if present
|
|
139
189
|
},
|
|
140
190
|
select(item, ev) {
|
|
141
191
|
const it = item ?? state.items[state.activeIndex];
|
package/dist/types.d.ts
CHANGED
|
@@ -44,9 +44,20 @@ export interface CtrlkOptions<D = unknown> {
|
|
|
44
44
|
/** Rows to show when the query is empty (recent searches, suggestions). Default: none in
|
|
45
45
|
* async mode, the full pool in static mode. */
|
|
46
46
|
empty?: CtrlkItem<D>[];
|
|
47
|
+
/** Wrap arrow navigation around the list ends. Default true; set false to clamp at the ends. */
|
|
48
|
+
loop?: boolean;
|
|
49
|
+
/** Static mode: run the built-in filter/sort. Set false when `items` are already filtered and
|
|
50
|
+
* ranked (e.g. you filter externally and feed results via {@link Ctrlk.setItems}). Default true. */
|
|
51
|
+
shouldFilter?: boolean;
|
|
52
|
+
/** Initial highlighted item id. Observe changes via {@link onValueChange}. */
|
|
53
|
+
value?: string;
|
|
47
54
|
/** Invoked when a row is chosen (Enter or click). The default renderer additionally
|
|
48
55
|
* navigates `item.href` when present and the event has no opening modifier. */
|
|
49
56
|
onSelect?: (item: CtrlkItem<D>, ev?: CtrlkSelectEvent) => void;
|
|
57
|
+
/** Fires when the highlighted item changes — its id, or null when the list is empty. */
|
|
58
|
+
onValueChange?: (value: string | null) => void;
|
|
59
|
+
/** Fires when the palette opens or closes (for URL/analytics sync). */
|
|
60
|
+
onOpenChange?: (open: boolean) => void;
|
|
50
61
|
}
|
|
51
62
|
/** A group of rows under one heading. The ungrouped bucket has `label === ""`. */
|
|
52
63
|
export interface CtrlkGroup<D = unknown> {
|
|
@@ -64,6 +75,9 @@ export interface CtrlkState<D = unknown> {
|
|
|
64
75
|
groups: CtrlkGroup<D>[];
|
|
65
76
|
/** Index into `items` of the highlighted row, or -1 when there are none. */
|
|
66
77
|
activeIndex: number;
|
|
78
|
+
/** Id of the highlighted row (its `item.id`), or null when there are none. Mirrors `activeIndex`
|
|
79
|
+
* but is stable across reorders — the cmdk-style controlled selection value. */
|
|
80
|
+
value: string | null;
|
|
67
81
|
/** The last async-source error, if any (cleared on the next successful resolve). */
|
|
68
82
|
error: Error | null;
|
|
69
83
|
}
|
|
@@ -80,6 +94,10 @@ export interface Ctrlk<D = unknown> {
|
|
|
80
94
|
move(delta: number): void;
|
|
81
95
|
/** Set the active row to an absolute index (clamped to range). */
|
|
82
96
|
setActive(index: number): void;
|
|
97
|
+
/** Highlight the row with this id (no-op if absent); null clears the highlight. */
|
|
98
|
+
setValue(id: string | null): void;
|
|
99
|
+
/** Replace the item pool (static mode), keeping the current highlight if it survives. */
|
|
100
|
+
setItems(items: CtrlkItem<D>[]): void;
|
|
83
101
|
/** Choose a row — the given `item`, or the active one — firing `onSelect`. */
|
|
84
102
|
select(item?: CtrlkItem<D>, ev?: CtrlkSelectEvent): void;
|
|
85
103
|
/** Cancel timers/in-flight requests and drop all subscribers. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kurajs/ctrlk",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Kura — ⌘K command palette: a headless, zero-dependency command/search menu with a built-in default renderer. Framework-agnostic (vanilla DOM); mirrors cmdk's model & accessibility without React.",
|
|
6
6
|
"exports": {
|