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 +7 -0
- data/CHANGELOG.md +22 -0
- data/LICENSE.md +26 -0
- data/README.md +398 -0
- data/assets/css/cmdk_themes.css +428 -0
- data/assets/js/cmdk.js +1015 -0
- data/assets/js/cmdk_controller.js +110 -0
- data/lib/cmdk/base.rb +24 -0
- data/lib/cmdk/dialog.rb +35 -0
- data/lib/cmdk/empty.rb +15 -0
- data/lib/cmdk/footer.rb +30 -0
- data/lib/cmdk/group.rb +46 -0
- data/lib/cmdk/input.rb +34 -0
- data/lib/cmdk/item.rb +54 -0
- data/lib/cmdk/list.rb +17 -0
- data/lib/cmdk/loading.rb +25 -0
- data/lib/cmdk/root.rb +62 -0
- data/lib/cmdk/separator.rb +16 -0
- data/lib/cmdk/version.rb +3 -0
- data/lib/cmdk.rb +40 -0
- data/lib/phlex-cmdk.rb +1 -0
- metadata +87 -0
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.
|