@spree/dashboard-ui 0.13.0 → 1.0.0-beta.1

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 (96) hide show
  1. package/README.md +1 -1
  2. package/package.json +15 -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 +39 -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/address-map.tsx +62 -0
  11. package/src/spree/back-button.tsx +14 -9
  12. package/src/spree/bulk-dialog.tsx +2 -2
  13. package/src/spree/bulk-price-table.tsx +145 -14
  14. package/src/spree/bulk-variants-table.tsx +434 -0
  15. package/src/spree/calculator-summary.tsx +5 -1
  16. package/src/spree/claim-resolve-dialog.tsx +219 -0
  17. package/src/spree/confirm-dialog.tsx +1 -1
  18. package/src/spree/copy-to-clipboard-button.tsx +1 -1
  19. package/src/spree/data-grid/cells.tsx +199 -25
  20. package/src/spree/data-grid/context.ts +6 -0
  21. package/src/spree/data-grid/data-grid.tsx +149 -7
  22. package/src/spree/data-grid/helpers.ts +6 -2
  23. package/src/spree/data-grid/index.ts +9 -1
  24. package/src/spree/data-grid/use-data-grid-keyboard.ts +4 -2
  25. package/src/spree/delivery-form-dialog.tsx +166 -0
  26. package/src/spree/drag-handle.tsx +2 -2
  27. package/src/spree/editable-note-card.tsx +103 -0
  28. package/src/spree/external-link.tsx +56 -0
  29. package/src/spree/file-type-icon.tsx +68 -0
  30. package/src/spree/form-actions.tsx +2 -3
  31. package/src/spree/form-section.tsx +46 -0
  32. package/src/spree/fulfill-items-form.tsx +282 -0
  33. package/src/spree/fulfillment-edit-dialog.tsx +211 -0
  34. package/src/spree/fulfillment-item-list.tsx +61 -0
  35. package/src/spree/fulfillment-panel.tsx +53 -0
  36. package/src/spree/icons.ts +167 -0
  37. package/src/spree/json-preview-drawer.tsx +4 -4
  38. package/src/spree/label-upload-dialog.tsx +195 -0
  39. package/src/spree/language-menu-items.tsx +37 -30
  40. package/src/spree/metadata/metadata-card.tsx +1 -1
  41. package/src/spree/order-cancel-dialog.tsx +149 -0
  42. package/src/spree/post-sale-create-dialogs.tsx +253 -0
  43. package/src/spree/post-sale-fields.tsx +133 -0
  44. package/src/spree/product-membership-list.tsx +453 -0
  45. package/src/spree/products/status-card.tsx +79 -0
  46. package/src/spree/quantity-tier-editor.tsx +162 -0
  47. package/src/spree/resource-combobox.tsx +12 -1
  48. package/src/spree/resource-name-cell.tsx +21 -4
  49. package/src/spree/return-dialogs.tsx +238 -0
  50. package/src/spree/route-error-boundary.tsx +1 -1
  51. package/src/spree/row-actions.tsx +2 -2
  52. package/src/spree/search-input.tsx +76 -0
  53. package/src/spree/secret-input.tsx +26 -40
  54. package/src/spree/tax-identifiers-card.tsx +418 -0
  55. package/src/spree/theme-toggle.tsx +36 -23
  56. package/src/spree/wizard.tsx +254 -0
  57. package/src/styles.css +750 -75
  58. package/src/ui/accordion.tsx +83 -0
  59. package/src/ui/alert.tsx +79 -0
  60. package/src/ui/attachment.tsx +1 -1
  61. package/src/ui/badge.tsx +198 -56
  62. package/src/ui/breadcrumb.tsx +1 -1
  63. package/src/ui/button.tsx +22 -10
  64. package/src/ui/calendar.tsx +1 -1
  65. package/src/ui/card.tsx +52 -6
  66. package/src/ui/checkbox.tsx +13 -5
  67. package/src/ui/combobox.tsx +125 -6
  68. package/src/ui/command.tsx +149 -23
  69. package/src/ui/context-menu.tsx +99 -0
  70. package/src/ui/data-table.tsx +448 -9
  71. package/src/ui/date-picker.tsx +18 -3
  72. package/src/ui/date-range-picker.tsx +47 -60
  73. package/src/ui/dialog.tsx +6 -6
  74. package/src/ui/dropdown-menu.tsx +18 -11
  75. package/src/ui/field.tsx +10 -2
  76. package/src/ui/input-group.tsx +1 -1
  77. package/src/ui/input.tsx +1 -1
  78. package/src/ui/map.tsx +2110 -0
  79. package/src/ui/media-preview.tsx +105 -0
  80. package/src/ui/pagination.tsx +19 -11
  81. package/src/ui/popover.tsx +4 -2
  82. package/src/ui/progress.tsx +5 -2
  83. package/src/ui/radio-group.tsx +4 -4
  84. package/src/ui/rich-text-editor.tsx +214 -88
  85. package/src/ui/scroll-area.tsx +65 -0
  86. package/src/ui/select.tsx +17 -10
  87. package/src/ui/separator.tsx +1 -1
  88. package/src/ui/sheet.tsx +49 -7
  89. package/src/ui/sidebar.tsx +138 -37
  90. package/src/ui/skeleton.tsx +3 -1
  91. package/src/ui/tabs.tsx +84 -0
  92. package/src/ui/textarea.tsx +1 -1
  93. package/src/ui/thumbnail.tsx +80 -0
  94. package/src/ui/toast.tsx +112 -0
  95. package/src/ui/tooltip.tsx +3 -1
  96. package/src/ui/sonner.tsx +0 -47
@@ -0,0 +1,65 @@
1
+ import { ScrollArea as ScrollAreaPrimitive } from '@base-ui/react/scroll-area'
2
+ import type * as React from 'react'
3
+ import { cn } from '../lib/utils'
4
+
5
+ /**
6
+ * shadcn's scroll-area, on Base UI rather than Radix — the registry ships the
7
+ * Radix build for this project's style, and nothing else here imports Radix.
8
+ * Same parts and the same slot names; the scrollbar fades in on hover or
9
+ * scroll, which is Base UI's own behaviour rather than a Radix `type` prop.
10
+ *
11
+ * Height belongs to the caller: set it here, or let a flex parent size it.
12
+ */
13
+ function ScrollArea({
14
+ className,
15
+ children,
16
+ ...props
17
+ }: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
18
+ return (
19
+ <ScrollAreaPrimitive.Root
20
+ data-slot="scroll-area"
21
+ className={cn('relative', className)}
22
+ {...props}
23
+ >
24
+ <ScrollAreaPrimitive.Viewport
25
+ data-slot="scroll-area-viewport"
26
+ className="size-full overscroll-contain rounded-[inherit] outline-none transition-[color,box-shadow] focus-visible:shadow-[0_0_0_3px_color-mix(in_srgb,var(--ring)_15%,transparent)]"
27
+ >
28
+ {children}
29
+ </ScrollAreaPrimitive.Viewport>
30
+ <ScrollBar />
31
+ <ScrollAreaPrimitive.Corner />
32
+ </ScrollAreaPrimitive.Root>
33
+ )
34
+ }
35
+
36
+ function ScrollBar({
37
+ className,
38
+ orientation = 'vertical',
39
+ ...props
40
+ }: React.ComponentProps<typeof ScrollAreaPrimitive.Scrollbar>) {
41
+ return (
42
+ <ScrollAreaPrimitive.Scrollbar
43
+ data-slot="scroll-area-scrollbar"
44
+ orientation={orientation}
45
+ className={cn(
46
+ 'flex touch-none select-none p-px',
47
+ 'data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:w-full data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent',
48
+ 'data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2.5 data-[orientation=vertical]:border-l data-[orientation=vertical]:border-l-transparent',
49
+ // Base UI marks the bar while it is scrolled or hovered, so it can
50
+ // stay out of the way of a short list until it is wanted.
51
+ 'opacity-0 transition-opacity duration-150 ease-out data-[hovering]:opacity-100 data-[scrolling]:opacity-100',
52
+ 'motion-reduce:transition-none',
53
+ className,
54
+ )}
55
+ {...props}
56
+ >
57
+ <ScrollAreaPrimitive.Thumb
58
+ data-slot="scroll-area-thumb"
59
+ className="relative flex-1 rounded-full bg-border-control"
60
+ />
61
+ </ScrollAreaPrimitive.Scrollbar>
62
+ )
63
+ }
64
+
65
+ export { ScrollArea, ScrollBar }
package/src/ui/select.tsx CHANGED
@@ -1,7 +1,7 @@
1
1
  import { Select as SelectPrimitive } from '@base-ui/react/select'
2
- import { CheckIcon, ChevronDownIcon } from 'lucide-react'
3
2
  import type * as React from 'react'
4
3
  import { cn } from '../lib/utils'
4
+ import { CheckIcon, ChevronDownIcon } from '../spree/icons'
5
5
 
6
6
  function Select({
7
7
  children,
@@ -54,19 +54,26 @@ function SelectTrigger({
54
54
  data-slot="select-trigger"
55
55
  data-size={size}
56
56
  className={cn(
57
- // `min-h-9.5` (default) and `data-[size=sm]:min-h-[1.9375rem]` lock
58
- // the trigger to the same natural height as `<Input>` so the field
59
- // doesn't collapse to chevron-height when the SelectValue render-prop
60
- // returns an empty string (e.g., when value is unset and the
61
- // placeholder isn't surfaced through the render-prop).
62
- "flex w-full min-h-8.5 items-center justify-between gap-1.5 rounded-lg border border-border bg-card py-1.5 pr-2 pl-2.5 text-base font-normal leading-normal text-foreground shadow-xs transition-all duration-100 ease-in-out outline-none select-none 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 data-[placeholder]:text-muted-foreground data-[size=sm]:py-1 data-[size=sm]:px-2 data-[size=sm]:text-sm data-[size=sm]:min-h-[1.9375rem] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
57
+ // `min-h-8` matches `<Input>`'s own minimum, so a select and a text
58
+ // field on the same row are the same height. It also stops the trigger
59
+ // collapsing to chevron-height when the SelectValue render-prop returns
60
+ // an empty string (value unset and the placeholder not surfaced through
61
+ // the render-prop); `data-[size=sm]` keeps its own floor for the same
62
+ // reason.
63
+ //
64
+ // `sm` is the toolbar size, matching `Button size="sm" variant="outline"`
65
+ // exactly — height, gap, padding, text, icon size and the resting lift.
66
+ // A row mixing a select with a popover trigger (the dashboard's channel
67
+ // picker beside its date range) then reads as one set of controls
68
+ // rather than two. The default size is a form field and stays flat.
69
+ "group/select-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-[placeholder]:text-muted-foreground 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 data-[size=sm]:[&_svg:not([class*='size-'])]:size-3.5 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
63
70
  className,
64
71
  )}
65
72
  {...props}
66
73
  >
67
74
  {children}
68
75
  <SelectPrimitive.Icon>
69
- <ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
76
+ <ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground group-data-[size=sm]/select-trigger:size-3.5" />
70
77
  </SelectPrimitive.Icon>
71
78
  </SelectPrimitive.Trigger>
72
79
  )
@@ -98,7 +105,7 @@ function SelectContent({
98
105
  data-slot="select-content"
99
106
  data-align-trigger={position === 'item-aligned'}
100
107
  className={cn(
101
- 'relative z-[100] max-h-[var(--available-height)] min-w-[var(--anchor-width)] overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[starting-style]:opacity-0 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[ending-style]:scale-95 transition-[opacity,transform]',
108
+ 'relative z-[100] max-h-[var(--available-height)] min-w-[var(--anchor-width)] overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[starting-style]:opacity-0 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[ending-style]:scale-95 transition-[opacity,transform]',
102
109
  position === 'popper' &&
103
110
  'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
104
111
  className,
@@ -137,7 +144,7 @@ function SelectItem({
137
144
  // Padding + font sized to match `<Input>` and `<SelectTrigger>` so
138
145
  // the dropdown rows feel like a continuation of the trigger field.
139
146
  // `pr-8` reserves room for the absolute-positioned check indicator.
140
- "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 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
147
+ "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 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
141
148
  className,
142
149
  )}
143
150
  {...props}
@@ -20,7 +20,7 @@ function Separator({
20
20
  orientation={orientation}
21
21
  data-orientation={orientation}
22
22
  className={cn(
23
- 'shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px',
23
+ 'shrink-0 bg-border-subtle data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px',
24
24
  className,
25
25
  )}
26
26
  {...props}
package/src/ui/sheet.tsx CHANGED
@@ -2,9 +2,10 @@
2
2
 
3
3
  import { Dialog as SheetPrimitive } from '@base-ui/react/dialog'
4
4
  import i18n from 'i18next'
5
- import { XIcon } from 'lucide-react'
6
5
  import * as React from 'react'
6
+ import { useScrolled } from '../hooks/use-scrolled'
7
7
  import { cn } from '../lib/utils'
8
+ import { XIcon } from '../spree/icons'
8
9
  import { Button } from './button'
9
10
 
10
11
  function Sheet({
@@ -94,7 +95,7 @@ function SheetOverlay({
94
95
  <SheetPrimitive.Backdrop
95
96
  data-slot="sheet-overlay"
96
97
  className={cn(
97
- 'fixed inset-0 z-50 bg-gray-100/75 dark:bg-black/60 duration-200 data-[starting-style]:opacity-0 data-[ending-style]:opacity-0 transition-opacity',
98
+ 'fixed inset-0 z-50 bg-background/60 dark:bg-black/60 duration-200 data-[starting-style]:opacity-0 data-[ending-style]:opacity-0 transition-opacity',
98
99
  className,
99
100
  )}
100
101
  {...props}
@@ -122,7 +123,13 @@ function SheetContent({
122
123
  data-side={side}
123
124
  dir={dir}
124
125
  className={cn(
125
- 'fixed z-50 flex flex-col border border-border bg-card text-foreground text-sm shadow-lg transition duration-250 ease-out',
126
+ // Tailwind v4 compiles `translate-*`/`scale-*` to the standalone
127
+ // `translate`/`scale` properties rather than folding them into
128
+ // `transform`, so all four are listed. The slide below needs
129
+ // `translate`; the rest are listed because `className` is merged, so
130
+ // a caller can animate any of them — and a missing entry fails
131
+ // silently, as a jump rather than an error.
132
+ 'fixed z-50 flex flex-col border border-border bg-card text-foreground text-sm shadow-lg transition-[opacity,transform,translate,scale] duration-250 ease-out',
126
133
  'data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-[calc(100dvh-1rem)] data-[side=right]:max-h-[calc(100dvh-1rem)] data-[side=right]:w-[calc(100vw-1rem)] data-[side=right]:max-w-[600px] data-[side=right]:min-w-[320px] data-[side=right]:m-2 data-[side=right]:rounded-xl',
127
134
  'data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:sm:max-w-sm',
128
135
  'data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t',
@@ -138,7 +145,13 @@ function SheetContent({
138
145
  <SheetPrimitive.Close
139
146
  data-slot="sheet-close"
140
147
  render={
141
- <Button variant="ghost" className="absolute top-3 right-3" size="icon-sm">
148
+ // `size-11` on touch: at `icon-sm`'s 28px this is well under the
149
+ // 44px minimum, and on a nav sheet it is the primary way out.
150
+ <Button
151
+ variant="ghost"
152
+ className="absolute top-2 right-2 size-11 md:top-3 md:right-3 md:size-7"
153
+ size="icon-sm"
154
+ >
142
155
  <XIcon />
143
156
  <span className="sr-only">{i18n.t('admin.actions.close')}</span>
144
157
  </Button>
@@ -150,11 +163,40 @@ function SheetContent({
150
163
  )
151
164
  }
152
165
 
166
+ /**
167
+ * A sheet's title bar. It keeps its separating rule at all times and gains a
168
+ * soft lift once the body scrolls under it, on the same trigger and curve as
169
+ * `PageHeader`.
170
+ */
153
171
  function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
172
+ // A caller passing `sr-only` wants the title announced but not drawn. Merging
173
+ // it with the visible chrome does not achieve that: `cn` keeps `flex` (a
174
+ // different utility group from `sr-only`'s `absolute`), so the element stays
175
+ // a laid-out box — a 33px bar with a border, above the nav. Detect the intent
176
+ // and render the bare utility instead.
177
+ const srOnly = className?.split(' ').includes('sr-only')
178
+ // Hooks cannot be called conditionally, so this reads the flag before the
179
+ // early return even though the bare variant never uses it.
180
+ const scrolled = useScrolled()
181
+ if (srOnly) return <div data-slot="sheet-header" className={className} {...props} />
182
+
154
183
  return (
155
184
  <div
156
185
  data-slot="sheet-header"
157
- className={cn('relative flex flex-col gap-1.5 border-b p-4 pr-12', className)}
186
+ className={cn(
187
+ // No `z-index` here: the close button is a sibling rendered BEFORE this,
188
+ // so any stacking context on the header paints over it and swallows the
189
+ // clicks. The header needs no lift anyway — it is a flex sibling of the
190
+ // body rather than an overlay, so nothing scrolls under it.
191
+ 'flex shrink-0 flex-col gap-1.5 border-b border-border-subtle p-4 pr-12',
192
+ // The rule stays: a sheet header is a fixed band over its own scroller,
193
+ // so it is divided from the body whether or not anything has moved. The
194
+ // lift is what scrolling adds, on the same trigger and curve as
195
+ // `PageHeader`.
196
+ 'transition-shadow duration-200 ease-out motion-reduce:transition-none',
197
+ scrolled && 'shadow-xs',
198
+ className,
199
+ )}
158
200
  {...props}
159
201
  />
160
202
  )
@@ -165,7 +207,7 @@ function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
165
207
  <div
166
208
  data-slot="sheet-footer"
167
209
  className={cn(
168
- 'flex flex-col-reverse gap-2 border-t bg-muted/50 p-4 sm:flex-row sm:justify-end rounded-b-xl',
210
+ 'flex flex-col-reverse gap-2 border-t border-border-subtle bg-muted/50 p-4 sm:flex-row sm:justify-end rounded-b-xl',
169
211
  className,
170
212
  )}
171
213
  {...props}
@@ -177,7 +219,7 @@ function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPr
177
219
  return (
178
220
  <SheetPrimitive.Title
179
221
  data-slot="sheet-title"
180
- className={cn('text-base leading-none font-medium', className)}
222
+ className={cn('text-lg font-medium', className)}
181
223
  {...props}
182
224
  />
183
225
  )
@@ -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
  )