@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/README.md +571 -0
- package/dist/index.cjs +1309 -0
- package/dist/index.d.cts +130 -0
- package/dist/index.d.ts +130 -0
- package/dist/index.js +1300 -0
- package/package.json +50 -0
- package/src/command-score.ts +171 -0
- package/src/command.tsx +482 -0
- package/src/components/command-divider.tsx +30 -0
- package/src/components/command-empty.tsx +30 -0
- package/src/components/command-footer.tsx +22 -0
- package/src/components/command-group.tsx +76 -0
- package/src/components/command-input.tsx +66 -0
- package/src/components/command-item.tsx +165 -0
- package/src/components/command-list.tsx +77 -0
- package/src/components/command-loading.tsx +30 -0
- package/src/components/command-tabs.tsx +20 -0
- package/src/components/command-value.tsx +23 -0
- package/src/components/index.ts +10 -0
- package/src/context/command-context.ts +5 -0
- package/src/context/create-command-context.ts +140 -0
- package/src/context/index.ts +2 -0
- package/src/hooks/index.ts +10 -0
- package/src/hooks/use-as-ref.ts +12 -0
- package/src/hooks/use-command-state.ts +18 -0
- package/src/hooks/use-command.ts +10 -0
- package/src/hooks/use-schedule-layout-effect.ts +19 -0
- package/src/hooks/use-value.ts +39 -0
- package/src/index.ts +31 -0
- package/src/store/index.ts +1 -0
- package/src/tv.ts +248 -0
- package/src/types.ts +84 -0
- package/src/utils/constants.ts +7 -0
- package/src/utils/dom.ts +19 -0
- package/src/utils/helpers.ts +45 -0
- package/src/utils/index.ts +3 -0
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
|
+
}
|
package/src/command.tsx
ADDED
|
@@ -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"
|