@spree/dashboard-ui 0.13.1 → 1.0.0-beta.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.
Files changed (94) hide show
  1. package/README.md +1 -1
  2. package/package.json +13 -9
  3. package/src/hooks/use-prefers-reduced-motion.ts +26 -0
  4. package/src/hooks/use-scrolled.ts +34 -14
  5. package/src/index.ts +38 -1
  6. package/src/lib/date-range-presets.ts +79 -0
  7. package/src/lib/same-rich-text.ts +145 -0
  8. package/src/spree/address-block.tsx +5 -4
  9. package/src/spree/address-book-row.tsx +96 -0
  10. package/src/spree/back-button.tsx +14 -9
  11. package/src/spree/bulk-dialog.tsx +2 -2
  12. package/src/spree/bulk-price-table.tsx +145 -14
  13. package/src/spree/bulk-variants-table.tsx +434 -0
  14. package/src/spree/calculator-summary.tsx +5 -1
  15. package/src/spree/claim-resolve-dialog.tsx +219 -0
  16. package/src/spree/confirm-dialog.tsx +1 -1
  17. package/src/spree/copy-to-clipboard-button.tsx +1 -1
  18. package/src/spree/data-grid/cells.tsx +199 -25
  19. package/src/spree/data-grid/context.ts +6 -0
  20. package/src/spree/data-grid/data-grid.tsx +149 -7
  21. package/src/spree/data-grid/helpers.ts +6 -2
  22. package/src/spree/data-grid/index.ts +9 -1
  23. package/src/spree/data-grid/use-data-grid-keyboard.ts +4 -2
  24. package/src/spree/delivery-form-dialog.tsx +166 -0
  25. package/src/spree/drag-handle.tsx +2 -2
  26. package/src/spree/editable-note-card.tsx +103 -0
  27. package/src/spree/external-link.tsx +56 -0
  28. package/src/spree/file-type-icon.tsx +68 -0
  29. package/src/spree/form-actions.tsx +2 -3
  30. package/src/spree/form-section.tsx +46 -0
  31. package/src/spree/fulfill-items-form.tsx +282 -0
  32. package/src/spree/fulfillment-edit-dialog.tsx +211 -0
  33. package/src/spree/fulfillment-item-list.tsx +61 -0
  34. package/src/spree/fulfillment-panel.tsx +53 -0
  35. package/src/spree/icons.ts +167 -0
  36. package/src/spree/json-preview-drawer.tsx +4 -4
  37. package/src/spree/label-upload-dialog.tsx +195 -0
  38. package/src/spree/language-menu-items.tsx +37 -30
  39. package/src/spree/metadata/metadata-card.tsx +1 -1
  40. package/src/spree/order-cancel-dialog.tsx +149 -0
  41. package/src/spree/post-sale-create-dialogs.tsx +253 -0
  42. package/src/spree/post-sale-fields.tsx +133 -0
  43. package/src/spree/product-membership-list.tsx +453 -0
  44. package/src/spree/products/status-card.tsx +79 -0
  45. package/src/spree/quantity-tier-editor.tsx +162 -0
  46. package/src/spree/resource-combobox.tsx +12 -1
  47. package/src/spree/resource-name-cell.tsx +21 -4
  48. package/src/spree/return-dialogs.tsx +238 -0
  49. package/src/spree/route-error-boundary.tsx +1 -1
  50. package/src/spree/row-actions.tsx +2 -2
  51. package/src/spree/search-input.tsx +76 -0
  52. package/src/spree/secret-input.tsx +26 -40
  53. package/src/spree/tax-identifiers-card.tsx +418 -0
  54. package/src/spree/theme-toggle.tsx +36 -23
  55. package/src/spree/wizard.tsx +254 -0
  56. package/src/styles.css +750 -75
  57. package/src/ui/accordion.tsx +83 -0
  58. package/src/ui/alert.tsx +79 -0
  59. package/src/ui/attachment.tsx +1 -1
  60. package/src/ui/badge.tsx +198 -56
  61. package/src/ui/breadcrumb.tsx +1 -1
  62. package/src/ui/button.tsx +22 -10
  63. package/src/ui/calendar.tsx +1 -1
  64. package/src/ui/card.tsx +52 -6
  65. package/src/ui/checkbox.tsx +13 -5
  66. package/src/ui/combobox.tsx +125 -6
  67. package/src/ui/command.tsx +149 -23
  68. package/src/ui/context-menu.tsx +99 -0
  69. package/src/ui/data-table.tsx +448 -9
  70. package/src/ui/date-picker.tsx +18 -3
  71. package/src/ui/date-range-picker.tsx +47 -60
  72. package/src/ui/dialog.tsx +6 -6
  73. package/src/ui/dropdown-menu.tsx +18 -11
  74. package/src/ui/field.tsx +10 -2
  75. package/src/ui/input-group.tsx +1 -1
  76. package/src/ui/input.tsx +1 -1
  77. package/src/ui/media-preview.tsx +105 -0
  78. package/src/ui/pagination.tsx +19 -11
  79. package/src/ui/popover.tsx +4 -2
  80. package/src/ui/progress.tsx +5 -2
  81. package/src/ui/radio-group.tsx +4 -4
  82. package/src/ui/rich-text-editor.tsx +214 -88
  83. package/src/ui/scroll-area.tsx +65 -0
  84. package/src/ui/select.tsx +17 -10
  85. package/src/ui/separator.tsx +1 -1
  86. package/src/ui/sheet.tsx +49 -7
  87. package/src/ui/sidebar.tsx +138 -37
  88. package/src/ui/skeleton.tsx +3 -1
  89. package/src/ui/tabs.tsx +84 -0
  90. package/src/ui/textarea.tsx +1 -1
  91. package/src/ui/thumbnail.tsx +80 -0
  92. package/src/ui/toast.tsx +112 -0
  93. package/src/ui/tooltip.tsx +3 -1
  94. package/src/ui/sonner.tsx +0 -47
@@ -1,7 +1,7 @@
1
1
  import { Checkbox as CheckboxPrimitive } from '@base-ui/react/checkbox'
2
- import { CheckIcon, MinusIcon } from 'lucide-react'
3
2
  import type * as React from 'react'
4
3
  import { cn } from '../lib/utils'
4
+ import { CheckIcon, MinusIcon } from '../spree/icons'
5
5
 
6
6
  function Checkbox({
7
7
  className,
@@ -25,13 +25,21 @@ function Checkbox({
25
25
  disabled={disabled}
26
26
  name={name}
27
27
  className={cn(
28
- 'peer relative inline-block size-4 shrink-0 rounded-[4px] border border-input align-[-3px] shadow-xs outline-none transition-shadow',
29
- 'focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50',
30
- 'disabled:cursor-not-allowed disabled:opacity-50',
28
+ 'peer relative inline-block size-4 shrink-0 cursor-pointer rounded-[4px] border border-border bg-card align-[-3px] outline-none transition-[color,background-color,border-color,box-shadow] duration-100 ease-out',
29
+ // The 16px box is the right *visual* size in a dense table, but on its
30
+ // own it is a 16px target — under the 24px WCAG 2.2 asks for, on the
31
+ // control every bulk action starts from. A pseudo-element extends the
32
+ // pressable area to 34px without taking layout space, so column widths
33
+ // and row height are unchanged. `z-1` keeps it above the row-click
34
+ // bridge, which would otherwise swallow presses in the padded margin
35
+ // and navigate instead of selecting.
36
+ "before:absolute before:inset-[-9px] before:z-1 before:content-['']",
37
+ 'focus:border-blue-500 focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--ring)_15%,transparent)]',
38
+ 'disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground',
31
39
  'aria-invalid:border-destructive aria-invalid:ring-destructive/20',
32
40
  'data-[checked]:border-blue-500 data-[checked]:bg-blue-500 data-[checked]:text-white',
33
41
  'data-[indeterminate]:border-blue-500 data-[indeterminate]:bg-blue-500 data-[indeterminate]:text-white',
34
- 'dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:data-[checked]:bg-blue-500',
42
+ 'dark:aria-invalid:ring-destructive/40 dark:data-[checked]:bg-blue-500',
35
43
  className,
36
44
  )}
37
45
  {...props}
@@ -1,9 +1,10 @@
1
1
  'use client'
2
2
 
3
3
  import { Combobox as ComboboxPrimitive } from '@base-ui/react'
4
- import { CheckIcon, ChevronDownIcon, XIcon } from 'lucide-react'
4
+ import i18n from 'i18next'
5
5
  import * as React from 'react'
6
6
  import { cn } from '../lib/utils'
7
+ import { CheckIcon, ChevronDownIcon, XIcon } from '../spree/icons'
7
8
  import { Button } from './button'
8
9
  import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from './input-group'
9
10
 
@@ -26,6 +27,91 @@ function ComboboxTrigger({ className, children, ...props }: ComboboxPrimitive.Tr
26
27
  )
27
28
  }
28
29
 
30
+ /**
31
+ * Button-shaped trigger for a searchable picker: the field renders the current
32
+ * selection (flag, formatted label) and the search box lives inside the popup,
33
+ * next to the results it filters.
34
+ *
35
+ * Preferred over the editable-input trigger (`<ComboboxInput>`) whenever the
36
+ * selection has a richer display than the raw query text. A text input has to
37
+ * show the typed query, so it erases the selected label the moment the user
38
+ * types, and the browser's address autofill treats a text field named after a
39
+ * country as part of an address form and covers the list with saved addresses.
40
+ * A button has nothing to autofill into, and the search box inside the popup
41
+ * sits outside the form's field flow where those heuristics don't reach.
42
+ *
43
+ * Deliberately matches `<SelectTrigger>` so a picker sitting beside a plain
44
+ * select is indistinguishable from it until opened.
45
+ */
46
+ function ComboboxButtonTrigger({
47
+ className,
48
+ children,
49
+ size = 'default',
50
+ ...props
51
+ }: ComboboxPrimitive.Trigger.Props & {
52
+ /** `sm` is the toolbar size — see `SelectTrigger`, which it matches exactly. */
53
+ size?: 'sm' | 'default'
54
+ }) {
55
+ return (
56
+ <ComboboxPrimitive.Trigger
57
+ data-slot="combobox-button-trigger"
58
+ data-size={size}
59
+ className={cn(
60
+ "group/combobox-trigger flex w-full min-h-8 cursor-pointer items-center justify-between gap-1.5 rounded-md border border-border bg-card py-1.5 pr-2 pl-2.5 text-base font-normal leading-normal text-foreground transition-[color,background-color,border-color,box-shadow] duration-100 ease-out outline-none select-none focus:border-blue-500 focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--ring)_15%,transparent)] disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground aria-invalid:border-destructive data-[size=sm]:shadow-xs data-[size=sm]:h-8 data-[size=sm]:min-h-8 data-[size=sm]:gap-2 data-[size=sm]:px-2 data-[size=sm]:py-1 data-[size=sm]:text-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
61
+ className,
62
+ )}
63
+ {...props}
64
+ >
65
+ <span className="flex min-w-0 items-center gap-2 truncate">{children}</span>
66
+ <ChevronDownIcon className="pointer-events-none size-4 shrink-0 text-muted-foreground group-data-[size=sm]/combobox-trigger:size-3.5" />
67
+ </ComboboxPrimitive.Trigger>
68
+ )
69
+ }
70
+
71
+ /**
72
+ * Placeholder text for a `<ComboboxButtonTrigger>` with nothing selected.
73
+ * A span rather than the trigger's own `data-placeholder`, because the
74
+ * trigger renders arbitrary children (a flag beside a name) rather than a
75
+ * single value string.
76
+ */
77
+ function ComboboxTriggerPlaceholder({ className, ...props }: React.ComponentProps<'span'>) {
78
+ return (
79
+ <span
80
+ data-slot="combobox-trigger-placeholder"
81
+ className={cn('truncate text-muted-foreground', className)}
82
+ {...props}
83
+ />
84
+ )
85
+ }
86
+
87
+ /**
88
+ * Search box for a button-triggered picker, rendered at the top of the popup.
89
+ * Autofill and spell-check are turned off across the board: this filters an
90
+ * on-screen list, so a password manager or a saved-address panel covering it
91
+ * is pure interference.
92
+ */
93
+ function ComboboxSearch({ className, ...props }: ComboboxPrimitive.Input.Props) {
94
+ return (
95
+ <div className="border-b border-border p-1">
96
+ <ComboboxInput
97
+ data-slot="combobox-search"
98
+ className={cn(
99
+ 'min-h-8 border-0 shadow-none has-[[data-slot=input-group-control]:focus-visible]:border-transparent has-[[data-slot=input-group-control]:focus-visible]:shadow-none',
100
+ className,
101
+ )}
102
+ autoComplete="off"
103
+ autoCorrect="off"
104
+ autoCapitalize="off"
105
+ spellCheck={false}
106
+ data-1p-ignore
107
+ data-lpignore="true"
108
+ showTrigger={false}
109
+ {...props}
110
+ />
111
+ </div>
112
+ )
113
+ }
114
+
29
115
  function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
30
116
  return (
31
117
  <ComboboxPrimitive.Clear
@@ -100,6 +186,12 @@ function ComboboxContent({
100
186
  data-slot="combobox-content"
101
187
  data-chips={!!anchor}
102
188
  className={cn(
189
+ // Deliberately keeps `animate-in`/`animate-out` keyframes where the
190
+ // other floating surfaces use transitions. Converting this one to
191
+ // `data-[starting-style]` transitions broke the multi-select chips
192
+ // picker: the list re-renders as the user filters, and options
193
+ // arriving mid-transition sit at `opacity-0` long enough to be
194
+ // unclickable. The keyframes make the panel opaque immediately.
103
195
  'group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
104
196
  className,
105
197
  )}
@@ -131,7 +223,7 @@ function ComboboxItem({ className, children, ...props }: ComboboxPrimitive.Item.
131
223
  // Padding + font sized to match `<Input>` (`py-1.5 px-2.5 text-base
132
224
  // leading-normal`) so the dropdown rows feel like a continuation of
133
225
  // the trigger field. `pr-8` keeps room for the check indicator.
134
- "relative flex w-full cursor-default items-center gap-2 rounded-md py-1.5 pr-8 pl-2.5 text-base font-normal leading-normal outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**: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",
226
+ "relative flex w-full cursor-pointer items-center gap-2 rounded-md py-1.5 pr-8 pl-2.5 text-base font-normal leading-normal outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**: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",
135
227
  className,
136
228
  )}
137
229
  {...props}
@@ -197,7 +289,13 @@ function ComboboxChips({
197
289
  <ComboboxPrimitive.Chips
198
290
  data-slot="combobox-chips"
199
291
  className={cn(
200
- 'flex flex-wrap items-center gap-1 rounded-lg border border-border bg-card text-foreground shadow-xs px-2.5 py-1.5 text-base transition-all duration-100 ease-in-out focus-within:border-blue-500 focus-within:shadow-[0_0_0_3px_rgba(59,130,246,0.15)] has-aria-invalid:border-destructive has-data-[slot=combobox-chip]:px-1',
292
+ // Keeps `transition-all` where the sibling inputs narrowed theirs: this
293
+ // element is the popup's anchor, and adding a chip reflows it
294
+ // (`flex-wrap`, `has-data-[slot=combobox-chip]:px-1`). Animating that
295
+ // resize lets the positioner follow the anchor smoothly; making it
296
+ // instant jumps the open popup out from under the pointer, so the next
297
+ // option click lands on nothing.
298
+ 'flex flex-wrap items-center gap-1.5 rounded-md border border-border bg-card text-foreground px-2.5 py-1.5 text-base transition-all duration-100 ease-out focus-within:border-blue-500 focus-within:shadow-[0_0_0_3px_color-mix(in_srgb,var(--ring)_15%,transparent)] has-aria-invalid:border-destructive has-data-[slot=combobox-chip]:p-1.5',
201
299
  className,
202
300
  )}
203
301
  {...props}
@@ -217,7 +315,15 @@ function ComboboxChip({
217
315
  <ComboboxPrimitive.Chip
218
316
  data-slot="combobox-chip"
219
317
  className={cn(
220
- 'flex h-[calc(--spacing(5.25))] w-fit max-w-full items-center justify-center gap-1 rounded-sm bg-muted px-1.5 text-xs font-medium text-foreground has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0',
318
+ // Height comes from the content rather than a fixed 21px: the old value
319
+ // was shorter than the remove button inside it, and left the label with
320
+ // no vertical breathing room at any screen size.
321
+ //
322
+ // Fill matches the highlighted row in the list below (`bg-accent`): a
323
+ // chip and a selected option are the same thing at two moments, so they
324
+ // read as the same thing. `bg-muted` sat within 1.03:1 of the field it
325
+ // sits on, which left the chips barely visible.
326
+ 'flex min-h-7 w-fit max-w-full items-center justify-center gap-1 rounded-sm bg-accent py-1 pl-2.5 pr-2 text-sm font-base text-foreground has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-1',
221
327
  className,
222
328
  )}
223
329
  {...props}
@@ -225,9 +331,19 @@ function ComboboxChip({
225
331
  <span className="min-w-0 truncate">{children}</span>
226
332
  {showRemove && (
227
333
  <ComboboxPrimitive.ChipRemove
228
- render={<Button variant="ghost" size="icon-xs" />}
229
- className="opacity-50 hover:opacity-100"
334
+ render={<Button variant="ghost" size="icon-sm" />}
335
+ className="size-6 shrink-0 opacity-50 hover:opacity-100"
230
336
  data-slot="combobox-chip-remove"
337
+ // Names the chip it clears ("Remove Wholesale") rather than a bare
338
+ // "Remove": several sit side by side, and a dialog's own submit
339
+ // button is often called Remove too. An `aria-label` rather than an
340
+ // `sr-only` span, so the chip's text isn't duplicated as a second
341
+ // text node beside the one already visible.
342
+ aria-label={
343
+ typeof children === 'string'
344
+ ? i18n.t('admin.actions.remove_item', { label: children })
345
+ : i18n.t('admin.actions.remove')
346
+ }
231
347
  >
232
348
  <XIcon className="pointer-events-none" />
233
349
  </ComboboxPrimitive.ChipRemove>
@@ -252,6 +368,7 @@ function useComboboxAnchor() {
252
368
 
253
369
  export {
254
370
  Combobox,
371
+ ComboboxButtonTrigger,
255
372
  ComboboxChip,
256
373
  ComboboxChips,
257
374
  ComboboxChipsInput,
@@ -263,8 +380,10 @@ export {
263
380
  ComboboxItem,
264
381
  ComboboxLabel,
265
382
  ComboboxList,
383
+ ComboboxSearch,
266
384
  ComboboxSeparator,
267
385
  ComboboxTrigger,
386
+ ComboboxTriggerPlaceholder,
268
387
  ComboboxValue,
269
388
  useComboboxAnchor,
270
389
  }
@@ -1,17 +1,23 @@
1
1
  import { Command as CommandPrimitive } from 'cmdk'
2
2
  import i18n from 'i18next'
3
- import { CheckIcon, SearchIcon } from 'lucide-react'
4
3
  import * as React from 'react'
5
4
  import { cn } from '../lib/utils'
5
+ import {
6
+ CheckIcon,
7
+ CornerDownLeftIcon,
8
+ Loader2Icon,
9
+ MoveDownIcon,
10
+ MoveUpIcon,
11
+ SearchIcon,
12
+ } from '../spree/icons'
6
13
  import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './dialog'
7
- import { InputGroup, InputGroupAddon } from './input-group'
8
14
 
9
15
  function Command({ className, ...props }: React.ComponentProps<typeof CommandPrimitive>) {
10
16
  return (
11
17
  <CommandPrimitive
12
18
  data-slot="command"
13
19
  className={cn(
14
- 'flex size-full flex-col overflow-hidden rounded-xl! bg-popover p-1 text-popover-foreground',
20
+ 'flex size-full flex-col overflow-hidden rounded-2xl! bg-popover text-popover-foreground',
15
21
  className,
16
22
  )}
17
23
  {...props}
@@ -39,7 +45,14 @@ function CommandDialog({
39
45
  <DialogDescription>{description}</DialogDescription>
40
46
  </DialogHeader>
41
47
  <DialogContent
42
- className={cn('top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0', className)}
48
+ // `-translate-x-1/2` must be restated alongside `translate-y-0`: in
49
+ // Tailwind v4 both compile to the standalone `translate` property, so
50
+ // setting only the Y axis wipes the base dialog's horizontal centring
51
+ // and the panel drifts off both edges of a narrow screen.
52
+ className={cn(
53
+ 'top-1/3 -translate-x-1/2 translate-y-0 overflow-hidden rounded-xl! p-0',
54
+ className,
55
+ )}
43
56
  showCloseButton={showCloseButton}
44
57
  >
45
58
  {children}
@@ -48,35 +61,66 @@ function CommandDialog({
48
61
  )
49
62
  }
50
63
 
64
+ /**
65
+ * The palette's search field — the top edge of the panel, spanning its full
66
+ * width above a single hairline.
67
+ *
68
+ * While `loading` is set, a spinner sits at the end of the field rather than in
69
+ * the results list, so rows don't shift down when a search starts and back up
70
+ * when it lands. The state is announced to screen readers as well as drawn.
71
+ */
51
72
  function CommandInput({
52
73
  className,
74
+ loading = false,
53
75
  ...props
54
- }: React.ComponentProps<typeof CommandPrimitive.Input>) {
76
+ }: React.ComponentProps<typeof CommandPrimitive.Input> & {
77
+ /** Shows a spinner at the end of the field while results are in flight. */
78
+ loading?: boolean
79
+ }) {
55
80
  return (
56
- <div data-slot="command-input-wrapper" className="p-1 pb-0">
57
- <InputGroup className="h-8! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2!">
58
- <CommandPrimitive.Input
59
- data-slot="command-input"
60
- className={cn(
61
- 'w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50',
62
- className,
63
- )}
64
- {...props}
81
+ <div
82
+ data-slot="command-input-wrapper"
83
+ className="flex h-14 shrink-0 items-center gap-3 border-b border-border px-5"
84
+ >
85
+ <SearchIcon className="size-5 shrink-0 text-muted-foreground" />
86
+ <CommandPrimitive.Input
87
+ data-slot="command-input"
88
+ aria-busy={loading || undefined}
89
+ className={cn(
90
+ 'h-full w-full bg-transparent text-base outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
91
+ className,
92
+ )}
93
+ {...props}
94
+ />
95
+ {loading && (
96
+ <Loader2Icon
97
+ data-slot="command-input-loading"
98
+ aria-hidden="true"
99
+ className="size-4 shrink-0 animate-spin text-muted-foreground"
65
100
  />
66
- <InputGroupAddon>
67
- <SearchIcon className="size-4 shrink-0 opacity-50" />
68
- </InputGroupAddon>
69
- </InputGroup>
101
+ )}
102
+ {/* The spinner is decorative, so the progress a sighted user reads from it
103
+ is announced here instead. Polite: it must not cut off the row count
104
+ the listbox announces when results land. */}
105
+ <span role="status" aria-live="polite" className="sr-only">
106
+ {loading ? i18n.t('admin.common.searching') : ''}
107
+ </span>
70
108
  </div>
71
109
  )
72
110
  }
73
111
 
112
+ /**
113
+ * The results area. Its height is fixed rather than content-driven, so the
114
+ * panel stays put as results arrive instead of resizing under the cursor on
115
+ * every keystroke. Pass `className="h-…"` to change it, or `h-auto max-h-…`
116
+ * for the shrink-to-fit behaviour a short static menu wants.
117
+ */
74
118
  function CommandList({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.List>) {
75
119
  return (
76
120
  <CommandPrimitive.List
77
121
  data-slot="command-list"
78
122
  className={cn(
79
- 'no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none',
123
+ 'h-80 min-h-0 scroll-py-2 overflow-x-hidden overflow-y-auto p-2 outline-none',
80
124
  className,
81
125
  )}
82
126
  {...props}
@@ -91,7 +135,7 @@ function CommandEmpty({
91
135
  return (
92
136
  <CommandPrimitive.Empty
93
137
  data-slot="command-empty"
94
- className={cn('py-6 text-center text-sm', className)}
138
+ className={cn('py-10 text-center text-sm text-muted-foreground', className)}
95
139
  {...props}
96
140
  />
97
141
  )
@@ -105,7 +149,7 @@ function CommandGroup({
105
149
  <CommandPrimitive.Group
106
150
  data-slot="command-group"
107
151
  className={cn(
108
- 'overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground',
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',
109
153
  className,
110
154
  )}
111
155
  {...props}
@@ -120,7 +164,7 @@ function CommandSeparator({
120
164
  return (
121
165
  <CommandPrimitive.Separator
122
166
  data-slot="command-separator"
123
- className={cn('-mx-1 h-px bg-border', className)}
167
+ className={cn('-mx-2 my-1 h-px bg-border-subtle', className)}
124
168
  {...props}
125
169
  />
126
170
  )
@@ -135,7 +179,7 @@ function CommandItem({
135
179
  <CommandPrimitive.Item
136
180
  data-slot="command-item"
137
181
  className={cn(
138
- "group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
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",
139
183
  className,
140
184
  )}
141
185
  {...props}
@@ -159,13 +203,95 @@ function CommandShortcut({ className, ...props }: React.ComponentProps<'span'>)
159
203
  )
160
204
  }
161
205
 
206
+ /** A single key cap — used for the palette's footer hints and inline shortcuts. */
207
+ function CommandKey({ className, ...props }: React.ComponentProps<'kbd'>) {
208
+ return (
209
+ <kbd
210
+ data-slot="command-key"
211
+ className={cn(
212
+ 'inline-flex h-5 min-w-6 items-center justify-center rounded-sm bg-card border border-border px-1 font-sans text-xs text-muted-foreground',
213
+ className,
214
+ )}
215
+ {...props}
216
+ />
217
+ )
218
+ }
219
+
220
+ /** One key cap (or a pair) followed by the action it performs. */
221
+ function CommandHint({
222
+ keys,
223
+ label,
224
+ className,
225
+ ...props
226
+ }: Omit<React.ComponentProps<'div'>, 'children'> & {
227
+ keys: React.ReactNode[]
228
+ label: React.ReactNode
229
+ }) {
230
+ return (
231
+ <div data-slot="command-hint" className={cn('flex items-center gap-1.5', className)} {...props}>
232
+ <span className="flex items-center gap-1">
233
+ {keys.map((key, index) => (
234
+ // Hints are static, ordered, and never reordered — index is a stable key.
235
+ // biome-ignore lint/suspicious/noArrayIndexKey: static list
236
+ <CommandKey key={index}>{key}</CommandKey>
237
+ ))}
238
+ </span>
239
+ <span className="text-xs text-muted-foreground">{label}</span>
240
+ </div>
241
+ )
242
+ }
243
+
244
+ /**
245
+ * Bottom bar of the palette. Renders the default navigate/select/close hints
246
+ * when given no children, so callers only pass children to replace them.
247
+ */
248
+ function CommandFooter({ className, children, ...props }: React.ComponentProps<'div'>) {
249
+ return (
250
+ <div
251
+ data-slot="command-footer"
252
+ className={cn(
253
+ // Hidden on touch: the footer documents arrow/enter/esc shortcuts,
254
+ // which a phone has no keys for, and it costs 45px of a short viewport.
255
+ 'hidden shrink-0 items-center justify-between gap-4 border-t border-border-subtle bg-muted px-4 py-2.5 text-muted-foreground md:flex capitalize',
256
+ className,
257
+ )}
258
+ {...props}
259
+ >
260
+ {children ?? (
261
+ <>
262
+ <div className="flex items-center gap-4">
263
+ <CommandHint
264
+ keys={[
265
+ <MoveUpIcon key="up" className="size-3.5" />,
266
+ <MoveDownIcon key="down" className="size-3.5" />,
267
+ ]}
268
+ label={i18n.t('admin.components.command_palette.hints.navigate')}
269
+ />
270
+ <CommandHint
271
+ keys={[<CornerDownLeftIcon key="enter" className="size-3.5" />]}
272
+ label={i18n.t('admin.components.command_palette.hints.select')}
273
+ />
274
+ </div>
275
+ <CommandHint
276
+ keys={[i18n.t('admin.components.command_palette.hints.esc_key')]}
277
+ label={i18n.t('admin.components.command_palette.hints.close')}
278
+ />
279
+ </>
280
+ )}
281
+ </div>
282
+ )
283
+ }
284
+
162
285
  export {
163
286
  Command,
164
287
  CommandDialog,
165
288
  CommandEmpty,
289
+ CommandFooter,
166
290
  CommandGroup,
291
+ CommandHint,
167
292
  CommandInput,
168
293
  CommandItem,
294
+ CommandKey,
169
295
  CommandList,
170
296
  CommandSeparator,
171
297
  CommandShortcut,
@@ -0,0 +1,99 @@
1
+ 'use client'
2
+
3
+ import { ContextMenu as ContextMenuPrimitive } from '@base-ui/react/context-menu'
4
+ import type * as React from 'react'
5
+ import { cn } from '../lib/utils'
6
+
7
+ /**
8
+ * Right-click menu. Styled to match {@link DropdownMenu} exactly — the same
9
+ * menu opened a different way should not look like a different menu.
10
+ *
11
+ * Always a shortcut to actions reachable another way: a right-click is
12
+ * undiscoverable on its own, and absent entirely on touch.
13
+ */
14
+ function ContextMenu({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
15
+ return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
16
+ }
17
+
18
+ function ContextMenuTrigger({
19
+ ...props
20
+ }: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
21
+ return <ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
22
+ }
23
+
24
+ function ContextMenuGroup({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
25
+ return <ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
26
+ }
27
+
28
+ function ContextMenuContent({
29
+ className,
30
+ children,
31
+ ...props
32
+ }: {
33
+ className?: string
34
+ children?: React.ReactNode
35
+ } & Omit<React.ComponentProps<typeof ContextMenuPrimitive.Popup>, 'className'>) {
36
+ return (
37
+ <ContextMenuPrimitive.Portal>
38
+ <ContextMenuPrimitive.Positioner className="z-50">
39
+ <ContextMenuPrimitive.Popup
40
+ data-slot="context-menu-content"
41
+ className={cn(
42
+ // `origin-(--transform-origin)` so the menu grows out of the
43
+ // pointer rather than its own centre.
44
+ 'z-50 max-h-[var(--available-height)] min-w-40 max-w-80 overflow-x-hidden overflow-y-auto rounded-xl border border-border bg-popover p-1 text-popover-foreground shadow-lg origin-(--transform-origin) duration-100 ease-out data-[starting-style]:opacity-0 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[ending-style]:scale-95 transition-[opacity,transform,translate,scale]',
45
+ className,
46
+ )}
47
+ {...props}
48
+ >
49
+ {children}
50
+ </ContextMenuPrimitive.Popup>
51
+ </ContextMenuPrimitive.Positioner>
52
+ </ContextMenuPrimitive.Portal>
53
+ )
54
+ }
55
+
56
+ function ContextMenuItem({
57
+ className,
58
+ inset,
59
+ variant = 'default',
60
+ ...props
61
+ }: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
62
+ inset?: boolean
63
+ variant?: 'default' | 'destructive'
64
+ }) {
65
+ return (
66
+ <ContextMenuPrimitive.Item
67
+ data-slot="context-menu-item"
68
+ data-inset={inset}
69
+ data-variant={variant}
70
+ className={cn(
71
+ "group/context-menu-item relative flex cursor-pointer items-center gap-3 rounded-lg px-2 py-1.5 text-sm outline-hidden select-none transition-colors duration-75 focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
72
+ className,
73
+ )}
74
+ {...props}
75
+ />
76
+ )
77
+ }
78
+
79
+ function ContextMenuSeparator({
80
+ className,
81
+ ...props
82
+ }: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
83
+ return (
84
+ <ContextMenuPrimitive.Separator
85
+ data-slot="context-menu-separator"
86
+ className={cn('mx-1 my-1 h-px bg-border-subtle', className)}
87
+ {...props}
88
+ />
89
+ )
90
+ }
91
+
92
+ export {
93
+ ContextMenu,
94
+ ContextMenuContent,
95
+ ContextMenuGroup,
96
+ ContextMenuItem,
97
+ ContextMenuSeparator,
98
+ ContextMenuTrigger,
99
+ }