@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.
@@ -0,0 +1,30 @@
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"
@@ -0,0 +1,30 @@
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"
@@ -0,0 +1,22 @@
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"
@@ -0,0 +1,76 @@
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"
@@ -0,0 +1,66 @@
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"
@@ -0,0 +1,165 @@
1
+ import { tcx } from "@choice-ui/shared"
2
+ import { Kbd, type KbdKey } from "@choice-ui/kbd"
3
+ import React, { forwardRef, HTMLProps, ReactNode, useEffect, useMemo, useRef } from "react"
4
+ import { useEventCallback } from "usehooks-ts"
5
+ import { GroupContext, useCommand, useCommandState, useValue } from "../hooks"
6
+ import { commandItemTv } from "../tv"
7
+ import { SELECT_EVENT } from "../utils"
8
+
9
+ export interface CommandItemProps extends Omit<HTMLProps<HTMLDivElement>, "onSelect"> {
10
+ disabled?: boolean
11
+ forceMount?: boolean
12
+ keywords?: string[]
13
+ onSelect?: (value: string) => void
14
+ prefixElement?: ReactNode
15
+ shortcut?: {
16
+ keys?: ReactNode
17
+ modifier?: KbdKey | KbdKey[] | undefined
18
+ }
19
+ suffixElement?: ReactNode
20
+ value?: string
21
+ }
22
+
23
+ export const CommandItem = forwardRef<HTMLDivElement, CommandItemProps>((props, forwardedRef) => {
24
+ const {
25
+ className,
26
+ disabled,
27
+ forceMount,
28
+ keywords,
29
+ onSelect,
30
+ value,
31
+ children,
32
+ prefixElement,
33
+ suffixElement,
34
+ shortcut,
35
+ ...rest
36
+ } = props
37
+
38
+ const ref = useRef<HTMLDivElement | null>(null)
39
+ const id = React.useId()
40
+ const context = useCommand()
41
+ const groupContext = React.useContext(GroupContext)
42
+
43
+ const propsRef = useRef({
44
+ disabled,
45
+ forceMount: forceMount ?? groupContext?.forceMount,
46
+ keywords,
47
+ onSelect,
48
+ value,
49
+ })
50
+
51
+ propsRef.current = {
52
+ disabled,
53
+ forceMount: forceMount ?? groupContext?.forceMount,
54
+ keywords,
55
+ onSelect,
56
+ value,
57
+ }
58
+
59
+ // 注册item
60
+ useEffect(() => {
61
+ if (!propsRef.current.forceMount) {
62
+ return context.item(id, groupContext?.id)
63
+ }
64
+ }, [context, groupContext?.id, id])
65
+
66
+ const valueDeps = useMemo(() => [value, children, ref], [value, children])
67
+ const stableKeywords = useMemo(() => keywords || [], [keywords])
68
+ const valueRef = useValue(id, ref, valueDeps, stableKeywords)
69
+
70
+ const store = context.store
71
+
72
+ const selected = useCommandState((state) =>
73
+ Boolean(state.value && state.value === valueRef?.current),
74
+ )
75
+
76
+ const render = useCommandState((state) =>
77
+ propsRef.current.forceMount
78
+ ? true
79
+ : context.filter() === false
80
+ ? true
81
+ : !state.search
82
+ ? true
83
+ : (state.filtered.items.get(id) ?? 0) > 0,
84
+ )
85
+
86
+ // 处理选择事件,当用户点击或按下Enter键时触发onSelect回调
87
+ useEffect(() => {
88
+ const element = ref.current
89
+ if (!element || disabled) return
90
+
91
+ const handleSelect = () => {
92
+ select()
93
+ propsRef.current.onSelect?.(valueRef.current || "")
94
+ }
95
+
96
+ element.addEventListener(SELECT_EVENT, handleSelect)
97
+ return () => element.removeEventListener(SELECT_EVENT, handleSelect)
98
+ // eslint-disable-next-line react-hooks/exhaustive-deps
99
+ }, [render, disabled, valueRef])
100
+
101
+ const select = () => {
102
+ store.setState("value", valueRef.current || "", true)
103
+ }
104
+
105
+ const hasValidShortcut = shortcut && (shortcut.modifier || shortcut.keys)
106
+
107
+ const handlePointerMove = useEventCallback(() => {
108
+ if (disabled || context.getDisablePointerSelection()) return
109
+ select()
110
+ })
111
+
112
+ const handleClick = useEventCallback(() => {
113
+ if (disabled) return
114
+ propsRef.current.onSelect?.(valueRef.current || "")
115
+ })
116
+
117
+ const tv = commandItemTv({
118
+ selected,
119
+ disabled,
120
+ size: context.size,
121
+ hasPrefix: !!prefixElement,
122
+ hasSuffix: !!suffixElement,
123
+ variant: context.variant,
124
+ })
125
+
126
+ if (!render) return null
127
+
128
+ return (
129
+ <div
130
+ ref={(el) => {
131
+ ref.current = el
132
+ if (typeof forwardedRef === "function") forwardedRef(el)
133
+ else if (forwardedRef) forwardedRef.current = el
134
+ }}
135
+ {...rest}
136
+ id={id}
137
+ className={tcx(tv.root({ className }))}
138
+ role="option"
139
+ aria-disabled={disabled}
140
+ aria-selected={selected || undefined}
141
+ data-disabled={disabled}
142
+ data-selected={selected}
143
+ data-value={valueRef.current}
144
+ onPointerMove={handlePointerMove}
145
+ onClick={handleClick}
146
+ >
147
+ {prefixElement && <div className={tv.icon()}>{prefixElement}</div>}
148
+
149
+ {children}
150
+
151
+ {hasValidShortcut && (
152
+ <Kbd
153
+ className={tv.shortcut()}
154
+ keys={shortcut!.modifier}
155
+ >
156
+ {shortcut!.keys}
157
+ </Kbd>
158
+ )}
159
+
160
+ {suffixElement && <div className={tv.icon()}>{suffixElement}</div>}
161
+ </div>
162
+ )
163
+ })
164
+
165
+ CommandItem.displayName = "CommandItem"
@@ -0,0 +1,77 @@
1
+ import { tcx } from "@choice-ui/shared"
2
+ import { ScrollArea, type ScrollAreaProps } from "@choice-ui/scroll-area"
3
+ import { forwardRef, useEffect, useRef } from "react"
4
+ import { useCommand, useCommandState } from "../hooks"
5
+ import { commandListTv } from "../tv"
6
+
7
+ export interface CommandListProps extends ScrollAreaProps {
8
+ children: React.ReactNode
9
+ className?: string
10
+ label?: string
11
+ }
12
+
13
+ export const CommandList = forwardRef<HTMLDivElement, CommandListProps>((props, forwardedRef) => {
14
+ const { children, className, label = "Suggestions", hoverBoundary = "none", ...rest } = props
15
+ const ref = useRef<HTMLDivElement | null>(null)
16
+ const height = useRef<HTMLDivElement | null>(null)
17
+ const selectedItemId = useCommandState((state) => state.selectedItemId)
18
+ const context = useCommand()
19
+
20
+ useEffect(() => {
21
+ if (height.current && ref.current) {
22
+ const el = height.current
23
+ const wrapper = ref.current
24
+ let animationFrame: number
25
+ const observer = new ResizeObserver(() => {
26
+ animationFrame = requestAnimationFrame(() => {
27
+ const height = el.offsetHeight
28
+ wrapper.style.setProperty(`--cmdk-list-height`, height.toFixed(1) + "px")
29
+ })
30
+ })
31
+ observer.observe(el)
32
+ return () => {
33
+ cancelAnimationFrame(animationFrame)
34
+ observer.unobserve(el)
35
+ }
36
+ }
37
+ }, [])
38
+
39
+ const tv = commandListTv()
40
+
41
+ return (
42
+ <ScrollArea
43
+ variant={context.variant}
44
+ hoverBoundary={hoverBoundary}
45
+ {...rest}
46
+ >
47
+ <ScrollArea.Viewport
48
+ ref={(el) => {
49
+ ref.current = el
50
+ if (typeof forwardedRef === "function") forwardedRef(el)
51
+ else if (forwardedRef) forwardedRef.current = el
52
+ }}
53
+ {...rest}
54
+ className={tcx(tv.root({ className }))}
55
+ role="listbox"
56
+ tabIndex={-1}
57
+ aria-activedescendant={selectedItemId}
58
+ aria-label={label}
59
+ id={context.listId}
60
+ >
61
+ <ScrollArea.Content
62
+ className={tcx(tv.content())}
63
+ ref={(el) => {
64
+ height.current = el
65
+ if (context.listInnerRef) {
66
+ context.listInnerRef.current = el
67
+ }
68
+ }}
69
+ >
70
+ {children}
71
+ </ScrollArea.Content>
72
+ </ScrollArea.Viewport>
73
+ </ScrollArea>
74
+ )
75
+ })
76
+
77
+ CommandList.displayName = "CommandList"
@@ -0,0 +1,30 @@
1
+ import { tcx } from "@choice-ui/shared"
2
+ import React, { forwardRef } from "react"
3
+ import { commandLoadingTv } from "../tv"
4
+
5
+ export interface CommandLoadingProps extends React.HTMLAttributes<HTMLDivElement> {
6
+ label?: string
7
+ progress?: number
8
+ }
9
+
10
+ export const CommandLoading = forwardRef<HTMLDivElement, CommandLoadingProps>((props, ref) => {
11
+ const { className, children, label = "Loading...", progress, ...rest } = props
12
+ const tv = commandLoadingTv()
13
+
14
+ return (
15
+ <div
16
+ ref={ref}
17
+ {...props}
18
+ className={tcx(tv.root({ className }))}
19
+ role="progressbar"
20
+ aria-valuenow={progress}
21
+ aria-valuemin={0}
22
+ aria-valuemax={100}
23
+ aria-label={label}
24
+ >
25
+ <div aria-hidden>{children}</div>
26
+ </div>
27
+ )
28
+ })
29
+
30
+ CommandLoading.displayName = "CommandLoading"
@@ -0,0 +1,20 @@
1
+ import { Tabs, type TabsProps } from "@choice-ui/tabs"
2
+ import { forwardRef } from "react"
3
+ import { useCommand } from "../hooks/use-command"
4
+ import { commandTabsTv } from "../tv"
5
+
6
+ export const CommandTabs = forwardRef<HTMLDivElement, TabsProps>((props, ref) => {
7
+ const context = useCommand()
8
+ const tv = commandTabsTv()
9
+
10
+ return (
11
+ <Tabs
12
+ ref={ref}
13
+ variant={props.variant || context.variant}
14
+ className={tv.tabs()}
15
+ {...props}
16
+ />
17
+ )
18
+ })
19
+
20
+ CommandTabs.displayName = "CommandTabs"
@@ -0,0 +1,23 @@
1
+ import { tcx } from "@choice-ui/shared"
2
+ import { forwardRef, HTMLProps } from "react"
3
+ import { useCommand } from "../hooks"
4
+ import { commandItemTv } from "../tv"
5
+
6
+ export const CommandValue = forwardRef<HTMLDivElement, HTMLProps<HTMLDivElement>>((props, ref) => {
7
+ const { className, children, ...rest } = props
8
+ const context = useCommand()
9
+
10
+ const tv = commandItemTv({ size: context.size })
11
+
12
+ return (
13
+ <div
14
+ ref={ref}
15
+ {...rest}
16
+ className={tcx(tv.value({ className }))}
17
+ >
18
+ {children}
19
+ </div>
20
+ )
21
+ })
22
+
23
+ CommandValue.displayName = "CommandValue"
@@ -0,0 +1,10 @@
1
+ export * from "./command-empty"
2
+ export * from "./command-footer"
3
+ export * from "./command-group"
4
+ export * from "./command-input"
5
+ export * from "./command-item"
6
+ export * from "./command-list"
7
+ export * from "./command-loading"
8
+ export * from "./command-divider"
9
+ export * from "./command-value"
10
+ export * from "./command-tabs"
@@ -0,0 +1,5 @@
1
+ import { createContext } from "react"
2
+ import type { Context, Store } from "../types"
3
+
4
+ export const CommandContext = createContext<Context | undefined>(undefined)
5
+ export const StoreContext = createContext<Store | undefined>(undefined)