@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,9 +1,9 @@
1
1
  import { cva, type VariantProps } from 'class-variance-authority'
2
2
  import i18n from 'i18next'
3
- import { PanelLeftIcon } from 'lucide-react'
4
3
  import * as React from 'react'
5
4
  import { useIsMobile } from '../hooks/use-mobile'
6
5
  import { cn } from '../lib/utils'
6
+ import { PanelLeftIcon } from '../spree/icons'
7
7
  import { Button } from './button'
8
8
  import { Input } from './input'
9
9
  import { Separator } from './separator'
@@ -14,15 +14,42 @@ import { Tooltip, TooltipContent, TooltipTrigger } from './tooltip'
14
14
 
15
15
  const SIDEBAR_COOKIE_NAME = 'sidebar_state'
16
16
  const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
17
- const SIDEBAR_WIDTH = '220px'
18
- const SIDEBAR_WIDTH_MOBILE = '220px'
17
+ const SIDEBAR_WIDTH = '240px'
18
+ /**
19
+ * Width of any nav drawer on a phone: near-full-width, capped so it doesn't
20
+ * stretch on a tablet. The desktop rail's width leaves ~40% of a phone screen
21
+ * unused while still cramping the labels. Exported so a second drawer (the
22
+ * settings nav sheet) matches this one instead of re-inlining the number.
23
+ */
24
+ export const SIDEBAR_WIDTH_MOBILE = 'min(86vw, 360px)'
25
+
26
+ /**
27
+ * Shared chrome for a mobile nav drawer — touch sizing, safe-area insets and
28
+ * the sidebar surface colours. Both drawers use it so a fix lands in one place.
29
+ */
30
+ export const mobileDrawerClassName =
31
+ 'touch-manipulation bg-sidebar p-0 pt-[env(safe-area-inset-top)] pb-[env(safe-area-inset-bottom)] text-sidebar-foreground'
19
32
  const SIDEBAR_WIDTH_ICON = '59px'
20
33
  const SIDEBAR_KEYBOARD_SHORTCUT = 'b'
21
34
 
35
+ function isEditableKeyboardTarget(target: EventTarget | null): boolean {
36
+ if (!(target instanceof HTMLElement)) return false
37
+ if (target.isContentEditable) return true
38
+ return Boolean(target.closest('input, textarea, select'))
39
+ }
40
+
22
41
  type SidebarContextProps = {
23
42
  state: 'expanded' | 'collapsed'
24
43
  open: boolean
44
+ /** Sets the state and remembers it as the user's preference. */
25
45
  setOpen: (open: boolean) => void
46
+ /**
47
+ * Sets the state without remembering it. For collapses the app imposes on
48
+ * the user's behalf (e.g. the settings area folding the nav to icons to make
49
+ * room for its own): the merchant's own choice, made through the trigger,
50
+ * must survive it.
51
+ */
52
+ setOpenTransient: (open: boolean) => void
26
53
  openMobile: boolean
27
54
  setOpenMobile: (open: boolean) => void
28
55
  isMobile: boolean
@@ -64,19 +91,34 @@ function SidebarProvider({
64
91
  return defaultOpen
65
92
  })
66
93
  const open = openProp ?? _open
94
+
95
+ // The updater form needs the current value, but reading it from a ref rather
96
+ // than closing over it keeps the setters referentially stable — effects that
97
+ // depend on them (e.g. the settings auto-collapse) must fire on their own
98
+ // trigger, not again on every toggle.
99
+ const openRef = React.useRef(open)
100
+ openRef.current = open
101
+ const setOpenPropRef = React.useRef(setOpenProp)
102
+ setOpenPropRef.current = setOpenProp
103
+
104
+ const setOpenTransient = React.useCallback((value: boolean | ((value: boolean) => boolean)) => {
105
+ const openState = typeof value === 'function' ? value(openRef.current) : value
106
+ if (setOpenPropRef.current) {
107
+ setOpenPropRef.current(openState)
108
+ } else {
109
+ _setOpen(openState)
110
+ }
111
+ return openState
112
+ }, [])
113
+
67
114
  const setOpen = React.useCallback(
68
115
  (value: boolean | ((value: boolean) => boolean)) => {
69
- const openState = typeof value === 'function' ? value(open) : value
70
- if (setOpenProp) {
71
- setOpenProp(openState)
72
- } else {
73
- _setOpen(openState)
74
- }
116
+ const openState = setOpenTransient(value)
75
117
 
76
118
  // This sets the cookie to keep the sidebar state.
77
119
  document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
78
120
  },
79
- [setOpenProp, open],
121
+ [setOpenTransient],
80
122
  )
81
123
 
82
124
  // Helper to toggle the sidebar.
@@ -84,13 +126,17 @@ function SidebarProvider({
84
126
  return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
85
127
  }, [isMobile, setOpen, setOpenMobile])
86
128
 
87
- // Adds a keyboard shortcut to toggle the sidebar.
129
+ // Cmd/Ctrl+B is also "bold" in every text field. Skip the shortcut when
130
+ // the merchant is typing so it cannot collapse the nav out from under
131
+ // a rich-text editor (or steal the keystroke from an input).
88
132
  React.useEffect(() => {
89
133
  const handleKeyDown = (event: KeyboardEvent) => {
90
- if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
91
- event.preventDefault()
92
- toggleSidebar()
93
- }
134
+ if (event.key !== SIDEBAR_KEYBOARD_SHORTCUT || !(event.metaKey || event.ctrlKey)) return
135
+ if (event.defaultPrevented) return
136
+ if (isEditableKeyboardTarget(event.target)) return
137
+
138
+ event.preventDefault()
139
+ toggleSidebar()
94
140
  }
95
141
 
96
142
  window.addEventListener('keydown', handleKeyDown)
@@ -106,12 +152,13 @@ function SidebarProvider({
106
152
  state,
107
153
  open,
108
154
  setOpen,
155
+ setOpenTransient,
109
156
  isMobile,
110
157
  openMobile,
111
158
  setOpenMobile,
112
159
  toggleSidebar,
113
160
  }),
114
- [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
161
+ [state, open, setOpen, setOpenTransient, isMobile, openMobile, setOpenMobile, toggleSidebar],
115
162
  )
116
163
 
117
164
  return (
@@ -126,7 +173,11 @@ function SidebarProvider({
126
173
  } as React.CSSProperties
127
174
  }
128
175
  className={cn(
129
- 'group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar',
176
+ // `h-svh` + `overflow-hidden`, not `min-h-svh`: an inset shell scrolls
177
+ // inside its own sheet, so the wrapper must be exactly the viewport
178
+ // and never let the document scroll behind it.
179
+ 'group/sidebar-wrapper flex w-full has-data-[variant=inset]:h-svh has-data-[variant=inset]:overflow-hidden has-data-[variant=inset]:bg-sidebar',
180
+ 'not-has-data-[variant=inset]:min-h-svh',
130
181
  className,
131
182
  )}
132
183
  {...props}
@@ -175,7 +226,7 @@ function Sidebar({
175
226
  data-sidebar="sidebar"
176
227
  data-slot="sidebar"
177
228
  data-mobile="true"
178
- className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
229
+ className={cn(mobileDrawerClassName, 'w-(--sidebar-width) [&>button]:hidden')}
179
230
  style={
180
231
  {
181
232
  '--sidebar-width': SIDEBAR_WIDTH_MOBILE,
@@ -187,7 +238,11 @@ function Sidebar({
187
238
  <SheetTitle>{i18n.t('admin.a11y.sidebar')}</SheetTitle>
188
239
  <SheetDescription>{i18n.t('admin.a11y.sidebar_description')}</SheetDescription>
189
240
  </SheetHeader>
190
- <div className="flex h-full w-full flex-col">{children}</div>
241
+ {/* `min-h-0` rather than `h-full`: the sheet carries safe-area
242
+ padding, and `h-full` resolves against its border box, so the
243
+ column ran past the bottom edge and clipped its last nav item
244
+ instead of letting `SidebarContent` scroll to it. */}
245
+ <div className="flex min-h-0 w-full flex-1 flex-col">{children}</div>
191
246
  </SheetContent>
192
247
  </Sheet>
193
248
  )
@@ -230,7 +285,7 @@ function Sidebar({
230
285
  <div
231
286
  data-sidebar="sidebar"
232
287
  data-slot="sidebar-inner"
233
- className="flex size-full flex-col bg-sidebar border-e border-border/50 group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
288
+ className="flex size-full flex-col bg-sidebar group-data-[variant=sidebar]:border-e group-data-[variant=sidebar]:border-sidebar-border group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
234
289
  >
235
290
  {children}
236
291
  </div>
@@ -248,7 +303,7 @@ function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<t
248
303
  data-slot="sidebar-trigger"
249
304
  variant="ghost"
250
305
  size="icon-sm"
251
- className={cn(className)}
306
+ className={cn('hover:bg-accent-strong-hover', className)}
252
307
  onClick={(event) => {
253
308
  onClick?.(event)
254
309
  toggleSidebar()
@@ -273,7 +328,7 @@ function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) {
273
328
  onClick={toggleSidebar}
274
329
  title={i18n.t('admin.a11y.toggle_sidebar')}
275
330
  className={cn(
276
- 'absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2',
331
+ 'absolute inset-y-0 z-20 hidden w-4 transition-colors duration-100 ease-out group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2',
277
332
  'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize',
278
333
  '[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
279
334
  'group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar',
@@ -286,12 +341,27 @@ function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) {
286
341
  )
287
342
  }
288
343
 
289
- function SidebarInset({ className, ...props }: React.ComponentProps<'main'>) {
344
+ function SidebarInset({ className, id = 'main-content', ...props }: React.ComponentProps<'main'>) {
290
345
  return (
291
346
  <main
347
+ // The skip link's target. Defaulted here rather than set by each host so
348
+ // the link cannot point at nothing.
349
+ id={id}
350
+ // `-1`: a container is not normally focusable, but the skip link has to
351
+ // be able to move focus here — without it the browser scrolls to the
352
+ // anchor and leaves focus behind in the nav, so the next Tab continues
353
+ // through the sidebar the user just asked to skip.
354
+ tabIndex={-1}
292
355
  data-slot="sidebar-inset"
293
356
  className={cn(
294
- 'relative z-0 flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2',
357
+ // `min-w-0`: `w-full` sets the flex basis to the wrapper's full width,
358
+ // and a flex item's default `min-width: auto` then refuses to shrink
359
+ // below it — so beside the collapsed icon rail the inset stays a full
360
+ // viewport wide and its content runs off the right edge.
361
+ // The inset sheet is the raised surface, so it takes the card colour —
362
+ // pure white in light mode — rather than the page grey, which is now the
363
+ // ground the rail sits on.
364
+ 'relative z-0 flex w-full min-w-0 flex-1 flex-col bg-background md:peer-data-[variant=inset]:bg-card md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:border md:peer-data-[variant=inset]:border-sidebar-border md:peer-data-[variant=inset]:shadow-sm',
295
365
  className,
296
366
  )}
297
367
  {...props}
@@ -316,7 +386,9 @@ function SidebarHeader({ className, ...props }: React.ComponentProps<'div'>) {
316
386
  data-slot="sidebar-header"
317
387
  data-sidebar="header"
318
388
  className={cn(
319
- 'flex flex-col gap-0 px-2 group-data-[collapsible=icon]:px-0 group-data-[collapsible=icon]:items-center',
389
+ // In the drawer the switcher is the only thing above the nav, so it
390
+ // needs a rule under it to read as a header rather than a first row.
391
+ 'flex flex-col gap-3 px-2 in-data-[mobile=true]:border-b in-data-[mobile=true]:border-border-subtle in-data-[mobile=true]:pb-2 group-data-[collapsible=icon]:px-0 group-data-[collapsible=icon]:items-center',
320
392
  className,
321
393
  )}
322
394
  {...props}
@@ -330,7 +402,7 @@ function SidebarFooter({ className, ...props }: React.ComponentProps<'div'>) {
330
402
  data-slot="sidebar-footer"
331
403
  data-sidebar="footer"
332
404
  className={cn(
333
- 'flex flex-col gap-0 p-2 mt-auto border-t border-border group-data-[collapsible=icon]:px-0 group-data-[collapsible=icon]:items-center',
405
+ 'flex flex-col gap-0 p-2 mt-auto border-t border-border-subtle group-data-[collapsible=icon]:px-0 group-data-[collapsible=icon]:items-center',
334
406
  className,
335
407
  )}
336
408
  {...props}
@@ -355,7 +427,7 @@ function SidebarContent({ className, ...props }: React.ComponentProps<'div'>) {
355
427
  data-slot="sidebar-content"
356
428
  data-sidebar="content"
357
429
  className={cn(
358
- 'no-scrollbar flex min-h-0 flex-1 flex-col gap-0 pt-2 overflow-auto group-data-[collapsible=icon]:overflow-visible',
430
+ 'no-scrollbar flex min-h-0 flex-1 flex-col gap-0 pt-3 overflow-auto group-data-[collapsible=icon]:overflow-visible',
359
431
  className,
360
432
  )}
361
433
  {...props}
@@ -369,7 +441,7 @@ function SidebarGroup({ className, ...props }: React.ComponentProps<'div'>) {
369
441
  data-slot="sidebar-group"
370
442
  data-sidebar="group"
371
443
  className={cn(
372
- 'relative flex w-full min-w-0 flex-col px-3 py-0 group-data-[collapsible=icon]:px-0 group-data-[collapsible=icon]:items-center',
444
+ 'relative flex w-full min-w-0 flex-col px-2 py-0 group-data-[collapsible=icon]:px-0 group-data-[collapsible=icon]:items-center',
373
445
  className,
374
446
  )}
375
447
  {...props}
@@ -389,7 +461,7 @@ function SidebarGroupLabel({
389
461
  data-slot="sidebar-group-label"
390
462
  data-sidebar="group-label"
391
463
  className={cn(
392
- 'flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:shadow-[0_0_0_3px_rgba(59,130,246,0.15)] [&>svg]:size-4 [&>svg]:shrink-0',
464
+ 'flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:shadow-[0_0_0_3px_color-mix(in_srgb,var(--ring)_15%,transparent)] [&>svg]:size-4 [&>svg]:shrink-0',
393
465
  className,
394
466
  )}
395
467
  {...props}
@@ -409,7 +481,7 @@ function SidebarGroupAction({
409
481
  data-slot="sidebar-group-action"
410
482
  data-sidebar="group-action"
411
483
  className={cn(
412
- 'absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:shadow-[0_0_0_3px_rgba(59,130,246,0.15)] md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0',
484
+ 'absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:shadow-[0_0_0_3px_color-mix(in_srgb,var(--ring)_15%,transparent)] md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0',
413
485
  className,
414
486
  )}
415
487
  {...props}
@@ -434,7 +506,9 @@ function SidebarMenu({ className, ...props }: React.ComponentProps<'ul'>) {
434
506
  data-slot="sidebar-menu"
435
507
  data-sidebar="menu"
436
508
  className={cn(
437
- 'flex w-full min-w-0 flex-col gap-1 group-data-[collapsible=icon]:items-center',
509
+ // `gap-2` on touch: adjacent tap targets need 8px between them, and
510
+ // the rail's 4px is a pointer-precision value.
511
+ 'flex w-full min-w-0 flex-col gap-1 in-data-[mobile=true]:gap-2 group-data-[collapsible=icon]:items-center',
438
512
  className,
439
513
  )}
440
514
  {...props}
@@ -454,7 +528,12 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<'li'>) {
454
528
  }
455
529
 
456
530
  const sidebarMenuButtonVariants = cva(
457
- 'peer/menu-button group/menu-button flex gap-2 w-full items-center overflow-hidden rounded-xl p-1 text-left text-base text-sidebar-foreground/80 outline-hidden transition-colors duration-100 ease-in-out group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-10! group-data-[collapsible=icon]:items-center group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:overflow-visible group-data-[collapsible=icon]:p-0! group-data-[collapsible=icon]:gap-0 group-data-[collapsible=icon]:[&>span:last-child]:hidden hover:bg-sidebar-accent hover:text-sidebar-foreground focus-visible:shadow-[0_0_0_3px_rgba(59,130,246,0.15)] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-foreground data-active:bg-sidebar-accent data-active:text-foreground data-active:font-semibold [&>span:last-child]:truncate',
531
+ // `whitespace-nowrap` matters during the collapse: the width animates over
532
+ // 200ms, and mid-transition the row is too narrow for a label plus a trailing
533
+ // badge. Without it the flex line wraps and the label visibly squashes to two
534
+ // lines inside the fixed height until the animation lands. Labels clip
535
+ // instead — which is what the `truncate` below already intends.
536
+ 'peer/menu-button group/menu-button flex gap-2 w-full items-center overflow-hidden whitespace-nowrap rounded-md p-1 text-left font-medium text-base text-sidebar-foreground/80 outline-hidden transition-[color,background-color,border-color,box-shadow] duration-100 ease-out group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-10! group-data-[collapsible=icon]:items-center group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:overflow-visible group-data-[collapsible=icon]:p-0! group-data-[collapsible=icon]:gap-0 group-data-[collapsible=icon]:[&>span:last-child]:hidden hover:bg-sidebar-accent-hover hover:text-sidebar-foreground focus-visible:shadow-[0_0_0_3px_color-mix(in_srgb,var(--ring)_15%,transparent)] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent-hover data-open:hover:text-sidebar-foreground data-active:bg-accent-strong data-active:text-foreground data-active:font-semibold data-active:hover:bg-accent-strong-hover [&>span:last-child]:truncate',
458
537
  {
459
538
  variants: {
460
539
  variant: {
@@ -463,8 +542,12 @@ const sidebarMenuButtonVariants = cva(
463
542
  'bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]',
464
543
  },
465
544
  size: {
466
- default: 'h-8 text-base',
467
- sm: 'h-7 text-xs',
545
+ // Inside the mobile drawer every row grows to a 48px touch target
546
+ // (iOS wants 44pt, Android 48dp) with a wider glyph to match. The
547
+ // desktop rail keeps its compact pointer-sized rows.
548
+ default:
549
+ 'h-8 text-base in-data-[mobile=true]:h-12 in-data-[mobile=true]:px-2 in-data-[mobile=true]:[&_svg]:size-5',
550
+ sm: 'h-7 text-xs in-data-[mobile=true]:h-11 in-data-[mobile=true]:text-sm',
468
551
  lg: 'h-12 text-base group-data-[collapsible=icon]:p-0!',
469
552
  },
470
553
  },
@@ -541,7 +624,7 @@ function SidebarMenuAction({
541
624
  data-slot="sidebar-menu-action"
542
625
  data-sidebar="menu-action"
543
626
  className={cn(
544
- 'absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:shadow-[0_0_0_3px_rgba(59,130,246,0.15)] md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0',
627
+ 'absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:shadow-[0_0_0_3px_color-mix(in_srgb,var(--ring)_15%,transparent)] md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0',
545
628
  showOnHover &&
546
629
  'group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0',
547
630
  className,
@@ -551,13 +634,31 @@ function SidebarMenuAction({
551
634
  )
552
635
  }
553
636
 
637
+ /**
638
+ * A count or progress marker on a nav item — "3" beside Orders, "2/6" beside
639
+ * Getting Started.
640
+ *
641
+ * Its own component rather than the page's `Badge`: this one sits on the rail
642
+ * rather than on a card, so it takes the sidebar's foreground token and follows
643
+ * the item it belongs to (brightening with the label on hover, going full
644
+ * strength when the item is active). A page badge on a nav row keeps the card
645
+ * palette and stays the same colour while everything around it moves.
646
+ *
647
+ * Rendered inline, at the end of the item's own row — not absolutely positioned
648
+ * like shadcn's original, which assumed a badge placed as a sibling of the
649
+ * button rather than inside its link.
650
+ */
554
651
  function SidebarMenuBadge({ className, ...props }: React.ComponentProps<'div'>) {
555
652
  return (
556
653
  <div
557
654
  data-slot="sidebar-menu-badge"
558
655
  data-sidebar="menu-badge"
559
656
  className={cn(
560
- 'pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground',
657
+ 'pointer-events-none flex h-5 min-w-5 shrink-0 items-center justify-center rounded-md border border-sidebar-border px-1.5 text-[11px] font-medium text-sidebar-foreground/70 tabular-nums select-none',
658
+ 'transition-colors duration-100 ease-out group-data-[collapsible=icon]:hidden',
659
+ // Follows its own row: brighter under the pointer, full strength when
660
+ // the item is the active one.
661
+ 'group-hover/menu-button:text-sidebar-foreground group-data-active/menu-button:text-foreground',
561
662
  className,
562
663
  )}
563
664
  {...props}
@@ -643,7 +744,7 @@ function SidebarMenuSubButton({
643
744
  data-size={size}
644
745
  data-active={isActive}
645
746
  className={cn(
646
- 'relative flex h-7 min-w-0 items-center gap-2 overflow-hidden rounded-xl p-1 text-sidebar-foreground/80 outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-foreground focus-visible:shadow-[0_0_0_3px_rgba(59,130,246,0.15)] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-base data-[size=sm]:text-xs data-active:font-semibold data-active:text-foreground data-active:bg-transparent data-active:shadow-none data-active:before:absolute data-active:before:-left-4 data-active:before:top-[10%] data-active:before:h-[80%] data-active:before:w-[3px] data-active:before:rounded-sm data-active:before:bg-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
747
+ 'relative flex h-7 in-data-[mobile=true]:h-11 in-data-[mobile=true]:px-2 min-w-0 items-center gap-2 overflow-hidden rounded-lg p-1 font-medium text-sidebar-foreground/80 outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent-hover hover:text-sidebar-foreground focus-visible:shadow-[0_0_0_3px_color-mix(in_srgb,var(--ring)_15%,transparent)] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-base data-[size=sm]:text-xs data-active:font-semibold data-active:text-foreground data-active:bg-transparent data-active:shadow-none data-active:before:absolute data-active:before:-left-4 data-active:before:top-[10%] data-active:before:h-[80%] data-active:before:w-[3px] data-active:before:rounded-sm data-active:before:bg-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
647
748
  className,
648
749
  )}
649
750
  {...props}
@@ -4,7 +4,9 @@ function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
4
4
  return (
5
5
  <div
6
6
  data-slot="skeleton"
7
- className={cn('animate-pulse rounded-md bg-muted', className)}
7
+ // `accent`, not `muted`: muted sits ~1.5% off white, which reads as nothing
8
+ // on a card. A skeleton has to be visible to do its job.
9
+ className={cn('animate-pulse rounded-md bg-accent', className)}
8
10
  {...props}
9
11
  />
10
12
  )
@@ -0,0 +1,84 @@
1
+ import { Tabs as TabsPrimitive } from '@base-ui/react/tabs'
2
+ import type * as React from 'react'
3
+ import { cn } from '../lib/utils'
4
+
5
+ function Tabs({
6
+ className,
7
+ orientation = 'horizontal',
8
+ ...props
9
+ }: React.ComponentProps<typeof TabsPrimitive.Root>) {
10
+ return (
11
+ <TabsPrimitive.Root
12
+ data-slot="tabs"
13
+ orientation={orientation}
14
+ className={cn(
15
+ 'group/tabs flex gap-2 data-[orientation=horizontal]:flex-col data-[orientation=vertical]:flex-row',
16
+ className,
17
+ )}
18
+ {...props}
19
+ />
20
+ )
21
+ }
22
+
23
+ /** The track a set of tabs sits in, recessed so the selected tab reads as raised. */
24
+ function TabsList({
25
+ className,
26
+ children,
27
+ ...props
28
+ }: React.ComponentProps<typeof TabsPrimitive.List>) {
29
+ return (
30
+ <TabsPrimitive.List
31
+ data-slot="tabs-list"
32
+ className={cn(
33
+ 'inline-flex w-fit items-center justify-center gap-1 rounded-lg bg-track-recessed p-1 text-muted-foreground',
34
+ 'group-data-[orientation=vertical]/tabs:h-auto group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:flex-col',
35
+ className,
36
+ )}
37
+ {...props}
38
+ >
39
+ {children}
40
+ </TabsPrimitive.List>
41
+ )
42
+ }
43
+
44
+ function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Tab>) {
45
+ return (
46
+ <TabsPrimitive.Tab
47
+ data-slot="tabs-trigger"
48
+ className={cn(
49
+ // min-h-7 + py-1 keeps the hit area past the 24px WCAG target size at
50
+ // every text size; the old h-[calc(100%-1px)] left it to the parent.
51
+ 'inline-flex min-h-7 flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-md px-2.5 py-1 text-sm font-medium whitespace-nowrap',
52
+ 'transition-[color,background-color,box-shadow] duration-150 ease-out outline-none motion-reduce:transition-none',
53
+ 'group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start',
54
+ // Unselected tabs recede so the selected one reads as chosen; hover
55
+ // promotes to full foreground rather than only shifting the surface,
56
+ // which is the part a colour-blind or low-vision reader can see.
57
+ 'text-muted-foreground hover:text-foreground',
58
+ // The raised surface lives on the tab itself, as it did before the
59
+ // indicator: it is the thing that makes the selection legible, and it
60
+ // must not depend on a separate element rendering correctly behind it.
61
+ 'data-[active]:bg-nested-raised data-[active]:text-foreground data-[active]:shadow-xs',
62
+ // Same focus treatment as Button, so a keyboard user sees one
63
+ // consistent indicator across the dashboard.
64
+ 'focus-visible:shadow-[0_0_0_3px_color-mix(in_srgb,var(--ring)_15%,transparent)]',
65
+ 'disabled:pointer-events-none disabled:opacity-70 disabled:cursor-not-allowed',
66
+ "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
67
+ className,
68
+ )}
69
+ {...props}
70
+ />
71
+ )
72
+ }
73
+
74
+ function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Panel>) {
75
+ return (
76
+ <TabsPrimitive.Panel
77
+ data-slot="tabs-content"
78
+ className={cn('flex-1 outline-none', className)}
79
+ {...props}
80
+ />
81
+ )
82
+ }
83
+
84
+ export { Tabs, TabsContent, TabsList, TabsTrigger }
@@ -7,7 +7,7 @@ function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
7
7
  <textarea
8
8
  data-slot="textarea"
9
9
  className={cn(
10
- 'flex field-sizing-content min-h-16 w-full rounded-lg border border-border bg-card px-2.5 py-1.5 text-base font-normal leading-normal text-foreground shadow-xs transition-all duration-100 ease-in-out outline-none placeholder:text-muted-foreground focus:border-blue-500 focus:shadow-[0_0_0_3px_rgba(59,130,246,0.15)] disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-muted disabled:border-border disabled:text-muted-foreground disabled:shadow-none aria-invalid:border-destructive',
10
+ 'flex field-sizing-content min-h-16 w-full rounded-md border border-border bg-card px-2.5 py-1.5 text-base font-normal leading-normal text-foreground transition-[color,background-color,border-color,box-shadow] duration-100 ease-out outline-none placeholder:text-muted-foreground 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',
11
11
  className,
12
12
  )}
13
13
  {...props}
@@ -0,0 +1,80 @@
1
+ import { cva, type VariantProps } from 'class-variance-authority'
2
+ import type * as React from 'react'
3
+ import { cn } from '../lib/utils'
4
+ import { ImageIcon } from '../spree/icons'
5
+
6
+ const thumbnailVariants = cva(
7
+ // The wrapper always renders, so a record without an image keeps the same
8
+ // footprint as one with it and rows never reflow. `shrink-0` matters in the
9
+ // flex rows these sit in — without it a long product name squashes the image.
10
+ 'flex shrink-0 items-center justify-center overflow-hidden border bg-muted text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*=size-])]:size-4',
11
+ {
12
+ variants: {
13
+ size: {
14
+ xs: 'size-5 [&_svg:not([class*=size-])]:size-3',
15
+ sm: 'size-8',
16
+ md: 'size-10',
17
+ lg: 'size-12 [&_svg:not([class*=size-])]:size-5',
18
+ },
19
+ shape: {
20
+ rounded: 'rounded-md',
21
+ square: 'rounded-none',
22
+ circle: 'rounded-full',
23
+ },
24
+ },
25
+ defaultVariants: {
26
+ size: 'md',
27
+ shape: 'rounded',
28
+ },
29
+ },
30
+ )
31
+
32
+ export interface ThumbnailProps
33
+ extends Omit<React.ComponentProps<'div'>, 'children'>,
34
+ VariantProps<typeof thumbnailVariants> {
35
+ /** Image to show. When absent or failed, the fallback renders instead. */
36
+ src?: string | null
37
+ /**
38
+ * Describes the image for screen readers. Leave unset when a visible label
39
+ * sits beside the thumbnail — the image is decorative then, and repeating
40
+ * the name makes screen readers announce it twice.
41
+ */
42
+ alt?: string
43
+ /** Overrides the placeholder shown when there is no image. */
44
+ fallback?: React.ReactNode
45
+ /** Defer loading until the thumbnail is near the viewport. */
46
+ loading?: 'eager' | 'lazy'
47
+ }
48
+
49
+ /**
50
+ * A product or record image at a fixed size, falling back to a placeholder.
51
+ *
52
+ * Images are cropped to fill the box (`object-cover`), which suits photography.
53
+ * Logos and artwork that must not be cropped want `Attachment` instead.
54
+ */
55
+ export function Thumbnail({
56
+ src,
57
+ alt = '',
58
+ fallback,
59
+ size,
60
+ shape,
61
+ className,
62
+ loading,
63
+ ...props
64
+ }: ThumbnailProps) {
65
+ return (
66
+ <div
67
+ data-slot="thumbnail"
68
+ className={cn(thumbnailVariants({ size, shape }), className)}
69
+ {...props}
70
+ >
71
+ {src ? (
72
+ <img src={src} alt={alt} loading={loading} className="size-full object-cover" />
73
+ ) : (
74
+ (fallback ?? <ImageIcon />)
75
+ )}
76
+ </div>
77
+ )
78
+ }
79
+
80
+ export { thumbnailVariants }