@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 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`, `onSelect`.
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 state = { open: false, query: "", loading: false, items: [], groups: [], activeIndex: -1, error: null };
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
- const applyItems = (items) => set({ items, groups: bucket(items), activeIndex: items.length ? 0 : -1, loading: false, error: null });
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
- const src = options.items ?? [];
87
+ if (options.shouldFilter === false) {
88
+ applyItems(staticItems.slice(), true);
89
+ return;
90
+ } // already ranked
62
91
  if (!q.trim()) {
63
- applyItems((options.empty ?? src).slice());
92
+ applyItems((options.empty ?? staticItems).slice(), true);
64
93
  return;
65
94
  }
66
95
  const filter = options.filter ?? defaultFilter;
67
- applyItems(src.map((it) => ({ it, s: filter(it, q) }))
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
- const i = state.activeIndex < 0 ? (delta > 0 ? 0 : n - 1) : (state.activeIndex + delta + n) % n;
133
- set({ activeIndex: i });
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
- set({ activeIndex: Math.max(0, Math.min(n - 1, index)) });
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.1",
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": {