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
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
|