@choice-ui/command 0.0.3 → 0.0.4

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/src/command.tsx DELETED
@@ -1,482 +0,0 @@
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"
@@ -1,30 +0,0 @@
1
- import { tcx } from "@choice-ui/shared"
2
- import { forwardRef, HTMLProps } from "react"
3
- import { useCommand, useCommandState } from "../hooks"
4
- import { commandTv } from "../tv"
5
-
6
- export interface CommandDividerProps extends HTMLProps<HTMLDivElement> {
7
- /** 是否始终渲染此分隔符。当禁用自动过滤功能时特别有用。 */
8
- alwaysRender?: boolean
9
- }
10
-
11
- export const CommandDivider = forwardRef<HTMLDivElement, CommandDividerProps>(
12
- ({ className, alwaysRender, ...props }, forwardedRef) => {
13
- const context = useCommand()
14
- const render = useCommandState((state) => !state.search)
15
- const tv = commandTv({ variant: context.variant })
16
-
17
- if (!alwaysRender && !render) return null
18
-
19
- return (
20
- <div
21
- ref={forwardedRef}
22
- {...props}
23
- className={tcx(tv.divider({ className }))}
24
- role="separator"
25
- />
26
- )
27
- },
28
- )
29
-
30
- CommandDivider.displayName = "CommandDivider"
@@ -1,30 +0,0 @@
1
- import { tcx } from "@choice-ui/shared"
2
- import { forwardRef, HTMLProps } from "react"
3
- import { useCommand, useCommandState } from "../hooks"
4
- import { commandEmptyTv } from "../tv"
5
-
6
- export interface CommandEmptyProps extends HTMLProps<HTMLDivElement> {
7
- className?: string
8
- }
9
-
10
- export const CommandEmpty = forwardRef<HTMLDivElement, CommandEmptyProps>(
11
- ({ className, ...props }, forwardedRef) => {
12
- const context = useCommand()
13
- const render = useCommandState((state) => state.filtered.count === 0)
14
-
15
- const tv = commandEmptyTv({ variant: context.variant })
16
-
17
- if (!render) return null
18
-
19
- return (
20
- <div
21
- ref={forwardedRef}
22
- {...props}
23
- className={tcx(tv.root({ className }))}
24
- role="presentation"
25
- />
26
- )
27
- },
28
- )
29
-
30
- CommandEmpty.displayName = "CommandEmpty"
@@ -1,22 +0,0 @@
1
- import { tcx } from "@choice-ui/shared"
2
- import { forwardRef, HTMLProps } from "react"
3
- import { useCommand } from "../hooks/use-command"
4
- import { commandFooterTv } from "../tv"
5
-
6
- export const CommandFooter = forwardRef<HTMLDivElement, HTMLProps<HTMLDivElement>>(
7
- ({ className, ...props }, ref) => {
8
- const context = useCommand()
9
-
10
- const tv = commandFooterTv({ variant: context.variant })
11
-
12
- return (
13
- <div
14
- ref={ref}
15
- className={tcx(tv.root({ className }))}
16
- {...props}
17
- />
18
- )
19
- },
20
- )
21
-
22
- CommandFooter.displayName = "CommandFooter"
@@ -1,76 +0,0 @@
1
- import { tcx } from "@choice-ui/shared"
2
- import React, { forwardRef, HTMLProps, useEffect, useId, useMemo, useRef } from "react"
3
- import { GroupContext, useCommand, useCommandState, useValue } from "../hooks"
4
- import { commandGroupTv } from "../tv"
5
-
6
- export interface CommandGroupProps extends HTMLProps<HTMLDivElement> {
7
- forceMount?: boolean
8
- heading?: React.ReactNode
9
- value?: string
10
- }
11
-
12
- export const CommandGroup = forwardRef<HTMLDivElement, CommandGroupProps>((props, forwardedRef) => {
13
- const { className, heading, children, forceMount, value, ...rest } = props
14
- const id = useId()
15
- const ref = useRef<HTMLDivElement | null>(null)
16
- const headingRef = useRef<HTMLDivElement | null>(null)
17
- const headingId = React.useId()
18
- const context = useCommand()
19
-
20
- const render = useCommandState((state) =>
21
- forceMount
22
- ? true
23
- : context.filter() === false
24
- ? true
25
- : !state.search
26
- ? true
27
- : state.filtered.groups.has(id),
28
- )
29
-
30
- // 注册group
31
- useEffect(() => {
32
- return context.group(id)
33
- }, [context, id])
34
-
35
- const valueDeps = useMemo(() => [value, heading, headingRef], [value, heading])
36
- useValue(id, ref, valueDeps)
37
-
38
- const contextValue = useMemo(() => ({ id, forceMount }), [id, forceMount])
39
-
40
- const tv = commandGroupTv({ variant: context.variant })
41
-
42
- if (!render) return null
43
-
44
- return (
45
- <div
46
- ref={(el) => {
47
- ref.current = el
48
- if (typeof forwardedRef === "function") forwardedRef(el)
49
- else if (forwardedRef) forwardedRef.current = el
50
- }}
51
- {...rest}
52
- className={tcx(tv.root({ className }))}
53
- role="presentation"
54
- data-value={value}
55
- >
56
- {heading && (
57
- <div
58
- ref={headingRef}
59
- className={tcx(tv.heading())}
60
- aria-hidden
61
- id={headingId}
62
- >
63
- {heading}
64
- </div>
65
- )}
66
- <div
67
- role="group"
68
- aria-labelledby={heading ? headingId : undefined}
69
- >
70
- <GroupContext.Provider value={contextValue}>{children}</GroupContext.Provider>
71
- </div>
72
- </div>
73
- )
74
- })
75
-
76
- CommandGroup.displayName = "CommandGroup"
@@ -1,66 +0,0 @@
1
- import { tcx } from "@choice-ui/shared"
2
- import { Input, type InputProps } from "@choice-ui/input"
3
- import { forwardRef, ReactNode, useEffect } from "react"
4
- import { useEventCallback } from "usehooks-ts"
5
- import { useCommand, useCommandState } from "../hooks"
6
- import { commandInputTv } from "../tv"
7
-
8
- export interface CommandInputProps extends Omit<InputProps, "value" | "onChange" | "type"> {
9
- onChange?: (search: string) => void
10
- prefixElement?: ReactNode
11
- suffixElement?: ReactNode
12
- value?: string
13
- }
14
-
15
- export const CommandInput = forwardRef<HTMLInputElement, CommandInputProps>((props, ref) => {
16
- const { className, onChange, value, prefixElement, suffixElement, ...rest } = props
17
- const isControlled = value != null
18
- const store = useCommand().store
19
- const search = useCommandState((state) => state.search)
20
- const selectedItemId = useCommandState((state) => state.selectedItemId)
21
- const context = useCommand()
22
-
23
- useEffect(() => {
24
- if (value != null) {
25
- store.setState("search", value)
26
- }
27
- }, [value, store])
28
-
29
- const handleChange = useEventCallback((value: string) => {
30
- if (!isControlled) {
31
- store.setState("search", value)
32
- }
33
- onChange?.(value)
34
- })
35
-
36
- const tv = commandInputTv({ size: context.size })
37
-
38
- return (
39
- <div className={tcx(tv.root({ className }))}>
40
- {prefixElement}
41
- <Input
42
- ref={ref}
43
- {...rest}
44
- className={tcx(tv.input({ className }))}
45
- variant={props.variant || context.variant}
46
- data-command-input=""
47
- autoComplete="off"
48
- autoCorrect="off"
49
- spellCheck={false}
50
- aria-autocomplete="list"
51
- role="combobox"
52
- aria-expanded={true}
53
- aria-controls={context.listId}
54
- aria-labelledby={context.labelId}
55
- aria-activedescendant={selectedItemId}
56
- id={context.inputId}
57
- type="text"
58
- value={isControlled ? value : search}
59
- onChange={handleChange}
60
- />
61
- {suffixElement}
62
- </div>
63
- )
64
- })
65
-
66
- CommandInput.displayName = "CommandInput"