@choice-ui/command 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@choice-ui/command",
3
+ "version": "0.0.3",
4
+ "description": "Command component for Choiceform Design System",
5
+ "sideEffects": false,
6
+ "type": "module",
7
+ "main": "./dist/index.cjs",
8
+ "module": "./dist/index.js",
9
+ "types": "./src/index.ts",
10
+ "files": [
11
+ "dist",
12
+ "src"
13
+ ],
14
+ "exports": {
15
+ ".": {
16
+ "types": "./src/index.ts",
17
+ "import": "./dist/index.js",
18
+ "require": "./dist/index.cjs"
19
+ }
20
+ },
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "dependencies": {
25
+ "usehooks-ts": "^3.1.0",
26
+ "@choice-ui/shared": "0.0.1",
27
+ "@choice-ui/input": "0.0.4",
28
+ "@choice-ui/kbd": "0.0.2",
29
+ "@choice-ui/scroll-area": "0.0.4",
30
+ "@choice-ui/tabs": "0.0.4"
31
+ },
32
+ "devDependencies": {
33
+ "@types/react": "^18.3.12",
34
+ "@types/react-dom": "^18.3.1",
35
+ "react": "^18.3.1",
36
+ "react-dom": "^18.3.1",
37
+ "rimraf": "^6.0.1",
38
+ "tsup": "^8.5.0",
39
+ "typescript": "^5.5.3"
40
+ },
41
+ "peerDependencies": {
42
+ "react": ">=18.0.0",
43
+ "react-dom": ">=18.0.0"
44
+ },
45
+ "scripts": {
46
+ "build": "tsup",
47
+ "build:watch": "tsup --watch",
48
+ "clean": "rimraf dist"
49
+ }
50
+ }
@@ -0,0 +1,171 @@
1
+ // The scores are arranged so that a continuous match of characters will
2
+ // result in a total score of 1.
3
+ //
4
+ // The best case, this character is a match, and either this is the start
5
+ // of the string, or the previous character was also a match.
6
+ const SCORE_CONTINUE_MATCH = 1,
7
+ // A new match at the start of a word scores better than a new match
8
+ // elsewhere as it's more likely that the user will type the starts
9
+ // of fragments.
10
+ // NOTE: We score word jumps between spaces slightly higher than slashes, brackets
11
+ // hyphens, etc.
12
+ SCORE_SPACE_WORD_JUMP = 0.9,
13
+ SCORE_NON_SPACE_WORD_JUMP = 0.8,
14
+ // Any other match isn't ideal, but we include it for completeness.
15
+ SCORE_CHARACTER_JUMP = 0.17,
16
+ // If the user transposed two letters, it should be significantly penalized.
17
+ //
18
+ // i.e. "ouch" is more likely than "curtain" when "uc" is typed.
19
+ SCORE_TRANSPOSITION = 0.1,
20
+ // The goodness of a match should decay slightly with each missing
21
+ // character.
22
+ //
23
+ // i.e. "bad" is more likely than "bard" when "bd" is typed.
24
+ //
25
+ // This will not change the order of suggestions based on SCORE_* until
26
+ // 100 characters are inserted between matches.
27
+ PENALTY_SKIPPED = 0.999,
28
+ // The goodness of an exact-case match should be higher than a
29
+ // case-insensitive match by a small amount.
30
+ //
31
+ // i.e. "HTML" is more likely than "haml" when "HM" is typed.
32
+ //
33
+ // This will not change the order of suggestions based on SCORE_* until
34
+ // 1000 characters are inserted between matches.
35
+ PENALTY_CASE_MISMATCH = 0.9999,
36
+ // Match higher for letters closer to the beginning of the word
37
+ PENALTY_DISTANCE_FROM_START = 0.9,
38
+ // If the word has more characters than the user typed, it should
39
+ // be penalised slightly.
40
+ //
41
+ // i.e. "html" is more likely than "html5" if I type "html".
42
+ //
43
+ // However, it may well be the case that there's a sensible secondary
44
+ // ordering (like alphabetical) that it makes sense to rely on when
45
+ // there are many prefix matches, so we don't make the penalty increase
46
+ // with the number of tokens.
47
+ PENALTY_NOT_COMPLETE = 0.99
48
+
49
+ const IS_GAP_REGEXP = /[\\/_+.#"@[({&]/,
50
+ COUNT_GAPS_REGEXP = /[\\/_+.#"@[({&]/g,
51
+ IS_SPACE_REGEXP = /[\s-]/,
52
+ COUNT_SPACE_REGEXP = /[\s-]/g
53
+
54
+ function commandScoreInner(
55
+ string: string,
56
+ abbreviation: string,
57
+ lowerString: string,
58
+ lowerAbbreviation: string,
59
+ stringIndex: number,
60
+ abbreviationIndex: number,
61
+ memoizedResults: Record<string, number>,
62
+ ): number {
63
+ if (abbreviationIndex === abbreviation.length) {
64
+ if (stringIndex === string.length) {
65
+ return SCORE_CONTINUE_MATCH
66
+ }
67
+ return PENALTY_NOT_COMPLETE
68
+ }
69
+
70
+ const memoizeKey = `${stringIndex},${abbreviationIndex}`
71
+ if (memoizedResults[memoizeKey] !== undefined) {
72
+ return memoizedResults[memoizeKey]
73
+ }
74
+
75
+ const abbreviationChar = lowerAbbreviation.charAt(abbreviationIndex)
76
+ let index = lowerString.indexOf(abbreviationChar, stringIndex)
77
+ let highScore = 0
78
+
79
+ let score, transposedScore, wordBreaks, spaceBreaks
80
+
81
+ while (index >= 0) {
82
+ score = commandScoreInner(
83
+ string,
84
+ abbreviation,
85
+ lowerString,
86
+ lowerAbbreviation,
87
+ index + 1,
88
+ abbreviationIndex + 1,
89
+ memoizedResults,
90
+ )
91
+ if (score > highScore) {
92
+ if (index === stringIndex) {
93
+ score *= SCORE_CONTINUE_MATCH
94
+ } else if (IS_GAP_REGEXP.test(string.charAt(index - 1))) {
95
+ score *= SCORE_NON_SPACE_WORD_JUMP
96
+ wordBreaks = string.slice(stringIndex, index - 1).match(COUNT_GAPS_REGEXP)
97
+ if (wordBreaks && stringIndex > 0) {
98
+ score *= Math.pow(PENALTY_SKIPPED, wordBreaks.length)
99
+ }
100
+ } else if (IS_SPACE_REGEXP.test(string.charAt(index - 1))) {
101
+ score *= SCORE_SPACE_WORD_JUMP
102
+ spaceBreaks = string.slice(stringIndex, index - 1).match(COUNT_SPACE_REGEXP)
103
+ if (spaceBreaks && stringIndex > 0) {
104
+ score *= Math.pow(PENALTY_SKIPPED, spaceBreaks.length)
105
+ }
106
+ } else {
107
+ score *= SCORE_CHARACTER_JUMP
108
+ if (stringIndex > 0) {
109
+ score *= Math.pow(PENALTY_SKIPPED, index - stringIndex)
110
+ }
111
+ }
112
+
113
+ if (string.charAt(index) !== abbreviation.charAt(abbreviationIndex)) {
114
+ score *= PENALTY_CASE_MISMATCH
115
+ }
116
+ }
117
+
118
+ if (
119
+ (score < SCORE_TRANSPOSITION &&
120
+ lowerString.charAt(index - 1) === lowerAbbreviation.charAt(abbreviationIndex + 1)) ||
121
+ (lowerAbbreviation.charAt(abbreviationIndex + 1) ===
122
+ lowerAbbreviation.charAt(abbreviationIndex) && // allow duplicate letters. Ref #7428
123
+ lowerString.charAt(index - 1) !== lowerAbbreviation.charAt(abbreviationIndex))
124
+ ) {
125
+ transposedScore = commandScoreInner(
126
+ string,
127
+ abbreviation,
128
+ lowerString,
129
+ lowerAbbreviation,
130
+ index + 1,
131
+ abbreviationIndex + 2,
132
+ memoizedResults,
133
+ )
134
+
135
+ if (transposedScore * SCORE_TRANSPOSITION > score) {
136
+ score = transposedScore * SCORE_TRANSPOSITION
137
+ }
138
+ }
139
+
140
+ if (score > highScore) {
141
+ highScore = score
142
+ }
143
+
144
+ index = lowerString.indexOf(abbreviationChar, index + 1)
145
+ }
146
+
147
+ memoizedResults[memoizeKey] = highScore
148
+ return highScore
149
+ }
150
+
151
+ function formatInput(string: string): string {
152
+ // convert all valid space characters to space so they match each other
153
+ return string.toLowerCase().replace(COUNT_SPACE_REGEXP, " ")
154
+ }
155
+
156
+ export function commandScore(string: string, abbreviation: string, aliases?: string[]): number {
157
+ /* NOTE:
158
+ * in the original, we used to do the lower-casing on each recursive call, but this meant that toLowerCase()
159
+ * was the dominating cost in the algorithm, passing both is a little ugly, but considerably faster.
160
+ */
161
+ string = aliases && aliases.length > 0 ? `${string + " " + aliases.join(" ")}` : string
162
+ return commandScoreInner(
163
+ string,
164
+ abbreviation,
165
+ formatInput(string),
166
+ formatInput(abbreviation),
167
+ 0,
168
+ 0,
169
+ {},
170
+ )
171
+ }
@@ -0,0 +1,482 @@
1
+ import { tcx } from "@choice-ui/shared"
2
+ import React, { forwardRef, useCallback, useId, useMemo, useRef } from "react"
3
+ import { useEventCallback, useIsomorphicLayoutEffect } from "usehooks-ts"
4
+ import { useLazyRef } from "@choice-ui/shared"
5
+ import { commandScore } from "./command-score"
6
+ import { CommandContext, createCommandContext, StoreContext } from "./context"
7
+ import { useAsRef, useScheduleLayoutEffect } from "./hooks"
8
+ import { commandTv } from "./tv"
9
+ import { CommandFilter, CommandProps, State, Store } from "./types"
10
+ import {
11
+ findNextSibling,
12
+ findPreviousSibling,
13
+ GROUP_HEADING_SELECTOR,
14
+ GROUP_ITEMS_SELECTOR,
15
+ GROUP_SELECTOR,
16
+ ITEM_SELECTOR,
17
+ SELECT_EVENT,
18
+ SlottableWithNestedChildren,
19
+ VALID_ITEM_SELECTOR,
20
+ VALUE_ATTR,
21
+ } from "./utils"
22
+
23
+ export const defaultFilter: CommandFilter = (value, search, keywords) =>
24
+ commandScore(value, search, keywords)
25
+
26
+ export const Command = forwardRef<HTMLDivElement, CommandProps>((props, forwardedRef) => {
27
+ const state = useLazyRef<State>(() => ({
28
+ /** Value of the search query. */
29
+ search: "",
30
+ /** Currently selected item value. */
31
+ value: props.value ?? props.defaultValue ?? "",
32
+ /** Currently selected item id. */
33
+ selectedItemId: undefined,
34
+ filtered: {
35
+ /** The count of all visible items. */
36
+ count: 0,
37
+ /** Map from visible item id to its search score. */
38
+ items: new Map(),
39
+ /** Set of groups with at least one visible item. */
40
+ groups: new Set(),
41
+ },
42
+ }))
43
+ const allItems = useLazyRef<Set<string>>(() => new Set()) // [...itemIds]
44
+ const allGroups = useLazyRef<Map<string, Set<string>>>(() => new Map()) // groupId → [...itemIds]
45
+ const ids = useLazyRef<Map<string, { keywords?: string[]; value: string }>>(() => new Map()) // id → { value, keywords }
46
+ const listeners = useLazyRef<Set<() => void>>(() => new Set()) // [...rerenders]
47
+ const propsRef = useAsRef(props)
48
+ const {
49
+ label,
50
+ children,
51
+ value,
52
+ onChange: onValueChange,
53
+ filter,
54
+ shouldFilter,
55
+ loop,
56
+ size = "default",
57
+ variant = "default",
58
+ disablePointerSelection = false,
59
+ vimBindings = true,
60
+ className,
61
+ ...etc
62
+ } = props
63
+
64
+ const listId = useId()
65
+ const labelId = useId()
66
+ const inputId = useId()
67
+
68
+ const listInnerRef = useRef<HTMLDivElement | null>(null)
69
+
70
+ const schedule = useScheduleLayoutEffect()
71
+
72
+ const tv = commandTv({ variant })
73
+
74
+ /** Controlled mode `value` handling. */
75
+ const store: Store = useMemo(() => {
76
+ return {
77
+ subscribe: (cb) => {
78
+ listeners.current.add(cb)
79
+ return () => listeners.current.delete(cb)
80
+ },
81
+ snapshot: () => {
82
+ return state.current
83
+ },
84
+ setState: (key, value, opts) => {
85
+ if (Object.is(state.current[key], value)) return
86
+ state.current[key] = value
87
+
88
+ if (key === "search") {
89
+ // Filter synchronously before emitting back to children
90
+ filterItems()
91
+ sort()
92
+ schedule(1, selectFirstItem)
93
+ } else if (key === "value") {
94
+ // Force focus input or root so accessibility works
95
+ const activeElement = document.activeElement as HTMLElement
96
+ if (
97
+ activeElement?.hasAttribute("data-command-input") ||
98
+ activeElement?.hasAttribute("data-command-root")
99
+ ) {
100
+ const input = document.getElementById(inputId)
101
+ if (input) input.focus()
102
+ else document.getElementById(listId)?.focus()
103
+ }
104
+
105
+ schedule(7, () => {
106
+ state.current.selectedItemId = getSelectedItem()?.id
107
+ store.emit()
108
+ })
109
+
110
+ // opts is a boolean referring to whether it should NOT be scrolled into view
111
+ if (!opts) {
112
+ // Scroll the selected item into view
113
+ schedule(5, scrollSelectedIntoView)
114
+ }
115
+ if (propsRef.current?.value !== undefined) {
116
+ // If controlled, just call the callback instead of updating state internally
117
+ const newValue = (value ?? "") as string
118
+ propsRef.current.onChange?.(newValue)
119
+ return
120
+ }
121
+ }
122
+
123
+ // Notify subscribers that state has changed
124
+ store.emit()
125
+ },
126
+ emit: () => {
127
+ listeners.current.forEach((l) => l())
128
+ },
129
+ }
130
+ }, [])
131
+
132
+ useIsomorphicLayoutEffect(() => {
133
+ if (value !== undefined) {
134
+ const v = value.trim()
135
+ state.current.value = v
136
+ store.emit()
137
+ }
138
+ }, [value])
139
+
140
+ useIsomorphicLayoutEffect(() => {
141
+ schedule(6, scrollSelectedIntoView)
142
+ }, [])
143
+
144
+ const score = useEventCallback((value: string, keywords?: string[]) => {
145
+ const filter = propsRef.current?.filter ?? defaultFilter
146
+ return value ? filter(value, state.current.search, keywords) : 0
147
+ })
148
+
149
+ /** Sorts items by score, and groups by highest item score. */
150
+ const sort = useCallback(() => {
151
+ if (
152
+ !state.current.search ||
153
+ // Explicitly false, because true | undefined is the default
154
+ propsRef.current.shouldFilter === false
155
+ ) {
156
+ return
157
+ }
158
+
159
+ const scores = state.current.filtered.items
160
+
161
+ // Sort the groups
162
+ const groups: [string, number][] = []
163
+ state.current.filtered.groups.forEach((value) => {
164
+ const items = allGroups.current.get(value)
165
+
166
+ // Get the maximum score of the group's items
167
+ let max = 0
168
+ items?.forEach((item) => {
169
+ const score = scores.get(item) ?? 0
170
+ max = Math.max(score, max)
171
+ })
172
+
173
+ groups.push([value, max])
174
+ })
175
+
176
+ // Sort items within groups to bottom
177
+ // Sort items outside of groups
178
+ // Sort groups to bottom (pushes all non-grouped items to the top)
179
+ const listInsertionElement = listInnerRef.current
180
+
181
+ // Sort the items
182
+ getValidItems()
183
+ .sort((a, b) => {
184
+ const valueA = a.getAttribute("id")
185
+ const valueB = b.getAttribute("id")
186
+ return (scores.get(valueB ?? "") ?? 0) - (scores.get(valueA ?? "") ?? 0)
187
+ })
188
+ .forEach((item) => {
189
+ const group = item.closest(GROUP_ITEMS_SELECTOR)
190
+
191
+ if (group) {
192
+ const elementToMove =
193
+ item.parentElement === group ? item : item.closest(`${GROUP_ITEMS_SELECTOR} > *`)
194
+ if (elementToMove) group.appendChild(elementToMove)
195
+ } else {
196
+ const elementToMove =
197
+ item.parentElement === listInsertionElement
198
+ ? item
199
+ : item.closest(`${GROUP_ITEMS_SELECTOR} > *`)
200
+ if (elementToMove) listInsertionElement?.appendChild(elementToMove)
201
+ }
202
+ })
203
+
204
+ groups
205
+ .sort((a, b) => b[1] - a[1])
206
+ .forEach((group) => {
207
+ const element = listInnerRef.current?.querySelector(
208
+ `${GROUP_SELECTOR}[${VALUE_ATTR}="${encodeURIComponent(group[0])}"]`,
209
+ )
210
+ if (element && element.parentElement) {
211
+ element.parentElement.appendChild(element)
212
+ }
213
+ })
214
+ }, [])
215
+
216
+ const selectFirstItem = useCallback(() => {
217
+ const item = getValidItems().find((item) => item.getAttribute("aria-disabled") !== "true")
218
+ const value = item?.getAttribute(VALUE_ATTR)
219
+ store.setState("value", value || "")
220
+ }, [])
221
+
222
+ /** Filters the current items. */
223
+ const filterItems = useCallback(() => {
224
+ if (
225
+ !state.current.search ||
226
+ // Explicitly false, because true | undefined is the default
227
+ propsRef.current.shouldFilter === false
228
+ ) {
229
+ state.current.filtered.count = allItems.current.size
230
+ // Do nothing, each item will know to show itself because search is empty
231
+ return
232
+ }
233
+
234
+ // Reset the groups
235
+ state.current.filtered.groups = new Set()
236
+ let itemCount = 0
237
+
238
+ // Check which items should be included
239
+ for (const id of allItems.current) {
240
+ const value = ids.current.get(id)?.value ?? ""
241
+ const keywords = ids.current.get(id)?.keywords ?? []
242
+ const rank = score(value, keywords)
243
+ state.current.filtered.items.set(id, rank)
244
+ if (rank > 0) itemCount++
245
+ }
246
+
247
+ // Check which groups have at least 1 item shown
248
+ for (const [groupId, group] of allGroups.current) {
249
+ for (const itemId of group) {
250
+ if ((state.current.filtered.items.get(itemId) ?? 0) > 0) {
251
+ state.current.filtered.groups.add(groupId)
252
+ break
253
+ }
254
+ }
255
+ }
256
+
257
+ state.current.filtered.count = itemCount
258
+ }, [])
259
+
260
+ const scrollSelectedIntoView = useCallback(() => {
261
+ const item = getSelectedItem()
262
+
263
+ if (item) {
264
+ if (item.parentElement?.firstChild === item) {
265
+ // First item in Group, ensure heading is in view
266
+ item
267
+ .closest(GROUP_SELECTOR)
268
+ ?.querySelector(GROUP_HEADING_SELECTOR)
269
+ ?.scrollIntoView({ block: "nearest" })
270
+ }
271
+
272
+ // Ensure the item is always in view
273
+ item.scrollIntoView({ block: "nearest" })
274
+ }
275
+ }, [])
276
+
277
+ /** Getters */
278
+
279
+ const getSelectedItem = useCallback(() => {
280
+ return listInnerRef.current?.querySelector(`${ITEM_SELECTOR}[aria-selected="true"]`)
281
+ }, [])
282
+
283
+ function getValidItems(): HTMLElement[] {
284
+ return Array.from(
285
+ listInnerRef.current?.querySelectorAll(VALID_ITEM_SELECTOR) || [],
286
+ ) as HTMLElement[]
287
+ }
288
+
289
+ /** Setters */
290
+
291
+ const updateSelectedToIndex = useEventCallback((index: number) => {
292
+ const items = getValidItems()
293
+ const item = items[index]
294
+ if (item) store.setState("value", item.getAttribute(VALUE_ATTR) || "")
295
+ })
296
+
297
+ const updateSelectedByItem = useEventCallback((change: 1 | -1) => {
298
+ const selected = getSelectedItem()
299
+ const items = getValidItems()
300
+ const index = items.findIndex((item) => item === selected)
301
+
302
+ // Get item at this index
303
+ let newSelected = items[index + change]
304
+
305
+ if (propsRef.current?.loop) {
306
+ newSelected =
307
+ index + change < 0
308
+ ? items[items.length - 1]
309
+ : index + change === items.length
310
+ ? items[0]
311
+ : items[index + change]
312
+ }
313
+
314
+ if (newSelected) store.setState("value", newSelected.getAttribute(VALUE_ATTR) || "")
315
+ })
316
+
317
+ const updateSelectedByGroup = useEventCallback((change: 1 | -1) => {
318
+ const selected = getSelectedItem()
319
+ let group = selected?.closest(GROUP_SELECTOR)
320
+ let item: HTMLElement | null = null
321
+
322
+ while (group && !item) {
323
+ group =
324
+ change > 0
325
+ ? (findNextSibling(group, GROUP_SELECTOR) as HTMLElement)
326
+ : (findPreviousSibling(group, GROUP_SELECTOR) as HTMLElement)
327
+ item = group?.querySelector(VALID_ITEM_SELECTOR) as HTMLElement
328
+ }
329
+
330
+ if (item) {
331
+ store.setState("value", item.getAttribute(VALUE_ATTR) || "")
332
+ } else {
333
+ updateSelectedByItem(change)
334
+ }
335
+ })
336
+
337
+ const context = useMemo(
338
+ () =>
339
+ createCommandContext({
340
+ allGroups,
341
+ allItems,
342
+ filterItems,
343
+ ids,
344
+ inputId,
345
+ label,
346
+ labelId,
347
+ listId,
348
+ listInnerRef,
349
+ propsRef,
350
+ schedule,
351
+ selectFirstItem,
352
+ size,
353
+ sort,
354
+ state,
355
+ store,
356
+ variant,
357
+ }),
358
+ [], // ❌ 空依赖数组,和原始实现一致
359
+ )
360
+
361
+ // Store now directly uses the functions in closure, no need for updateHandlers
362
+
363
+ const last = () => updateSelectedToIndex(getValidItems().length - 1)
364
+
365
+ const next = (e: React.KeyboardEvent) => {
366
+ e.preventDefault()
367
+
368
+ if (e.metaKey) {
369
+ // Last item
370
+ last()
371
+ } else if (e.altKey) {
372
+ // Next group
373
+ updateSelectedByGroup(1)
374
+ } else {
375
+ // Next item
376
+ updateSelectedByItem(1)
377
+ }
378
+ }
379
+
380
+ const prev = (e: React.KeyboardEvent) => {
381
+ e.preventDefault()
382
+
383
+ if (e.metaKey) {
384
+ // First item
385
+ updateSelectedToIndex(0)
386
+ } else if (e.altKey) {
387
+ // Previous group
388
+ updateSelectedByGroup(-1)
389
+ } else {
390
+ // Previous item
391
+ updateSelectedByItem(-1)
392
+ }
393
+ }
394
+
395
+ const handleKeyDown = useEventCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
396
+ etc.onKeyDown?.(e)
397
+
398
+ // Check if IME composition is finished before triggering key binds
399
+ // This prevents unwanted triggering while user is still inputting text with IME
400
+ // e.keyCode === 229 is for the CJK IME with Legacy Browser [https://w3c.github.io/uievents/#determine-keydown-keyup-keyCode]
401
+ // isComposing is for the CJK IME with Modern Browser [https://developer.mozilla.org/en-US/docs/Web/API/CompositionEvent/isComposing]
402
+ const isComposing = e.nativeEvent.isComposing || e.keyCode === 229
403
+
404
+ if (e.defaultPrevented || isComposing) {
405
+ return
406
+ }
407
+
408
+ switch (e.key) {
409
+ case "n":
410
+ case "j": {
411
+ // vim keybind down
412
+ if (vimBindings && e.ctrlKey) {
413
+ next(e)
414
+ }
415
+ break
416
+ }
417
+ case "ArrowDown": {
418
+ next(e)
419
+ break
420
+ }
421
+ case "p":
422
+ case "k": {
423
+ // vim keybind up
424
+ if (vimBindings && e.ctrlKey) {
425
+ prev(e)
426
+ }
427
+ break
428
+ }
429
+ case "ArrowUp": {
430
+ prev(e)
431
+ break
432
+ }
433
+ case "Home": {
434
+ // First item
435
+ e.preventDefault()
436
+ updateSelectedToIndex(0)
437
+ break
438
+ }
439
+ case "End": {
440
+ // Last item
441
+ e.preventDefault()
442
+ last()
443
+ break
444
+ }
445
+ case "Enter": {
446
+ // Trigger item onSelect
447
+ e.preventDefault()
448
+ const item = getSelectedItem()
449
+ if (item) {
450
+ const event = new Event(SELECT_EVENT)
451
+ item.dispatchEvent(event)
452
+ }
453
+ }
454
+ }
455
+ })
456
+
457
+ return (
458
+ <div
459
+ ref={forwardedRef}
460
+ tabIndex={-1}
461
+ {...etc}
462
+ className={tcx(tv.root({ className }))}
463
+ data-command-root=""
464
+ onKeyDown={handleKeyDown}
465
+ >
466
+ <label
467
+ htmlFor={context.inputId}
468
+ id={context.labelId}
469
+ className="sr-only"
470
+ >
471
+ {label}
472
+ </label>
473
+ {SlottableWithNestedChildren(props, (child) => (
474
+ <StoreContext.Provider value={store}>
475
+ <CommandContext.Provider value={context}>{child}</CommandContext.Provider>
476
+ </StoreContext.Provider>
477
+ ))}
478
+ </div>
479
+ )
480
+ })
481
+
482
+ Command.displayName = "Command"