phlex-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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 64fd7358d8d5c71ef5038a191b08dd08f735ac5cd64319ba65617591b9fd5944
4
+ data.tar.gz: 796d52044bd196d61bdab9da53752a3fd1b12eac2612656531449506d67cd348
5
+ SHA512:
6
+ metadata.gz: 980b9a9b18c6965cf967a5f0fc1580294879f46f8a657dd3178bc7fd86f8f6dde2fefadb5b12604fefaa27cb188b9096fb44690419b1690b584e4c9feb666172
7
+ data.tar.gz: 7cb81bd470337f4278bf1388b47c39dcf127b76fbe6cb44f84737335d43c9fa20ffad6e55fa7b2de2a1c8275915861beaa074e40a4f035ec677e876bf545b73f
data/CHANGELOG.md ADDED
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — 2026-06-15
4
+
5
+ Initial release: a feature-parity Phlex port of the cmdk React command menu.
6
+
7
+ - Phlex components rendering the cmdk markup contract: `Cmdk::Root`, `Input`,
8
+ `List`, `Item`, `Group`, `Separator`, `Empty`, `Loading`, `Dialog`, `Footer`
9
+ - Dependency-free ES module runtime (`Cmdk.javascript_path`): command-score
10
+ fuzzy filtering, score-based item/group sorting, full keyboard navigation
11
+ (arrows, vim bindings, Home/End, group jumps), pointer selection, IME guard,
12
+ `--cmdk-list-height`, native `<dialog>` palette with sensible, overridable
13
+ placement defaults
14
+ - Turbo-native: event delegation + MutationObservers; items streamed in via
15
+ Turbo Streams register automatically
16
+ - Extensions over the React API: scoped search (`/` picker, scope pills,
17
+ `scope_only:`, `active_scope:`), footer with selection-driven hints
18
+ (`hint:`/`kbd:`), `href:` items, item-aware custom filters
19
+ - Light/dark/system theming via `light-dark()`; Vercel, Linear and Raycast
20
+ theme ports; Lookbook preview suite with a theme switcher
21
+ - Mobile defaults: top-anchored sheet dialog, iOS zoom guard, touch-aware
22
+ pointer selection, `enterkeyhint`
data/LICENSE.md ADDED
@@ -0,0 +1,26 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Leon Gieser
4
+
5
+ This gem is a Ruby/Phlex port of cmdk (https://github.com/pacocoursey/cmdk),
6
+ Copyright (c) 2022 Paco Coursey, MIT licensed. The bundled JavaScript runtime
7
+ includes a port of command-score, originally derived from fuzzaldrin-plus and
8
+ adapted in cmdk, under the same license.
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,398 @@
1
+ # ⌘K for Phlex <img src="https://img.shields.io/badge/port%20of-cmdk-blue" align="right">
2
+
3
+ A feature-parity port of the [cmdk](https://cmdk.paco.me) React command menu, built for
4
+ [Phlex](https://www.phlex.fun). Two pieces, and that is the whole dependency surface:
5
+
6
+ - **Phlex components** (`Cmdk::Root`, `Input`, `List`, `Item`, `Group`, `Separator`, `Empty`,
7
+ `Loading`, `Dialog`, `Footer`) render the exact same markup contract as the React package - the
8
+ `cmdk-*` attributes and ARIA roles. Existing cmdk themes work unchanged.
9
+ - **One dependency-free ES module** ([assets/js/cmdk.js](assets/js/cmdk.js)) ports
10
+ `command-score` verbatim and reimplements the cmdk component's behavior in vanilla JS: fuzzy filtering, score-based sorting of items and
11
+ groups, keyboard navigation (arrows, `ctrl+n/j/p/k` vim bindings, Home/End, `alt` = group
12
+ jump, `meta` = first/last), pointer selection, empty state, IME composition guard,
13
+ `--cmdk-list-height`, and a native `<dialog>` command palette.
14
+
15
+ The only runtime dependency is `phlex`. Everything past that is your choice:
16
+
17
+ - **Styling** - the components ship no styles of their own, only the `cmdk-*` attribute
18
+ contract. Bring plain CSS, SCSS or Tailwind, or drop in one of the [ready-made themes](#styling).
19
+ - **Behavior** - every interaction is a bubbling DOM event. Wire it with a plain
20
+ `addEventListener`, the [optional Stimulus base controller](#with-stimulus), or any framework.
21
+ - **Navigation** - `href:` items use Turbo's `visit` when [Turbo](https://turbo.hotwired.dev)
22
+ is on the page and fall back to a normal navigation when it is not. Nothing to configure either way.
23
+
24
+ The runtime uses event delegation and MutationObservers rather than mounting, so it survives
25
+ Turbo navigation and morphing with no per-page setup, and items appended later (Turbo Streams,
26
+ your own DOM writes) are registered, filtered and sorted automatically.
27
+
28
+ ## Install
29
+
30
+ ```ruby
31
+ # Gemfile
32
+ gem 'phlex-cmdk'
33
+ ```
34
+
35
+ Serve or bundle the runtime once per page. Its path is exposed as `Cmdk.javascript_path`
36
+ (copy it into your assets, pin it in your importmap, or serve it directly):
37
+
38
+ ```html
39
+ <script type="module" src="/cmdk.js"></script> <!-- auto-starts on import -->
40
+ ```
41
+
42
+ The components are unstyled; optionally start from the shipped themes
43
+ (`Cmdk.stylesheet_path` - see [Styling](#styling)).
44
+
45
+ ### With Rails (importmap + Propshaft)
46
+
47
+ Serve the gem assets straight from the gem - no copying:
48
+
49
+ ```ruby
50
+ # config/initializers/cmdk.rb
51
+ Rails.application.config.assets.paths << File.dirname(Cmdk.javascript_path)
52
+ Rails.application.config.assets.paths << File.dirname(Cmdk.stylesheet_path)
53
+ ```
54
+
55
+ ```ruby
56
+ # config/importmap.rb
57
+ pin "cmdk", to: "cmdk.js"
58
+ pin "cmdk_controller", to: "cmdk_controller.js" # optional Stimulus base controller
59
+ ```
60
+
61
+ ```js
62
+ // app/javascript/application.js
63
+ import "cmdk"
64
+ ```
65
+
66
+ ```erb
67
+ <%# layout - optional ready-made themes %>
68
+ <%= stylesheet_link_tag "cmdk_themes", "data-turbo-track": "reload" %>
69
+ ```
70
+
71
+ ### With Tailwind
72
+
73
+ Everything composes with a Tailwind v4 setup out of the box: components accept
74
+ `class:` attributes like any Phlex element, the runtime needs no build step, and
75
+ the shipped themes are plain CSS you can import into your input stylesheet:
76
+
77
+ ```css
78
+ @import 'tailwindcss';
79
+ @import '../path/to/cmdk_themes.css'; /* copied from Cmdk.stylesheet_path */
80
+ ```
81
+
82
+ ```ruby
83
+ Cmdk::Root(class: 'cmdk-vercel w-full max-w-xl') do ... end
84
+ ```
85
+
86
+ No `@source` configuration is needed for the gem - its components emit no
87
+ Tailwind utilities of their own.
88
+
89
+ **Do I need tailwind-merge / cn()?** No, by design. That pattern exists because
90
+ React component libraries ship utility-class defaults which consumers override
91
+ in the same class attribute; which one wins depends on stylesheet order, so
92
+ tailwind-merge rewrites the string. cmdk-phlex components emit no utility
93
+ classes at all - your `class:` passes through untouched, so there is nothing to
94
+ conflict with. The shipped themes live in `@layer components` while Tailwind's
95
+ utilities layer comes later, so a utility on a component
96
+ (`Cmdk::Item(class: 'pt-3')`) overrides the theme without any merging - and
97
+ without Tailwind, your own unlayered CSS overrides the layered themes just the
98
+ same. If you build your own variant components with conditional utility
99
+ defaults on top, that is regular Phlex + Tailwind territory: reach for the
100
+ [tailwind_merge](https://github.com/gjtorikian/tailwind_merge) gem exactly
101
+ where you would reach for cn(). (Heads-up: Tailwind's preflight resets break
102
+ native `<dialog>` centering; the runtime ships zero-specificity defaults that
103
+ handle this - see [Dialog](#dialog).)
104
+
105
+ ## Use
106
+
107
+ ```ruby
108
+ class CommandMenu < Phlex::HTML
109
+ def view_template
110
+ Cmdk::Root(label: 'Global Command Menu', loop: true) do
111
+ Cmdk::Input(placeholder: 'What do you need?')
112
+ Cmdk::List() do
113
+ Cmdk::Empty() { 'No results found.' }
114
+
115
+ Cmdk::Group(heading: 'Suggestions') do
116
+ Cmdk::Item(value: 'linear', keywords: %w[issue tracker]) { 'Linear' }
117
+ Cmdk::Item(value: 'figma', disabled: true) { 'Figma' }
118
+ end
119
+
120
+ Cmdk::Separator()
121
+ Cmdk::Item(href: '/settings') { 'Settings' } # Turbo.visit on select
122
+ end
123
+ end
124
+ end
125
+ end
126
+ ```
127
+
128
+ Every interaction is a bubbling DOM event, so listen on the root, the document, or via a
129
+ Stimulus action (`data-action="cmdk-item-select->palette#run"`):
130
+
131
+ ```js
132
+ root.addEventListener('cmdk-item-select', (e) => run(e.detail.value)) // cancelable
133
+ root.addEventListener('cmdk-value-change', (e) => preview(e.detail.value))
134
+ root.addEventListener('cmdk-search-change', (e) => e.detail.search)
135
+ ```
136
+
137
+ ### Dialog
138
+
139
+ ```ruby
140
+ Cmdk::Dialog(label: 'Command Menu', hotkey: 'k') do # ⌘K / ctrl+K toggles it
141
+ Cmdk::Input()
142
+ Cmdk::List() { ... }
143
+ end
144
+ ```
145
+
146
+ Renders a native `<dialog cmdk-dialog>`: Escape and backdrop clicks close it, and
147
+ `Cmdk.openDialog(el)` / `Cmdk.closeDialog(el)` toggle it programmatically. Style the
148
+ backdrop with `dialog[cmdk-dialog]::backdrop` (replaces Radix's `[cmdk-overlay]`).
149
+
150
+ By default the dialog renders as a top-third, horizontally centered palette; on
151
+ viewports ≤640px it becomes a top-anchored, full-width sheet (the GitHub/Jira
152
+ pattern - the software keyboard owns the bottom of the screen, so the input
153
+ belongs at the top), sized with `dvh` units so dynamic viewports behave. CSS
154
+ resets (e.g. Tailwind preflight's universal `margin: 0`) break native `<dialog>`
155
+ centering, so the runtime injects these defaults with zero specificity (`:where()`)
156
+ - any rule of yours wins, even a bare element selector:
157
+
158
+ ```css
159
+ dialog[cmdk-dialog] { margin-top: 30vh; } /* overrides the default placement */
160
+ ```
161
+
162
+ Other mobile defaults: the shipped themes bump the input to 16px under 640px
163
+ (prevents iOS Safari's focus zoom), `Cmdk::Input` sets `enterkeyhint="go"` for
164
+ the mobile keyboard, and touch-move over items doesn't drag the selection
165
+ around while scrolling (only real pointer hover selects).
166
+
167
+ ### Scoped search
168
+
169
+ cmdk deliberately keeps its filter vanilla; modes like `fruit: <query>` are userland.
170
+ This port gives you both levels:
171
+
172
+ **Declarative scopes** - declare them on the root, tag items or groups, and offer
173
+ scope-entry items for the picker:
174
+
175
+ ```ruby
176
+ Cmdk::Root(label: 'Search', scopes: %w[fruits doc]) do
177
+ div(class: 'cmdk-search-row') { Cmdk::Input() } # flex row hosts the pill
178
+ Cmdk::List() do
179
+ Cmdk::Item(enters_scope: 'fruits') { 'Search fruits…' }
180
+ Cmdk::Group(heading: 'Fruits', scope: 'fruits', scope_only: true) { ... }
181
+ Cmdk::Group(heading: 'Docs', scope: 'doc') { ... }
182
+ end
183
+ end
184
+ ```
185
+
186
+ The flow follows the Linear/Slack/Raycast pattern (and cmdk's own "pages" recipe):
187
+
188
+ - Typing `/` suggests the `enters_scope:` items; `/f` narrows them.
189
+ - Enter (or click) pins the scope as a **pill** (`[cmdk-scope-pill]`, a button
190
+ inserted before the input) and clears the input - typing then filters only
191
+ items/groups tagged with that `scope:`. The pill carries
192
+ `data-scope="fruits"`, so you can style each scope distinctly
193
+ (`[cmdk-scope-pill][data-scope="fruits"]`) and fall back to the bare
194
+ `[cmdk-scope-pill]` rule.
195
+ - Typing the name out (`/fruits `) commits too.
196
+ - Backspace on an empty input or clicking the pill leaves the scope.
197
+
198
+ The root mirrors the state as `data-cmdk-active-scope="fruits"`, and events carry the
199
+ parsed parts - ideal for a server-backed lookup in a Turbo app, since streamed-in
200
+ items register automatically:
201
+
202
+ ```js
203
+ root.addEventListener('cmdk-scope-change', (e) => {
204
+ if (e.detail.scope === 'fruits') frame.src = `/search/fruits?q=${e.detail.query}`
205
+ })
206
+ ```
207
+
208
+ The picker prefix is configurable (`scope_picker: ':'`) or can be turned off
209
+ (`scope_picker: false`). Server-render an already-pinned scope with
210
+ `Cmdk::Root(active_scope: 'fruits')`. Programmatic: `Cmdk.enterScope(root, 'fruits')` /
211
+ `Cmdk.exitScope(root)`.
212
+
213
+ By default scoped items also match global (unscoped) searches. Mark a group or item
214
+ with `scope_only: true` to require deliberate entry - it stays hidden (and excluded
215
+ from the result count) unless its scope is active:
216
+
217
+ ```ruby
218
+ Cmdk::Group(heading: 'Fruits', scope: 'fruits', scope_only: true) { ... }
219
+ ```
220
+
221
+ **Server-backed scopes** - for data that lives in your database (fruits, documents),
222
+ mark the scoped group `server_filtered: true` and put a turbo-frame inside it. The
223
+ runtime then shows the streamed-in items as-is instead of fuzzy-matching them against
224
+ the query - which means the query can be a *server-side grammar*, e.g. `color:red
225
+ sweet`:
226
+
227
+ ```ruby
228
+ Cmdk::Group(heading: 'Fruits', scope: 'fruits', scope_only: true, server_filtered: true) do
229
+ turbo_frame(id: 'fruit-results')
230
+ end
231
+ ```
232
+
233
+ ```js
234
+ searchChanged({ detail: { scope, query } }) { // Stimulus base controller hook
235
+ if (scope === 'fruits') frame.src = `/search/fruits?q=${encodeURIComponent(query)}`
236
+ }
237
+ ```
238
+
239
+ The endpoint parses the predicates, queries the database and renders `Cmdk::Item`s
240
+ into the frame; selection, keyboard navigation, footer hints and the empty state all
241
+ work on the streamed items automatically.
242
+
243
+ **Fully custom syntax** - the filter function receives the item element as a 4th
244
+ argument (an extension over the React signature), so any operator grammar is possible:
245
+
246
+ ```js
247
+ Cmdk.setFilter(root, (value, query, keywords, item) => {
248
+ // parse your own syntax here; return 0 to hide, 0..1 to rank
249
+ return Cmdk.defaultFilter(value, query, keywords)
250
+ })
251
+ ```
252
+
253
+ ### Footer with selection hints
254
+
255
+ Raycast-style palettes show a footer hinting at what Enter will do for the
256
+ *selected* item. Declare hints on items and drop a `Cmdk::Footer` after the list:
257
+
258
+ ```ruby
259
+ Cmdk::Item(hint: 'Open in New Tab', kbd: '⌘ ↵') { 'Figma' }
260
+
261
+ Cmdk::Footer() do # or no block for just the hint container
262
+ span { '🚀' }
263
+ div('cmdk-footer-hint' => '')
264
+ end
265
+ ```
266
+
267
+ The runtime fills `[cmdk-footer-hint]` as the selection moves - the hint text in a
268
+ `<span>`, each key of `kbd:` as its own `<kbd>` cap - and sets `data-empty` when the
269
+ selected item declares no hint. For anything richer, drive your own footer from the
270
+ `cmdk-value-change` event.
271
+
272
+ ### With Stimulus
273
+
274
+ The bubbling events work with plain action descriptors - no controller required:
275
+
276
+ ```html
277
+ <div data-controller="palette"
278
+ data-action="cmdk-item-select->palette#run cmdk-scope-change->palette#search">
279
+ ```
280
+
281
+ For more structure, the gem ships an optional base controller
282
+ (`Cmdk.stimulus_controller_path`; serve it next to the runtime, it imports
283
+ `./cmdk.js` and `@hotwired/stimulus`). Extend it and override the hooks:
284
+
285
+ ```js
286
+ import CmdkController from 'cmdk_controller' // pin to Cmdk.stimulus_controller_path
287
+
288
+ export default class extends CmdkController {
289
+ itemSelected({ detail: { value } }) { this.run(value) }
290
+ scopeChanged({ detail: { scope, query } }) { /* server-backed lookup */ }
291
+ }
292
+ ```
293
+
294
+ Hooks: `itemSelected`, `valueChanged`, `searchChanged`, `scopeChanged`. API and
295
+ actions: `open`/`close`/`toggle` (dialog), `setSearch`, `setValue`, `enterScope`
296
+ (param-friendly: `data-action="cmdk#enterScope" data-cmdk-scope-param="fruits"`),
297
+ `exitScope`, and a `state` getter.
298
+
299
+ ### Styling
300
+
301
+ Unstyled by design: the components ship no styles, only the `cmdk-*` attribute
302
+ contract. With Tailwind, the idiomatic way is utilities on the components
303
+ themselves - the runtime toggles `data-*` attributes,
304
+ so Tailwind's data variants cover the states:
305
+
306
+ ```ruby
307
+ Cmdk::Item(class: 'flex h-10 items-center rounded-lg px-3
308
+ data-[selected=true]:bg-neutral-100
309
+ data-[disabled=true]:text-neutral-300') { 'Apple' }
310
+ ```
311
+
312
+ Or target the attribute contract from a stylesheet (plain CSS, no build needed):
313
+
314
+ ```css
315
+ [cmdk-item][data-selected='true'] { background: #f5f5f5; }
316
+ [cmdk-group-heading] { padding: 8px 12px 6px; font-size: 12px; color: #a3a3a3; }
317
+ [cmdk-list] { height: min(330px, var(--cmdk-list-height)); transition: height 100ms ease; }
318
+ ```
319
+
320
+ The gem also ships a default theme as plain, dependency-free CSS
321
+ ([assets/css/cmdk_themes.css](assets/css/cmdk_themes.css), path via
322
+ `Cmdk.stylesheet_path`). Opt in with `class: 'cmdk'` on the root - it only
323
+ styles menus you ask it to, never the ones you style yourself. The look is
324
+ driven by CSS variables, so you re-theme by overriding a handful of tokens
325
+ instead of rewriting selectors:
326
+
327
+ ```css
328
+ /* The defaults (override any of these to re-theme): */
329
+ :root {
330
+ --cmdk-radius: 12px; --cmdk-item-radius: 8px; --cmdk-pill-radius: 6px;
331
+ --cmdk-bg: light-dark(#ffffff, #18181b);
332
+ --cmdk-fg: light-dark(#171717, #ededef);
333
+ --cmdk-muted: light-dark(#a3a3a3, #71717a); /* headings, footer, placeholder */
334
+ --cmdk-border: light-dark(#e5e5e5, #27272a);
335
+ --cmdk-accent: light-dark(#f5f5f5, #27272a); /* selected row */
336
+ --cmdk-accent-fg: light-dark(#0a0a0a, #fafafa);
337
+ --cmdk-pill: light-dark(#e5e5e5, #3f3f46);
338
+ --cmdk-pill-fg: light-dark(#404040, #d4d4d8);
339
+ }
340
+
341
+ /* Re-theme globally or on a wrapper by overriding tokens: */
342
+ :root { --cmdk-accent: #ffe08a; --cmdk-radius: 6px; }
343
+ ```
344
+
345
+ Two ready-made looks ship as token presets: `class: 'cmdk-linear'` or
346
+ `'cmdk-raycast'` (`'cmdk-vercel'` is an alias for the default). All are
347
+ browsable in Lookbook under "Themes". The
348
+ [styling page](https://leongieser.github.io/phlex-cmdk/styling.html) has a live
349
+ token builder that emits these overrides as CSS or Tailwind.
350
+
351
+ **Dark mode** - the shipped themes declare every color with `light-dark()` and resolve
352
+ through `color-scheme`, giving the standard tri-state:
353
+
354
+ ```css
355
+ :root { color-scheme: light dark; } /* "system": the OS decides */
356
+ :root[data-theme='light'] { color-scheme: light; }
357
+ :root[data-theme='dark'] { color-scheme: dark; }
358
+ ```
359
+
360
+ Leave `data-theme` off (or `system`) to follow the OS preference; set
361
+ `<html data-theme="dark">` to force a side - no duplicated selectors, one declaration
362
+ per color. The Lookbook previews expose this as a Theme dropdown in the preview toolbar.
363
+
364
+ ## React → Phlex parity map
365
+
366
+ | React | Here |
367
+ |---|---|
368
+ | `<Command label shouldFilter loop vimBindings disablePointerSelection defaultValue>` | `Cmdk::Root(label:, should_filter:, loop:, vim_bindings:, disable_pointer_selection:, default_value:)` |
369
+ | `<Command value onValueChange>` (controlled) | `Cmdk.setValue(root, v)` + `cmdk-value-change` event |
370
+ | `filter={fn}` | `Cmdk.setFilter(fn)` or `Cmdk.setFilter(root, fn)` - same `(value, search, keywords) → 0..1` signature |
371
+ | `<Command.Input value onValueChange>` | `Cmdk::Input(value:)`; `Cmdk.setSearch(root, q)`; `cmdk-search-change` |
372
+ | `<Command.List label>` | `Cmdk::List(label:)` |
373
+ | `<Command.Item value keywords disabled forceMount onSelect>` | `Cmdk::Item(value:, keywords:, disabled:, force_mount:)`; `cmdk-item-select` event; value inferred from text content when omitted |
374
+ | `<Command.Group heading value forceMount>` | `Cmdk::Group(heading:, value:, force_mount:)` |
375
+ | `<Command.Separator alwaysRender>` | `Cmdk::Separator(always_render:)` |
376
+ | `<Command.Empty>` / `<Command.Loading progress label>` | `Cmdk::Empty()` / `Cmdk::Loading(progress:, label:)` |
377
+ | `<Command.Dialog open onOpenChange container>` | `Cmdk::Dialog(open:, hotkey:)` - native `<dialog>`, no portal needed |
378
+ | `useCommandState(selector)` | `Cmdk.getState(root)` + the events above |
379
+ | vim bindings, Home/End, alt/meta arrows, IME guard | identical, ported from the same keydown logic |
380
+
381
+ Extensions beyond the React API: `Cmdk::Item(href:)` visits a URL on select (via Turbo when
382
+ present), and clearing the search restores the server-rendered order (React leaves the
383
+ sorted order in place).
384
+
385
+ ## Demo, previews & tests
386
+
387
+ ```sh
388
+ bundle install
389
+ bundle exec rake test # component markup contract tests
390
+ bundle exec rake demo # builds Tailwind CSS, serves http://localhost:9292
391
+ bundle exec rake lookbook # Lookbook component previews on http://localhost:9293
392
+ ```
393
+
394
+ The [Lookbook](https://lookbook.build) previews live in [lookbook/](lookbook/) - Lookbook is a
395
+ Rails engine, so a minimal single-file Rails host ([lookbook/app.rb](lookbook/app.rb)) boots it;
396
+ the gem itself stays Rails-free. Scenarios cover the default menu (with live params for
397
+ placeholder/loop/vim bindings), ungrouped items, `should_filter: false`, force-mounted
398
+ items, loading, the empty state, the event log, and the ⌘K dialog.