@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.
- package/README.md +1 -1
- package/package.json +15 -9
- package/src/hooks/use-prefers-reduced-motion.ts +26 -0
- package/src/hooks/use-scrolled.ts +34 -14
- package/src/index.ts +39 -1
- package/src/lib/date-range-presets.ts +79 -0
- package/src/lib/same-rich-text.ts +145 -0
- package/src/spree/address-block.tsx +5 -4
- package/src/spree/address-book-row.tsx +96 -0
- package/src/spree/address-map.tsx +62 -0
- package/src/spree/back-button.tsx +14 -9
- package/src/spree/bulk-dialog.tsx +2 -2
- package/src/spree/bulk-price-table.tsx +145 -14
- package/src/spree/bulk-variants-table.tsx +434 -0
- package/src/spree/calculator-summary.tsx +5 -1
- package/src/spree/claim-resolve-dialog.tsx +219 -0
- package/src/spree/confirm-dialog.tsx +1 -1
- package/src/spree/copy-to-clipboard-button.tsx +1 -1
- package/src/spree/data-grid/cells.tsx +199 -25
- package/src/spree/data-grid/context.ts +6 -0
- package/src/spree/data-grid/data-grid.tsx +149 -7
- package/src/spree/data-grid/helpers.ts +6 -2
- package/src/spree/data-grid/index.ts +9 -1
- package/src/spree/data-grid/use-data-grid-keyboard.ts +4 -2
- package/src/spree/delivery-form-dialog.tsx +166 -0
- package/src/spree/drag-handle.tsx +2 -2
- package/src/spree/editable-note-card.tsx +103 -0
- package/src/spree/external-link.tsx +56 -0
- package/src/spree/file-type-icon.tsx +68 -0
- package/src/spree/form-actions.tsx +2 -3
- package/src/spree/form-section.tsx +46 -0
- package/src/spree/fulfill-items-form.tsx +282 -0
- package/src/spree/fulfillment-edit-dialog.tsx +211 -0
- package/src/spree/fulfillment-item-list.tsx +61 -0
- package/src/spree/fulfillment-panel.tsx +53 -0
- package/src/spree/icons.ts +167 -0
- package/src/spree/json-preview-drawer.tsx +4 -4
- package/src/spree/label-upload-dialog.tsx +195 -0
- package/src/spree/language-menu-items.tsx +37 -30
- package/src/spree/metadata/metadata-card.tsx +1 -1
- package/src/spree/order-cancel-dialog.tsx +149 -0
- package/src/spree/post-sale-create-dialogs.tsx +253 -0
- package/src/spree/post-sale-fields.tsx +133 -0
- package/src/spree/product-membership-list.tsx +453 -0
- package/src/spree/products/status-card.tsx +79 -0
- package/src/spree/quantity-tier-editor.tsx +162 -0
- package/src/spree/resource-combobox.tsx +12 -1
- package/src/spree/resource-name-cell.tsx +21 -4
- package/src/spree/return-dialogs.tsx +238 -0
- package/src/spree/route-error-boundary.tsx +1 -1
- package/src/spree/row-actions.tsx +2 -2
- package/src/spree/search-input.tsx +76 -0
- package/src/spree/secret-input.tsx +26 -40
- package/src/spree/tax-identifiers-card.tsx +418 -0
- package/src/spree/theme-toggle.tsx +36 -23
- package/src/spree/wizard.tsx +254 -0
- package/src/styles.css +750 -75
- package/src/ui/accordion.tsx +83 -0
- package/src/ui/alert.tsx +79 -0
- package/src/ui/attachment.tsx +1 -1
- package/src/ui/badge.tsx +198 -56
- package/src/ui/breadcrumb.tsx +1 -1
- package/src/ui/button.tsx +22 -10
- package/src/ui/calendar.tsx +1 -1
- package/src/ui/card.tsx +52 -6
- package/src/ui/checkbox.tsx +13 -5
- package/src/ui/combobox.tsx +125 -6
- package/src/ui/command.tsx +149 -23
- package/src/ui/context-menu.tsx +99 -0
- package/src/ui/data-table.tsx +448 -9
- package/src/ui/date-picker.tsx +18 -3
- package/src/ui/date-range-picker.tsx +47 -60
- package/src/ui/dialog.tsx +6 -6
- package/src/ui/dropdown-menu.tsx +18 -11
- package/src/ui/field.tsx +10 -2
- package/src/ui/input-group.tsx +1 -1
- package/src/ui/input.tsx +1 -1
- package/src/ui/map.tsx +2110 -0
- package/src/ui/media-preview.tsx +105 -0
- package/src/ui/pagination.tsx +19 -11
- package/src/ui/popover.tsx +4 -2
- package/src/ui/progress.tsx +5 -2
- package/src/ui/radio-group.tsx +4 -4
- package/src/ui/rich-text-editor.tsx +214 -88
- package/src/ui/scroll-area.tsx +65 -0
- package/src/ui/select.tsx +17 -10
- package/src/ui/separator.tsx +1 -1
- package/src/ui/sheet.tsx +49 -7
- package/src/ui/sidebar.tsx +138 -37
- package/src/ui/skeleton.tsx +3 -1
- package/src/ui/tabs.tsx +84 -0
- package/src/ui/textarea.tsx +1 -1
- package/src/ui/thumbnail.tsx +80 -0
- package/src/ui/toast.tsx +112 -0
- package/src/ui/tooltip.tsx +3 -1
- 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-
|
|
58
|
-
// the
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
|
|
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-
|
|
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-
|
|
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}
|
package/src/ui/separator.tsx
CHANGED
|
@@ -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-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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-
|
|
222
|
+
className={cn('text-lg font-medium', className)}
|
|
181
223
|
{...props}
|
|
182
224
|
/>
|
|
183
225
|
)
|
package/src/ui/sidebar.tsx
CHANGED
|
@@ -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 = '
|
|
18
|
-
|
|
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 =
|
|
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
|
-
[
|
|
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
|
-
//
|
|
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
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
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
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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-[
|
|
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-[
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
467
|
-
|
|
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-[
|
|
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
|
|
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-
|
|
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}
|
package/src/ui/skeleton.tsx
CHANGED
|
@@ -4,7 +4,9 @@ function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
|
|
|
4
4
|
return (
|
|
5
5
|
<div
|
|
6
6
|
data-slot="skeleton"
|
|
7
|
-
|
|
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
|
)
|