@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.
@@ -1,165 +0,0 @@
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"
@@ -1,77 +0,0 @@
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"
@@ -1,30 +0,0 @@
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"
@@ -1,20 +0,0 @@
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"
@@ -1,23 +0,0 @@
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"
@@ -1,10 +0,0 @@
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"
@@ -1,5 +0,0 @@
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)
@@ -1,140 +0,0 @@
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
- }
@@ -1,2 +0,0 @@
1
- export { CommandContext, StoreContext } from "./command-context"
2
- export { createCommandContext } from "./create-command-context"
@@ -1,10 +0,0 @@
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)
@@ -1,12 +0,0 @@
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
- }
@@ -1,18 +0,0 @@
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
- }
@@ -1,10 +0,0 @@
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
- }
@@ -1,19 +0,0 @@
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
- }
@@ -1,39 +0,0 @@
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 DELETED
@@ -1,31 +0,0 @@
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
- })
@@ -1 +0,0 @@
1
- // Store is now created inline in command.tsx, following original cmdk pattern