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.
data/assets/js/cmdk.js ADDED
@@ -0,0 +1,1015 @@
1
+ /**
2
+ * cmdk runtime — a dependency-free port of cmdk (React) for server-rendered HTML.
3
+ *
4
+ * Pairs with the `phlex-cmdk` Ruby gem, which renders the same markup contract
5
+ * as the React package (cmdk-* attributes, ARIA roles). This module replicates
6
+ * the React runtime: command-score filtering, group/item sorting, keyboard
7
+ * navigation (including vim bindings), pointer selection, empty state,
8
+ * separators, `--cmdk-list-height`, and a native <dialog> wrapper.
9
+ *
10
+ * It uses document-level event delegation and MutationObservers, so it works
11
+ * with Turbo navigation, morphing and streams without any per-page setup:
12
+ *
13
+ * import 'cmdk' // auto-starts
14
+ *
15
+ * Events (all bubble from within the root):
16
+ * cmdk-item-select detail: { value } — item chosen via click or Enter (cancelable)
17
+ * cmdk-value-change detail: { value } — selection (highlight) changed
18
+ * cmdk-search-change detail: { search, scope, query } — search query changed
19
+ * cmdk-scope-change detail: { scope, query } — active search scope entered/left
20
+ *
21
+ * Scoped search: declare scopes on the root (`data-cmdk-scopes="user doc"`)
22
+ * and tag items or groups with `data-cmdk-scope="user"`. Typing the picker
23
+ * prefix ("/" by default, `data-cmdk-scope-picker` overrides, "false"
24
+ * disables) suggests items marked `data-cmdk-enters-scope="user"`; selecting
25
+ * one — or typing the name out ("/user ") — pins the scope as a removable
26
+ * pill before the input and leaves only the query text. Backspace on an
27
+ * empty input or a pill click exits the scope. Server-render
28
+ * `data-cmdk-active-scope` on the root to start with a pinned scope. Listen
29
+ * to cmdk-scope-change / cmdk-search-change to fetch scoped results from the
30
+ * server (e.g. via Turbo). Add `data-cmdk-scope-only` to keep items hidden
31
+ * unless their scope is active.
32
+ */
33
+
34
+ // ─────────────────────────────────────────────────────────────────────────────
35
+ // command-score (verbatim port of cmdk/src/command-score.ts)
36
+ // ─────────────────────────────────────────────────────────────────────────────
37
+
38
+ const SCORE_CONTINUE_MATCH = 1,
39
+ SCORE_SPACE_WORD_JUMP = 0.9,
40
+ SCORE_NON_SPACE_WORD_JUMP = 0.8,
41
+ SCORE_CHARACTER_JUMP = 0.17,
42
+ SCORE_TRANSPOSITION = 0.1,
43
+ PENALTY_SKIPPED = 0.999,
44
+ PENALTY_CASE_MISMATCH = 0.9999,
45
+ PENALTY_NOT_COMPLETE = 0.99
46
+
47
+ const IS_GAP_REGEXP = /[\\/_+.#"@[({&]/,
48
+ COUNT_GAPS_REGEXP = /[\\/_+.#"@[({&]/g,
49
+ IS_SPACE_REGEXP = /[\s-]/,
50
+ COUNT_SPACE_REGEXP = /[\s-]/g
51
+
52
+ function commandScoreInner(string, abbreviation, lowerString, lowerAbbreviation, stringIndex, abbreviationIndex, memoizedResults) {
53
+ if (abbreviationIndex === abbreviation.length) {
54
+ if (stringIndex === string.length) {
55
+ return SCORE_CONTINUE_MATCH
56
+ }
57
+ return PENALTY_NOT_COMPLETE
58
+ }
59
+
60
+ const memoizeKey = `${stringIndex},${abbreviationIndex}`
61
+ if (memoizedResults[memoizeKey] !== undefined) {
62
+ return memoizedResults[memoizeKey]
63
+ }
64
+
65
+ const abbreviationChar = lowerAbbreviation.charAt(abbreviationIndex)
66
+ let index = lowerString.indexOf(abbreviationChar, stringIndex)
67
+ let highScore = 0
68
+
69
+ let score, transposedScore, wordBreaks, spaceBreaks
70
+
71
+ while (index >= 0) {
72
+ score = commandScoreInner(string, abbreviation, lowerString, lowerAbbreviation, index + 1, abbreviationIndex + 1, memoizedResults)
73
+ if (score > highScore) {
74
+ if (index === stringIndex) {
75
+ score *= SCORE_CONTINUE_MATCH
76
+ } else if (IS_GAP_REGEXP.test(string.charAt(index - 1))) {
77
+ score *= SCORE_NON_SPACE_WORD_JUMP
78
+ wordBreaks = string.slice(stringIndex, index - 1).match(COUNT_GAPS_REGEXP)
79
+ if (wordBreaks && stringIndex > 0) {
80
+ score *= Math.pow(PENALTY_SKIPPED, wordBreaks.length)
81
+ }
82
+ } else if (IS_SPACE_REGEXP.test(string.charAt(index - 1))) {
83
+ score *= SCORE_SPACE_WORD_JUMP
84
+ spaceBreaks = string.slice(stringIndex, index - 1).match(COUNT_SPACE_REGEXP)
85
+ if (spaceBreaks && stringIndex > 0) {
86
+ score *= Math.pow(PENALTY_SKIPPED, spaceBreaks.length)
87
+ }
88
+ } else {
89
+ score *= SCORE_CHARACTER_JUMP
90
+ if (stringIndex > 0) {
91
+ score *= Math.pow(PENALTY_SKIPPED, index - stringIndex)
92
+ }
93
+ }
94
+
95
+ if (string.charAt(index) !== abbreviation.charAt(abbreviationIndex)) {
96
+ score *= PENALTY_CASE_MISMATCH
97
+ }
98
+ }
99
+
100
+ if (
101
+ (score < SCORE_TRANSPOSITION && lowerString.charAt(index - 1) === lowerAbbreviation.charAt(abbreviationIndex + 1)) ||
102
+ (lowerAbbreviation.charAt(abbreviationIndex + 1) === lowerAbbreviation.charAt(abbreviationIndex) &&
103
+ lowerString.charAt(index - 1) !== lowerAbbreviation.charAt(abbreviationIndex))
104
+ ) {
105
+ transposedScore = commandScoreInner(string, abbreviation, lowerString, lowerAbbreviation, index + 1, abbreviationIndex + 2, memoizedResults)
106
+
107
+ if (transposedScore * SCORE_TRANSPOSITION > score) {
108
+ score = transposedScore * SCORE_TRANSPOSITION
109
+ }
110
+ }
111
+
112
+ if (score > highScore) {
113
+ highScore = score
114
+ }
115
+
116
+ index = lowerString.indexOf(abbreviationChar, index + 1)
117
+ }
118
+
119
+ memoizedResults[memoizeKey] = highScore
120
+ return highScore
121
+ }
122
+
123
+ function formatInput(string) {
124
+ return string.toLowerCase().replace(COUNT_SPACE_REGEXP, ' ')
125
+ }
126
+
127
+ export function commandScore(string, abbreviation, aliases) {
128
+ string = aliases && aliases.length > 0 ? `${string + ' ' + aliases.join(' ')}` : string
129
+ return commandScoreInner(string, abbreviation, formatInput(string), formatInput(abbreviation), 0, 0, {})
130
+ }
131
+
132
+ // ─────────────────────────────────────────────────────────────────────────────
133
+ // Runtime (port of cmdk/src/index.tsx)
134
+ // ─────────────────────────────────────────────────────────────────────────────
135
+
136
+ const ROOT_SELECTOR = '[cmdk-root]'
137
+ const INPUT_SELECTOR = '[cmdk-input]'
138
+ const LIST_SELECTOR = '[cmdk-list]'
139
+ const SIZER_SELECTOR = '[cmdk-list-sizer]'
140
+ const GROUP_SELECTOR = '[cmdk-group]'
141
+ const GROUP_ITEMS_SELECTOR = '[cmdk-group-items]'
142
+ const GROUP_HEADING_SELECTOR = '[cmdk-group-heading]'
143
+ const ITEM_SELECTOR = '[cmdk-item]'
144
+ const VALID_ITEM_SELECTOR = `${ITEM_SELECTOR}:not([aria-disabled="true"])`
145
+ const DIALOG_SELECTOR = 'dialog[cmdk-dialog]'
146
+ const VALUE_ATTR = 'data-value'
147
+
148
+ const SELECT_EVENT = 'cmdk-item-select'
149
+ const VALUE_CHANGE_EVENT = 'cmdk-value-change'
150
+ const SEARCH_CHANGE_EVENT = 'cmdk-search-change'
151
+
152
+ export const defaultFilter = (value, search, keywords) => commandScore(value, search, keywords)
153
+
154
+ const instances = new WeakMap()
155
+ const knownDialogs = new WeakSet()
156
+ let globalFilter = defaultFilter
157
+ let uid = 0
158
+
159
+ /** Scope names declared on the root (`data-cmdk-scopes="user doc"`), or null. */
160
+ function scopesOf(root) {
161
+ const attr = root.getAttribute('data-cmdk-scopes')
162
+ if (!attr) return null
163
+ const names = attr.split(/\s+/).filter(Boolean)
164
+ return names.length ? names : null
165
+ }
166
+
167
+ /** The scope an item belongs to (own attribute or inherited from its group). */
168
+ function itemScope(item) {
169
+ return item.closest('[data-cmdk-scope]')?.getAttribute('data-cmdk-scope') ?? null
170
+ }
171
+
172
+ /** Scope-only elements are hidden unless their scope is active (deliberate entry). */
173
+ function isScopeOnly(el) {
174
+ return Boolean(el.closest('[data-cmdk-scope-only]'))
175
+ }
176
+
177
+ /** The scope-picker prefix ("/" by default) — null when disabled or no scopes. */
178
+ function pickerChar(root) {
179
+ if (!scopesOf(root)) return null
180
+ const attr = root.getAttribute('data-cmdk-scope-picker')
181
+ if (attr === 'false') return null
182
+ return attr || '/'
183
+ }
184
+
185
+ /** Recompute picker mode and the effective query from the raw search. */
186
+ function syncSearchMeta(inst) {
187
+ const pc = pickerChar(inst.root)
188
+ inst.picker = Boolean(!inst.scope && pc && inst.search.startsWith(pc))
189
+ inst.query = inst.picker ? inst.search.slice(pc.length).replace(/^\s+/, '') : inst.search
190
+ }
191
+
192
+ /** Keep the scope pill (a removable chip inserted before the input) in sync. */
193
+ function renderPill(inst) {
194
+ const input = inst.root.querySelector(INPUT_SELECTOR)
195
+ let pill = inst.root.querySelector('[cmdk-scope-pill]')
196
+ if (!inst.scope) {
197
+ pill?.remove()
198
+ return
199
+ }
200
+ if (!pill) {
201
+ pill = document.createElement('button')
202
+ pill.type = 'button'
203
+ pill.setAttribute('cmdk-scope-pill', '')
204
+ input?.parentElement?.insertBefore(pill, input)
205
+ }
206
+ pill.textContent = inst.scope
207
+ // Expose the scope name so a pill can be styled per scope
208
+ // (`[cmdk-scope-pill][data-scope="user"]`), falling back to the plain
209
+ // `[cmdk-scope-pill]` rule when no per-scope style is defined.
210
+ pill.setAttribute('data-scope', inst.scope)
211
+ pill.setAttribute('aria-label', `Remove ${inst.scope} filter`)
212
+ }
213
+
214
+ /** Pin a scope: render the pill, leave only the query in the input. */
215
+ export function enterScope(target, scope, { query = '', emit = true } = {}) {
216
+ const inst = target.root ? target : getInstance(target)
217
+ if (!inst || inst.scope === scope) return
218
+ inst.scope = scope
219
+ inst.search = query
220
+ syncSearchMeta(inst)
221
+ const input = inst.root.querySelector(INPUT_SELECTOR)
222
+ if (input) input.value = query
223
+ renderPill(inst)
224
+ inst.root.setAttribute('data-cmdk-active-scope', scope)
225
+ filterItems(inst)
226
+ sortItems(inst)
227
+ selectFirstItem(inst, emit ? undefined : { scroll: false, emit: false })
228
+ if (emit) {
229
+ inst.root.dispatchEvent(
230
+ new CustomEvent('cmdk-scope-change', { bubbles: true, detail: { scope, query: inst.query } }),
231
+ )
232
+ inst.root.dispatchEvent(
233
+ new CustomEvent(SEARCH_CHANGE_EVENT, { bubbles: true, detail: { search: inst.search, scope, query: inst.query } }),
234
+ )
235
+ input?.focus()
236
+ }
237
+ }
238
+
239
+ /** Leave the active scope (Backspace on empty input, pill click, or API). */
240
+ export function exitScope(target) {
241
+ const inst = target.root ? target : getInstance(target)
242
+ if (!inst || !inst.scope) return
243
+ inst.scope = null
244
+ syncSearchMeta(inst)
245
+ renderPill(inst)
246
+ inst.root.removeAttribute('data-cmdk-active-scope')
247
+ filterItems(inst)
248
+ sortItems(inst)
249
+ selectFirstItem(inst)
250
+ inst.root.dispatchEvent(new CustomEvent('cmdk-scope-change', { bubbles: true, detail: { scope: null, query: inst.query } }))
251
+ inst.root.dispatchEvent(
252
+ new CustomEvent(SEARCH_CHANGE_EVENT, { bubbles: true, detail: { search: inst.search, scope: null, query: inst.query } }),
253
+ )
254
+ }
255
+
256
+ function config(root) {
257
+ return {
258
+ shouldFilter: root.getAttribute('data-cmdk-should-filter') !== 'false',
259
+ loop: root.hasAttribute('data-cmdk-loop'),
260
+ vimBindings: root.getAttribute('data-cmdk-vim-bindings') !== 'false',
261
+ disablePointerSelection: root.hasAttribute('data-cmdk-disable-pointer-selection'),
262
+ }
263
+ }
264
+
265
+ function resolveRoot(target) {
266
+ const el = typeof target === 'string' ? document.querySelector(target) : target
267
+ if (!el) return null
268
+ return el.matches?.(ROOT_SELECTOR) ? el : el.querySelector(ROOT_SELECTOR)
269
+ }
270
+
271
+ export function getInstance(target) {
272
+ const root = resolveRoot(target)
273
+ if (!root) return null
274
+ let inst = instances.get(root)
275
+ if (!inst) inst = createInstance(root)
276
+ return inst
277
+ }
278
+
279
+ function createInstance(root) {
280
+ const inst = {
281
+ root,
282
+ search: '',
283
+ scope: null, // pinned scope (rendered as a pill before the input)
284
+ picker: false, // true while the search starts with the scope-picker prefix
285
+ query: '', // the effective query (picker prefix stripped in picker mode)
286
+ value: (root.getAttribute('data-cmdk-default-value') || '').trim(),
287
+ count: 0,
288
+ filter: null, // per-root custom filter, see setFilter()
289
+ scores: new Map(), // item element → score
290
+ knownItems: new Set(),
291
+ order: new Map(), // element → original index, to restore server order when search clears
292
+ orderUid: 0,
293
+ itemUid: 0,
294
+ }
295
+ instances.set(root, inst)
296
+
297
+ wireIds(inst)
298
+ registerNodes(inst)
299
+ // A server-rendered input value is the initial search (React: <Command.Input value>).
300
+ inst.search = root.querySelector(INPUT_SELECTOR)?.value || ''
301
+ // Server-rendered scope state: data-cmdk-active-scope pins the pill at init.
302
+ const ssrScope = root.getAttribute('data-cmdk-active-scope')
303
+ if (ssrScope) {
304
+ inst.scope = ssrScope
305
+ renderPill(inst)
306
+ }
307
+ syncSearchMeta(inst)
308
+ filterItems(inst)
309
+ sortItems(inst)
310
+ if (!inst.value || !getSelectedItem(inst)) selectFirstItem(inst, { scroll: false, emit: false })
311
+ else applySelection(inst)
312
+ observeListHeight(inst)
313
+
314
+ // Item mount/unmount lifecycle (Turbo streams, morphing, manual DOM changes).
315
+ new MutationObserver(() => onItemsChanged(inst)).observe(root, { childList: true, subtree: true })
316
+
317
+ return inst
318
+ }
319
+
320
+ function wireIds(inst) {
321
+ const { root } = inst
322
+ if (!root.id) root.id = `cmdk-${++uid}`
323
+ const label = root.querySelector('[cmdk-label]')
324
+ const input = root.querySelector(INPUT_SELECTOR)
325
+ const list = root.querySelector(LIST_SELECTOR)
326
+ if (label && !label.id) label.id = `${root.id}-label`
327
+ if (input) {
328
+ if (!input.id) input.id = `${root.id}-input`
329
+ if (label) {
330
+ label.htmlFor = input.id
331
+ input.setAttribute('aria-labelledby', label.id)
332
+ }
333
+ }
334
+ if (list) {
335
+ if (!list.id) list.id = `${root.id}-list`
336
+ if (input) input.setAttribute('aria-controls', list.id)
337
+ }
338
+ }
339
+
340
+ /** Assign ids, infer data-value from textContent (like React cmdk), record DOM order. */
341
+ function registerNodes(inst) {
342
+ const { root } = inst
343
+ for (const item of root.querySelectorAll(ITEM_SELECTOR)) {
344
+ if (!item.id) item.id = `${root.id}-item-${++inst.itemUid}`
345
+ if (!item.hasAttribute(VALUE_ATTR)) item.setAttribute(VALUE_ATTR, (item.textContent || '').trim())
346
+ inst.knownItems.add(item)
347
+ }
348
+ for (const group of root.querySelectorAll(GROUP_SELECTOR)) {
349
+ if (!group.hasAttribute(VALUE_ATTR)) {
350
+ const heading = group.querySelector(GROUP_HEADING_SELECTOR)
351
+ group.setAttribute(VALUE_ATTR, (heading?.textContent || '').trim())
352
+ }
353
+ }
354
+ for (const container of containersOf(inst)) {
355
+ for (const child of container.children) {
356
+ if (!inst.order.has(child)) inst.order.set(child, inst.orderUid++)
357
+ }
358
+ }
359
+ }
360
+
361
+ function containersOf(inst) {
362
+ const sizer = inst.root.querySelector(SIZER_SELECTOR)
363
+ return [...(sizer ? [sizer] : []), ...inst.root.querySelectorAll(GROUP_ITEMS_SELECTOR)]
364
+ }
365
+
366
+ function itemValue(item) {
367
+ return item.getAttribute(VALUE_ATTR) || ''
368
+ }
369
+
370
+ function itemKeywords(item) {
371
+ return (item.getAttribute('data-cmdk-keywords') || '').split(' ').filter(Boolean)
372
+ }
373
+
374
+ /** forceMount items are always rendered and excluded from filtering, like in React. */
375
+ function forceMounted(item) {
376
+ return (
377
+ item.hasAttribute('data-cmdk-force-mount') ||
378
+ Boolean(item.closest(GROUP_SELECTOR)?.hasAttribute('data-cmdk-force-mount'))
379
+ )
380
+ }
381
+
382
+ function isVisible(el) {
383
+ if (el.style.display === 'none') return false
384
+ const group = el.closest(GROUP_SELECTOR)
385
+ if (group && group.hidden) return false
386
+ return true
387
+ }
388
+
389
+ function getValidItems(inst) {
390
+ return Array.from(inst.root.querySelectorAll(VALID_ITEM_SELECTOR)).filter(isVisible)
391
+ }
392
+
393
+ function getSelectedItem(inst) {
394
+ if (!inst.value) return null
395
+ return Array.from(inst.root.querySelectorAll(ITEM_SELECTOR)).find((item) => itemValue(item) === inst.value) || null
396
+ }
397
+
398
+ function score(inst, value, keywords, item) {
399
+ const filter = inst.filter || globalFilter
400
+ // The query (scope trigger stripped) is what gets matched; the item element
401
+ // is an extension over the React filter signature for userland scoping.
402
+ return value ? filter(value, inst.query, keywords, item) : 0
403
+ }
404
+
405
+ /** Port of filterItems(): score items, toggle item/group/separator/empty visibility. */
406
+ function filterItems(inst) {
407
+ const { root, search, scope, picker } = inst
408
+ const filtering = Boolean(search) && config(root).shouldFilter
409
+ let count = 0
410
+
411
+ for (const item of root.querySelectorAll(ITEM_SELECTOR)) {
412
+ const fm = forceMounted(item)
413
+ // Scope-only items require deliberate entry: hidden (even from global
414
+ // search and force mount) unless their scope is the active one.
415
+ const scopeHidden = isScopeOnly(item) && itemScope(item) !== scope
416
+ let rank
417
+ if (scopeHidden) {
418
+ rank = 0
419
+ } else if (picker) {
420
+ // Scope-picker mode ("/..."): only scope-entry items are suggested.
421
+ rank = item.hasAttribute('data-cmdk-enters-scope')
422
+ ? score(inst, itemValue(item), itemKeywords(item), item)
423
+ : 0
424
+ } else if (scope && itemScope(item) !== scope) {
425
+ rank = 0
426
+ } else if (item.closest('[data-cmdk-server-filtered]')) {
427
+ // The server already filtered these (e.g. a Turbo-framed scope search);
428
+ // don't fuzzy-match them against the query again.
429
+ rank = 1
430
+ } else {
431
+ rank = filtering ? score(inst, itemValue(item), itemKeywords(item), item) : 1
432
+ }
433
+ inst.scores.set(item, rank)
434
+ const shown = !scopeHidden && (fm || rank > 0)
435
+ // React removes filtered items from the DOM; we hide them with an inline
436
+ // style so theme CSS (e.g. `[cmdk-item] { display: flex }`) cannot win.
437
+ item.style.display = shown ? '' : 'none'
438
+ if (!fm && shown) count++
439
+ }
440
+ inst.count = count
441
+
442
+ for (const group of root.querySelectorAll(GROUP_SELECTOR)) {
443
+ let shown = !(isScopeOnly(group) && itemScope(group) !== scope)
444
+ if (shown && (filtering || picker || scope)) {
445
+ shown =
446
+ (!picker && group.hasAttribute('data-cmdk-force-mount')) ||
447
+ Array.from(group.querySelectorAll(ITEM_SELECTOR)).some(
448
+ (item) => !forceMounted(item) && inst.scores.get(item) > 0,
449
+ )
450
+ } else if (shown) {
451
+ // Hide groups whose items are all scope-hidden; keep itemless groups
452
+ // visible (they may be filled by a server-backed scope search).
453
+ const items = Array.from(group.querySelectorAll(ITEM_SELECTOR))
454
+ shown = items.length === 0 || items.some((item) => item.style.display !== 'none')
455
+ }
456
+ group.hidden = !shown
457
+ }
458
+
459
+ for (const separator of root.querySelectorAll('[cmdk-separator]')) {
460
+ const shown = (!search && !scope) || separator.hasAttribute('data-cmdk-always-render')
461
+ separator.style.display = shown ? '' : 'none'
462
+ }
463
+
464
+ for (const empty of root.querySelectorAll('[cmdk-empty]')) {
465
+ empty.style.display = count === 0 ? '' : 'none'
466
+ }
467
+ }
468
+
469
+ /** Find the ancestor of `el` that is a direct child of `container`. */
470
+ function directChild(container, el) {
471
+ let node = el
472
+ while (node && node.parentElement !== container) node = node.parentElement
473
+ return node || el
474
+ }
475
+
476
+ /** Port of sort(): order items by score and groups by their best item. */
477
+ function sortItems(inst) {
478
+ const { root, search, scores } = inst
479
+ const sizer = root.querySelector(SIZER_SELECTOR) || root.querySelector(LIST_SELECTOR) || root
480
+
481
+ if (!search || !config(root).shouldFilter) {
482
+ // Deviation from React cmdk: restore the server-rendered order once the
483
+ // search clears, since that order is canonical.
484
+ restoreOrder(inst)
485
+ return
486
+ }
487
+
488
+ getValidItems(inst)
489
+ .sort((a, b) => (scores.get(b) ?? 0) - (scores.get(a) ?? 0))
490
+ .forEach((item) => {
491
+ const container = item.closest(GROUP_ITEMS_SELECTOR) || sizer
492
+ container.appendChild(directChild(container, item))
493
+ })
494
+
495
+ Array.from(root.querySelectorAll(GROUP_SELECTOR))
496
+ .filter((group) => !group.hidden)
497
+ .map((group) => {
498
+ let max = 0
499
+ for (const item of group.querySelectorAll(ITEM_SELECTOR)) {
500
+ max = Math.max(max, scores.get(item) ?? 0)
501
+ }
502
+ return [group, max]
503
+ })
504
+ .sort((a, b) => b[1] - a[1])
505
+ .forEach(([group]) => group.parentElement?.appendChild(group))
506
+ }
507
+
508
+ function restoreOrder(inst) {
509
+ for (const container of containersOf(inst)) {
510
+ Array.from(container.children)
511
+ .filter((child) => inst.order.has(child))
512
+ .sort((a, b) => inst.order.get(a) - inst.order.get(b))
513
+ .forEach((child) => container.appendChild(child))
514
+ }
515
+ }
516
+
517
+ function applySelection(inst) {
518
+ let selected = null
519
+ for (const item of inst.root.querySelectorAll(ITEM_SELECTOR)) {
520
+ const isSelected = Boolean(inst.value) && itemValue(item) === inst.value
521
+ if (isSelected && !selected) selected = item
522
+ item.setAttribute('aria-selected', String(isSelected))
523
+ item.setAttribute('data-selected', String(isSelected))
524
+ }
525
+
526
+ for (const el of [inst.root.querySelector(INPUT_SELECTOR), inst.root.querySelector(LIST_SELECTOR)]) {
527
+ if (!el) continue
528
+ if (selected) el.setAttribute('aria-activedescendant', selected.id)
529
+ else el.removeAttribute('aria-activedescendant')
530
+ }
531
+
532
+ updateFooterHint(inst, selected)
533
+ }
534
+
535
+ /** Fill [cmdk-footer-hint] with the selected item's data-cmdk-hint / data-cmdk-kbd. */
536
+ function updateFooterHint(inst, selected) {
537
+ const el = inst.root.querySelector('[cmdk-footer-hint]')
538
+ if (!el) return
539
+ const hint = selected?.getAttribute('data-cmdk-hint')
540
+ const kbd = selected?.getAttribute('data-cmdk-kbd')
541
+ el.replaceChildren()
542
+ el.toggleAttribute('data-empty', !hint && !kbd)
543
+ if (hint) {
544
+ const label = document.createElement('span')
545
+ label.textContent = hint
546
+ el.appendChild(label)
547
+ }
548
+ for (const key of (kbd || '').split(/\s+/).filter(Boolean)) {
549
+ const cap = document.createElement('kbd')
550
+ cap.textContent = key
551
+ el.appendChild(cap)
552
+ }
553
+ }
554
+
555
+ function scrollSelectedIntoView(inst) {
556
+ const item = getSelectedItem(inst)
557
+ if (!item) return
558
+
559
+ const siblings = item.parentElement ? Array.from(item.parentElement.children) : []
560
+ if (siblings.find(isVisible) === item) {
561
+ // First item in its group: keep the heading in view too.
562
+ item.closest(GROUP_SELECTOR)?.querySelector(GROUP_HEADING_SELECTOR)?.scrollIntoView({ block: 'nearest' })
563
+ }
564
+ item.scrollIntoView({ block: 'nearest' })
565
+ }
566
+
567
+ function setValueState(inst, value, { scroll = true, emit = true } = {}) {
568
+ value = value || ''
569
+ if (Object.is(inst.value, value)) return
570
+ inst.value = value
571
+ applySelection(inst)
572
+ if (scroll) scrollSelectedIntoView(inst)
573
+ if (emit) inst.root.dispatchEvent(new CustomEvent(VALUE_CHANGE_EVENT, { bubbles: true, detail: { value } }))
574
+ }
575
+
576
+ function setSearchState(inst, search) {
577
+ if (Object.is(inst.search, search)) return
578
+ inst.search = search
579
+
580
+ if (!inst.scope) {
581
+ // Typing a scope name out in picker mode ("/user ") commits it.
582
+ const pc = pickerChar(inst.root)
583
+ if (pc && search.startsWith(pc)) {
584
+ const rest = search.slice(pc.length)
585
+ const hit = scopesOf(inst.root).find((name) => rest === `${name} `)
586
+ if (hit) return enterScope(inst, hit)
587
+ }
588
+ }
589
+
590
+ syncSearchMeta(inst)
591
+ filterItems(inst)
592
+ sortItems(inst)
593
+ selectFirstItem(inst)
594
+ inst.root.dispatchEvent(
595
+ new CustomEvent(SEARCH_CHANGE_EVENT, { bubbles: true, detail: { search, scope: inst.scope, query: inst.query } }),
596
+ )
597
+ }
598
+
599
+ function selectFirstItem(inst, opts) {
600
+ const item = getValidItems(inst)[0]
601
+ setValueState(inst, item ? itemValue(item) : '', opts)
602
+ }
603
+
604
+ /** Item chosen via click or Enter. Dispatches cmdk-item-select; honors data-href. */
605
+ function triggerSelect(inst, item) {
606
+ if (item.getAttribute('aria-disabled') === 'true') return
607
+ const value = itemValue(item)
608
+ setValueState(inst, value, { scroll: false })
609
+ const event = new CustomEvent(SELECT_EVENT, { bubbles: true, cancelable: true, detail: { value } })
610
+ const proceed = item.dispatchEvent(event)
611
+ if (!proceed) return
612
+ const enters = item.getAttribute('data-cmdk-enters-scope')
613
+ if (enters) {
614
+ enterScope(inst, enters)
615
+ return
616
+ }
617
+ const href = item.getAttribute('data-href')
618
+ if (href) {
619
+ if (window.Turbo?.visit) window.Turbo.visit(href)
620
+ else window.location.assign(href)
621
+ }
622
+ }
623
+
624
+ // ── Keyboard navigation ──
625
+
626
+ function updateSelectedToIndex(inst, index) {
627
+ const item = getValidItems(inst)[index]
628
+ if (item) setValueState(inst, itemValue(item))
629
+ }
630
+
631
+ function updateSelectedByItem(inst, change) {
632
+ const selected = getSelectedItem(inst)
633
+ const items = getValidItems(inst)
634
+ const index = items.findIndex((item) => item === selected)
635
+ let newSelected = items[index + change]
636
+
637
+ if (config(inst.root).loop) {
638
+ newSelected =
639
+ index + change < 0 ? items[items.length - 1] : index + change === items.length ? items[0] : items[index + change]
640
+ }
641
+
642
+ if (newSelected) setValueState(inst, itemValue(newSelected))
643
+ }
644
+
645
+ function findSibling(el, selector, direction) {
646
+ let sibling = direction > 0 ? el.nextElementSibling : el.previousElementSibling
647
+ while (sibling) {
648
+ if (sibling.matches(selector)) return sibling
649
+ sibling = direction > 0 ? sibling.nextElementSibling : sibling.previousElementSibling
650
+ }
651
+ return null
652
+ }
653
+
654
+ function updateSelectedByGroup(inst, change) {
655
+ const selected = getSelectedItem(inst)
656
+ let group = selected?.closest(GROUP_SELECTOR)
657
+ let item = null
658
+
659
+ while (group && !item) {
660
+ group = findSibling(group, GROUP_SELECTOR, change)
661
+ item = group ? Array.from(group.querySelectorAll(VALID_ITEM_SELECTOR)).find(isVisible) : null
662
+ }
663
+
664
+ if (item) setValueState(inst, itemValue(item))
665
+ else updateSelectedByItem(inst, change)
666
+ }
667
+
668
+ function next(inst, e) {
669
+ e.preventDefault()
670
+ if (e.metaKey) updateSelectedToIndex(inst, getValidItems(inst).length - 1)
671
+ else if (e.altKey) updateSelectedByGroup(inst, 1)
672
+ else updateSelectedByItem(inst, 1)
673
+ }
674
+
675
+ function prev(inst, e) {
676
+ e.preventDefault()
677
+ if (e.metaKey) updateSelectedToIndex(inst, 0)
678
+ else if (e.altKey) updateSelectedByGroup(inst, -1)
679
+ else updateSelectedByItem(inst, -1)
680
+ }
681
+
682
+ // ── Item lifecycle (MutationObserver callback) ──
683
+
684
+ function onItemsChanged(inst) {
685
+ const current = new Set(inst.root.querySelectorAll(ITEM_SELECTOR))
686
+ let changed = current.size !== inst.knownItems.size
687
+ if (!changed) {
688
+ for (const item of current) {
689
+ if (!inst.knownItems.has(item)) {
690
+ changed = true
691
+ break
692
+ }
693
+ }
694
+ }
695
+ // Pure reorders (our own sorting) and attribute changes don't re-filter.
696
+ if (!changed) return
697
+
698
+ inst.knownItems = current
699
+ registerNodes(inst)
700
+ filterItems(inst)
701
+ sortItems(inst)
702
+ if (!inst.value || !getSelectedItem(inst)) selectFirstItem(inst)
703
+ else applySelection(inst)
704
+ }
705
+
706
+ // ── --cmdk-list-height (port of the List ResizeObserver) ──
707
+
708
+ function observeListHeight(inst) {
709
+ const list = inst.root.querySelector(LIST_SELECTOR)
710
+ const sizer = inst.root.querySelector(SIZER_SELECTOR)
711
+ if (!list || !sizer || typeof ResizeObserver === 'undefined') return
712
+ const update = () => list.style.setProperty('--cmdk-list-height', sizer.offsetHeight.toFixed(1) + 'px')
713
+ update()
714
+ let frame
715
+ new ResizeObserver(() => {
716
+ cancelAnimationFrame(frame)
717
+ frame = requestAnimationFrame(update)
718
+ }).observe(sizer)
719
+ }
720
+
721
+ // ── Dialog (native <dialog> port of Command.Dialog) ──
722
+
723
+ function resolveDialog(target) {
724
+ const el = typeof target === 'string' ? document.querySelector(target) : target
725
+ if (!el) return null
726
+ return el.matches?.(DIALOG_SELECTOR) ? el : el.closest?.(DIALOG_SELECTOR) || el.querySelector?.(DIALOG_SELECTOR)
727
+ }
728
+
729
+ export function openDialog(target) {
730
+ const dialog = resolveDialog(target)
731
+ if (!dialog || dialog.open) return
732
+ dialog.showModal()
733
+ const root = dialog.querySelector(ROOT_SELECTOR)
734
+ if (root) {
735
+ getInstance(root)
736
+ root.querySelector(INPUT_SELECTOR)?.focus()
737
+ }
738
+ }
739
+
740
+ export function closeDialog(target) {
741
+ resolveDialog(target)?.close()
742
+ }
743
+
744
+ function setupDialog(dialog) {
745
+ if (knownDialogs.has(dialog)) return
746
+ knownDialogs.add(dialog)
747
+ if (dialog.hasAttribute('data-cmdk-open')) {
748
+ dialog.removeAttribute('data-cmdk-open')
749
+ openDialog(dialog)
750
+ }
751
+ }
752
+
753
+ function handleDialogHotkey(e) {
754
+ if (!(e.metaKey || e.ctrlKey) || e.repeat) return
755
+ // ctrl+n/j/p/k inside a menu with vim bindings is navigation, not a hotkey.
756
+ if (e.ctrlKey && !e.metaKey && ['n', 'j', 'p', 'k'].includes(e.key)) {
757
+ const root = e.target instanceof Element ? e.target.closest(ROOT_SELECTOR) : null
758
+ if (root && config(root).vimBindings) return
759
+ }
760
+ for (const dialog of document.querySelectorAll(`${DIALOG_SELECTOR}[data-cmdk-dialog-hotkey]`)) {
761
+ if (e.key.toLowerCase() === dialog.getAttribute('data-cmdk-dialog-hotkey').toLowerCase()) {
762
+ e.preventDefault()
763
+ dialog.open ? closeDialog(dialog) : openDialog(dialog)
764
+ }
765
+ }
766
+ }
767
+
768
+ // ── Delegated events ──
769
+
770
+ function onKeydown(e) {
771
+ handleDialogHotkey(e)
772
+
773
+ const root = e.target instanceof Element ? e.target.closest(ROOT_SELECTOR) : null
774
+ if (!root) return
775
+ const inst = getInstance(root)
776
+
777
+ // IME composition guard (keyCode 229 covers legacy CJK IMEs).
778
+ if (e.defaultPrevented || e.isComposing || e.keyCode === 229) return
779
+ const { vimBindings } = config(root)
780
+
781
+ switch (e.key) {
782
+ case 'n':
783
+ case 'j': {
784
+ if (vimBindings && e.ctrlKey) next(inst, e)
785
+ break
786
+ }
787
+ case 'ArrowDown': {
788
+ next(inst, e)
789
+ break
790
+ }
791
+ case 'p':
792
+ case 'k': {
793
+ if (vimBindings && e.ctrlKey) prev(inst, e)
794
+ break
795
+ }
796
+ case 'ArrowUp': {
797
+ prev(inst, e)
798
+ break
799
+ }
800
+ case 'Backspace': {
801
+ // Backspace on an empty input pops the scope pill.
802
+ if (inst.scope && e.target.matches?.(INPUT_SELECTOR) && e.target.value === '') {
803
+ e.preventDefault()
804
+ exitScope(inst)
805
+ }
806
+ break
807
+ }
808
+ case 'Home': {
809
+ e.preventDefault()
810
+ updateSelectedToIndex(inst, 0)
811
+ break
812
+ }
813
+ case 'End': {
814
+ e.preventDefault()
815
+ updateSelectedToIndex(inst, getValidItems(inst).length - 1)
816
+ break
817
+ }
818
+ case 'Enter': {
819
+ e.preventDefault()
820
+ const item = getSelectedItem(inst)
821
+ if (item) triggerSelect(inst, item)
822
+ break
823
+ }
824
+ }
825
+ }
826
+
827
+ function onInput(e) {
828
+ if (!(e.target instanceof Element) || !e.target.matches(INPUT_SELECTOR)) return
829
+ const root = e.target.closest(ROOT_SELECTOR)
830
+ if (root) setSearchState(getInstance(root), e.target.value)
831
+ }
832
+
833
+ function onClick(e) {
834
+ if (!(e.target instanceof Element)) return
835
+
836
+ // Click on the backdrop closes the dialog (the dialog itself is the target).
837
+ if (e.target instanceof HTMLDialogElement && e.target.matches(DIALOG_SELECTOR)) {
838
+ const rect = e.target.getBoundingClientRect()
839
+ const inside =
840
+ e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom
841
+ if (!inside) closeDialog(e.target)
842
+ return
843
+ }
844
+
845
+ const pill = e.target.closest('[cmdk-scope-pill]')
846
+ if (pill) {
847
+ const root = pill.closest(ROOT_SELECTOR)
848
+ if (root) exitScope(getInstance(root))
849
+ return
850
+ }
851
+
852
+ const item = e.target.closest(ITEM_SELECTOR)
853
+ const root = item?.closest(ROOT_SELECTOR)
854
+ if (item && root) triggerSelect(getInstance(root), item)
855
+ }
856
+
857
+ function onPointerMove(e) {
858
+ if (!(e.target instanceof Element)) return
859
+ // Touch "hover" is just scrolling — don't drag the selection under the finger.
860
+ if (e.pointerType === 'touch') return
861
+ const item = e.target.closest(ITEM_SELECTOR)
862
+ const root = item?.closest(ROOT_SELECTOR)
863
+ if (!item || !root) return
864
+ if (item.getAttribute('aria-disabled') === 'true') return
865
+ const inst = getInstance(root)
866
+ if (config(root).disablePointerSelection) return
867
+ setValueState(inst, itemValue(item), { scroll: false })
868
+ }
869
+
870
+ // ── Public API ──
871
+
872
+ export function scan(node = document) {
873
+ if (node instanceof Element && node.matches(ROOT_SELECTOR)) getInstance(node)
874
+ if (node instanceof Element && node.matches(DIALOG_SELECTOR)) setupDialog(node)
875
+ node.querySelectorAll?.(ROOT_SELECTOR).forEach((root) => getInstance(root))
876
+ node.querySelectorAll?.(DIALOG_SELECTOR).forEach(setupDialog)
877
+ }
878
+
879
+ /** Programmatically set the search query (the React `<Command.Input value>` controlled mode). */
880
+ export function setSearch(target, search) {
881
+ const inst = getInstance(target)
882
+ if (!inst) return
883
+ const input = inst.root.querySelector(INPUT_SELECTOR)
884
+ if (input) input.value = search
885
+ setSearchState(inst, search)
886
+ }
887
+
888
+ /** Programmatically set the selected value (the React `<Command value>` controlled mode). */
889
+ export function setValue(target, value) {
890
+ const inst = getInstance(target)
891
+ if (inst) setValueState(inst, (value || '').trim())
892
+ }
893
+
894
+ /** Read the current state, the closest equivalent of React's useCommandState. */
895
+ export function getState(target) {
896
+ const inst = getInstance(target)
897
+ if (!inst) return null
898
+ return {
899
+ search: inst.search,
900
+ scope: inst.scope,
901
+ picker: inst.picker,
902
+ query: inst.query,
903
+ value: inst.value,
904
+ filtered: { count: inst.count },
905
+ }
906
+ }
907
+
908
+ /**
909
+ * Override the filter function: `setFilter(fn)` replaces the default for all
910
+ * menus, `setFilter(rootEl, fn)` for one. Pass `null` to reset.
911
+ * fn(value, query, keywords, item) → number in [0, 1], 0 hides the item.
912
+ * The item element (4th arg, an extension over the React signature) enables
913
+ * fully custom search syntax — scoping, operators, per-item logic.
914
+ */
915
+ export function setFilter(target, fn) {
916
+ if (typeof target === 'function' || target === null) {
917
+ globalFilter = target || defaultFilter
918
+ return
919
+ }
920
+ const inst = getInstance(target)
921
+ if (!inst) return
922
+ inst.filter = fn
923
+ filterItems(inst)
924
+ sortItems(inst)
925
+ selectFirstItem(inst)
926
+ }
927
+
928
+ /**
929
+ * Sensible default dialog placement: a top-third command palette, centered
930
+ * horizontally. CSS resets (e.g. Tailwind preflight's universal `margin: 0`)
931
+ * destroy the native <dialog> centering, so the runtime re-asserts it here.
932
+ * `:where()` keeps specificity at zero — any consumer rule on
933
+ * `dialog[cmdk-dialog]` or a class overrides these.
934
+ */
935
+ function injectBaseStyles() {
936
+ if (document.getElementById('cmdk-base-styles')) return
937
+ const style = document.createElement('style')
938
+ style.id = 'cmdk-base-styles'
939
+ style.textContent = `
940
+ :where(dialog[cmdk-dialog]) {
941
+ position: fixed;
942
+ inset: 0;
943
+ margin: 16dvh auto auto;
944
+ width: fit-content;
945
+ height: fit-content;
946
+ max-width: calc(100vw - 2rem);
947
+ max-height: calc(84dvh - 2rem);
948
+ /* The list scrolls internally; keep the dialog itself unclipped so a themed
949
+ root's border-radius and drop-shadow are not squared off at the corners. */
950
+ overflow: visible;
951
+ }
952
+ :where(dialog[cmdk-dialog])::backdrop {
953
+ background: transparent;
954
+ }
955
+ :where([cmdk-list-sizer]) {
956
+ display: flow-root; /* child margins must count into --cmdk-list-height */
957
+ }
958
+ @media (max-width: 640px) {
959
+ :where(dialog[cmdk-dialog]) {
960
+ margin: 0.5rem auto auto;
961
+ width: calc(100vw - 1rem);
962
+ max-width: calc(100vw - 1rem);
963
+ max-height: calc(100dvh - 1rem);
964
+ }
965
+ }`
966
+ document.head.appendChild(style)
967
+ }
968
+
969
+ let started = false
970
+
971
+ export function start() {
972
+ if (started || typeof document === 'undefined') return
973
+ started = true
974
+ injectBaseStyles()
975
+
976
+ document.addEventListener('keydown', onKeydown)
977
+ document.addEventListener('input', onInput)
978
+ document.addEventListener('click', onClick)
979
+ document.addEventListener('pointermove', onPointerMove)
980
+
981
+ const init = () => scan()
982
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init)
983
+ else init()
984
+
985
+ // Picks up roots/dialogs added by Turbo navigation, frames and streams.
986
+ new MutationObserver((records) => {
987
+ for (const record of records) {
988
+ for (const node of record.addedNodes) {
989
+ if (node.nodeType === Node.ELEMENT_NODE) scan(node)
990
+ }
991
+ }
992
+ }).observe(document.documentElement, { childList: true, subtree: true })
993
+ }
994
+
995
+ const Cmdk = {
996
+ start,
997
+ scan,
998
+ commandScore,
999
+ defaultFilter,
1000
+ setFilter,
1001
+ setSearch,
1002
+ setValue,
1003
+ getState,
1004
+ getInstance,
1005
+ enterScope,
1006
+ exitScope,
1007
+ openDialog,
1008
+ closeDialog,
1009
+ }
1010
+
1011
+ if (typeof window !== 'undefined') window.Cmdk = Cmdk
1012
+
1013
+ start()
1014
+
1015
+ export default Cmdk