@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,140 @@
1
+ import React from "react"
2
+ import { commandScore } from "../command-score"
3
+ import type { CommandProps, Context, State, Store } from "../types"
4
+
5
+ interface CreateCommandContextOptions {
6
+ allGroups: React.MutableRefObject<Map<string, Set<string>>>
7
+ allItems: React.MutableRefObject<Set<string>>
8
+ filterItems: () => void
9
+ ids: React.MutableRefObject<Map<string, { keywords?: string[]; value: string }>>
10
+ inputId: string
11
+ label?: string
12
+ labelId: string
13
+ listId: string
14
+ listInnerRef: React.MutableRefObject<HTMLDivElement | null>
15
+ propsRef: React.MutableRefObject<CommandProps>
16
+ schedule: (id: string | number, cb: () => void) => void
17
+ selectFirstItem: () => void
18
+ size?: "default" | "large"
19
+ sort: () => void
20
+ state: React.MutableRefObject<State>
21
+ store: Store
22
+ variant?: "default" | "dark"
23
+ }
24
+
25
+ export function createCommandContext(options: CreateCommandContextOptions): Context {
26
+ const {
27
+ allGroups,
28
+ allItems,
29
+ filterItems,
30
+ ids,
31
+ inputId,
32
+ label,
33
+ labelId,
34
+ listId,
35
+ listInnerRef,
36
+ propsRef,
37
+ schedule,
38
+ selectFirstItem,
39
+ size,
40
+ sort,
41
+ state,
42
+ store,
43
+ variant,
44
+ } = options
45
+
46
+ function score(value: string, keywords?: string[]) {
47
+ const filter =
48
+ propsRef.current?.filter ??
49
+ ((value: string, search: string, keywords?: string[]) =>
50
+ commandScore(value, search, keywords))
51
+ return value ? filter(value, state.current.search, keywords) : 0
52
+ }
53
+
54
+ return {
55
+ // Keep id → {value, keywords} mapping up-to-date
56
+ value: (id, value, keywords) => {
57
+ if (value !== ids.current.get(id)?.value) {
58
+ ids.current.set(id, { value: value || "", keywords })
59
+ state.current.filtered.items.set(id, score(value || "", keywords))
60
+ schedule(2, () => {
61
+ sort()
62
+ store.emit()
63
+ })
64
+ }
65
+ },
66
+ // Track item lifecycle (mount, unmount)
67
+ item: (id, groupId) => {
68
+ allItems.current.add(id)
69
+
70
+ // Track this item within the group
71
+ if (groupId) {
72
+ if (!allGroups.current.has(groupId)) {
73
+ allGroups.current.set(groupId, new Set([id]))
74
+ } else {
75
+ allGroups.current.get(groupId)?.add(id)
76
+ }
77
+ }
78
+
79
+ // Batch this, multiple items can mount in one pass
80
+ // and we should not be filtering/sorting/emitting each time
81
+ schedule(3, () => {
82
+ filterItems()
83
+ sort()
84
+
85
+ // Could be initial mount, select the first item if none already selected
86
+ if (!state.current.value) {
87
+ selectFirstItem()
88
+ }
89
+
90
+ store.emit()
91
+ })
92
+
93
+ return () => {
94
+ ids.current.delete(id)
95
+ allItems.current.delete(id)
96
+ state.current.filtered.items.delete(id)
97
+
98
+ // Batch this, multiple items could be removed in one pass
99
+ schedule(4, () => {
100
+ filterItems()
101
+
102
+ // The item removed have been the selected one,
103
+ // so selection should be moved to the first
104
+ const ITEM_SELECTOR = `[role="option"]`
105
+ const selectedItem = listInnerRef.current?.querySelector(
106
+ `${ITEM_SELECTOR}[aria-selected="true"]`,
107
+ )
108
+ if (selectedItem?.getAttribute("id") === id) selectFirstItem()
109
+
110
+ store.emit()
111
+ })
112
+ }
113
+ },
114
+ // Track group lifecycle (mount, unmount)
115
+ group: (id) => {
116
+ if (!allGroups.current.has(id)) {
117
+ allGroups.current.set(id, new Set())
118
+ }
119
+
120
+ return () => {
121
+ ids.current.delete(id)
122
+ allGroups.current.delete(id)
123
+ }
124
+ },
125
+ filter: () => {
126
+ return propsRef.current.shouldFilter !== false
127
+ },
128
+ label: label || propsRef.current["aria-label"],
129
+ getDisablePointerSelection: () => {
130
+ return propsRef.current.disablePointerSelection ?? false
131
+ },
132
+ listId,
133
+ inputId,
134
+ labelId,
135
+ listInnerRef,
136
+ store,
137
+ size,
138
+ variant,
139
+ }
140
+ }
@@ -0,0 +1,2 @@
1
+ export { CommandContext, StoreContext } from "./command-context"
2
+ export { createCommandContext } from "./create-command-context"
@@ -0,0 +1,10 @@
1
+ export * from "./use-as-ref"
2
+ export * from "./use-command"
3
+ export * from "./use-command-state"
4
+ export * from "./use-schedule-layout-effect"
5
+ export * from "./use-value"
6
+
7
+ import React from "react"
8
+ import { Group } from "../types"
9
+
10
+ export const GroupContext = React.createContext<Group | undefined>(undefined)
@@ -0,0 +1,12 @@
1
+ import React, { useRef } from "react"
2
+ import { useIsomorphicLayoutEffect } from "usehooks-ts"
3
+
4
+ export function useAsRef<T>(data: T): React.MutableRefObject<T> {
5
+ const ref = useRef<T>(data)
6
+
7
+ useIsomorphicLayoutEffect(() => {
8
+ ref.current = data
9
+ })
10
+
11
+ return ref
12
+ }
@@ -0,0 +1,18 @@
1
+ import React from "react"
2
+ import { State } from "../types"
3
+ import { StoreContext } from "../context"
4
+
5
+ export const useStore = () => {
6
+ const store = React.useContext(StoreContext)
7
+ if (!store) {
8
+ throw new Error("useStore must be used within a Command component")
9
+ }
10
+ return store
11
+ }
12
+
13
+ /** Run a selector against the store state. */
14
+ export function useCommandState<T>(selector: (state: State) => T): T {
15
+ const store = useStore()
16
+ const cb = () => selector(store.snapshot())
17
+ return React.useSyncExternalStore(store.subscribe, cb, cb)
18
+ }
@@ -0,0 +1,10 @@
1
+ import React from "react"
2
+ import { CommandContext } from "../context"
3
+
4
+ export const useCommand = () => {
5
+ const context = React.useContext(CommandContext)
6
+ if (!context) {
7
+ throw new Error("useCommand must be used within a Command component")
8
+ }
9
+ return context
10
+ }
@@ -0,0 +1,19 @@
1
+ import { useCallback, useState } from "react"
2
+ import { useIsomorphicLayoutEffect } from "usehooks-ts"
3
+ import { useLazyRef } from "@choice-ui/shared"
4
+
5
+ /** 在下一个 layout effect 周期内以命令式方式运行函数。 */
6
+ export const useScheduleLayoutEffect = () => {
7
+ const [updateCount, setUpdateCount] = useState(0)
8
+ const fns = useLazyRef(() => new Map<string | number, () => void>())
9
+
10
+ useIsomorphicLayoutEffect(() => {
11
+ fns.current.forEach((f) => f())
12
+ fns.current = new Map()
13
+ }, [updateCount])
14
+
15
+ return useCallback((id: string | number, cb: () => void) => {
16
+ fns.current.set(id, cb)
17
+ setUpdateCount((prev) => prev + 1)
18
+ }, [])
19
+ }
@@ -0,0 +1,39 @@
1
+ import React, { useRef } from "react"
2
+ import { VALUE_ATTR, useLayoutEffect } from "../utils"
3
+ import { useCommand } from "./use-command"
4
+
5
+ export function useValue(
6
+ id: string,
7
+ ref: React.RefObject<HTMLElement>,
8
+ deps: (string | React.ReactNode | React.RefObject<HTMLElement>)[],
9
+ aliases: string[] = [],
10
+ ) {
11
+ const valueRef = useRef<string>()
12
+ const context = useCommand()
13
+
14
+ useLayoutEffect(() => {
15
+ const value = (() => {
16
+ for (const part of deps) {
17
+ if (typeof part === "string") {
18
+ return part.trim()
19
+ }
20
+
21
+ if (typeof part === "object" && part && "current" in part) {
22
+ if (part.current) {
23
+ return part.current.textContent?.trim()
24
+ }
25
+ return valueRef.current
26
+ }
27
+ }
28
+ return undefined // 关键:和原始实现一致,找不到值时返回undefined
29
+ })()
30
+
31
+ const keywords = aliases.map((alias) => alias.trim())
32
+
33
+ context.value(id, value || "", keywords)
34
+ ref.current?.setAttribute(VALUE_ATTR, value || "")
35
+ valueRef.current = value
36
+ }) // 和原始实现一致:故意不使用依赖数组
37
+
38
+ return valueRef
39
+ }
package/src/index.ts ADDED
@@ -0,0 +1,31 @@
1
+ export { Command as CommandRoot, defaultFilter } from "./command"
2
+ export { useCommandState } from "./hooks"
3
+
4
+ import { Command as CommandRoot } from "./command"
5
+ import { TabItem } from "@choice-ui/tabs"
6
+ import {
7
+ CommandDivider,
8
+ CommandEmpty,
9
+ CommandFooter,
10
+ CommandGroup,
11
+ CommandInput,
12
+ CommandItem,
13
+ CommandList,
14
+ CommandLoading,
15
+ CommandTabs,
16
+ CommandValue,
17
+ } from "./components"
18
+
19
+ export const Command = Object.assign(CommandRoot, {
20
+ Empty: CommandEmpty,
21
+ Footer: CommandFooter,
22
+ Group: CommandGroup,
23
+ Input: CommandInput,
24
+ Item: CommandItem,
25
+ List: CommandList,
26
+ Loading: CommandLoading,
27
+ Divider: CommandDivider,
28
+ Value: CommandValue,
29
+ Tabs: CommandTabs,
30
+ TabItem: TabItem,
31
+ })
@@ -0,0 +1 @@
1
+ // Store is now created inline in command.tsx, following original cmdk pattern
package/src/tv.ts ADDED
@@ -0,0 +1,248 @@
1
+ import { tcv } from "@choice-ui/shared"
2
+
3
+ export const commandTv = tcv({
4
+ slots: {
5
+ root: "flex h-full w-full flex-col overflow-hidden",
6
+ divider: "my-2 h-px",
7
+ },
8
+ variants: {
9
+ variant: {
10
+ default: {
11
+ root: "bg-default-background text-default-foreground",
12
+ divider: "bg-default-boundary",
13
+ },
14
+ dark: {
15
+ root: "bg-menu-background text-white",
16
+ divider: "bg-menu-boundary",
17
+ },
18
+ },
19
+ },
20
+ })
21
+
22
+ export const commandInputTv = tcv({
23
+ slots: {
24
+ root: "m-2 flex items-center",
25
+ input: "w-full rounded-lg",
26
+ },
27
+ variants: {
28
+ size: {
29
+ default: {
30
+ input: "text-body-medium h-8 px-2",
31
+ },
32
+ large: {
33
+ input: "leading-lg tracking-lg h-10 px-4 text-body-large",
34
+ },
35
+ },
36
+ },
37
+ defaultVariants: {
38
+ size: "default",
39
+ },
40
+ })
41
+
42
+ export const commandListTv = tcv({
43
+ slots: {
44
+ root: "px-2 pb-2",
45
+ content: "flex flex-col",
46
+ },
47
+ })
48
+
49
+ export const commandGroupTv = tcv({
50
+ slots: {
51
+ root: "flex flex-col gap-1 not-first:mt-4",
52
+ heading: "text-body-medium px-2",
53
+ },
54
+ variants: {
55
+ variant: {
56
+ default: {
57
+ heading: "text-secondary-foreground",
58
+ },
59
+ dark: {
60
+ heading: "text-white/50",
61
+ },
62
+ },
63
+ },
64
+ })
65
+
66
+ export const commandItemTv = tcv({
67
+ slots: {
68
+ root: ["group/item relative flex items-center rounded-lg select-none", "focus:outline-none"],
69
+ icon: "flex flex-shrink-0 items-center justify-center rounded-md",
70
+ value: "flex-1 truncate",
71
+ shortcut: "text-secondary-foreground",
72
+ },
73
+ variants: {
74
+ variant: {
75
+ default: {},
76
+ dark: {
77
+ root: "text-white",
78
+ },
79
+ },
80
+ size: {
81
+ default: {
82
+ root: "text-body-medium min-h-8 p-1",
83
+ icon: "h-6 min-w-6",
84
+ },
85
+ large: {
86
+ root: "leading-lg tracking-lg min-h-10 p-2 text-body-large",
87
+ icon: "h-6 min-w-6",
88
+ },
89
+ },
90
+ hasPrefix: {
91
+ true: "",
92
+ false: "",
93
+ },
94
+ hasSuffix: {
95
+ true: "",
96
+ false: "",
97
+ },
98
+ selected: {
99
+ true: {},
100
+ false: {},
101
+ },
102
+ disabled: {
103
+ true: {
104
+ root: "pointer-events-none",
105
+ },
106
+ },
107
+ },
108
+ compoundVariants: [
109
+ {
110
+ hasPrefix: true,
111
+ size: "default",
112
+ class: {
113
+ root: "gap-1 pl-1",
114
+ },
115
+ },
116
+ {
117
+ hasPrefix: false,
118
+ size: "default",
119
+ class: {
120
+ root: "pl-2",
121
+ },
122
+ },
123
+ {
124
+ hasSuffix: true,
125
+ size: "default",
126
+ class: {
127
+ root: "gap-1 pr-1",
128
+ },
129
+ },
130
+ {
131
+ hasSuffix: false,
132
+ size: "default",
133
+ class: {
134
+ root: "pr-2",
135
+ },
136
+ },
137
+ // large
138
+ {
139
+ hasPrefix: true,
140
+ size: "large",
141
+ class: {
142
+ root: "gap-2 pl-2",
143
+ },
144
+ },
145
+ {
146
+ hasPrefix: false,
147
+ size: "large",
148
+ class: {
149
+ root: "pl-4",
150
+ },
151
+ },
152
+ {
153
+ hasSuffix: true,
154
+ size: "large",
155
+ class: {
156
+ root: "gap-2 pr-2",
157
+ },
158
+ },
159
+ {
160
+ hasSuffix: false,
161
+ size: "large",
162
+ class: {
163
+ root: "pr-4",
164
+ },
165
+ },
166
+ {
167
+ variant: "default",
168
+ selected: true,
169
+ class: {
170
+ root: "bg-secondary-background",
171
+ },
172
+ },
173
+ {
174
+ variant: "dark",
175
+ selected: true,
176
+ class: {
177
+ root: "bg-gray-700",
178
+ },
179
+ },
180
+ {
181
+ variant: "default",
182
+ disabled: true,
183
+ class: {
184
+ root: "text-secondary-foreground",
185
+ },
186
+ },
187
+ {
188
+ variant: "dark",
189
+ disabled: true,
190
+ class: {
191
+ root: "text-white/50",
192
+ },
193
+ },
194
+ ],
195
+ defaultVariants: {
196
+ size: "default",
197
+ hasPrefix: false,
198
+ hasSuffix: false,
199
+ variant: "default",
200
+ },
201
+ })
202
+
203
+ export const commandFooterTv = tcv({
204
+ slots: {
205
+ root: "flex h-10 items-center justify-between border-t px-2",
206
+ },
207
+ variants: {
208
+ variant: {
209
+ default: {
210
+ root: "border-default-boundary",
211
+ },
212
+ dark: {
213
+ root: "border-menu-boundary",
214
+ },
215
+ },
216
+ },
217
+ defaultVariants: {
218
+ variant: "default",
219
+ },
220
+ })
221
+
222
+ export const commandTabsTv = tcv({
223
+ slots: {
224
+ tabs: "mx-2 mb-2",
225
+ },
226
+ })
227
+
228
+ export const commandEmptyTv = tcv({
229
+ slots: {
230
+ root: "py-6 text-center",
231
+ },
232
+ variants: {
233
+ variant: {
234
+ default: {
235
+ root: "text-secondary-foreground",
236
+ },
237
+ dark: {
238
+ root: "text-white/50",
239
+ },
240
+ },
241
+ },
242
+ })
243
+
244
+ export const commandLoadingTv = tcv({
245
+ slots: {
246
+ root: "flex items-center justify-center py-6 text-center",
247
+ },
248
+ })
package/src/types.ts ADDED
@@ -0,0 +1,84 @@
1
+ import React from "react"
2
+
3
+ export type CommandFilter = (value: string, search: string, keywords?: string[]) => number
4
+
5
+ export interface CommandProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange"> {
6
+ /**
7
+ * Optional default item value when it is initially rendered.
8
+ */
9
+ defaultValue?: string
10
+ /**
11
+ * Optionally set to `true` to disable selection via pointer events.
12
+ */
13
+ disablePointerSelection?: boolean
14
+ /**
15
+ * Custom filter function for whether each command menu item should matches the given search query.
16
+ * It should return a number between 0 and 1, with 1 being the best match and 0 being hidden entirely.
17
+ * By default, uses the `command-score` library.
18
+ */
19
+ filter?: CommandFilter
20
+ /**
21
+ * Accessible label for this command menu. Not shown visibly.
22
+ */
23
+ label?: string
24
+ /**
25
+ * Optionally set to `true` to turn on looping around when using the arrow keys.
26
+ */
27
+ loop?: boolean
28
+ /**
29
+ * Event handler called when the selected item of the menu changes.
30
+ */
31
+ onChange?: (value: string) => void
32
+ /**
33
+ * Optionally set to `false` to turn off the automatic filtering and sorting.
34
+ * If `false`, you must conditionally render valid items based on the search query yourself.
35
+ */
36
+ shouldFilter?: boolean
37
+ size?: "default" | "large"
38
+ /**
39
+ * Optional controlled state of the selected command menu item.
40
+ */
41
+ value?: string
42
+ variant?: "default" | "dark"
43
+ /**
44
+ * Set to `false` to disable ctrl+n/j/p/k shortcuts. Defaults to `true`.
45
+ */
46
+ vimBindings?: boolean
47
+ }
48
+
49
+ export type Context = {
50
+ filter: () => boolean
51
+ getDisablePointerSelection: () => boolean
52
+ group: (id: string) => () => void
53
+ inputId: string
54
+ item: (id: string, groupId?: string) => () => void
55
+ label?: string
56
+ labelId: string
57
+ // Ids
58
+ listId: string
59
+ // Refs
60
+ listInnerRef: React.MutableRefObject<HTMLDivElement | null>
61
+ size?: "default" | "large"
62
+ store: Store
63
+ value: (id: string, value?: string, keywords?: string[]) => void
64
+ variant?: "default" | "dark"
65
+ }
66
+
67
+ export type State = {
68
+ filtered: { count: number; groups: Set<string>; items: Map<string, number> }
69
+ search: string
70
+ selectedItemId?: string
71
+ value: string
72
+ }
73
+
74
+ export type Store = {
75
+ emit: () => void
76
+ setState: <K extends keyof State>(key: K, value: State[K], opts?: unknown) => void
77
+ snapshot: () => State
78
+ subscribe: (callback: () => void) => () => void
79
+ }
80
+
81
+ export type Group = {
82
+ forceMount?: boolean
83
+ id: string
84
+ }
@@ -0,0 +1,7 @@
1
+ export const GROUP_SELECTOR = `[role="presentation"]`
2
+ export const GROUP_ITEMS_SELECTOR = `[role="group"]`
3
+ export const GROUP_HEADING_SELECTOR = `[aria-hidden="true"]`
4
+ export const ITEM_SELECTOR = `[role="option"]`
5
+ export const VALID_ITEM_SELECTOR = `${ITEM_SELECTOR}:not([aria-disabled="true"])`
6
+ export const SELECT_EVENT = `cmdk-item-select`
7
+ export const VALUE_ATTR = `data-value`
@@ -0,0 +1,19 @@
1
+ export function findNextSibling(el: Element, selector: string): Element | null {
2
+ let sibling = el.nextElementSibling
3
+
4
+ while (sibling) {
5
+ if (sibling.matches(selector)) return sibling
6
+ sibling = sibling.nextElementSibling
7
+ }
8
+ return null
9
+ }
10
+
11
+ export function findPreviousSibling(el: Element, selector: string): Element | null {
12
+ let sibling = el.previousElementSibling
13
+
14
+ while (sibling) {
15
+ if (sibling.matches(selector)) return sibling
16
+ sibling = sibling.previousElementSibling
17
+ }
18
+ return null
19
+ }
@@ -0,0 +1,45 @@
1
+ import React from "react"
2
+
3
+ export const useLayoutEffect =
4
+ typeof window === "undefined" ? React.useEffect : React.useLayoutEffect
5
+
6
+ export function renderChildren(children: React.ReactElement): React.ReactNode {
7
+ const childrenType = children.type
8
+
9
+ // The children is a function component or class component
10
+ if (typeof childrenType === "function") {
11
+ // Try to call as function component first
12
+ try {
13
+ return (childrenType as React.FunctionComponent)(children.props)
14
+ } catch {
15
+ // If it fails, it might be a class component, return the original element
16
+ return children
17
+ }
18
+ }
19
+
20
+ // The children is a component with `forwardRef`
21
+ if (typeof childrenType === "object" && childrenType !== null && "render" in childrenType) {
22
+ const forwardRefComponent = childrenType as {
23
+ render: (props: unknown, ref: unknown) => React.ReactNode
24
+ }
25
+ return forwardRefComponent.render(children.props, null)
26
+ }
27
+
28
+ // It's a string, boolean, etc.
29
+ return children
30
+ }
31
+
32
+ export function SlottableWithNestedChildren(
33
+ { asChild, children }: { asChild?: boolean; children?: React.ReactNode },
34
+ render: (child: React.ReactNode) => JSX.Element,
35
+ ) {
36
+ if (asChild && React.isValidElement(children)) {
37
+ const renderedChild = renderChildren(children)
38
+ if (React.isValidElement(renderedChild)) {
39
+ const childProps = children.props
40
+ // Use a safer approach that avoids ref type issues
41
+ return React.cloneElement(renderedChild, {}, render(childProps.children))
42
+ }
43
+ }
44
+ return render(children)
45
+ }