ark-cmdk 0.0.0 → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) Alec Larson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,55 @@
1
+ # ark-cmdk
2
+
3
+ ## Purpose
4
+
5
+ `ark-cmdk` is a small, data-driven command menu for React 19 built on Ark UI.
6
+
7
+ It ships one first-class API for:
8
+
9
+ - inline command menus with `Command.Root`
10
+ - modal command palettes with `Command.Dialog`
11
+ - explicit item identity, search ranking, and active-item state
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ pnpm add ark-cmdk react react-dom
17
+ ```
18
+
19
+ `ark-cmdk` targets React 19, browser-side CSR, and ESM only.
20
+
21
+ ## Quick Example
22
+
23
+ ```tsx
24
+ import { Command } from 'ark-cmdk'
25
+
26
+ const items = [
27
+ { id: 'open', label: 'Open File' },
28
+ { id: 'save', label: 'Save File' },
29
+ ]
30
+
31
+ export function Example() {
32
+ return (
33
+ <Command.Root
34
+ itemToString={(item) => item.label}
35
+ itemToValue={(item) => item.id}
36
+ items={items}
37
+ label="Command Menu"
38
+ >
39
+ <Command.Input placeholder="Search commands..." />
40
+ <Command.List empty={<div>No results.</div>}>
41
+ {(item) => <div>{item.label}</div>}
42
+ </Command.List>
43
+ </Command.Root>
44
+ )
45
+ }
46
+ ```
47
+
48
+ ## Documentation Map
49
+
50
+ - Concepts and recommended patterns: [docs/context.md](docs/context.md)
51
+ - Inline usage example: [examples/inline.tsx](examples/inline.tsx)
52
+ - Dialog usage example: [examples/dialog.tsx](examples/dialog.tsx)
53
+ - Controlled state example: [examples/controlled-state.tsx](examples/controlled-state.tsx)
54
+ - Exact exported signatures: [dist/index.d.mts](dist/index.d.mts)
55
+ - API contract: [docs/v0.1-api.md](docs/v0.1-api.md)
@@ -0,0 +1,128 @@
1
+ import * as React from "react";
2
+ import { ReactElement, RefAttributes } from "react";
3
+
4
+ //#region src/command/types.d.ts
5
+ /**
6
+ * Scores an item against the current search string.
7
+ *
8
+ * Return a value less than or equal to `0` to hide the item. Higher scores rank
9
+ * earlier in the visible list.
10
+ *
11
+ * @param itemText The trimmed searchable text derived from `itemToString`.
12
+ * @param search The trimmed current search string.
13
+ * @param keywords The trimmed keyword aliases derived from `itemToKeywords`.
14
+ */
15
+ type CommandFilter = (itemText: string, search: string, keywords: readonly string[]) => number;
16
+ /**
17
+ * Props for the inline command surface.
18
+ *
19
+ * `Command.Root` owns item normalization, filtering, active-item state, and
20
+ * selection for a data-driven item array. It never inspects arbitrary child
21
+ * trees to discover items.
22
+ */
23
+ interface CommandRootProps<TItem = any> extends Omit<React.ComponentPropsWithoutRef<'div'>, 'children' | 'defaultValue' | 'onSelect'> {
24
+ activeValue?: string | null;
25
+ children: React.ReactNode;
26
+ class?: string;
27
+ defaultActiveValue?: string | null;
28
+ defaultSearch?: string;
29
+ filter?: CommandFilter;
30
+ itemToDisabled?: (item: TItem) => boolean | undefined;
31
+ itemToKeywords?: (item: TItem) => readonly string[] | undefined;
32
+ itemToString: (item: TItem) => string;
33
+ itemToValue: (item: TItem) => string;
34
+ items: readonly TItem[];
35
+ label: string;
36
+ onActiveValueChange?: (value: string | null) => void;
37
+ onSearchChange?: (search: string) => void;
38
+ onSelect?: (item: TItem) => void;
39
+ search?: string;
40
+ }
41
+ /**
42
+ * Props for the search input part.
43
+ *
44
+ * The rendered input value is bound to the parent command's `search` state.
45
+ */
46
+ interface CommandInputProps extends Omit<React.ComponentPropsWithoutRef<'input'>, 'children' | 'defaultValue' | 'onChange' | 'type' | 'value'> {
47
+ class?: string;
48
+ }
49
+ /**
50
+ * Derived state passed to `Command.List` item render functions.
51
+ */
52
+ interface CommandListItemState {
53
+ active: boolean;
54
+ disabled: boolean;
55
+ value: string;
56
+ }
57
+ /**
58
+ * Props for the visible item list.
59
+ *
60
+ * `children` is a render function over the current visible items, not an item
61
+ * registration API.
62
+ */
63
+ interface CommandListProps<TItem = any> extends Omit<React.ComponentPropsWithoutRef<'div'>, 'children'> {
64
+ children: (item: TItem, state: CommandListItemState) => React.ReactNode;
65
+ class?: string;
66
+ empty?: React.ReactNode;
67
+ }
68
+ /**
69
+ * Props for the modal command wrapper.
70
+ *
71
+ * `Command.Dialog` reuses the same item, search, active-item, and selection
72
+ * semantics as `Command.Root`, while delegating overlay behavior to Ark
73
+ * `Dialog`.
74
+ */
75
+ interface CommandDialogProps<TItem = any> extends CommandRootProps<TItem> {
76
+ contentClass?: string;
77
+ contentClassName?: string;
78
+ onOpenChange: (open: boolean) => void;
79
+ open: boolean;
80
+ overlayClass?: string;
81
+ overlayClassName?: string;
82
+ }
83
+ //#endregion
84
+ //#region src/command/dialog.d.ts
85
+ interface CommandDialogComponent {
86
+ <TItem = any>(props: CommandDialogProps<TItem> & RefAttributes<HTMLDivElement>): ReactElement;
87
+ }
88
+ //#endregion
89
+ //#region src/command/list.d.ts
90
+ interface CommandListComponent {
91
+ <TItem = any>(props: CommandListProps<TItem> & RefAttributes<HTMLDivElement>): ReactElement;
92
+ }
93
+ //#endregion
94
+ //#region src/command/root.d.ts
95
+ interface CommandRootComponent {
96
+ <TItem = any>(props: CommandRootProps<TItem> & RefAttributes<HTMLDivElement>): ReactElement;
97
+ }
98
+ //#endregion
99
+ //#region src/index.d.ts
100
+ /**
101
+ * Public namespace for `ark-cmdk`.
102
+ *
103
+ * Use `Command.Root` for inline command menus and `Command.Dialog` for modal
104
+ * command palettes. Pair either root with `Command.Input` and `Command.List`.
105
+ *
106
+ * @example
107
+ * ```tsx
108
+ * import { Command } from 'ark-cmdk'
109
+ *
110
+ * <Command.Root
111
+ * itemToString={(item) => item.label}
112
+ * itemToValue={(item) => item.id}
113
+ * items={items}
114
+ * label="Command Menu"
115
+ * >
116
+ * <Command.Input />
117
+ * <Command.List>{(item) => <div>{item.label}</div>}</Command.List>
118
+ * </Command.Root>
119
+ * ```
120
+ */
121
+ declare const Command: {
122
+ readonly Dialog: CommandDialogComponent;
123
+ readonly Input: React.ForwardRefExoticComponent<CommandInputProps & React.RefAttributes<HTMLInputElement>>;
124
+ readonly List: CommandListComponent;
125
+ readonly Root: CommandRootComponent;
126
+ };
127
+ //#endregion
128
+ export { Command, type CommandDialogProps, type CommandFilter, type CommandInputProps, type CommandListItemState, type CommandListProps, type CommandRootProps };
package/dist/index.mjs ADDED
@@ -0,0 +1,422 @@
1
+ import { Dialog } from "@ark-ui/react/dialog";
2
+ import { Portal } from "@ark-ui/react/portal";
3
+ import { createContext, forwardRef, useCallback, useContext, useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
4
+ import { Combobox, createListCollection } from "@ark-ui/react/combobox";
5
+ import { jsx, jsxs } from "react/jsx-runtime";
6
+ //#region src/command/state.ts
7
+ function composeRefs(...refs) {
8
+ return (value) => {
9
+ for (const ref of refs) {
10
+ if (!ref) continue;
11
+ if (typeof ref === "function") {
12
+ ref(value);
13
+ continue;
14
+ }
15
+ ref.current = value;
16
+ }
17
+ };
18
+ }
19
+ function mergeClassName(className, classProp) {
20
+ const merged = [classProp, className].filter(Boolean).join(" ").trim();
21
+ return merged === "" ? void 0 : merged;
22
+ }
23
+ function useCommandActiveState({ activeValue, defaultActiveValue = null, onActiveValueChange, search, visibleItems }) {
24
+ const changeActiveValue = useEffectEvent((value) => {
25
+ onActiveValueChange?.(value);
26
+ });
27
+ const [internalActiveValue, setInternalActiveValue] = useState(() => resolveInitialActiveValue(visibleItems, defaultActiveValue));
28
+ const isControlled = activeValue !== void 0;
29
+ const currentActiveValue = isControlled ? activeValue : internalActiveValue;
30
+ const previousSearchRef = useRef(search);
31
+ const previousValueOrderRef = useRef(visibleItems.map((item) => item.value));
32
+ const isFirstRenderRef = useRef(true);
33
+ const setActiveValue = useCallback((value) => {
34
+ if (Object.is(currentActiveValue, value)) return;
35
+ if (!isControlled) setInternalActiveValue(value);
36
+ changeActiveValue(value);
37
+ }, [
38
+ changeActiveValue,
39
+ currentActiveValue,
40
+ isControlled
41
+ ]);
42
+ useEffect(() => {
43
+ if (isFirstRenderRef.current) {
44
+ isFirstRenderRef.current = false;
45
+ previousSearchRef.current = search;
46
+ previousValueOrderRef.current = visibleItems.map((item) => item.value);
47
+ return;
48
+ }
49
+ const nextActiveValue = reconcileActiveValue({
50
+ currentActiveValue,
51
+ previousValueOrder: previousValueOrderRef.current,
52
+ searchChanged: search !== previousSearchRef.current,
53
+ visibleItems
54
+ });
55
+ previousSearchRef.current = search;
56
+ previousValueOrderRef.current = visibleItems.map((item) => item.value);
57
+ if (Object.is(nextActiveValue, currentActiveValue)) return;
58
+ if (!isControlled) setInternalActiveValue(nextActiveValue);
59
+ changeActiveValue(nextActiveValue);
60
+ }, [
61
+ changeActiveValue,
62
+ currentActiveValue,
63
+ isControlled,
64
+ search,
65
+ visibleItems
66
+ ]);
67
+ return {
68
+ activeValue: isVisibleEnabledValue(visibleItems, currentActiveValue) ? currentActiveValue : null,
69
+ setActiveValue
70
+ };
71
+ }
72
+ function useControllableState({ defaultValue, onChange, value }) {
73
+ const changeValue = useEffectEvent((nextValue) => {
74
+ onChange?.(nextValue);
75
+ });
76
+ const [internalValue, setInternalValue] = useState(defaultValue);
77
+ const isControlled = value !== void 0;
78
+ const currentValue = isControlled ? value : internalValue;
79
+ return {
80
+ isControlled,
81
+ setValue: useCallback((nextValue) => {
82
+ if (Object.is(currentValue, nextValue)) return;
83
+ if (!isControlled) setInternalValue(nextValue);
84
+ changeValue(nextValue);
85
+ }, [
86
+ changeValue,
87
+ currentValue,
88
+ isControlled
89
+ ]),
90
+ value: currentValue
91
+ };
92
+ }
93
+ function getFirstVisibleEnabledValue(items) {
94
+ for (const item of items) if (!item.disabled) return item.value;
95
+ return null;
96
+ }
97
+ function reconcileActiveValue({ currentActiveValue, previousValueOrder, searchChanged, visibleItems }) {
98
+ if (visibleItems.length === 0) return null;
99
+ if (searchChanged) return getFirstVisibleEnabledValue(visibleItems);
100
+ if (currentActiveValue == null) return getFirstVisibleEnabledValue(visibleItems);
101
+ if (isVisibleEnabledValue(visibleItems, currentActiveValue)) return currentActiveValue;
102
+ return getNextFallbackValue(previousValueOrder, visibleItems, currentActiveValue);
103
+ }
104
+ function resolveInitialActiveValue(items, defaultActiveValue) {
105
+ if (defaultActiveValue && isVisibleEnabledValue(items, defaultActiveValue)) return defaultActiveValue;
106
+ return getFirstVisibleEnabledValue(items);
107
+ }
108
+ function getNextFallbackValue(previousValueOrder, visibleItems, removedValue) {
109
+ const enabledValues = new Set(visibleItems.filter((item) => !item.disabled).map((item) => item.value));
110
+ if (enabledValues.size === 0) return null;
111
+ const removedIndex = previousValueOrder.indexOf(removedValue);
112
+ if (removedIndex === -1) return getFirstVisibleEnabledValue(visibleItems);
113
+ for (let index = removedIndex + 1; index < previousValueOrder.length; index += 1) {
114
+ const candidate = previousValueOrder[index];
115
+ if (enabledValues.has(candidate)) return candidate;
116
+ }
117
+ for (let index = removedIndex - 1; index >= 0; index -= 1) {
118
+ const candidate = previousValueOrder[index];
119
+ if (enabledValues.has(candidate)) return candidate;
120
+ }
121
+ return getFirstVisibleEnabledValue(visibleItems);
122
+ }
123
+ function isVisibleEnabledValue(items, value) {
124
+ if (!value) return false;
125
+ return items.some((item) => item.value === value && !item.disabled);
126
+ }
127
+ //#endregion
128
+ //#region src/command/context.tsx
129
+ const CommandContext = createContext(null);
130
+ function CommandContextProvider({ children, value }) {
131
+ return /* @__PURE__ */ jsx(CommandContext.Provider, {
132
+ value,
133
+ children
134
+ });
135
+ }
136
+ function useCommandContext() {
137
+ const value = useContext(CommandContext);
138
+ if (!value) throw new Error("Command parts must be rendered inside Command.Root or Command.Dialog.");
139
+ return value;
140
+ }
141
+ //#endregion
142
+ //#region src/command/filter.ts
143
+ const defaultFilter = (itemText, search, keywords) => {
144
+ const normalizedItemText = normalizeText(itemText);
145
+ const normalizedSearch = normalizeText(search);
146
+ if (normalizedSearch === "") return 1;
147
+ return commandScore(normalizedItemText, normalizedSearch, [...keywords]);
148
+ };
149
+ function getVisibleItems(items, search, filter) {
150
+ const normalizedSearch = normalizeText(search);
151
+ if (normalizedSearch === "") return items.map((item) => ({
152
+ ...item,
153
+ score: 1
154
+ }));
155
+ return items.map((item) => ({
156
+ ...item,
157
+ score: filter(item.text, normalizedSearch, item.keywords)
158
+ })).filter((item) => item.score > 0).sort((left, right) => {
159
+ if (left.score !== right.score) return right.score - left.score;
160
+ return left.index - right.index;
161
+ });
162
+ }
163
+ function normalizeKeywords(keywords) {
164
+ if (!keywords) return [];
165
+ return keywords.map((keyword) => normalizeText(keyword)).filter((keyword) => keyword.length > 0);
166
+ }
167
+ function normalizeText(value) {
168
+ return value.trim();
169
+ }
170
+ const SCORE_CONTINUE_MATCH = 1;
171
+ const SCORE_SPACE_WORD_JUMP = .9;
172
+ const SCORE_NON_SPACE_WORD_JUMP = .8;
173
+ const SCORE_CHARACTER_JUMP = .17;
174
+ const SCORE_TRANSPOSITION = .1;
175
+ const PENALTY_SKIPPED = .999;
176
+ const PENALTY_CASE_MISMATCH = .9999;
177
+ const PENALTY_NOT_COMPLETE = .99;
178
+ const isGap = /[\\/_+.#"@\x5b({&]/;
179
+ const gapMatches = /[\\/_+.#"@\x5b({&]/g;
180
+ const isSpace = /[\s-]/;
181
+ const spaceMatches = /[\s-]/g;
182
+ function commandScore(string, abbreviation, aliases) {
183
+ const haystack = aliases.length > 0 ? `${string} ${aliases.join(" ")}` : string;
184
+ return commandScoreInner(haystack, abbreviation, formatInput(haystack), formatInput(abbreviation), 0, 0, {});
185
+ }
186
+ function commandScoreInner(string, abbreviation, lowerString, lowerAbbreviation, stringIndex, abbreviationIndex, memoizedResults) {
187
+ if (abbreviationIndex === abbreviation.length) {
188
+ if (stringIndex === string.length) return SCORE_CONTINUE_MATCH;
189
+ return PENALTY_NOT_COMPLETE;
190
+ }
191
+ const memoizeKey = `${stringIndex},${abbreviationIndex}`;
192
+ const memoizedValue = memoizedResults[memoizeKey];
193
+ if (memoizedValue !== void 0) return memoizedValue;
194
+ const abbreviationChar = lowerAbbreviation.charAt(abbreviationIndex);
195
+ let index = lowerString.indexOf(abbreviationChar, stringIndex);
196
+ let highScore = 0;
197
+ while (index >= 0) {
198
+ let score = commandScoreInner(string, abbreviation, lowerString, lowerAbbreviation, index + 1, abbreviationIndex + 1, memoizedResults);
199
+ if (score > highScore) {
200
+ if (index === stringIndex) score *= SCORE_CONTINUE_MATCH;
201
+ else if (isGap.test(string.charAt(index - 1))) {
202
+ score *= SCORE_NON_SPACE_WORD_JUMP;
203
+ const wordBreaks = string.slice(stringIndex, index - 1).match(gapMatches);
204
+ if (wordBreaks && stringIndex > 0) score *= Math.pow(PENALTY_SKIPPED, wordBreaks.length);
205
+ } else if (isSpace.test(string.charAt(index - 1))) {
206
+ score *= SCORE_SPACE_WORD_JUMP;
207
+ const spaceBreaks = string.slice(stringIndex, index - 1).match(spaceMatches);
208
+ if (spaceBreaks && stringIndex > 0) score *= Math.pow(PENALTY_SKIPPED, spaceBreaks.length);
209
+ } else {
210
+ score *= SCORE_CHARACTER_JUMP;
211
+ if (stringIndex > 0) score *= Math.pow(PENALTY_SKIPPED, index - stringIndex);
212
+ }
213
+ if (string.charAt(index) !== abbreviation.charAt(abbreviationIndex)) score *= PENALTY_CASE_MISMATCH;
214
+ }
215
+ const transposed = lowerString.charAt(index - 1) === lowerAbbreviation.charAt(abbreviationIndex + 1) || lowerAbbreviation.charAt(abbreviationIndex + 1) === lowerAbbreviation.charAt(abbreviationIndex) && lowerString.charAt(index - 1) !== lowerAbbreviation.charAt(abbreviationIndex);
216
+ if (score < SCORE_TRANSPOSITION && transposed) {
217
+ const transposedScore = commandScoreInner(string, abbreviation, lowerString, lowerAbbreviation, index + 1, abbreviationIndex + 2, memoizedResults);
218
+ if (transposedScore * SCORE_TRANSPOSITION > score) score = transposedScore * SCORE_TRANSPOSITION;
219
+ }
220
+ if (score > highScore) highScore = score;
221
+ index = lowerString.indexOf(abbreviationChar, index + 1);
222
+ }
223
+ memoizedResults[memoizeKey] = highScore;
224
+ return highScore;
225
+ }
226
+ function formatInput(value) {
227
+ return value.toLowerCase().replace(spaceMatches, " ");
228
+ }
229
+ //#endregion
230
+ //#region src/command/root.tsx
231
+ const EMPTY_SELECTION = [];
232
+ const CommandRootInternal = forwardRef(function CommandRootImpl(props, ref) {
233
+ const { activeValue, children, class: classProp, className, defaultActiveValue, defaultSearch = "", filter = defaultFilter, itemToDisabled, itemToKeywords, itemToString, itemToValue, items, label, onActiveValueChange, onSearchChange, onSelect, search, inputRef: inputRefProp, ...rootProps } = props;
234
+ const fallbackInputRef = useRef(null);
235
+ const inputRef = inputRefProp ?? fallbackInputRef;
236
+ const emitSelect = useEffectEvent((value) => {
237
+ const selectedItem = normalizedItems.find((item) => item.value === value)?.item;
238
+ if (selectedItem) onSelect?.(selectedItem);
239
+ });
240
+ const searchState = useControllableState({
241
+ defaultValue: defaultSearch,
242
+ onChange: onSearchChange,
243
+ value: search
244
+ });
245
+ const normalizedItems = useMemo(() => {
246
+ const seenValues = /* @__PURE__ */ new Set();
247
+ return items.map((item, index) => {
248
+ const value = itemToValue(item);
249
+ if (value.trim() === "") throw new Error("Command items require a non-empty string value.");
250
+ if (seenValues.has(value)) throw new Error(`Command item values must be unique. Received "${value}".`);
251
+ seenValues.add(value);
252
+ return {
253
+ disabled: itemToDisabled?.(item) ?? false,
254
+ index,
255
+ item,
256
+ keywords: normalizeKeywords(itemToKeywords?.(item)),
257
+ text: normalizeText(itemToString(item)),
258
+ value
259
+ };
260
+ });
261
+ }, [
262
+ itemToDisabled,
263
+ itemToKeywords,
264
+ itemToString,
265
+ itemToValue,
266
+ items
267
+ ]);
268
+ const visibleItems = useMemo(() => getVisibleItems(normalizedItems, searchState.value, filter), [
269
+ filter,
270
+ normalizedItems,
271
+ searchState.value
272
+ ]);
273
+ const activeState = useCommandActiveState({
274
+ activeValue,
275
+ defaultActiveValue,
276
+ onActiveValueChange,
277
+ search: searchState.value,
278
+ visibleItems
279
+ });
280
+ const collection = useMemo(() => createListCollection({
281
+ isItemDisabled: (item) => item.disabled,
282
+ itemToString: (item) => item.text,
283
+ itemToValue: (item) => item.value,
284
+ items: visibleItems
285
+ }), [visibleItems]);
286
+ return /* @__PURE__ */ jsx(CommandContextProvider, {
287
+ value: {
288
+ inputRef,
289
+ label,
290
+ visibleActiveValue: activeState.activeValue,
291
+ visibleItems
292
+ },
293
+ children: /* @__PURE__ */ jsx(Combobox.Root, {
294
+ ...rootProps,
295
+ ref,
296
+ className: mergeClassName(className, classProp),
297
+ closeOnSelect: false,
298
+ collection,
299
+ disableLayer: true,
300
+ highlightedValue: activeState.activeValue,
301
+ inputBehavior: "none",
302
+ inputValue: searchState.value,
303
+ loopFocus: false,
304
+ onHighlightChange: ({ highlightedValue }) => {
305
+ activeState.setActiveValue(highlightedValue);
306
+ },
307
+ onInputValueChange: ({ inputValue }) => {
308
+ searchState.setValue(inputValue);
309
+ },
310
+ onOpenChange: () => {},
311
+ onSelect: ({ itemValue }) => {
312
+ emitSelect(itemValue);
313
+ },
314
+ open: true,
315
+ openOnChange: false,
316
+ openOnClick: false,
317
+ openOnKeyPress: false,
318
+ selectionBehavior: "preserve",
319
+ value: EMPTY_SELECTION,
320
+ children
321
+ })
322
+ });
323
+ });
324
+ const CommandRoot = CommandRootInternal;
325
+ //#endregion
326
+ //#region src/command/dialog.tsx
327
+ const CommandDialog = forwardRef(function CommandDialog(props, ref) {
328
+ const { contentClass, contentClassName, onOpenChange, open, overlayClass, overlayClassName, ...rootProps } = props;
329
+ const inputRef = useRef(null);
330
+ useEffect(() => {
331
+ if (open) inputRef.current?.focus();
332
+ }, [open]);
333
+ return /* @__PURE__ */ jsx(Dialog.Root, {
334
+ "aria-label": rootProps.label,
335
+ initialFocusEl: () => inputRef.current,
336
+ onOpenChange: ({ open }) => {
337
+ onOpenChange(open);
338
+ },
339
+ open,
340
+ unmountOnExit: true,
341
+ children: /* @__PURE__ */ jsxs(Portal, { children: [/* @__PURE__ */ jsx(Dialog.Backdrop, { className: mergeClassName(overlayClassName, overlayClass) }), /* @__PURE__ */ jsx(Dialog.Positioner, { children: /* @__PURE__ */ jsx(Dialog.Content, {
342
+ className: mergeClassName(contentClassName, contentClass),
343
+ ref,
344
+ children: /* @__PURE__ */ jsx(CommandRootInternal, {
345
+ ...rootProps,
346
+ inputRef
347
+ })
348
+ }) })] })
349
+ });
350
+ });
351
+ //#endregion
352
+ //#region src/command/input.tsx
353
+ const CommandInput = forwardRef(function CommandInput(props, ref) {
354
+ const { "aria-label": ariaLabel, "aria-labelledby": ariaLabelledby, class: classProp, className, ...inputProps } = props;
355
+ const { inputRef, label } = useCommandContext();
356
+ return /* @__PURE__ */ jsx(Combobox.Input, {
357
+ ...inputProps,
358
+ "aria-label": ariaLabelledby ? void 0 : ariaLabel ?? label,
359
+ "aria-labelledby": ariaLabelledby,
360
+ className: mergeClassName(className, classProp),
361
+ ref: composeRefs(ref, inputRef)
362
+ });
363
+ });
364
+ //#endregion
365
+ //#region src/command/list.tsx
366
+ const CommandList = forwardRef(function CommandList(props, ref) {
367
+ const { children, class: classProp, className, empty, onPointerMoveCapture, ...listProps } = props;
368
+ const { visibleActiveValue, visibleItems } = useCommandContext();
369
+ return /* @__PURE__ */ jsx(Combobox.Content, { children: /* @__PURE__ */ jsxs(Combobox.List, {
370
+ ...listProps,
371
+ className: mergeClassName(className, classProp),
372
+ onPointerMoveCapture: callAll(onPointerMoveCapture, stopPointerHighlightCapture),
373
+ ref,
374
+ children: [visibleItems.length === 0 ? empty : null, visibleItems.map((item) => /* @__PURE__ */ jsx(Combobox.Item, {
375
+ item,
376
+ children: children(item.item, {
377
+ active: item.value === visibleActiveValue,
378
+ disabled: item.disabled,
379
+ value: item.value
380
+ })
381
+ }, item.value))]
382
+ }) });
383
+ });
384
+ function callAll(...handlers) {
385
+ return (event) => {
386
+ for (const handler of handlers) handler?.(event);
387
+ };
388
+ }
389
+ function stopPointerHighlightCapture(event) {
390
+ if (event.target !== event.currentTarget) event.stopPropagation();
391
+ }
392
+ //#endregion
393
+ //#region src/index.ts
394
+ /**
395
+ * Public namespace for `ark-cmdk`.
396
+ *
397
+ * Use `Command.Root` for inline command menus and `Command.Dialog` for modal
398
+ * command palettes. Pair either root with `Command.Input` and `Command.List`.
399
+ *
400
+ * @example
401
+ * ```tsx
402
+ * import { Command } from 'ark-cmdk'
403
+ *
404
+ * <Command.Root
405
+ * itemToString={(item) => item.label}
406
+ * itemToValue={(item) => item.id}
407
+ * items={items}
408
+ * label="Command Menu"
409
+ * >
410
+ * <Command.Input />
411
+ * <Command.List>{(item) => <div>{item.label}</div>}</Command.List>
412
+ * </Command.Root>
413
+ * ```
414
+ */
415
+ const Command = {
416
+ Dialog: CommandDialog,
417
+ Input: CommandInput,
418
+ List: CommandList,
419
+ Root: CommandRoot
420
+ };
421
+ //#endregion
422
+ export { Command };
@@ -0,0 +1,121 @@
1
+ # Overview
2
+
3
+ `ark-cmdk` is a small, data-driven command menu for React 19 built on Ark UI.
4
+
5
+ The library keeps one first-class model:
6
+
7
+ - the consumer owns an explicit item array
8
+ - `ark-cmdk` derives visibility, ranking, active-item state, and selection from that data
9
+ - Ark UI owns combobox and dialog accessibility behavior
10
+
11
+ # When to Use
12
+
13
+ Use `ark-cmdk` when you want:
14
+
15
+ - an inline command menu or modal command palette in a React 19 client app
16
+ - explicit item identity instead of DOM-driven item discovery
17
+ - built-in search ranking over a data array
18
+ - controlled or uncontrolled search state
19
+ - controlled or uncontrolled active-item state
20
+ - unstyled parts with consumer-owned item rendering
21
+
22
+ # When Not to Use
23
+
24
+ `ark-cmdk` is the wrong fit when you need:
25
+
26
+ - `cmdk` drop-in compatibility
27
+ - manual filtering or manual ordering control
28
+ - groups or loading helpers
29
+ - DOM-driven child inspection
30
+ - pointer-hover activation, loop navigation, Vim bindings, or Alt-based group jumps
31
+ - React Server Component usage or React versions older than 19
32
+
33
+ # Core Abstractions
34
+
35
+ - `Command.Root`: the inline state owner for item normalization, filtering, active-item state, and selection
36
+ - `Command.Input`: the search input bound to the root's `search` state
37
+ - `Command.List`: the visible item list rendered through a function child
38
+ - `Command.Dialog`: a thin modal wrapper over the same command semantics
39
+ - `search`: the current text query
40
+ - `active item`: the highlighted visible enabled item used for keyboard navigation and `aria-activedescendant`
41
+ - `committed selection`: the item passed to `onSelect`
42
+
43
+ # Data Flow / Lifecycle
44
+
45
+ 1. The consumer passes `items` plus `itemToValue` and `itemToString`.
46
+ 2. `ark-cmdk` normalizes item text, keywords, and disabled state from that data.
47
+ 3. The current `search` string is scored against normalized items.
48
+ 4. Visible items are derived from scores and sorted by rank.
49
+ 5. The active item is reconciled against the visible enabled set.
50
+ 6. Keyboard navigation updates the active item.
51
+ 7. `Enter` or click commits the current item through `onSelect`.
52
+
53
+ The DOM is a projection of derived state. It is not the source of truth for item identity, ordering, or filtering.
54
+
55
+ # Common Tasks -> Recommended APIs
56
+
57
+ - Inline command menu: `Command.Root` + `Command.Input` + `Command.List`
58
+ - Modal command palette: `Command.Dialog`
59
+ - Controlled search: `search` + `onSearchChange`
60
+ - Controlled active item: `activeValue` + `onActiveValueChange`
61
+ - Search aliases: `itemToKeywords`
62
+ - Disabled-item handling: `itemToDisabled`
63
+ - Empty states: `Command.List` `empty`
64
+ - Custom row rendering: `Command.List` render function
65
+ - Styling: `className` on `Command.Root`, `Command.Input`, and `Command.List`, plus `overlayClassName` and `contentClassName` on `Command.Dialog`
66
+
67
+ # Recommended Patterns
68
+
69
+ - Keep `itemToValue` stable, unique, and non-empty across renders.
70
+ - Treat `search` and `activeValue` as separate concerns.
71
+ - Keep side effects in `onSelect`, not in active-item change handlers.
72
+ - Use `itemToKeywords` for aliases instead of concatenating extra text into rendered labels.
73
+ - Render item UI entirely inside the `Command.List` render function.
74
+ - Use examples in [examples/inline.tsx](../examples/inline.tsx), [examples/dialog.tsx](../examples/dialog.tsx), and [examples/controlled-state.tsx](../examples/controlled-state.tsx) as the canonical usage patterns.
75
+
76
+ # Patterns to Avoid
77
+
78
+ - Deriving item identity from rendered text or DOM structure
79
+ - Rebuilding item values with random or unstable IDs
80
+ - Treating active-item movement as a committed selection
81
+ - Assuming hidden or disabled items remain keyboard targets
82
+ - Depending on undocumented selector compatibility with `cmdk`
83
+ - Reintroducing manual filtering as an implicit second mode
84
+
85
+ # Invariants and Constraints
86
+
87
+ - React 19 only
88
+ - browser-side CSR only
89
+ - ESM only
90
+ - one first-class data-driven API
91
+ - item identity is explicit through `itemToValue`
92
+ - `Command.List` renders only the current visible item set
93
+ - disabled items are skipped by active-item selection and committed selection
94
+ - dialog behavior remains Ark-owned
95
+
96
+ # Error Model
97
+
98
+ - An empty `itemToValue` result throws during render.
99
+ - Duplicate `itemToValue` results throw during render.
100
+ - Returning an active value that does not resolve to a visible enabled item leaves the command with no rendered active item.
101
+ - A filter score less than or equal to `0` hides the item from the visible list.
102
+
103
+ # Terminology
104
+
105
+ - `search`: the current query string
106
+ - `visible item`: an item whose filter score is greater than `0`, or any item when search is empty
107
+ - `active item`: the highlighted visible enabled item
108
+ - `committed selection`: the item passed to `onSelect`
109
+ - `keywords`: alias terms used during scoring
110
+
111
+ # Non-Goals
112
+
113
+ - `cmdk` parity
114
+ - manual filtering mode
115
+ - groups
116
+ - loading helpers
117
+ - pointer-hover activation
118
+ - loop navigation
119
+ - Vim bindings
120
+ - Alt-based group jumps
121
+ - selector compatibility shims
@@ -0,0 +1,36 @@
1
+ import { useState } from 'react'
2
+
3
+ import { Command } from 'ark-cmdk'
4
+
5
+ const items = [
6
+ { id: 'alpha', label: 'Alpha' },
7
+ { id: 'bravo', label: 'Bravo' },
8
+ ]
9
+
10
+ export function ControlledStateExample() {
11
+ const [search, setSearch] = useState('')
12
+ const [activeValue, setActiveValue] = useState<string | null>('alpha')
13
+
14
+ return (
15
+ <Command.Root
16
+ activeValue={activeValue}
17
+ itemToString={(item) => item.label}
18
+ itemToValue={(item) => item.id}
19
+ items={items}
20
+ label="Command Menu"
21
+ onActiveValueChange={setActiveValue}
22
+ onSearchChange={setSearch}
23
+ search={search}
24
+ >
25
+ <Command.Input />
26
+ <Command.List empty={<div>No results.</div>}>
27
+ {(item, state) => (
28
+ <div>
29
+ {state.active ? 'Active: ' : null}
30
+ {item.label}
31
+ </div>
32
+ )}
33
+ </Command.List>
34
+ </Command.Root>
35
+ )
36
+ }
@@ -0,0 +1,32 @@
1
+ import { useState } from 'react'
2
+
3
+ import { Command } from 'ark-cmdk'
4
+
5
+ const items = [
6
+ { id: 'open', label: 'Open File' },
7
+ { id: 'save', label: 'Save File' },
8
+ ]
9
+
10
+ export function DialogExample() {
11
+ const [open, setOpen] = useState(false)
12
+
13
+ return (
14
+ <Command.Dialog
15
+ contentClassName="command-dialog"
16
+ itemToString={(item) => item.label}
17
+ itemToValue={(item) => item.id}
18
+ items={items}
19
+ label="Command Menu"
20
+ onOpenChange={setOpen}
21
+ open={open}
22
+ overlayClassName="command-overlay"
23
+ >
24
+ <Command.Input placeholder="Type a command..." />
25
+ <Command.List empty={<div>No results.</div>}>
26
+ {(item, state) => (
27
+ <div className={state.active ? 'is-active' : undefined}>{item.label}</div>
28
+ )}
29
+ </Command.List>
30
+ </Command.Dialog>
31
+ )
32
+ }
@@ -0,0 +1,30 @@
1
+ import { Command } from 'ark-cmdk'
2
+
3
+ const items = [
4
+ { id: 'open', label: 'Open File', keywords: ['file'] },
5
+ { id: 'save', label: 'Save File', keywords: ['write'] },
6
+ { id: 'close', disabled: true, label: 'Close Window' },
7
+ ]
8
+
9
+ export function InlineExample() {
10
+ return (
11
+ <Command.Root
12
+ itemToDisabled={(item) => item.disabled}
13
+ itemToKeywords={(item) => item.keywords}
14
+ itemToString={(item) => item.label}
15
+ itemToValue={(item) => item.id}
16
+ items={items}
17
+ label="Command Menu"
18
+ onSelect={(item) => {
19
+ console.log(item.id)
20
+ }}
21
+ >
22
+ <Command.Input placeholder="Search commands..." />
23
+ <Command.List empty={<div>No results.</div>}>
24
+ {(item, state) => (
25
+ <div className={state.active ? 'is-active' : undefined}>{item.label}</div>
26
+ )}
27
+ </Command.List>
28
+ </Command.Root>
29
+ )
30
+ }
package/package.json CHANGED
@@ -1,13 +1,53 @@
1
1
  {
2
2
  "name": "ark-cmdk",
3
- "version": "0.0.0",
4
- "description": "",
5
- "main": "index.js",
6
- "scripts": {
7
- "test": "echo \"Error: no test specified\" && exit 1"
8
- },
9
- "keywords": [],
10
- "author": "",
3
+ "description": "A small, data-driven command menu for React 19 built on Ark UI",
4
+ "version": "0.1.0",
11
5
  "license": "MIT",
12
- "type": "commonjs"
13
- }
6
+ "author": "Alec Larson",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/aleclarson/ark-cmdk.git"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "docs",
14
+ "examples"
15
+ ],
16
+ "type": "module",
17
+ "exports": {
18
+ ".": {
19
+ "types": "./dist/index.d.mts",
20
+ "import": "./dist/index.mjs"
21
+ }
22
+ },
23
+ "devDependencies": {
24
+ "@testing-library/react": "^16.3.2",
25
+ "@testing-library/user-event": "^14.6.1",
26
+ "@types/node": "^25.6.0",
27
+ "@types/react": "^19.2.14",
28
+ "@types/react-dom": "^19.2.3",
29
+ "jsdom": "^29.0.2",
30
+ "oxfmt": "^0.44.0",
31
+ "oxlint": "^1.59.0",
32
+ "radashi": "^12.7.2",
33
+ "react": "^19.2.5",
34
+ "react-dom": "^19.2.5",
35
+ "tsdown": "^0.21.7",
36
+ "tsnapi": "^0.1.1",
37
+ "typescript": "^6.0.2",
38
+ "vitest": "^4.1.4"
39
+ },
40
+ "peerDependencies": {
41
+ "@ark-ui/react": ">=5",
42
+ "react": ">=19"
43
+ },
44
+ "scripts": {
45
+ "dev": "tsdown --sourcemap --watch",
46
+ "build": "tsdown",
47
+ "format": "oxfmt .",
48
+ "lint": "oxlint src",
49
+ "typecheck": "tsc --noEmit && tsc -p test --noEmit",
50
+ "test": "vitest run",
51
+ "test:watch": "vitest"
52
+ }
53
+ }
package/readme.md DELETED
@@ -1 +0,0 @@
1
- Coming soon...