@spree/dashboard-ui 6.0.0 → 6.0.2

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,4 +1,4 @@
1
- import { Command as CommandPrimitive } from 'cmdk'
1
+ import { Autocomplete as AutocompletePrimitive } from '@base-ui/react'
2
2
  import i18n from 'i18next'
3
3
  import * as React from 'react'
4
4
  import { cn } from '../lib/utils'
@@ -12,16 +12,67 @@ import {
12
12
  } from '../spree/icons'
13
13
  import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './dialog'
14
14
 
15
- function Command({ className, ...props }: React.ComponentProps<typeof CommandPrimitive>) {
15
+ const CommandContext = React.createContext<{
16
+ query: string
17
+ setQuery: (query: string) => void
18
+ shouldFilter: boolean
19
+ onInputChange: React.RefObject<((value: string) => void) | undefined>
20
+ }>({ query: '', setQuery: () => {}, shouldFilter: false, onInputChange: { current: undefined } })
21
+
22
+ /**
23
+ * A search field over a list of actions, on Base UI's Autocomplete rendered
24
+ * inline. The query lives on `CommandInput`. Rows whose `value` or text does not
25
+ * contain the query are hidden unless `shouldFilter` is false.
26
+ */
27
+ function Command({
28
+ className,
29
+ children,
30
+ shouldFilter = true,
31
+ ...props
32
+ }: React.ComponentProps<'div'> & { shouldFilter?: boolean }) {
33
+ const [query, setQuery] = React.useState('')
34
+ const onInputChange = React.useRef<((value: string) => void) | undefined>(undefined)
35
+
36
+ const context = React.useMemo(
37
+ () => ({ query, setQuery, shouldFilter, onInputChange }),
38
+ [query, shouldFilter],
39
+ )
40
+
16
41
  return (
17
- <CommandPrimitive
18
- data-slot="command"
19
- className={cn(
20
- 'flex size-full flex-col overflow-hidden rounded-2xl! bg-popover text-popover-foreground',
21
- className,
22
- )}
23
- {...props}
24
- />
42
+ <AutocompletePrimitive.Root
43
+ inline
44
+ open
45
+ autoHighlight="always"
46
+ keepHighlight
47
+ filter={null}
48
+ value={query}
49
+ onValueChange={(next, eventDetails) => {
50
+ // Choosing a row would otherwise write that row's value into the field.
51
+ if (eventDetails.reason === 'item-press') return
52
+ setQuery(next)
53
+ onInputChange.current?.(next)
54
+ }}
55
+ onOpenChange={(_open, eventDetails) => {
56
+ // The list is always shown, so Escape belongs to the dialog around it.
57
+ if (eventDetails.reason === 'escape-key') {
58
+ eventDetails.cancel()
59
+ eventDetails.allowPropagation()
60
+ }
61
+ }}
62
+ >
63
+ <CommandContext.Provider value={context}>
64
+ <div
65
+ data-slot="command"
66
+ className={cn(
67
+ 'flex size-full flex-col overflow-hidden rounded-2xl! bg-popover text-popover-foreground',
68
+ className,
69
+ )}
70
+ {...props}
71
+ >
72
+ {children}
73
+ </div>
74
+ </CommandContext.Provider>
75
+ </AutocompletePrimitive.Root>
25
76
  )
26
77
  }
27
78
 
@@ -72,18 +123,32 @@ function CommandDialog({
72
123
  function CommandInput({
73
124
  className,
74
125
  loading = false,
126
+ value,
127
+ onValueChange,
75
128
  ...props
76
- }: React.ComponentProps<typeof CommandPrimitive.Input> & {
129
+ }: Omit<AutocompletePrimitive.Input.Props, 'value'> & {
130
+ value?: string
131
+ onValueChange?: (value: string) => void
77
132
  /** Shows a spinner at the end of the field while results are in flight. */
78
133
  loading?: boolean
79
134
  }) {
135
+ const { query, setQuery, onInputChange } = React.useContext(CommandContext)
136
+ React.useLayoutEffect(() => {
137
+ onInputChange.current = onValueChange
138
+ }, [onInputChange, onValueChange])
139
+ // A controlled field shows only what its owner accepts: a reset from outside,
140
+ // or the previous text when an edit is rejected.
141
+ React.useLayoutEffect(() => {
142
+ if (value !== undefined && value !== query) setQuery(value)
143
+ }, [value, query, setQuery])
144
+
80
145
  return (
81
146
  <div
82
147
  data-slot="command-input-wrapper"
83
148
  className="flex h-14 shrink-0 items-center gap-3 border-b border-border px-5"
84
149
  >
85
150
  <SearchIcon className="size-5 shrink-0 text-muted-foreground" />
86
- <CommandPrimitive.Input
151
+ <AutocompletePrimitive.Input
87
152
  data-slot="command-input"
88
153
  aria-busy={loading || undefined}
89
154
  className={cn(
@@ -115,12 +180,12 @@ function CommandInput({
115
180
  * every keystroke. Pass `className="h-…"` to change it, or `h-auto max-h-…`
116
181
  * for the shrink-to-fit behaviour a short static menu wants.
117
182
  */
118
- function CommandList({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.List>) {
183
+ function CommandList({ className, ...props }: AutocompletePrimitive.List.Props) {
119
184
  return (
120
- <CommandPrimitive.List
185
+ <AutocompletePrimitive.List
121
186
  data-slot="command-list"
122
187
  className={cn(
123
- 'h-80 min-h-0 scroll-py-2 overflow-x-hidden overflow-y-auto p-2 outline-none',
188
+ 'group/command-list h-80 min-h-0 scroll-py-2 overflow-x-hidden overflow-y-auto p-2 outline-none',
124
189
  className,
125
190
  )}
126
191
  {...props}
@@ -128,14 +193,15 @@ function CommandList({ className, ...props }: React.ComponentProps<typeof Comman
128
193
  )
129
194
  }
130
195
 
131
- function CommandEmpty({
132
- className,
133
- ...props
134
- }: React.ComponentProps<typeof CommandPrimitive.Empty>) {
196
+ /** The message for an empty list. Hidden while the list has any rows. */
197
+ function CommandEmpty({ className, ...props }: React.ComponentProps<'div'>) {
135
198
  return (
136
- <CommandPrimitive.Empty
199
+ <div
137
200
  data-slot="command-empty"
138
- className={cn('py-10 text-center text-sm text-muted-foreground', className)}
201
+ className={cn(
202
+ 'py-10 text-center text-sm text-muted-foreground group-has-[[data-slot=command-item]]/command-list:hidden',
203
+ className,
204
+ )}
139
205
  {...props}
140
206
  />
141
207
  )
@@ -143,26 +209,35 @@ function CommandEmpty({
143
209
 
144
210
  function CommandGroup({
145
211
  className,
212
+ heading,
213
+ children,
146
214
  ...props
147
- }: React.ComponentProps<typeof CommandPrimitive.Group>) {
215
+ }: AutocompletePrimitive.Group.Props & { heading?: React.ReactNode }) {
148
216
  return (
149
- <CommandPrimitive.Group
217
+ <AutocompletePrimitive.Group
150
218
  data-slot="command-group"
151
219
  className={cn(
152
- 'overflow-hidden text-foreground not-last:pb-2 **:[[cmdk-group-heading]]:px-3 **:[[cmdk-group-heading]]:pt-2 **:[[cmdk-group-heading]]:pb-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground',
220
+ 'overflow-hidden text-foreground not-last:pb-2 not-has-[[data-slot=command-item]]:hidden',
153
221
  className,
154
222
  )}
155
223
  {...props}
156
- />
224
+ >
225
+ {heading && (
226
+ <AutocompletePrimitive.GroupLabel
227
+ data-slot="command-group-heading"
228
+ className="px-3 pt-2 pb-1.5 text-xs font-medium text-muted-foreground"
229
+ >
230
+ {heading}
231
+ </AutocompletePrimitive.GroupLabel>
232
+ )}
233
+ {children}
234
+ </AutocompletePrimitive.Group>
157
235
  )
158
236
  }
159
237
 
160
- function CommandSeparator({
161
- className,
162
- ...props
163
- }: React.ComponentProps<typeof CommandPrimitive.Separator>) {
238
+ function CommandSeparator({ className, ...props }: AutocompletePrimitive.Separator.Props) {
164
239
  return (
165
- <CommandPrimitive.Separator
240
+ <AutocompletePrimitive.Separator
166
241
  data-slot="command-separator"
167
242
  className={cn('-mx-2 my-1 h-px bg-border-subtle', className)}
168
243
  {...props}
@@ -170,23 +245,47 @@ function CommandSeparator({
170
245
  )
171
246
  }
172
247
 
248
+ /**
249
+ * One row. `onSelect` runs on click and on Enter while the row is highlighted,
250
+ * and receives the row's `value`.
251
+ */
173
252
  function CommandItem({
174
253
  className,
175
254
  children,
255
+ value,
256
+ keywords,
257
+ onSelect,
176
258
  ...props
177
- }: React.ComponentProps<typeof CommandPrimitive.Item>) {
259
+ }: Omit<AutocompletePrimitive.Item.Props, 'onClick' | 'onSelect' | 'value'> & {
260
+ value?: string
261
+ keywords?: string[]
262
+ onSelect?: (value: string) => void
263
+ }) {
264
+ const { query, shouldFilter } = React.useContext(CommandContext)
265
+ const text = value ?? (typeof children === 'string' ? children : '')
266
+ const needle = query.trim().toLowerCase()
267
+ if (
268
+ shouldFilter &&
269
+ needle &&
270
+ ![text, ...(keywords ?? [])].some((term) => term.toLowerCase().includes(needle))
271
+ ) {
272
+ return null
273
+ }
274
+
178
275
  return (
179
- <CommandPrimitive.Item
276
+ <AutocompletePrimitive.Item
180
277
  data-slot="command-item"
278
+ value={value}
279
+ onClick={() => onSelect?.(text)}
181
280
  className={cn(
182
- "group/command-item relative flex cursor-pointer items-center gap-3 rounded-md px-3 py-2.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-accent-strong data-selected:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-selected:*:[svg]:text-foreground",
281
+ "group/command-item relative flex cursor-pointer items-center gap-3 rounded-md px-3 py-2.5 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:bg-accent-strong data-highlighted:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-highlighted:*:[svg]:text-foreground",
183
282
  className,
184
283
  )}
185
284
  {...props}
186
285
  >
187
286
  {children}
188
287
  <CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
189
- </CommandPrimitive.Item>
288
+ </AutocompletePrimitive.Item>
190
289
  )
191
290
  }
192
291
 
@@ -195,7 +294,7 @@ function CommandShortcut({ className, ...props }: React.ComponentProps<'span'>)
195
294
  <span
196
295
  data-slot="command-shortcut"
197
296
  className={cn(
198
- 'ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground',
297
+ 'ml-auto text-xs tracking-widest text-muted-foreground group-data-highlighted/command-item:text-foreground',
199
298
  className,
200
299
  )}
201
300
  {...props}
@@ -160,19 +160,23 @@ function DropdownMenuCheckboxItem({
160
160
  data-slot="dropdown-menu-checkbox-item"
161
161
  data-inset={inset}
162
162
  className={cn(
163
- "relative flex cursor-pointer items-center gap-3 rounded-lg py-1.5 pr-8 pl-2.5 text-sm outline-hidden select-none transition-colors duration-75 focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
163
+ "group/checkbox-item relative flex cursor-pointer items-center gap-2.5 rounded-lg py-1.5 pr-2.5 pl-2.5 text-sm outline-hidden select-none transition-colors duration-75 focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
164
164
  className,
165
165
  )}
166
166
  checked={checked}
167
167
  onCheckedChange={onCheckedChange ? (checked) => onCheckedChange(checked) : undefined}
168
168
  {...props}
169
169
  >
170
+ {/* Drawn rather than a <Checkbox>: the item is already the checkbox to
171
+ assistive technology, and a second control inside it would be read
172
+ twice. Styled to match <Checkbox> so a menu and a table agree. */}
170
173
  <span
171
- className="pointer-events-none absolute right-2 flex items-center justify-center"
174
+ aria-hidden
172
175
  data-slot="dropdown-menu-checkbox-item-indicator"
176
+ className="pointer-events-none flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-border bg-card text-white shadow-xs transition-colors duration-100 group-data-[checked]/checkbox-item:border-blue-500 group-data-[checked]/checkbox-item:bg-blue-500"
173
177
  >
174
178
  <MenuPrimitive.CheckboxItemIndicator keepMounted={false}>
175
- <CheckIcon />
179
+ <CheckIcon className="size-3.5" />
176
180
  </MenuPrimitive.CheckboxItemIndicator>
177
181
  </span>
178
182
  {children}
package/src/ui/field.tsx CHANGED
@@ -94,7 +94,7 @@ function FieldLabel({ className, ...props }: React.ComponentProps<typeof Label>)
94
94
  <Label
95
95
  data-slot="field-label"
96
96
  className={cn(
97
- 'group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-blue-500/30 has-data-checked:bg-blue-500/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border *:data-[slot=field]:p-2.5 dark:has-data-checked:border-blue-500/20 dark:has-data-checked:bg-blue-500/10',
97
+ 'group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-blue-100 has-data-checked:bg-blue-50 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border *:data-[slot=field]:p-2.5 dark:has-data-checked:border-blue-500/20 dark:has-data-checked:bg-blue-500/10',
98
98
  // Card-shaped labels (the ones wrapping a whole `Field`) are clickable
99
99
  // targets, so they need the affordance a bare text label doesn't:
100
100
  // a pointer and a hover tint. Scoped by `has-[>[data-slot=field]]` so