@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.
- package/README.md +1 -1
- package/package.json +13 -9
- package/src/hooks/use-prefers-reduced-motion.ts +26 -0
- package/src/hooks/use-scrolled.ts +34 -14
- package/src/index.ts +38 -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/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/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
|
@@ -1,10 +1,16 @@
|
|
|
1
|
-
import { format
|
|
2
|
-
import {
|
|
3
|
-
import { useState } from 'react'
|
|
1
|
+
import { format } from 'date-fns'
|
|
2
|
+
import { useMemo, useState } from 'react'
|
|
4
3
|
import type { DateRange as DayPickerDateRange } from 'react-day-picker'
|
|
5
4
|
import { useTranslation } from 'react-i18next'
|
|
6
5
|
import { activeDateLocale } from '../lib/date-locale'
|
|
6
|
+
import {
|
|
7
|
+
DATE_RANGE_PRESET_KEYS,
|
|
8
|
+
type DateRangePresetKey,
|
|
9
|
+
matchDateRangePreset,
|
|
10
|
+
resolveDateRangePreset,
|
|
11
|
+
} from '../lib/date-range-presets'
|
|
7
12
|
import { cn } from '../lib/utils'
|
|
13
|
+
import { CalendarIcon, ChevronDownIcon } from '../spree/icons'
|
|
8
14
|
import { Button } from './button'
|
|
9
15
|
import { Calendar } from './calendar'
|
|
10
16
|
import { Popover, PopoverContent, PopoverTrigger } from './popover'
|
|
@@ -14,79 +20,58 @@ export interface DateRange {
|
|
|
14
20
|
to: Date
|
|
15
21
|
}
|
|
16
22
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
+
const PRESET_LABEL_KEYS: Record<Exclude<DateRangePresetKey, 'custom'>, string> = {
|
|
24
|
+
'7d': 'admin.components.date_range_picker.presets.last_7_days',
|
|
25
|
+
'14d': 'admin.components.date_range_picker.presets.last_14_days',
|
|
26
|
+
'30d': 'admin.components.date_range_picker.presets.last_30_days',
|
|
27
|
+
'90d': 'admin.components.date_range_picker.presets.last_90_days',
|
|
28
|
+
this_month: 'admin.components.date_range_picker.presets.this_month',
|
|
29
|
+
ytd: 'admin.components.date_range_picker.presets.year_to_date',
|
|
23
30
|
}
|
|
24
31
|
|
|
25
|
-
|
|
26
|
-
{
|
|
27
|
-
key: '7d',
|
|
28
|
-
labelKey: 'admin.components.date_range_picker.presets.last_7_days',
|
|
29
|
-
value: () => ({ from: subDays(new Date(), 7), to: new Date() }),
|
|
30
|
-
},
|
|
31
|
-
{
|
|
32
|
-
key: '14d',
|
|
33
|
-
labelKey: 'admin.components.date_range_picker.presets.last_14_days',
|
|
34
|
-
value: () => ({ from: subDays(new Date(), 14), to: new Date() }),
|
|
35
|
-
},
|
|
36
|
-
{
|
|
37
|
-
key: '30d',
|
|
38
|
-
labelKey: 'admin.components.date_range_picker.presets.last_30_days',
|
|
39
|
-
value: () => ({ from: subDays(new Date(), 30), to: new Date() }),
|
|
40
|
-
},
|
|
41
|
-
{
|
|
42
|
-
key: '90d',
|
|
43
|
-
labelKey: 'admin.components.date_range_picker.presets.last_90_days',
|
|
44
|
-
value: () => ({ from: subDays(new Date(), 90), to: new Date() }),
|
|
45
|
-
},
|
|
46
|
-
{
|
|
47
|
-
key: 'this_month',
|
|
48
|
-
labelKey: 'admin.components.date_range_picker.presets.this_month',
|
|
49
|
-
value: () => ({ from: startOfMonth(new Date()), to: new Date() }),
|
|
50
|
-
},
|
|
51
|
-
{
|
|
52
|
-
key: 'ytd',
|
|
53
|
-
labelKey: 'admin.components.date_range_picker.presets.year_to_date',
|
|
54
|
-
value: () => ({ from: startOfYear(new Date()), to: new Date() }),
|
|
55
|
-
},
|
|
56
|
-
]
|
|
57
|
-
|
|
58
|
-
interface DateRangePickerProps {
|
|
32
|
+
export interface DateRangePickerProps {
|
|
59
33
|
value: DateRange
|
|
60
34
|
onChange: (range: DateRange) => void
|
|
35
|
+
/**
|
|
36
|
+
* IANA timezone used to resolve relative presets ("last 30 days").
|
|
37
|
+
* Defaults to the browser zone. Pass the store timezone on admin
|
|
38
|
+
* surfaces so every merchant sees the same span.
|
|
39
|
+
*/
|
|
40
|
+
timezone?: string
|
|
61
41
|
}
|
|
62
42
|
|
|
63
|
-
export function DateRangePicker({ value, onChange }: DateRangePickerProps) {
|
|
43
|
+
export function DateRangePicker({ value, onChange, timezone }: DateRangePickerProps) {
|
|
64
44
|
const { t } = useTranslation()
|
|
65
45
|
const [open, setOpen] = useState(false)
|
|
66
|
-
const [activePreset, setActivePreset] = useState<PresetKey>('30d')
|
|
67
46
|
const [calendarRange, setCalendarRange] = useState<DayPickerDateRange | undefined>()
|
|
47
|
+
const [month, setMonth] = useState<Date>(value.from)
|
|
68
48
|
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
49
|
+
const zone = useMemo(
|
|
50
|
+
() => timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC',
|
|
51
|
+
[timezone],
|
|
52
|
+
)
|
|
53
|
+
// Derived from `value` so a remount — or a range written by the parent —
|
|
54
|
+
// keeps the trigger and the highlighted preset in sync. A second piece of
|
|
55
|
+
// preset state is what made "Last 90 days" revert to "Last 30 days"
|
|
56
|
+
// after the dashboard swapped the picker for a skeleton.
|
|
57
|
+
const activePreset = matchDateRangePreset(value, zone)
|
|
72
58
|
|
|
73
59
|
const dateLocale = activeDateLocale()
|
|
74
60
|
const triggerLabel =
|
|
75
61
|
activePreset === 'custom'
|
|
76
62
|
? `${format(value.from, 'MMM d', { locale: dateLocale })} – ${format(value.to, 'MMM d', { locale: dateLocale })}`
|
|
77
|
-
: t(
|
|
63
|
+
: t(PRESET_LABEL_KEYS[activePreset])
|
|
78
64
|
|
|
79
|
-
function selectPreset(
|
|
80
|
-
const range =
|
|
81
|
-
setActivePreset(preset.key)
|
|
65
|
+
function selectPreset(key: Exclude<DateRangePresetKey, 'custom'>) {
|
|
66
|
+
const range = resolveDateRangePreset(key, zone)
|
|
82
67
|
setCalendarRange({ from: range.from, to: range.to })
|
|
68
|
+
setMonth(range.from)
|
|
83
69
|
onChange(range)
|
|
84
70
|
setOpen(false)
|
|
85
71
|
}
|
|
86
72
|
|
|
87
73
|
function applyCustomRange() {
|
|
88
74
|
if (calendarRange?.from && calendarRange?.to) {
|
|
89
|
-
setActivePreset('custom')
|
|
90
75
|
onChange({ from: calendarRange.from, to: calendarRange.to })
|
|
91
76
|
setOpen(false)
|
|
92
77
|
}
|
|
@@ -101,6 +86,7 @@ export function DateRangePicker({ value, onChange }: DateRangePickerProps) {
|
|
|
101
86
|
setOpen(nextOpen)
|
|
102
87
|
if (nextOpen) {
|
|
103
88
|
setCalendarRange({ from: value.from, to: value.to })
|
|
89
|
+
setMonth(value.from)
|
|
104
90
|
}
|
|
105
91
|
}}
|
|
106
92
|
>
|
|
@@ -114,25 +100,26 @@ export function DateRangePicker({ value, onChange }: DateRangePickerProps) {
|
|
|
114
100
|
<PopoverContent align="end" className="w-auto p-0">
|
|
115
101
|
<div className="flex">
|
|
116
102
|
<div className="flex flex-col border-r py-2">
|
|
117
|
-
{
|
|
103
|
+
{DATE_RANGE_PRESET_KEYS.map((key) => (
|
|
118
104
|
<button
|
|
119
|
-
key={
|
|
105
|
+
key={key}
|
|
120
106
|
type="button"
|
|
121
|
-
onClick={() => selectPreset(
|
|
107
|
+
onClick={() => selectPreset(key)}
|
|
122
108
|
className={cn(
|
|
123
109
|
'whitespace-nowrap px-4 py-1.5 text-left text-sm transition-colors hover:bg-accent',
|
|
124
|
-
activePreset ===
|
|
125
|
-
activePreset !==
|
|
110
|
+
activePreset === key && 'font-medium text-foreground',
|
|
111
|
+
activePreset !== key && 'text-muted-foreground',
|
|
126
112
|
)}
|
|
127
113
|
>
|
|
128
|
-
{t(
|
|
114
|
+
{t(PRESET_LABEL_KEYS[key])}
|
|
129
115
|
</button>
|
|
130
116
|
))}
|
|
131
117
|
</div>
|
|
132
118
|
<div className="flex flex-col p-3">
|
|
133
119
|
<Calendar
|
|
134
120
|
mode="range"
|
|
135
|
-
|
|
121
|
+
month={month}
|
|
122
|
+
onMonthChange={setMonth}
|
|
136
123
|
selected={calendarRange}
|
|
137
124
|
onSelect={setCalendarRange}
|
|
138
125
|
numberOfMonths={2}
|
package/src/ui/dialog.tsx
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { Dialog as DialogPrimitive } from '@base-ui/react/dialog'
|
|
2
2
|
import i18n from 'i18next'
|
|
3
|
-
import { XIcon } from 'lucide-react'
|
|
4
3
|
import * as React from 'react'
|
|
5
4
|
import { cn } from '../lib/utils'
|
|
5
|
+
import { XIcon } from '../spree/icons'
|
|
6
6
|
import { Button } from './button'
|
|
7
7
|
|
|
8
8
|
function Dialog({
|
|
@@ -83,7 +83,7 @@ function DialogOverlay({
|
|
|
83
83
|
<DialogPrimitive.Backdrop
|
|
84
84
|
data-slot="dialog-overlay"
|
|
85
85
|
className={cn(
|
|
86
|
-
'fixed inset-0 isolate z-50 bg-
|
|
86
|
+
'fixed inset-0 isolate z-50 bg-background/60 dark:bg-black/60 duration-100 data-[starting-style]:opacity-0 data-[ending-style]:opacity-0 transition-opacity',
|
|
87
87
|
className,
|
|
88
88
|
)}
|
|
89
89
|
{...props}
|
|
@@ -105,7 +105,7 @@ function DialogContent({
|
|
|
105
105
|
<DialogPrimitive.Popup
|
|
106
106
|
data-slot="dialog-content"
|
|
107
107
|
className={cn(
|
|
108
|
-
'fixed top-1/2 left-1/2 z-50 flex w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-xl border border-border bg-card text-foreground text-sm shadow-lg duration-100 outline-none sm:max-w-[500px] data-[starting-style]:opacity-0 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[ending-style]:scale-95 transition-[opacity,transform]',
|
|
108
|
+
'fixed top-1/2 left-1/2 z-50 flex w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-xl border border-border bg-card text-foreground text-sm shadow-lg duration-100 outline-none sm:max-w-[min(500px,calc(100%-2rem))] data-[starting-style]:opacity-0 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[ending-style]:scale-95 transition-[opacity,transform,translate,scale]',
|
|
109
109
|
className,
|
|
110
110
|
)}
|
|
111
111
|
style={{ maxHeight: '90vh' }}
|
|
@@ -132,7 +132,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
|
|
132
132
|
return (
|
|
133
133
|
<div
|
|
134
134
|
data-slot="dialog-header"
|
|
135
|
-
className={cn('flex flex-col gap-1.5 border-b p-4', className)}
|
|
135
|
+
className={cn('flex flex-col gap-1.5 border-b border-border-subtle p-4', className)}
|
|
136
136
|
{...props}
|
|
137
137
|
/>
|
|
138
138
|
)
|
|
@@ -160,7 +160,7 @@ function DialogFooter({
|
|
|
160
160
|
<div
|
|
161
161
|
data-slot="dialog-footer"
|
|
162
162
|
className={cn(
|
|
163
|
-
'flex flex-col-reverse gap-2 border-t bg-muted/50 p-4 sm:flex-row sm:justify-end',
|
|
163
|
+
'flex flex-col-reverse gap-2 border-t border-border-subtle bg-muted/50 p-4 sm:flex-row sm:justify-end',
|
|
164
164
|
className,
|
|
165
165
|
)}
|
|
166
166
|
{...props}
|
|
@@ -177,7 +177,7 @@ function DialogTitle({ className, ...props }: React.ComponentProps<typeof Dialog
|
|
|
177
177
|
return (
|
|
178
178
|
<DialogPrimitive.Title
|
|
179
179
|
data-slot="dialog-title"
|
|
180
|
-
className={cn('text-
|
|
180
|
+
className={cn('text-lg font-medium', className)}
|
|
181
181
|
{...props}
|
|
182
182
|
/>
|
|
183
183
|
)
|
package/src/ui/dropdown-menu.tsx
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import { Menu as MenuPrimitive } from '@base-ui/react/menu'
|
|
4
|
-
import { CheckIcon, ChevronRightIcon } from 'lucide-react'
|
|
5
4
|
import * as React from 'react'
|
|
6
5
|
import { cn } from '../lib/utils'
|
|
6
|
+
import { CheckIcon, ChevronRightIcon } from '../spree/icons'
|
|
7
7
|
|
|
8
8
|
function DropdownMenu({
|
|
9
9
|
children,
|
|
@@ -81,7 +81,9 @@ function DropdownMenuContent({
|
|
|
81
81
|
<MenuPrimitive.Popup
|
|
82
82
|
data-slot="dropdown-menu-content"
|
|
83
83
|
className={cn(
|
|
84
|
-
|
|
84
|
+
// `origin-(--transform-origin)` so the menu grows out of its
|
|
85
|
+
// trigger rather than its own centre.
|
|
86
|
+
'z-50 max-h-[var(--available-height)] min-w-40 max-w-80 overflow-x-hidden overflow-y-auto rounded-xl border border-border bg-popover p-1 text-popover-foreground shadow-lg origin-(--transform-origin) duration-100 ease-out data-[starting-style]:opacity-0 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[ending-style]:scale-95 transition-[opacity,transform,translate,scale]',
|
|
85
87
|
className,
|
|
86
88
|
)}
|
|
87
89
|
{...props}
|
|
@@ -117,7 +119,7 @@ function DropdownMenuItem({
|
|
|
117
119
|
data-variant={variant}
|
|
118
120
|
render={children}
|
|
119
121
|
className={cn(
|
|
120
|
-
"group/dropdown-menu-item relative flex cursor-
|
|
122
|
+
"group/dropdown-menu-item relative flex cursor-pointer items-center gap-3 rounded-lg px-2 py-1.5 text-sm outline-hidden select-none transition-colors duration-75 focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
121
123
|
className,
|
|
122
124
|
)}
|
|
123
125
|
{...props}
|
|
@@ -131,7 +133,7 @@ function DropdownMenuItem({
|
|
|
131
133
|
data-inset={inset}
|
|
132
134
|
data-variant={variant}
|
|
133
135
|
className={cn(
|
|
134
|
-
"group/dropdown-menu-item relative flex cursor-
|
|
136
|
+
"group/dropdown-menu-item relative flex cursor-pointer items-center gap-3 rounded-lg px-2 py-1.5 text-sm outline-hidden select-none transition-colors duration-75 focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
135
137
|
className,
|
|
136
138
|
)}
|
|
137
139
|
{...props}
|
|
@@ -158,7 +160,7 @@ function DropdownMenuCheckboxItem({
|
|
|
158
160
|
data-slot="dropdown-menu-checkbox-item"
|
|
159
161
|
data-inset={inset}
|
|
160
162
|
className={cn(
|
|
161
|
-
"relative flex cursor-
|
|
163
|
+
"relative flex cursor-pointer items-center gap-3 rounded-lg py-1.5 pr-8 pl-2.5 text-sm outline-hidden select-none transition-colors duration-75 focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
162
164
|
className,
|
|
163
165
|
)}
|
|
164
166
|
checked={checked}
|
|
@@ -197,7 +199,7 @@ function DropdownMenuRadioItem({
|
|
|
197
199
|
data-slot="dropdown-menu-radio-item"
|
|
198
200
|
data-inset={inset}
|
|
199
201
|
className={cn(
|
|
200
|
-
"relative flex cursor-
|
|
202
|
+
"relative flex cursor-pointer items-center gap-3 rounded-lg py-1.5 pr-8 pl-2.5 text-sm outline-hidden select-none transition-colors duration-75 focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
201
203
|
className,
|
|
202
204
|
)}
|
|
203
205
|
{...props}
|
|
@@ -228,7 +230,7 @@ function DropdownMenuLabel({
|
|
|
228
230
|
data-slot="dropdown-menu-label"
|
|
229
231
|
data-inset={inset}
|
|
230
232
|
className={cn(
|
|
231
|
-
'px-2
|
|
233
|
+
'px-2 py-1.5 text-sm font-normal text-muted-foreground data-inset:pl-7',
|
|
232
234
|
className,
|
|
233
235
|
)}
|
|
234
236
|
{...props}
|
|
@@ -244,7 +246,7 @@ function DropdownMenuSeparator({
|
|
|
244
246
|
return (
|
|
245
247
|
<MenuPrimitive.Separator
|
|
246
248
|
data-slot="dropdown-menu-separator"
|
|
247
|
-
className={cn('
|
|
249
|
+
className={cn('mx-1 my-1 h-px bg-border-subtle', className)}
|
|
248
250
|
{...props}
|
|
249
251
|
/>
|
|
250
252
|
)
|
|
@@ -280,22 +282,25 @@ function DropdownMenuSubTrigger({
|
|
|
280
282
|
className,
|
|
281
283
|
inset,
|
|
282
284
|
children,
|
|
285
|
+
hideChevron,
|
|
283
286
|
...props
|
|
284
287
|
}: React.ComponentProps<typeof MenuPrimitive.SubmenuTrigger> & {
|
|
285
288
|
inset?: boolean
|
|
289
|
+
/** Suppress the default trailing chevron when the trigger renders its own affordance. */
|
|
290
|
+
hideChevron?: boolean
|
|
286
291
|
}) {
|
|
287
292
|
return (
|
|
288
293
|
<MenuPrimitive.SubmenuTrigger
|
|
289
294
|
data-slot="dropdown-menu-sub-trigger"
|
|
290
295
|
data-inset={inset}
|
|
291
296
|
className={cn(
|
|
292
|
-
"flex cursor-
|
|
297
|
+
"flex cursor-pointer items-center gap-3 rounded-lg px-2 py-1.5 text-sm outline-hidden select-none transition-colors duration-75 focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-[popup-open]:bg-accent data-[popup-open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
293
298
|
className,
|
|
294
299
|
)}
|
|
295
300
|
{...props}
|
|
296
301
|
>
|
|
297
302
|
{children}
|
|
298
|
-
<ChevronRightIcon className="ml-auto" />
|
|
303
|
+
{!hideChevron && <ChevronRightIcon className="ml-auto" />}
|
|
299
304
|
</MenuPrimitive.SubmenuTrigger>
|
|
300
305
|
)
|
|
301
306
|
}
|
|
@@ -314,7 +319,9 @@ function DropdownMenuSubContent({
|
|
|
314
319
|
<MenuPrimitive.Popup
|
|
315
320
|
data-slot="dropdown-menu-sub-content"
|
|
316
321
|
className={cn(
|
|
317
|
-
|
|
322
|
+
// `origin-(--transform-origin)` so the submenu grows out of its
|
|
323
|
+
// parent item rather than its own centre.
|
|
324
|
+
'z-50 min-w-[96px] overflow-hidden rounded-xl border border-border bg-popover p-1 text-popover-foreground shadow-lg origin-(--transform-origin) duration-100 ease-out data-[starting-style]:opacity-0 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[ending-style]:scale-95 transition-[opacity,transform,translate,scale]',
|
|
318
325
|
className,
|
|
319
326
|
)}
|
|
320
327
|
{...props}
|
package/src/ui/field.tsx
CHANGED
|
@@ -9,7 +9,7 @@ function FieldSet({ className, ...props }: React.ComponentProps<'fieldset'>) {
|
|
|
9
9
|
<fieldset
|
|
10
10
|
data-slot="field-set"
|
|
11
11
|
className={cn(
|
|
12
|
-
'flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3',
|
|
12
|
+
'flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3 rounded-md p-3 border border-border-subtle shadow-xs bg-card',
|
|
13
13
|
className,
|
|
14
14
|
)}
|
|
15
15
|
{...props}
|
|
@@ -27,7 +27,7 @@ function FieldLegend({
|
|
|
27
27
|
data-slot="field-legend"
|
|
28
28
|
data-variant={variant}
|
|
29
29
|
className={cn(
|
|
30
|
-
'mb-1.5 font-
|
|
30
|
+
'mb-1.5 font-semibold data-[variant=label]:text-sm data-[variant=legend]:text-base',
|
|
31
31
|
className,
|
|
32
32
|
)}
|
|
33
33
|
{...props}
|
|
@@ -95,6 +95,14 @@ function FieldLabel({ className, ...props }: React.ComponentProps<typeof Label>)
|
|
|
95
95
|
data-slot="field-label"
|
|
96
96
|
className={cn(
|
|
97
97
|
'group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-blue-500/30 has-data-checked:bg-blue-500/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border *:data-[slot=field]:p-2.5 dark:has-data-checked:border-blue-500/20 dark:has-data-checked:bg-blue-500/10',
|
|
98
|
+
// Card-shaped labels (the ones wrapping a whole `Field`) are clickable
|
|
99
|
+
// targets, so they need the affordance a bare text label doesn't:
|
|
100
|
+
// a pointer and a hover tint. Scoped by `has-[>[data-slot=field]]` so
|
|
101
|
+
// plain field labels keep their default cursor, and excluded on the
|
|
102
|
+
// checked card by `not-has-data-checked` — a competing hover rule of
|
|
103
|
+
// equal specificity would just race the selected tint and win.
|
|
104
|
+
'has-[>[data-slot=field]]:cursor-pointer has-[>[data-slot=field]]:transition-colors',
|
|
105
|
+
'has-[>[data-slot=field]]:not-has-data-checked:hover:border-border has-[>[data-slot=field]]:not-has-data-checked:hover:bg-accent/60',
|
|
98
106
|
'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col',
|
|
99
107
|
className,
|
|
100
108
|
)}
|
package/src/ui/input-group.tsx
CHANGED
|
@@ -11,7 +11,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
|
|
11
11
|
data-slot="input-group"
|
|
12
12
|
role="group"
|
|
13
13
|
className={cn(
|
|
14
|
-
'group/input-group relative flex w-full min-w-0 items-center rounded-
|
|
14
|
+
'group/input-group relative flex w-full min-w-0 items-center rounded-md border border-border bg-card text-foreground transition-[color,background-color,border-color,box-shadow] duration-100 ease-out outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:shadow-none has-disabled:bg-muted has-disabled:cursor-not-allowed has-disabled:text-muted-foreground has-[[data-slot=input-group-control]:focus-visible]:border-blue-500 has-[[data-slot=input-group-control]:focus-visible]:shadow-[0_0_0_3px_color-mix(in_srgb,var(--ring)_15%,transparent)] has-[[data-slot][aria-invalid=true]]:border-destructive has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5',
|
|
15
15
|
className,
|
|
16
16
|
)}
|
|
17
17
|
{...props}
|
package/src/ui/input.tsx
CHANGED
|
@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
|
|
|
8
8
|
type={type}
|
|
9
9
|
data-slot="input"
|
|
10
10
|
className={cn(
|
|
11
|
-
'block w-full min-w-0 min-h-8 rounded-
|
|
11
|
+
'block w-full min-w-0 min-h-8 rounded-md border border-border bg-card py-1.5 px-2.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 file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground',
|
|
12
12
|
className,
|
|
13
13
|
)}
|
|
14
14
|
{...props}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type * as React from 'react'
|
|
2
|
+
import { useTranslation } from 'react-i18next'
|
|
3
|
+
import { cn } from '../lib/utils'
|
|
4
|
+
import { FilmIcon, ImagePlusIcon } from '../spree/icons'
|
|
5
|
+
|
|
6
|
+
export interface MediaPreviewProps {
|
|
7
|
+
mediaType?: 'image' | 'video' | 'external_video' | string
|
|
8
|
+
/** Still image URL — the image itself, or a video's poster. */
|
|
9
|
+
previewUrl?: string | null
|
|
10
|
+
/** Playable file for an uploaded video. */
|
|
11
|
+
videoUrl?: string | null
|
|
12
|
+
/**
|
|
13
|
+
* Player URL for an external video. Parsing the provider link is the
|
|
14
|
+
* caller's job — the server is the authority on what is embeddable, and
|
|
15
|
+
* this component only renders what it is handed.
|
|
16
|
+
*/
|
|
17
|
+
embedUrl?: string | null
|
|
18
|
+
alt?: string
|
|
19
|
+
/** Normalized 0–1. Drawn as a marker over an image. */
|
|
20
|
+
focalPoint?: { x: number; y: number } | null
|
|
21
|
+
/** Click handler for setting the focal point; images only. */
|
|
22
|
+
onFocalPointClick?: (event: React.MouseEvent<HTMLElement>) => void
|
|
23
|
+
className?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* What a media row looks like, whatever its kind: an external video as its
|
|
28
|
+
* provider's embed, an uploaded video as a player, an image as a picture —
|
|
29
|
+
* with a focal point marker when one is set.
|
|
30
|
+
*
|
|
31
|
+
* Headless by the package rule: everything arrives as props, so the same
|
|
32
|
+
* component serves a saved record and a form row that only has blob URLs
|
|
33
|
+
* because it has not been saved yet.
|
|
34
|
+
*/
|
|
35
|
+
export function MediaPreview({
|
|
36
|
+
mediaType = 'image',
|
|
37
|
+
previewUrl,
|
|
38
|
+
videoUrl,
|
|
39
|
+
embedUrl,
|
|
40
|
+
alt = '',
|
|
41
|
+
focalPoint,
|
|
42
|
+
onFocalPointClick,
|
|
43
|
+
className = 'max-h-[60vh]',
|
|
44
|
+
}: MediaPreviewProps) {
|
|
45
|
+
const { t } = useTranslation()
|
|
46
|
+
|
|
47
|
+
const isImage = mediaType === 'image'
|
|
48
|
+
|
|
49
|
+
if (mediaType === 'external_video' && embedUrl) {
|
|
50
|
+
return (
|
|
51
|
+
<div className="aspect-video w-full shrink-0 overflow-hidden rounded-lg border border-border bg-black">
|
|
52
|
+
<iframe
|
|
53
|
+
src={embedUrl}
|
|
54
|
+
title={alt || t('admin.components.media_preview.video_title')}
|
|
55
|
+
allowFullScreen
|
|
56
|
+
className="size-full"
|
|
57
|
+
/>
|
|
58
|
+
</div>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (mediaType === 'video' && videoUrl) {
|
|
63
|
+
return (
|
|
64
|
+
// biome-ignore lint/a11y/useMediaCaption: merchant-supplied footage has no track to offer
|
|
65
|
+
<video
|
|
66
|
+
src={videoUrl}
|
|
67
|
+
poster={previewUrl ?? undefined}
|
|
68
|
+
controls
|
|
69
|
+
playsInline
|
|
70
|
+
preload="metadata"
|
|
71
|
+
className={cn('w-full shrink-0 rounded-lg bg-black', className)}
|
|
72
|
+
/>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const canSetFocalPoint = isImage && !!previewUrl && !!onFocalPointClick
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<button
|
|
80
|
+
type="button"
|
|
81
|
+
disabled={!canSetFocalPoint}
|
|
82
|
+
onClick={onFocalPointClick}
|
|
83
|
+
className="group relative block w-full shrink-0 overflow-hidden rounded-lg border border-border bg-muted disabled:cursor-default"
|
|
84
|
+
>
|
|
85
|
+
{previewUrl ? (
|
|
86
|
+
<>
|
|
87
|
+
<img src={previewUrl} alt={alt} className={cn('w-full object-contain', className)} />
|
|
88
|
+
{isImage && focalPoint && (
|
|
89
|
+
<span
|
|
90
|
+
aria-hidden
|
|
91
|
+
className="pointer-events-none absolute size-5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-[0_0_0_2px_rgba(0,0,0,0.4)]"
|
|
92
|
+
style={{ left: `${focalPoint.x * 100}%`, top: `${focalPoint.y * 100}%` }}
|
|
93
|
+
/>
|
|
94
|
+
)}
|
|
95
|
+
</>
|
|
96
|
+
) : (
|
|
97
|
+
// A video with no poster has no still to show; say which kind of file
|
|
98
|
+
// it is rather than rendering a broken image.
|
|
99
|
+
<div className="flex aspect-square w-full items-center justify-center text-muted-foreground">
|
|
100
|
+
{isImage ? <ImagePlusIcon className="size-8" /> : <FilmIcon className="size-8" />}
|
|
101
|
+
</div>
|
|
102
|
+
)}
|
|
103
|
+
</button>
|
|
104
|
+
)
|
|
105
|
+
}
|
package/src/ui/pagination.tsx
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
+
import { useTranslation } from 'react-i18next'
|
|
1
2
|
import {
|
|
2
3
|
ChevronLeftIcon,
|
|
3
4
|
ChevronRightIcon,
|
|
4
5
|
ChevronsLeftIcon,
|
|
5
6
|
ChevronsRightIcon,
|
|
6
|
-
} from '
|
|
7
|
-
import { useTranslation } from 'react-i18next'
|
|
7
|
+
} from '../spree/icons'
|
|
8
8
|
import { Button } from './button'
|
|
9
9
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './select'
|
|
10
10
|
|
|
@@ -33,27 +33,29 @@ export function Pagination({
|
|
|
33
33
|
pageSizeOptions = [10, 20, 25, 30, 50],
|
|
34
34
|
}: PaginationProps) {
|
|
35
35
|
const { t } = useTranslation()
|
|
36
|
+
const showPageSize = Boolean(onPageSizeChange) && meta.count > 0
|
|
37
|
+
const showPageNav = meta.pages > 1
|
|
36
38
|
|
|
37
|
-
if (
|
|
39
|
+
if (!showPageSize && !showPageNav) return null
|
|
38
40
|
|
|
39
41
|
return (
|
|
40
|
-
<div className="flex items-center justify-between
|
|
41
|
-
<div className="text-sm text-muted-foreground">
|
|
42
|
+
<div className="flex items-center justify-between ps-2 py-2">
|
|
43
|
+
<div className="whitespace-nowrap text-sm text-muted-foreground">
|
|
42
44
|
{t('admin.components.pagination.range', {
|
|
43
45
|
from: meta.from,
|
|
44
46
|
to: meta.to,
|
|
45
47
|
count: meta.count,
|
|
46
48
|
})}
|
|
47
49
|
</div>
|
|
48
|
-
<div className="flex items-center gap-6">
|
|
49
|
-
{
|
|
50
|
-
<div className="
|
|
50
|
+
<div className="flex items-center gap-2 sm:gap-6">
|
|
51
|
+
{showPageSize && (
|
|
52
|
+
<div className="hidden items-center gap-2 sm:flex">
|
|
51
53
|
<span className="text-sm text-muted-foreground">
|
|
52
54
|
{t('admin.components.pagination.rows_per_page')}
|
|
53
55
|
</span>
|
|
54
56
|
<Select
|
|
55
57
|
value={`${meta.limit}`}
|
|
56
|
-
onValueChange={(value) => onPageSizeChange(Number(value))}
|
|
58
|
+
onValueChange={(value) => onPageSizeChange?.(Number(value))}
|
|
57
59
|
>
|
|
58
60
|
<SelectTrigger className="h-8 w-[70px]">
|
|
59
61
|
<SelectValue />
|
|
@@ -68,13 +70,14 @@ export function Pagination({
|
|
|
68
70
|
</Select>
|
|
69
71
|
</div>
|
|
70
72
|
)}
|
|
71
|
-
{
|
|
73
|
+
{showPageNav && (
|
|
72
74
|
<>
|
|
73
|
-
<span className="text-sm text-muted-foreground">
|
|
75
|
+
<span className="hidden whitespace-nowrap text-sm text-muted-foreground sm:inline">
|
|
74
76
|
{t('admin.common.page_of', { page: meta.page, total: meta.pages })}
|
|
75
77
|
</span>
|
|
76
78
|
<div className="flex items-center gap-1">
|
|
77
79
|
<Button
|
|
80
|
+
type="button"
|
|
78
81
|
size="icon-sm"
|
|
79
82
|
variant="outline"
|
|
80
83
|
className="hidden lg:flex"
|
|
@@ -85,8 +88,10 @@ export function Pagination({
|
|
|
85
88
|
<ChevronsLeftIcon />
|
|
86
89
|
</Button>
|
|
87
90
|
<Button
|
|
91
|
+
type="button"
|
|
88
92
|
size="icon-sm"
|
|
89
93
|
variant="outline"
|
|
94
|
+
className="size-9 sm:size-8"
|
|
90
95
|
disabled={!meta.previous}
|
|
91
96
|
onClick={() => meta.previous && onPageChange(meta.previous)}
|
|
92
97
|
>
|
|
@@ -94,8 +99,10 @@ export function Pagination({
|
|
|
94
99
|
<ChevronLeftIcon />
|
|
95
100
|
</Button>
|
|
96
101
|
<Button
|
|
102
|
+
type="button"
|
|
97
103
|
size="icon-sm"
|
|
98
104
|
variant="outline"
|
|
105
|
+
className="size-9 sm:size-8"
|
|
99
106
|
disabled={!meta.next}
|
|
100
107
|
onClick={() => meta.next && onPageChange(meta.next)}
|
|
101
108
|
>
|
|
@@ -103,6 +110,7 @@ export function Pagination({
|
|
|
103
110
|
<ChevronRightIcon />
|
|
104
111
|
</Button>
|
|
105
112
|
<Button
|
|
113
|
+
type="button"
|
|
106
114
|
size="icon-sm"
|
|
107
115
|
className="hidden lg:flex"
|
|
108
116
|
variant="outline"
|
package/src/ui/popover.tsx
CHANGED
|
@@ -56,8 +56,10 @@ function PopoverContent({
|
|
|
56
56
|
<PopoverPrimitive.Popup
|
|
57
57
|
data-slot="popover-content"
|
|
58
58
|
className={cn(
|
|
59
|
-
'z-50 w-auto rounded-
|
|
60
|
-
|
|
59
|
+
'z-50 w-auto rounded-xl border border-border bg-popover p-2 text-popover-foreground shadow-lg',
|
|
60
|
+
// `origin-(--transform-origin)` (published by Base UI's Positioner)
|
|
61
|
+
// so the panel grows out of its trigger rather than its own centre.
|
|
62
|
+
'origin-(--transform-origin) duration-100 ease-out data-[starting-style]:opacity-0 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[ending-style]:scale-95 transition-[opacity,transform,translate,scale]',
|
|
61
63
|
className,
|
|
62
64
|
)}
|
|
63
65
|
{...props}
|
package/src/ui/progress.tsx
CHANGED
|
@@ -30,7 +30,7 @@ function ProgressTrack({ className, ...props }: ProgressPrimitive.Track.Props) {
|
|
|
30
30
|
return (
|
|
31
31
|
<ProgressPrimitive.Track
|
|
32
32
|
data-slot="progress-track"
|
|
33
|
-
className={cn('relative h-2 w-full overflow-hidden rounded-full bg-
|
|
33
|
+
className={cn('relative h-2 w-full overflow-hidden rounded-full bg-blue-500/20', className)}
|
|
34
34
|
{...props}
|
|
35
35
|
/>
|
|
36
36
|
)
|
|
@@ -41,7 +41,10 @@ function ProgressIndicator({ className, ...props }: ProgressPrimitive.Indicator.
|
|
|
41
41
|
<ProgressPrimitive.Indicator
|
|
42
42
|
data-slot="progress-indicator"
|
|
43
43
|
className={cn(
|
|
44
|
-
'
|
|
44
|
+
// `width` is a layout property, but it's the right one here: a
|
|
45
|
+
// determinate bar has to grow, and `scaleX` would distort the fill's
|
|
46
|
+
// rounded ends. Narrowed from `transition-all` so only width animates.
|
|
47
|
+
'h-full rounded-full bg-blue-500 transition-[width] duration-200 ease-out',
|
|
45
48
|
'data-[indeterminate]:w-1/3 data-[indeterminate]:animate-pulse',
|
|
46
49
|
className,
|
|
47
50
|
)}
|