@octanejs/cmdk 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) 2026 Dominic Gannaway
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,57 @@
1
+ # @octanejs/cmdk
2
+
3
+ [cmdk](https://github.com/dip/cmdk) — the fast, composable command menu — ported
4
+ to the [Octane](https://github.com/octanejs/octane) renderer.
5
+
6
+ This is an Octane port of `cmdk@1.1.1`. It preserves cmdk's public API (the
7
+ `Command` namespace, `useCommandState`, `defaultFilter`) and its
8
+ DOM-authoritative filter/selection model, adapting only the React-renderer
9
+ wiring to Octane (native events, ref-as-prop, `useId`), and building on
10
+ [`@octanejs/radix`](../radix) for the Radix primitives cmdk depends on.
11
+
12
+ > **Alpha.** The whole component set is implemented — `Command`, `Command.Input`,
13
+ > `Command.List`, `Command.Item`, `Command.Group`, `Command.Separator`,
14
+ > `Command.Dialog`, `Command.Empty`, `Command.Loading`, plus `useCommandState`
15
+ > and `defaultFilter` — with filtering, score sorting, keyboard navigation,
16
+ > controlled modes, and SSR + hydration. `asChild` is the one unsupported prop.
17
+ > A differential suite runs the same fixture through this port and the published
18
+ > `cmdk@1.1.1` on React and asserts byte-equal HTML. See
19
+ > [`docs/cmdk-port-plan.md`](../../docs/cmdk-port-plan.md) and the authoritative
20
+ > [`docs/bindings-status.md`](../../docs/bindings-status.md).
21
+
22
+ ## Divergences from React cmdk
23
+
24
+ Standard Octane binding adaptations (see
25
+ [`docs/react-parity-migration-plan.md`](../../docs/react-parity-migration-plan.md)):
26
+
27
+ - `Command.Input` drives search from the native `onInput` event; the public
28
+ `onValueChange(search)` API is unchanged.
29
+ - Components take `ref` as a normal prop (Octane has no `forwardRef`).
30
+ - Callbacks (`onSelect`, pointer, keyboard) observe native DOM events.
31
+ - Item/group values come from the `value` prop or the rendered `textContent`;
32
+ cmdk's string-child inspection is dropped (Octane's compiled children are
33
+ opaque).
34
+ - `asChild` is **not** supported: cmdk clones the child element and re-parents
35
+ the component's own content into it, which has no faithful equivalent over
36
+ opaque compiled children.
37
+ - Group reordering resolves the group element by its registered value; upstream
38
+ looks it up by `[data-value="<groupId>"]`, which can never match.
39
+ - `Command.Dialog` is built on `@octanejs/radix`'s Dialog; ids come from
40
+ `octane`'s `useId` (prefixed to match upstream's `@radix-ui/react-id`). Radix
41
+ `Primitive`/`Slot` are not used — without `asChild` there is nothing to slot,
42
+ so every component renders its own host element.
43
+ - `Command.Empty` and a visible item are mutually exclusive, including for
44
+ `forceMount` items and during SSR. Upstream can render both at once.
45
+ - `forceMount` items are full citizens of registration: they release the value
46
+ they registered on unmount, and removing the selected one moves the selection
47
+ on. Upstream never registers them, so it does neither.
48
+ - `Command.Dialog` also accepts `defaultOpen` and `modal`; upstream forwards
49
+ only `open`/`onOpenChange`.
50
+ - After a search is cleared, items stay in the order `sort()` left them rather
51
+ than returning to source order — octane's reconciler does not reposition nodes
52
+ it did not move. Same items, same selection; only the residual order differs.
53
+
54
+ ## License
55
+
56
+ MIT. The vendored `command-score` implementation retains its upstream MIT
57
+ license.
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@octanejs/cmdk",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=22"
8
+ },
9
+ "description": "cmdk for the octane renderer — the cmdk 1.1.1 command-menu source ported onto octane's hooks and native events: the Command namespace, useCommandState, and the DOM-authoritative filter/selection model, built on @octanejs/radix primitives.",
10
+ "author": {
11
+ "name": "Dominic Gannaway",
12
+ "email": "dg@domgan.com"
13
+ },
14
+ "publishConfig": {
15
+ "access": "public"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/octanejs/octane.git",
20
+ "directory": "packages/cmdk"
21
+ },
22
+ "main": "src/index.ts",
23
+ "module": "src/index.ts",
24
+ "types": "src/index.ts",
25
+ "files": [
26
+ "src",
27
+ "README.md"
28
+ ],
29
+ "exports": {
30
+ ".": "./src/index.ts"
31
+ },
32
+ "dependencies": {
33
+ "@octanejs/radix": "0.1.16"
34
+ },
35
+ "peerDependencies": {
36
+ "octane": "0.1.17"
37
+ },
38
+ "devDependencies": {
39
+ "@testing-library/jest-dom": "^6.9.1",
40
+ "@tsrx/react": "^0.2.50",
41
+ "cmdk": "1.1.1",
42
+ "esbuild": "^0.28.1",
43
+ "react": "^19.2.7",
44
+ "react-dom": "^19.2.7",
45
+ "vitest": "^4.1.10",
46
+ "octane": "0.1.17",
47
+ "@octanejs/testing-library": "0.1.14"
48
+ },
49
+ "scripts": {
50
+ "test": "vitest run"
51
+ }
52
+ }
package/src/cmdk.tsrx ADDED
@@ -0,0 +1,52 @@
1
+ // @octanejs/cmdk — the Command menu, ported from cmdk@1.1.1 (dip/cmdk,
2
+ // cmdk/src/index.tsx) onto the octane renderer.
3
+ //
4
+ // This module is the barrel: it assembles the `Command` namespace from the
5
+ // per-component modules under ./components and re-exports the public surface.
6
+ // The store/registration hooks live in ./hooks.tsrx, the contexts in
7
+ // ./context.tsrx, and the DOM constants/helpers in ./lib.
8
+ //
9
+ // octane adaptations (see docs/react-parity-migration-plan.md):
10
+ // - No forwardRef: every component takes `ref` as a normal prop; composeRefs
11
+ // becomes octane's multi-ref `ref={[a, b]}`.
12
+ // - Input drives search from native `onInput`; the public `onValueChange` API
13
+ // is unchanged (there is no synthetic `onChange`).
14
+ // - Radix `useId` becomes octane's `useId`; the store subscribes through
15
+ // octane's `useSyncExternalStore`.
16
+ // - `useValue` derives an item's value from its provided `value` prop or the
17
+ // rendered `textContent`; cmdk's string-child inspection is dropped because
18
+ // octane's compiled children are opaque render bodies.
19
+ // - `sort()` reorders only the surviving (score > 0) items — moving a
20
+ // to-be-unmounted node orphans it in an octane hydrated tree (see
21
+ // ./components/command.tsrx).
22
+ // - `asChild` is NOT supported: cmdk's SlottableWithNestedChildren clones a child
23
+ // element via React.cloneElement/isValidElement and re-parents the component's
24
+ // own content into it. Octane's compiled children are opaque render bodies, so
25
+ // that introspect-and-clone has no faithful equivalent — components always
26
+ // render their own host element. `Command.Dialog` composes @octanejs/radix via
27
+ // createElement descriptors instead.
28
+ import { CommandRoot } from './components/command.tsrx';
29
+ import { Item } from './components/item.tsrx';
30
+ import { Input } from './components/input.tsrx';
31
+ import { List } from './components/list.tsrx';
32
+ import { Group } from './components/group.tsrx';
33
+ import { Separator } from './components/separator.tsrx';
34
+ import { Empty } from './components/empty.tsrx';
35
+ import { Loading } from './components/loading.tsrx';
36
+ import { Dialog } from './components/dialog.tsrx';
37
+ import { useCmdk } from './hooks.tsrx';
38
+
39
+ const Command = Object.assign(CommandRoot, {
40
+ List,
41
+ Item,
42
+ Input,
43
+ Group,
44
+ Separator,
45
+ Dialog,
46
+ Empty,
47
+ Loading,
48
+ });
49
+
50
+ export { Command, CommandRoot, Item, Input, List, Group, Separator, Dialog, Empty, Loading };
51
+ export { useCmdk as useCommandState };
52
+ export { Item as CommandItem, Input as CommandInput, List as CommandList, Group as CommandGroup, Separator as CommandSeparator, Dialog as CommandDialog, Empty as CommandEmpty, Loading as CommandLoading };
@@ -0,0 +1,49 @@
1
+ // Ambient declaration for cmdk.tsrx so `.ts` consumers and tsgo see real types
2
+ // without parsing the .tsrx source (kept in sync with cmdk.tsrx's exports).
3
+ import type { OctaneNode } from 'octane';
4
+ import type {
5
+ CommandProps,
6
+ DialogProps,
7
+ EmptyProps,
8
+ GroupProps,
9
+ InputProps,
10
+ ItemProps,
11
+ ListProps,
12
+ LoadingProps,
13
+ SeparatorProps,
14
+ State,
15
+ } from './types';
16
+
17
+ export declare function CommandRoot(props: CommandProps): OctaneNode;
18
+ export declare function Item(props: ItemProps): OctaneNode;
19
+ export declare function Input(props: InputProps): OctaneNode;
20
+ export declare function List(props: ListProps): OctaneNode;
21
+ export declare function Group(props: GroupProps): OctaneNode;
22
+ export declare function Separator(props: SeparatorProps): OctaneNode;
23
+ export declare function Dialog(props: DialogProps): OctaneNode;
24
+ export declare function Empty(props: EmptyProps): OctaneNode;
25
+ export declare function Loading(props: LoadingProps): OctaneNode;
26
+
27
+ export declare const Command: typeof CommandRoot & {
28
+ List: typeof List;
29
+ Item: typeof Item;
30
+ Input: typeof Input;
31
+ Group: typeof Group;
32
+ Separator: typeof Separator;
33
+ Dialog: typeof Dialog;
34
+ Empty: typeof Empty;
35
+ Loading: typeof Loading;
36
+ };
37
+
38
+ export declare function useCommandState<T>(selector: (state: State) => T): T;
39
+
40
+ export {
41
+ Item as CommandItem,
42
+ Input as CommandInput,
43
+ List as CommandList,
44
+ Group as CommandGroup,
45
+ Separator as CommandSeparator,
46
+ Dialog as CommandDialog,
47
+ Empty as CommandEmpty,
48
+ Loading as CommandLoading,
49
+ };
@@ -0,0 +1,177 @@
1
+ // Vendored from cmdk@1.1.1 (source: cmdk/src/command-score.ts, upstream repo
2
+ // dip/cmdk). The algorithm and every score constant are unchanged. The only
3
+ // edits are toolchain adaptations: the inner helpers gained explicit parameter
4
+ // type annotations for this package's `strict` typecheck (upstream ships them
5
+ // untyped), and the file is formatted to repo style. No logic was touched.
6
+
7
+ // The scores are arranged so that a continuous match of characters will
8
+ // result in a total score of 1.
9
+ //
10
+ // The best case, this character is a match, and either this is the start
11
+ // of the string, or the previous character was also a match.
12
+ var SCORE_CONTINUE_MATCH = 1,
13
+ // A new match at the start of a word scores better than a new match
14
+ // elsewhere as it's more likely that the user will type the starts
15
+ // of fragments.
16
+ // NOTE: We score word jumps between spaces slightly higher than slashes, brackets
17
+ // hyphens, etc.
18
+ SCORE_SPACE_WORD_JUMP = 0.9,
19
+ SCORE_NON_SPACE_WORD_JUMP = 0.8,
20
+ // Any other match isn't ideal, but we include it for completeness.
21
+ SCORE_CHARACTER_JUMP = 0.17,
22
+ // If the user transposed two letters, it should be significantly penalized.
23
+ //
24
+ // i.e. "ouch" is more likely than "curtain" when "uc" is typed.
25
+ SCORE_TRANSPOSITION = 0.1,
26
+ // The goodness of a match should decay slightly with each missing
27
+ // character.
28
+ //
29
+ // i.e. "bad" is more likely than "bard" when "bd" is typed.
30
+ //
31
+ // This will not change the order of suggestions based on SCORE_* until
32
+ // 100 characters are inserted between matches.
33
+ PENALTY_SKIPPED = 0.999,
34
+ // The goodness of an exact-case match should be higher than a
35
+ // case-insensitive match by a small amount.
36
+ //
37
+ // i.e. "HTML" is more likely than "haml" when "HM" is typed.
38
+ //
39
+ // This will not change the order of suggestions based on SCORE_* until
40
+ // 1000 characters are inserted between matches.
41
+ PENALTY_CASE_MISMATCH = 0.9999,
42
+ // Match higher for letters closer to the beginning of the word
43
+ PENALTY_DISTANCE_FROM_START = 0.9,
44
+ // If the word has more characters than the user typed, it should
45
+ // be penalised slightly.
46
+ //
47
+ // i.e. "html" is more likely than "html5" if I type "html".
48
+ //
49
+ // However, it may well be the case that there's a sensible secondary
50
+ // ordering (like alphabetical) that it makes sense to rely on when
51
+ // there are many prefix matches, so we don't make the penalty increase
52
+ // with the number of tokens.
53
+ PENALTY_NOT_COMPLETE = 0.99;
54
+
55
+ var IS_GAP_REGEXP = /[\\\/_+.#"@\[\(\{&]/,
56
+ COUNT_GAPS_REGEXP = /[\\\/_+.#"@\[\(\{&]/g,
57
+ IS_SPACE_REGEXP = /[\s-]/,
58
+ COUNT_SPACE_REGEXP = /[\s-]/g;
59
+
60
+ function commandScoreInner(
61
+ string: string,
62
+ abbreviation: string,
63
+ lowerString: string,
64
+ lowerAbbreviation: string,
65
+ stringIndex: number,
66
+ abbreviationIndex: number,
67
+ memoizedResults: Record<string, number>,
68
+ ): number {
69
+ if (abbreviationIndex === abbreviation.length) {
70
+ if (stringIndex === string.length) {
71
+ return SCORE_CONTINUE_MATCH;
72
+ }
73
+ return PENALTY_NOT_COMPLETE;
74
+ }
75
+
76
+ var memoizeKey = `${stringIndex},${abbreviationIndex}`;
77
+ if (memoizedResults[memoizeKey] !== undefined) {
78
+ return memoizedResults[memoizeKey];
79
+ }
80
+
81
+ var abbreviationChar = lowerAbbreviation.charAt(abbreviationIndex);
82
+ var index = lowerString.indexOf(abbreviationChar, stringIndex);
83
+ var highScore = 0;
84
+
85
+ var score, transposedScore, wordBreaks, spaceBreaks;
86
+
87
+ while (index >= 0) {
88
+ score = commandScoreInner(
89
+ string,
90
+ abbreviation,
91
+ lowerString,
92
+ lowerAbbreviation,
93
+ index + 1,
94
+ abbreviationIndex + 1,
95
+ memoizedResults,
96
+ );
97
+ if (score > highScore) {
98
+ if (index === stringIndex) {
99
+ score *= SCORE_CONTINUE_MATCH;
100
+ } else if (IS_GAP_REGEXP.test(string.charAt(index - 1))) {
101
+ score *= SCORE_NON_SPACE_WORD_JUMP;
102
+ wordBreaks = string.slice(stringIndex, index - 1).match(COUNT_GAPS_REGEXP);
103
+ if (wordBreaks && stringIndex > 0) {
104
+ score *= Math.pow(PENALTY_SKIPPED, wordBreaks.length);
105
+ }
106
+ } else if (IS_SPACE_REGEXP.test(string.charAt(index - 1))) {
107
+ score *= SCORE_SPACE_WORD_JUMP;
108
+ spaceBreaks = string.slice(stringIndex, index - 1).match(COUNT_SPACE_REGEXP);
109
+ if (spaceBreaks && stringIndex > 0) {
110
+ score *= Math.pow(PENALTY_SKIPPED, spaceBreaks.length);
111
+ }
112
+ } else {
113
+ score *= SCORE_CHARACTER_JUMP;
114
+ if (stringIndex > 0) {
115
+ score *= Math.pow(PENALTY_SKIPPED, index - stringIndex);
116
+ }
117
+ }
118
+
119
+ if (string.charAt(index) !== abbreviation.charAt(abbreviationIndex)) {
120
+ score *= PENALTY_CASE_MISMATCH;
121
+ }
122
+ }
123
+
124
+ if (
125
+ (score < SCORE_TRANSPOSITION &&
126
+ lowerString.charAt(index - 1) === lowerAbbreviation.charAt(abbreviationIndex + 1)) ||
127
+ (lowerAbbreviation.charAt(abbreviationIndex + 1) ===
128
+ lowerAbbreviation.charAt(abbreviationIndex) && // allow duplicate letters. Ref #7428
129
+ lowerString.charAt(index - 1) !== lowerAbbreviation.charAt(abbreviationIndex))
130
+ ) {
131
+ transposedScore = commandScoreInner(
132
+ string,
133
+ abbreviation,
134
+ lowerString,
135
+ lowerAbbreviation,
136
+ index + 1,
137
+ abbreviationIndex + 2,
138
+ memoizedResults,
139
+ );
140
+
141
+ if (transposedScore * SCORE_TRANSPOSITION > score) {
142
+ score = transposedScore * SCORE_TRANSPOSITION;
143
+ }
144
+ }
145
+
146
+ if (score > highScore) {
147
+ highScore = score;
148
+ }
149
+
150
+ index = lowerString.indexOf(abbreviationChar, index + 1);
151
+ }
152
+
153
+ memoizedResults[memoizeKey] = highScore;
154
+ return highScore;
155
+ }
156
+
157
+ function formatInput(string: string): string {
158
+ // convert all valid space characters to space so they match each other
159
+ return string.toLowerCase().replace(COUNT_SPACE_REGEXP, ' ');
160
+ }
161
+
162
+ export function commandScore(string: string, abbreviation: string, aliases: string[]): number {
163
+ /* NOTE:
164
+ * in the original, we used to do the lower-casing on each recursive call, but this meant that toLowerCase()
165
+ * was the dominating cost in the algorithm, passing both is a little ugly, but considerably faster.
166
+ */
167
+ string = aliases && aliases.length > 0 ? `${string + ' ' + aliases.join(' ')}` : string;
168
+ return commandScoreInner(
169
+ string,
170
+ abbreviation,
171
+ formatInput(string),
172
+ formatInput(abbreviation),
173
+ 0,
174
+ 0,
175
+ {},
176
+ );
177
+ }