@spree/dashboard-ui 1.0.0-rc.3 → 1.0.0-rc.4
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/package.json +23 -21
- package/src/index.ts +1 -0
- package/src/spree/address-book-row.tsx +37 -51
- package/src/spree/bulk-price-table.tsx +17 -11
- package/src/spree/bulk-variants-table.tsx +12 -5
- package/src/spree/claim-resolve-dialog.tsx +18 -15
- package/src/spree/data-grid/data-grid.tsx +23 -13
- package/src/spree/data-grid/index.ts +1 -1
- package/src/spree/fulfillment-item-list.tsx +10 -9
- package/src/spree/icons.ts +3 -0
- package/src/spree/post-sale-fields.tsx +22 -20
- package/src/spree/resource-combobox.tsx +20 -4
- package/src/spree/secret-input.tsx +29 -13
- package/src/spree/tax-identifiers-card.tsx +69 -76
- package/src/spree/wizard.tsx +1 -7
- package/src/ui/dialog.tsx +11 -2
- package/src/ui/item.tsx +188 -0
- package/src/ui/toast.tsx +99 -45
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spree/dashboard-ui",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "1.0.0-rc.
|
|
4
|
+
"version": "1.0.0-rc.4",
|
|
5
5
|
"description": "Spree Dashboard design system — shadcn primitives, headless composed components, and design tokens. Source-only package; consumers compile via their own Vite/Tailwind setup. Developer Preview.",
|
|
6
6
|
"author": "Vendo Connect Inc.",
|
|
7
7
|
"homepage": "https://spreecommerce.org",
|
|
@@ -37,7 +37,6 @@
|
|
|
37
37
|
"LICENSE"
|
|
38
38
|
],
|
|
39
39
|
"peerDependencies": {
|
|
40
|
-
"@tanstack/react-hotkeys": "^0.10.0",
|
|
41
40
|
"@tanstack/react-router": "^1.169.2",
|
|
42
41
|
"i18next": "^26.2.0",
|
|
43
42
|
"react": "^19.2.6",
|
|
@@ -60,37 +59,40 @@
|
|
|
60
59
|
"@dnd-kit/core": "6.3.1",
|
|
61
60
|
"@dnd-kit/sortable": "10.0.0",
|
|
62
61
|
"@dnd-kit/utilities": "3.2.2",
|
|
63
|
-
"@fontsource-variable/geist": "5.
|
|
64
|
-
"@fontsource-variable/geist-mono": "5.
|
|
65
|
-
"@tanstack/react-
|
|
66
|
-
"@
|
|
67
|
-
"@tiptap/
|
|
68
|
-
"@tiptap/extension-
|
|
69
|
-
"@tiptap/extension-
|
|
70
|
-
"@tiptap/
|
|
71
|
-
"@tiptap/
|
|
72
|
-
"@tiptap/
|
|
62
|
+
"@fontsource-variable/geist": "5.3.0",
|
|
63
|
+
"@fontsource-variable/geist-mono": "5.3.0",
|
|
64
|
+
"@tanstack/react-hotkeys": "^0.14.3",
|
|
65
|
+
"@tanstack/react-table": "9.3.1",
|
|
66
|
+
"@tiptap/core": "3.32.0",
|
|
67
|
+
"@tiptap/extension-bubble-menu": "3.32.0",
|
|
68
|
+
"@tiptap/extension-floating-menu": "3.32.0",
|
|
69
|
+
"@tiptap/extension-image": "3.32.0",
|
|
70
|
+
"@tiptap/extension-link": "3.32.0",
|
|
71
|
+
"@tiptap/extension-placeholder": "3.32.0",
|
|
72
|
+
"@tiptap/pm": "3.32.0",
|
|
73
|
+
"@tiptap/react": "3.32.0",
|
|
74
|
+
"@tiptap/starter-kit": "3.32.0",
|
|
73
75
|
"@uiw/react-json-view": "1.12.2",
|
|
74
76
|
"class-variance-authority": "0.7.1",
|
|
75
77
|
"clsx": "2.1.1",
|
|
76
78
|
"cmdk": "1.1.1",
|
|
77
|
-
"date-fns": "4.
|
|
79
|
+
"date-fns": "4.4.0",
|
|
78
80
|
"date-fns-tz": "3.2.0",
|
|
79
|
-
"flag-icons": "7.
|
|
80
|
-
"lucide-react": "1.
|
|
81
|
-
"react-colorful": "5.
|
|
82
|
-
"react-day-picker": "
|
|
81
|
+
"flag-icons": "7.6.0",
|
|
82
|
+
"lucide-react": "1.58.0",
|
|
83
|
+
"react-colorful": "5.8.1",
|
|
84
|
+
"react-day-picker": "10.0.2",
|
|
83
85
|
"recharts": "3.10.1",
|
|
84
|
-
"shadcn": "4.
|
|
85
|
-
"tailwind-merge": "3.
|
|
86
|
+
"shadcn": "4.22.0",
|
|
87
|
+
"tailwind-merge": "3.7.0",
|
|
86
88
|
"tw-animate-css": "1.4.0"
|
|
87
89
|
},
|
|
88
90
|
"devDependencies": {
|
|
89
91
|
"@types/react": "^19.2.14",
|
|
90
92
|
"@types/react-dom": "^19.2.3",
|
|
91
93
|
"tailwindcss": "^4.2.4",
|
|
92
|
-
"typescript": "~
|
|
93
|
-
"vite": "^8.
|
|
94
|
+
"typescript": "~7.0.2",
|
|
95
|
+
"vite": "^8.3.4"
|
|
94
96
|
},
|
|
95
97
|
"scripts": {
|
|
96
98
|
"lint": "biome check src",
|
package/src/index.ts
CHANGED
|
@@ -117,6 +117,7 @@ export * from './ui/empty'
|
|
|
117
117
|
export * from './ui/field'
|
|
118
118
|
export * from './ui/input'
|
|
119
119
|
export * from './ui/input-group'
|
|
120
|
+
export * from './ui/item'
|
|
120
121
|
export * from './ui/label'
|
|
121
122
|
export * from './ui/media-preview'
|
|
122
123
|
export * from './ui/pagination'
|
|
@@ -1,14 +1,8 @@
|
|
|
1
1
|
import { useTranslation } from 'react-i18next'
|
|
2
2
|
import { Badge } from '../ui/badge'
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
DropdownMenu,
|
|
6
|
-
DropdownMenuContent,
|
|
7
|
-
DropdownMenuItem,
|
|
8
|
-
DropdownMenuTrigger,
|
|
9
|
-
} from '../ui/dropdown-menu'
|
|
3
|
+
import { Item, ItemActions, ItemContent, ItemTitle } from '../ui/item'
|
|
10
4
|
import { AddressBlock, type AddressBlockValue } from './address-block'
|
|
11
|
-
import {
|
|
5
|
+
import { RowActions } from './row-actions'
|
|
12
6
|
|
|
13
7
|
/** What every address book row needs, whoever owns the book. */
|
|
14
8
|
export interface AddressBookEntry extends AddressBlockValue {
|
|
@@ -45,52 +39,44 @@ export function AddressBookRow({
|
|
|
45
39
|
const { t } = useTranslation()
|
|
46
40
|
|
|
47
41
|
return (
|
|
48
|
-
<
|
|
49
|
-
<
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
42
|
+
<Item variant="outline" className="items-start">
|
|
43
|
+
<ItemContent className="min-w-0">
|
|
44
|
+
{(address.label || address.is_default_billing || address.is_default_shipping) && (
|
|
45
|
+
<ItemTitle className="flex-wrap">
|
|
46
|
+
{address.label}
|
|
47
|
+
{address.is_default_billing && (
|
|
48
|
+
<Badge variant="outline">{t('admin.address_book.default_billing')}</Badge>
|
|
49
|
+
)}
|
|
50
|
+
{address.is_default_shipping && (
|
|
51
|
+
<Badge variant="outline">{t('admin.address_book.default_shipping')}</Badge>
|
|
52
|
+
)}
|
|
53
|
+
</ItemTitle>
|
|
54
|
+
)}
|
|
61
55
|
<AddressBlock address={address} />
|
|
62
|
-
</
|
|
56
|
+
</ItemContent>
|
|
63
57
|
|
|
64
58
|
{canEdit && (
|
|
65
|
-
<
|
|
66
|
-
<
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
{
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
</DropdownMenuItem>
|
|
86
|
-
)}
|
|
87
|
-
<DropdownMenuItem variant="destructive" onClick={onRemove}>
|
|
88
|
-
<TrashIcon className="size-4" />
|
|
89
|
-
{t('admin.actions.delete')}
|
|
90
|
-
</DropdownMenuItem>
|
|
91
|
-
</DropdownMenuContent>
|
|
92
|
-
</DropdownMenu>
|
|
59
|
+
<ItemActions>
|
|
60
|
+
<RowActions
|
|
61
|
+
actions={[
|
|
62
|
+
{ key: 'edit', onSelect: onEdit },
|
|
63
|
+
{
|
|
64
|
+
key: 'set_default_billing',
|
|
65
|
+
label: t('admin.address_book.set_default_billing'),
|
|
66
|
+
visible: !!onSetDefaultBilling && !address.is_default_billing,
|
|
67
|
+
onSelect: () => onSetDefaultBilling?.(),
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
key: 'set_default_shipping',
|
|
71
|
+
label: t('admin.address_book.set_default_shipping'),
|
|
72
|
+
visible: !!onSetDefaultShipping && !address.is_default_shipping,
|
|
73
|
+
onSelect: () => onSetDefaultShipping?.(),
|
|
74
|
+
},
|
|
75
|
+
{ key: 'delete', destructive: true, onSelect: onRemove },
|
|
76
|
+
]}
|
|
77
|
+
/>
|
|
78
|
+
</ItemActions>
|
|
93
79
|
)}
|
|
94
|
-
</
|
|
80
|
+
</Item>
|
|
95
81
|
)
|
|
96
82
|
}
|
|
@@ -1,8 +1,16 @@
|
|
|
1
|
-
import type { ColumnDef } from '@tanstack/react-table'
|
|
2
1
|
import { PlusIcon, XIcon } from 'lucide-react'
|
|
3
2
|
import { useMemo } from 'react'
|
|
3
|
+
import { Alert, AlertDescription } from '../ui/alert'
|
|
4
4
|
import { Button } from '../ui/button'
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
DataGrid,
|
|
7
|
+
type DataGridColumn,
|
|
8
|
+
DecimalCell,
|
|
9
|
+
editableRowIndex,
|
|
10
|
+
MoneyCell,
|
|
11
|
+
ReadOnlyCell,
|
|
12
|
+
} from './data-grid'
|
|
13
|
+
import { CircleAlertIcon } from './icons'
|
|
6
14
|
import { SearchInput } from './search-input'
|
|
7
15
|
|
|
8
16
|
export interface BulkPriceRow {
|
|
@@ -131,7 +139,7 @@ export function BulkPriceTable({
|
|
|
131
139
|
isLoading,
|
|
132
140
|
error,
|
|
133
141
|
}: BulkPriceTableProps) {
|
|
134
|
-
const columns = useMemo<
|
|
142
|
+
const columns = useMemo<DataGridColumn<BulkPriceRow>[]>(
|
|
135
143
|
() => [
|
|
136
144
|
{
|
|
137
145
|
id: 'variant',
|
|
@@ -203,7 +211,7 @@ export function BulkPriceTable({
|
|
|
203
211
|
/>
|
|
204
212
|
)
|
|
205
213
|
},
|
|
206
|
-
} satisfies
|
|
214
|
+
} satisfies DataGridColumn<BulkPriceRow>,
|
|
207
215
|
]
|
|
208
216
|
: []),
|
|
209
217
|
{
|
|
@@ -291,7 +299,7 @@ export function BulkPriceTable({
|
|
|
291
299
|
</Button>
|
|
292
300
|
)
|
|
293
301
|
},
|
|
294
|
-
} satisfies
|
|
302
|
+
} satisfies DataGridColumn<BulkPriceRow>,
|
|
295
303
|
]
|
|
296
304
|
: []),
|
|
297
305
|
],
|
|
@@ -312,12 +320,10 @@ export function BulkPriceTable({
|
|
|
312
320
|
// table in a card does.
|
|
313
321
|
<div className="flex h-full flex-col gap-3">
|
|
314
322
|
{error && (
|
|
315
|
-
<
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
>
|
|
319
|
-
{error}
|
|
320
|
-
</div>
|
|
323
|
+
<Alert variant="destructive" className="mx-3 w-auto shrink-0">
|
|
324
|
+
<CircleAlertIcon />
|
|
325
|
+
<AlertDescription>{error}</AlertDescription>
|
|
326
|
+
</Alert>
|
|
321
327
|
)}
|
|
322
328
|
{showToolbar && (
|
|
323
329
|
// Always-mounted toolbar. Conditionally rendering the search input
|
|
@@ -1,6 +1,13 @@
|
|
|
1
|
-
import type { ColumnDef } from '@tanstack/react-table'
|
|
2
1
|
import { useMemo } from 'react'
|
|
3
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
DataGrid,
|
|
4
|
+
type DataGridColumn,
|
|
5
|
+
DecimalCell,
|
|
6
|
+
ReadOnlyCell,
|
|
7
|
+
SelectCell,
|
|
8
|
+
SwitchCell,
|
|
9
|
+
TextCell,
|
|
10
|
+
} from './data-grid'
|
|
4
11
|
|
|
5
12
|
export interface BulkVariantsRow {
|
|
6
13
|
id: string
|
|
@@ -139,7 +146,7 @@ export function BulkVariantsTable({
|
|
|
139
146
|
cartonOptions,
|
|
140
147
|
onChange,
|
|
141
148
|
}: BulkVariantsTableProps) {
|
|
142
|
-
const columns = useMemo<
|
|
149
|
+
const columns = useMemo<DataGridColumn<BulkVariantsRow>[]>(() => {
|
|
143
150
|
// Keyboard column coordinates, fixed per column: variant label is
|
|
144
151
|
// read-only (unregistered), then sku=1, barcode=2, weight=3, unit=4,
|
|
145
152
|
// height=5, width=6, depth=7, unit=8, preorderable=9, backorder=10,
|
|
@@ -148,7 +155,7 @@ export function BulkVariantsTable({
|
|
|
148
155
|
const numericHeader = (label: string) => () => <span className="block text-right">{label}</span>
|
|
149
156
|
const rowLabel = (r: BulkVariantsRow) => r.variantLabel ?? labels.variantDefault
|
|
150
157
|
|
|
151
|
-
const defs:
|
|
158
|
+
const defs: DataGridColumn<BulkVariantsRow>[] = [
|
|
152
159
|
{
|
|
153
160
|
id: 'variant',
|
|
154
161
|
header: labels.variant,
|
|
@@ -182,7 +189,7 @@ export function BulkVariantsTable({
|
|
|
182
189
|
/>
|
|
183
190
|
),
|
|
184
191
|
},
|
|
185
|
-
...DECIMAL_COLUMNS.map<
|
|
192
|
+
...DECIMAL_COLUMNS.map<DataGridColumn<BulkVariantsRow>>(({ field, col }) => ({
|
|
186
193
|
id: field,
|
|
187
194
|
header: numericHeader(labels[field]),
|
|
188
195
|
cell: ({ row }) => (
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from '../ui/dialog'
|
|
12
12
|
import { Field, FieldLabel } from '../ui/field'
|
|
13
13
|
import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from '../ui/input-group'
|
|
14
|
+
import { Item, ItemActions, ItemContent, ItemTitle } from '../ui/item'
|
|
14
15
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
|
|
15
16
|
import { Switch } from '../ui/switch'
|
|
16
17
|
import type { RefundMethod } from './return-dialogs'
|
|
@@ -178,21 +179,23 @@ export function ClaimResolveDialog({
|
|
|
178
179
|
{t('admin.pages.orders.detail.claims.replacement_items')}
|
|
179
180
|
</span>
|
|
180
181
|
{lines.map((line) => (
|
|
181
|
-
<
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
182
|
+
<Item key={line.id} variant="outline" className="flex-nowrap border-border-subtle">
|
|
183
|
+
<ItemContent className="min-w-0">
|
|
184
|
+
<ItemTitle className="block truncate font-normal">
|
|
185
|
+
{line.label}
|
|
186
|
+
<span className="text-muted-foreground"> ×{line.quantity}</span>
|
|
187
|
+
</ItemTitle>
|
|
188
|
+
</ItemContent>
|
|
189
|
+
<ItemActions>
|
|
190
|
+
<Switch
|
|
191
|
+
aria-label={line.label}
|
|
192
|
+
checked={replacing[line.id] ?? false}
|
|
193
|
+
onCheckedChange={(checked) =>
|
|
194
|
+
setReplacing({ ...replacing, [line.id]: checked })
|
|
195
|
+
}
|
|
196
|
+
/>
|
|
197
|
+
</ItemActions>
|
|
198
|
+
</Item>
|
|
196
199
|
))}
|
|
197
200
|
</div>
|
|
198
201
|
)}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
|
+
type ColumnDef,
|
|
2
3
|
flexRender,
|
|
3
|
-
getCoreRowModel,
|
|
4
4
|
type Row,
|
|
5
|
+
type RowData,
|
|
5
6
|
type Table,
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
tableFeatures,
|
|
8
|
+
useTable,
|
|
8
9
|
} from '@tanstack/react-table'
|
|
9
10
|
import { useCallback, useMemo, useRef, useState } from 'react'
|
|
10
11
|
import { cn } from '../../lib/utils'
|
|
@@ -14,9 +15,18 @@ import type { CellCoords, CellKey, CellRegistration, RenderSectionHeader } from
|
|
|
14
15
|
import { cellKey } from './types'
|
|
15
16
|
import { useDataGridKeyboard } from './use-data-grid-keyboard'
|
|
16
17
|
|
|
17
|
-
|
|
18
|
+
// The grid renders rows as given — no sorting, filtering or column state — so
|
|
19
|
+
// it needs only the table's core features.
|
|
20
|
+
const dataGridFeatures = tableFeatures({})
|
|
21
|
+
type DataGridFeatures = typeof dataGridFeatures
|
|
22
|
+
|
|
23
|
+
/** A column of a `DataGrid`. */
|
|
24
|
+
// biome-ignore lint/suspicious/noExplicitAny: each column reads a value of its own type
|
|
25
|
+
export type DataGridColumn<T extends RowData> = ColumnDef<DataGridFeatures, T, any>
|
|
26
|
+
|
|
27
|
+
interface DataGridProps<T extends RowData> {
|
|
18
28
|
rows: T[]
|
|
19
|
-
columns:
|
|
29
|
+
columns: DataGridColumn<T>[]
|
|
20
30
|
getRowId: (row: T) => string
|
|
21
31
|
/** Optional grouping. Return a node spanning all columns to render this row
|
|
22
32
|
* as a (non-editable) section header. Return null to render the row as
|
|
@@ -39,7 +49,7 @@ interface DataGridProps<T> {
|
|
|
39
49
|
scrollable?: boolean
|
|
40
50
|
}
|
|
41
51
|
|
|
42
|
-
export function DataGrid<T>({
|
|
52
|
+
export function DataGrid<T extends RowData>({
|
|
43
53
|
rows,
|
|
44
54
|
columns,
|
|
45
55
|
getRowId,
|
|
@@ -48,11 +58,11 @@ export function DataGrid<T>({
|
|
|
48
58
|
'aria-label': ariaLabel,
|
|
49
59
|
scrollable = false,
|
|
50
60
|
}: DataGridProps<T>) {
|
|
51
|
-
const table =
|
|
61
|
+
const table = useTable({
|
|
62
|
+
features: dataGridFeatures,
|
|
52
63
|
data: rows,
|
|
53
64
|
columns,
|
|
54
65
|
getRowId,
|
|
55
|
-
getCoreRowModel: getCoreRowModel(),
|
|
56
66
|
})
|
|
57
67
|
|
|
58
68
|
return (
|
|
@@ -66,14 +76,14 @@ export function DataGrid<T>({
|
|
|
66
76
|
)
|
|
67
77
|
}
|
|
68
78
|
|
|
69
|
-
function DataGridShell<T>({
|
|
79
|
+
function DataGridShell<T extends RowData>({
|
|
70
80
|
table,
|
|
71
81
|
renderSectionHeader,
|
|
72
82
|
className,
|
|
73
83
|
ariaLabel,
|
|
74
84
|
scrollable,
|
|
75
85
|
}: {
|
|
76
|
-
table: Table<T>
|
|
86
|
+
table: Table<DataGridFeatures, T>
|
|
77
87
|
renderSectionHeader?: RenderSectionHeader<T>
|
|
78
88
|
className?: string
|
|
79
89
|
ariaLabel?: string
|
|
@@ -324,12 +334,12 @@ function DataGridShell<T>({
|
|
|
324
334
|
)
|
|
325
335
|
}
|
|
326
336
|
|
|
327
|
-
function DataGridRow<T>({
|
|
337
|
+
function DataGridRow<T extends RowData>({
|
|
328
338
|
row,
|
|
329
339
|
renderSectionHeader,
|
|
330
340
|
columnCount,
|
|
331
341
|
}: {
|
|
332
|
-
row: Row<T>
|
|
342
|
+
row: Row<DataGridFeatures, T>
|
|
333
343
|
renderSectionHeader?: RenderSectionHeader<T>
|
|
334
344
|
columnCount: number
|
|
335
345
|
}) {
|
|
@@ -345,7 +355,7 @@ function DataGridRow<T>({
|
|
|
345
355
|
}
|
|
346
356
|
return (
|
|
347
357
|
<tr>
|
|
348
|
-
{row.
|
|
358
|
+
{row.getAllCells().map((cell) => (
|
|
349
359
|
<td key={cell.id} className="h-9 p-0 align-middle">
|
|
350
360
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
351
361
|
</td>
|
|
@@ -8,6 +8,6 @@ export {
|
|
|
8
8
|
TextCell,
|
|
9
9
|
} from './cells'
|
|
10
10
|
export { useDataGridContext } from './context'
|
|
11
|
-
export { DataGrid } from './data-grid'
|
|
11
|
+
export { DataGrid, type DataGridColumn } from './data-grid'
|
|
12
12
|
export { editableRowIndex } from './helpers'
|
|
13
13
|
export type { CellCoords, RenderSectionHeader } from './types'
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ReactNode } from 'react'
|
|
2
|
+
import { Item, ItemActions, ItemContent, ItemDescription, ItemTitle } from '../ui/item'
|
|
2
3
|
import { Thumbnail } from '../ui/thumbnail'
|
|
3
4
|
import { PackageIcon } from './icons'
|
|
4
5
|
|
|
@@ -30,17 +31,17 @@ export type FulfillmentItemRowData = {
|
|
|
30
31
|
*/
|
|
31
32
|
function ItemRow({ row }: { row: FulfillmentItemRowData }) {
|
|
32
33
|
return (
|
|
33
|
-
<
|
|
34
|
+
<Item className="flex-nowrap gap-3 rounded-none border-0 p-3">
|
|
34
35
|
<Thumbnail src={row.thumbnailUrl} fallback={<PackageIcon />} />
|
|
35
36
|
|
|
36
|
-
<
|
|
37
|
-
<
|
|
37
|
+
<ItemContent className="min-w-0 gap-0">
|
|
38
|
+
<ItemTitle className="block truncate">{row.name}</ItemTitle>
|
|
38
39
|
{row.optionsText && (
|
|
39
|
-
<
|
|
40
|
+
<ItemDescription className="line-clamp-1 text-xs">{row.optionsText}</ItemDescription>
|
|
40
41
|
)}
|
|
41
|
-
</
|
|
42
|
+
</ItemContent>
|
|
42
43
|
|
|
43
|
-
<
|
|
44
|
+
<ItemActions className="min-w-0 shrink flex-col items-end gap-0 text-right text-muted-foreground text-sm">
|
|
44
45
|
{row.displayPrice ? (
|
|
45
46
|
<span className="whitespace-nowrap">
|
|
46
47
|
{row.displayPrice} × {row.quantity}
|
|
@@ -48,9 +49,9 @@ function ItemRow({ row }: { row: FulfillmentItemRowData }) {
|
|
|
48
49
|
) : (
|
|
49
50
|
<span className="whitespace-nowrap">× {row.quantity}</span>
|
|
50
51
|
)}
|
|
51
|
-
{row.priceSource && <div className="truncate text-xs">{row.priceSource}</div>}
|
|
52
|
-
</
|
|
53
|
-
</
|
|
52
|
+
{row.priceSource && <div className="max-w-full truncate text-xs">{row.priceSource}</div>}
|
|
53
|
+
</ItemActions>
|
|
54
|
+
</Item>
|
|
54
55
|
)
|
|
55
56
|
}
|
|
56
57
|
|
package/src/spree/icons.ts
CHANGED
|
@@ -21,9 +21,11 @@ export {
|
|
|
21
21
|
BellIcon,
|
|
22
22
|
BoldIcon,
|
|
23
23
|
BookOpenIcon,
|
|
24
|
+
BotIcon,
|
|
24
25
|
BoxesIcon,
|
|
25
26
|
BracesIcon,
|
|
26
27
|
Building2Icon,
|
|
28
|
+
CalculatorIcon,
|
|
27
29
|
CalendarIcon,
|
|
28
30
|
CalendarOffIcon,
|
|
29
31
|
ChartColumnIcon,
|
|
@@ -36,6 +38,7 @@ export {
|
|
|
36
38
|
ChevronsLeftIcon,
|
|
37
39
|
ChevronsRightIcon,
|
|
38
40
|
ChevronsUpDownIcon,
|
|
41
|
+
CircleAlertIcon,
|
|
39
42
|
CircleCheckIcon,
|
|
40
43
|
CircleDotIcon,
|
|
41
44
|
CircleIcon,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useTranslation } from 'react-i18next'
|
|
2
2
|
import { Field, FieldLabel } from '../ui/field'
|
|
3
3
|
import { Input } from '../ui/input'
|
|
4
|
+
import { Item, ItemActions, ItemContent, ItemTitle } from '../ui/item'
|
|
4
5
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
|
|
5
6
|
|
|
6
7
|
/** A unit on the order that a return, exchange or claim can name. */
|
|
@@ -31,26 +32,27 @@ export function QuantityPicker({
|
|
|
31
32
|
return (
|
|
32
33
|
<div className="flex flex-col gap-2">
|
|
33
34
|
{units.map((unit) => (
|
|
34
|
-
<
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
<
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
35
|
+
<Item key={unit.id} variant="outline" className="flex-nowrap border-border-subtle">
|
|
36
|
+
<ItemContent className="min-w-0">
|
|
37
|
+
<ItemTitle className="block truncate font-normal">{unit.label}</ItemTitle>
|
|
38
|
+
</ItemContent>
|
|
39
|
+
<ItemActions>
|
|
40
|
+
<Input
|
|
41
|
+
type="number"
|
|
42
|
+
min={0}
|
|
43
|
+
max={unit.quantity}
|
|
44
|
+
className="w-20"
|
|
45
|
+
value={selection[unit.id] ?? 0}
|
|
46
|
+
aria-label={unit.label}
|
|
47
|
+
onChange={(event) =>
|
|
48
|
+
onChange({
|
|
49
|
+
...selection,
|
|
50
|
+
[unit.id]: Math.max(0, Math.min(Number(event.target.value), unit.quantity)),
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
/>
|
|
54
|
+
</ItemActions>
|
|
55
|
+
</Item>
|
|
54
56
|
))}
|
|
55
57
|
</div>
|
|
56
58
|
)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type ReactNode, useState } from 'react'
|
|
2
2
|
import {
|
|
3
3
|
Combobox,
|
|
4
4
|
ComboboxContent,
|
|
@@ -84,14 +84,30 @@ export function ResourceCombobox<T extends ComboboxOption>({
|
|
|
84
84
|
disabled,
|
|
85
85
|
id,
|
|
86
86
|
}: ResourceComboboxProps<T>) {
|
|
87
|
-
|
|
87
|
+
// The picked record is remembered so it stays selected while `items` is
|
|
88
|
+
// between fetches. Without it, the selection drops out of the list for a
|
|
89
|
+
// moment, Base UI reads that as the value being cleared, empties the input,
|
|
90
|
+
// and the field flickers between labels until the queries settle.
|
|
91
|
+
const [picked, setPicked] = useState<T | null>(null)
|
|
92
|
+
const selected =
|
|
93
|
+
items.find((r) => r.id === value) ?? (picked && picked.id === value ? picked : null)
|
|
88
94
|
|
|
89
95
|
return (
|
|
90
96
|
<Combobox
|
|
91
97
|
items={items}
|
|
92
98
|
value={selected}
|
|
93
|
-
|
|
94
|
-
|
|
99
|
+
// Compared by id: a refetch hands back equal records as new objects.
|
|
100
|
+
isItemEqualToValue={(item: T, current: T) => item.id === current.id}
|
|
101
|
+
onValueChange={(record: T | null) => {
|
|
102
|
+
setPicked(record)
|
|
103
|
+
onChange(record?.id, record)
|
|
104
|
+
}}
|
|
105
|
+
// Picking an option writes its label into the input. That is not a
|
|
106
|
+
// search, so the query goes back to the empty list instead of searching
|
|
107
|
+
// for the full label of what was just chosen.
|
|
108
|
+
onInputValueChange={(next, details) =>
|
|
109
|
+
onInputChange?.(details.reason === 'item-press' ? '' : next)
|
|
110
|
+
}
|
|
95
111
|
itemToStringLabel={(record: T | null) => (record ? getOptionLabel(record) : '')}
|
|
96
112
|
itemToStringValue={(record: T | null) => record?.id ?? ''}
|
|
97
113
|
// Filtering is server-side: the caller's `onInputChange` already narrows
|
|
@@ -2,7 +2,8 @@ import { useState } from 'react'
|
|
|
2
2
|
import { useTranslation } from 'react-i18next'
|
|
3
3
|
import { Button } from '../ui/button'
|
|
4
4
|
import { Field, FieldLabel } from '../ui/field'
|
|
5
|
-
import {
|
|
5
|
+
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '../ui/input-group'
|
|
6
|
+
import { EyeIcon, EyeOffIcon } from './icons'
|
|
6
7
|
|
|
7
8
|
// Server masks stored secrets as `••••…` on read so admins can see "yes,
|
|
8
9
|
// something is stored" without exposing the actual value. Detecting that
|
|
@@ -35,7 +36,9 @@ interface SecretInputProps {
|
|
|
35
36
|
* mask (`••••3K9z`) and renders as a read-only badge with a "Replace"
|
|
36
37
|
* button — never as an input, and never with a reveal toggle, because the
|
|
37
38
|
* server does not send the secret and there is nothing to unhide. Replace
|
|
38
|
-
* switches to an empty password input so the admin opts in to rotation;
|
|
39
|
+
* switches to an empty password input so the admin opts in to rotation; that
|
|
40
|
+
* input does carry a reveal toggle, since what it hides is what the admin is
|
|
41
|
+
* typing right now;
|
|
39
42
|
* Cancel reverts to the badge so the next save preserves the existing
|
|
40
43
|
* secret via the backend's masked round-trip guard.
|
|
41
44
|
*
|
|
@@ -57,6 +60,7 @@ export function SecretInput({
|
|
|
57
60
|
// after `onChange('')` has cleared the parent value.
|
|
58
61
|
const [pendingMask, setPendingMask] = useState<string | null>(null)
|
|
59
62
|
const replacing = pendingMask !== null
|
|
63
|
+
const [revealed, setRevealed] = useState(false)
|
|
60
64
|
|
|
61
65
|
// A stored secret always renders as the badge: the server sent a mask,
|
|
62
66
|
// not the value, so there is nothing a reveal toggle could show.
|
|
@@ -93,17 +97,29 @@ export function SecretInput({
|
|
|
93
97
|
<Field>
|
|
94
98
|
<FieldLabel htmlFor={id}>{label}</FieldLabel>
|
|
95
99
|
<div className="flex items-center gap-2">
|
|
96
|
-
<
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
100
|
+
<InputGroup className="flex-1">
|
|
101
|
+
<InputGroupInput
|
|
102
|
+
id={id}
|
|
103
|
+
type={revealed ? 'text' : 'password'}
|
|
104
|
+
autoComplete="new-password"
|
|
105
|
+
spellCheck={false}
|
|
106
|
+
placeholder={
|
|
107
|
+
replacing ? t('admin.components.secret_input.new_value_placeholder') : placeholder
|
|
108
|
+
}
|
|
109
|
+
value={(value as string) ?? ''}
|
|
110
|
+
onChange={(e) => onChange(e.target.value)}
|
|
111
|
+
/>
|
|
112
|
+
<InputGroupAddon align="inline-end">
|
|
113
|
+
<InputGroupButton
|
|
114
|
+
size="icon-xs"
|
|
115
|
+
aria-label={revealed ? t('admin.actions.hide') : t('admin.actions.show')}
|
|
116
|
+
aria-pressed={revealed}
|
|
117
|
+
onClick={() => setRevealed((visible) => !visible)}
|
|
118
|
+
>
|
|
119
|
+
{revealed ? <EyeOffIcon /> : <EyeIcon />}
|
|
120
|
+
</InputGroupButton>
|
|
121
|
+
</InputGroupAddon>
|
|
122
|
+
</InputGroup>
|
|
107
123
|
{replacing && (
|
|
108
124
|
<Button
|
|
109
125
|
type="button"
|
|
@@ -15,17 +15,20 @@ import {
|
|
|
15
15
|
ComboboxInput,
|
|
16
16
|
ComboboxItem,
|
|
17
17
|
ComboboxList,
|
|
18
|
-
DropdownMenu,
|
|
19
|
-
DropdownMenuContent,
|
|
20
|
-
DropdownMenuItem,
|
|
21
|
-
DropdownMenuTrigger,
|
|
22
18
|
Field,
|
|
23
19
|
FieldDescription,
|
|
24
20
|
FieldError,
|
|
25
21
|
FieldGroup,
|
|
26
22
|
FieldLabel,
|
|
27
23
|
Input,
|
|
24
|
+
Item,
|
|
25
|
+
ItemActions,
|
|
26
|
+
ItemContent,
|
|
27
|
+
ItemDescription,
|
|
28
|
+
ItemGroup,
|
|
29
|
+
ItemTitle,
|
|
28
30
|
RelativeTime,
|
|
31
|
+
RowActions,
|
|
29
32
|
Sheet,
|
|
30
33
|
SheetContent,
|
|
31
34
|
SheetDescription,
|
|
@@ -35,7 +38,7 @@ import {
|
|
|
35
38
|
StatusBadge,
|
|
36
39
|
useConfirm,
|
|
37
40
|
} from '../index'
|
|
38
|
-
import { BadgeCheckIcon,
|
|
41
|
+
import { BadgeCheckIcon, PlusIcon } from './icons'
|
|
39
42
|
|
|
40
43
|
/** One selectable registration kind. */
|
|
41
44
|
interface KindOption {
|
|
@@ -153,81 +156,71 @@ export function TaxIdentifiersCard({
|
|
|
153
156
|
<p className="text-muted-foreground text-sm">{t('admin.tax_identifiers.empty')}</p>
|
|
154
157
|
</CardContent>
|
|
155
158
|
) : (
|
|
156
|
-
<CardContent
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
key={identifier.id}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
defaultValue: identifier.kind,
|
|
168
|
-
})}
|
|
169
|
-
</Badge>
|
|
170
|
-
{identifier.validation_status ? (
|
|
171
|
-
<StatusBadge
|
|
172
|
-
status={identifier.validation_status}
|
|
173
|
-
label={t(
|
|
174
|
-
`admin.tax_identifiers.validation_status.${identifier.validation_status}`,
|
|
175
|
-
{ defaultValue: identifier.validation_status },
|
|
176
|
-
)}
|
|
177
|
-
/>
|
|
178
|
-
) : (
|
|
179
|
-
<Badge variant="secondary">
|
|
180
|
-
{t('admin.tax_identifiers.validation_status.not_checked')}
|
|
159
|
+
<CardContent>
|
|
160
|
+
<ItemGroup className="gap-3">
|
|
161
|
+
{identifiers.map((identifier) => (
|
|
162
|
+
<Item key={identifier.id} role="listitem" variant="outline" className="items-start">
|
|
163
|
+
<ItemContent className="min-w-0">
|
|
164
|
+
<ItemTitle className="flex-wrap">
|
|
165
|
+
{identifier.value}
|
|
166
|
+
<Badge variant="outline">
|
|
167
|
+
{t(`admin.tax_identifiers.kinds.${identifier.kind}`, {
|
|
168
|
+
defaultValue: identifier.kind,
|
|
169
|
+
})}
|
|
181
170
|
</Badge>
|
|
171
|
+
{identifier.validation_status ? (
|
|
172
|
+
<StatusBadge
|
|
173
|
+
status={identifier.validation_status}
|
|
174
|
+
label={t(
|
|
175
|
+
`admin.tax_identifiers.validation_status.${identifier.validation_status}`,
|
|
176
|
+
{ defaultValue: identifier.validation_status },
|
|
177
|
+
)}
|
|
178
|
+
/>
|
|
179
|
+
) : (
|
|
180
|
+
<Badge variant="secondary">
|
|
181
|
+
{t('admin.tax_identifiers.validation_status.not_checked')}
|
|
182
|
+
</Badge>
|
|
183
|
+
)}
|
|
184
|
+
</ItemTitle>
|
|
185
|
+
{identifier.validated_at && (
|
|
186
|
+
<ItemDescription className="text-xs">
|
|
187
|
+
<RelativeTime
|
|
188
|
+
iso={identifier.validated_at}
|
|
189
|
+
prefix={t('admin.tax_identifiers.checked_prefix')}
|
|
190
|
+
/>
|
|
191
|
+
</ItemDescription>
|
|
192
|
+
)}
|
|
193
|
+
{!identifier.validatable && (
|
|
194
|
+
<ItemDescription className="text-xs">
|
|
195
|
+
{t('admin.tax_identifiers.not_validatable')}
|
|
196
|
+
</ItemDescription>
|
|
182
197
|
)}
|
|
183
|
-
</
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
198
|
+
</ItemContent>
|
|
199
|
+
|
|
200
|
+
{canEdit && (
|
|
201
|
+
<ItemActions>
|
|
202
|
+
<RowActions
|
|
203
|
+
actions={[
|
|
204
|
+
{
|
|
205
|
+
key: 'validate',
|
|
206
|
+
label: t('admin.tax_identifiers.validate_action'),
|
|
207
|
+
icon: <BadgeCheckIcon className="size-4" />,
|
|
208
|
+
disabled: !identifier.validatable || mutations.isValidating,
|
|
209
|
+
onSelect: () => mutations.validate(identifier.id).catch(() => undefined),
|
|
210
|
+
},
|
|
211
|
+
{ key: 'edit', onSelect: () => setEditing(identifier) },
|
|
212
|
+
{
|
|
213
|
+
key: 'delete',
|
|
214
|
+
destructive: true,
|
|
215
|
+
onSelect: () => handleDelete(identifier),
|
|
216
|
+
},
|
|
217
|
+
]}
|
|
189
218
|
/>
|
|
190
|
-
</
|
|
191
|
-
)}
|
|
192
|
-
{!identifier.validatable && (
|
|
193
|
-
<span className="text-muted-foreground text-xs">
|
|
194
|
-
{t('admin.tax_identifiers.not_validatable')}
|
|
195
|
-
</span>
|
|
219
|
+
</ItemActions>
|
|
196
220
|
)}
|
|
197
|
-
</
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
<DropdownMenu>
|
|
201
|
-
<DropdownMenuTrigger asChild>
|
|
202
|
-
<Button variant="ghost" size="icon-xs">
|
|
203
|
-
<EllipsisVerticalIcon className="size-4" />
|
|
204
|
-
<span className="sr-only">{t('admin.actions.actions_menu')}</span>
|
|
205
|
-
</Button>
|
|
206
|
-
</DropdownMenuTrigger>
|
|
207
|
-
<DropdownMenuContent align="end">
|
|
208
|
-
<DropdownMenuItem
|
|
209
|
-
disabled={!identifier.validatable || mutations.isValidating}
|
|
210
|
-
onClick={() => mutations.validate(identifier.id).catch(() => undefined)}
|
|
211
|
-
>
|
|
212
|
-
<BadgeCheckIcon className="size-4" />
|
|
213
|
-
{t('admin.tax_identifiers.validate_action')}
|
|
214
|
-
</DropdownMenuItem>
|
|
215
|
-
<DropdownMenuItem onClick={() => setEditing(identifier)}>
|
|
216
|
-
<PencilIcon className="size-4" />
|
|
217
|
-
{t('admin.actions.edit')}
|
|
218
|
-
</DropdownMenuItem>
|
|
219
|
-
<DropdownMenuItem
|
|
220
|
-
className="text-destructive focus:text-destructive"
|
|
221
|
-
onClick={() => handleDelete(identifier)}
|
|
222
|
-
>
|
|
223
|
-
<TrashIcon className="size-4" />
|
|
224
|
-
{t('admin.actions.delete')}
|
|
225
|
-
</DropdownMenuItem>
|
|
226
|
-
</DropdownMenuContent>
|
|
227
|
-
</DropdownMenu>
|
|
228
|
-
)}
|
|
229
|
-
</div>
|
|
230
|
-
))}
|
|
221
|
+
</Item>
|
|
222
|
+
))}
|
|
223
|
+
</ItemGroup>
|
|
231
224
|
</CardContent>
|
|
232
225
|
)}
|
|
233
226
|
|
package/src/spree/wizard.tsx
CHANGED
|
@@ -188,13 +188,7 @@ export function Wizard({
|
|
|
188
188
|
}}
|
|
189
189
|
modal
|
|
190
190
|
>
|
|
191
|
-
<DialogContent
|
|
192
|
-
// Edge-to-edge minus a gutter, like the import wizard and the bulk
|
|
193
|
-
// price editor — every inset/translate/max is overridden.
|
|
194
|
-
className="!inset-3 !w-auto !max-w-none !translate-x-0 !translate-y-0 flex flex-col p-0"
|
|
195
|
-
style={{ maxHeight: 'none' }}
|
|
196
|
-
showCloseButton={false}
|
|
197
|
-
>
|
|
191
|
+
<DialogContent fullScreen showCloseButton={false}>
|
|
198
192
|
<DialogHeader className="h-14 shrink-0 flex-row items-center gap-3 space-y-0 border-b border-border-subtle bg-background px-4 py-0">
|
|
199
193
|
<div className="flex min-w-0 items-center gap-3">
|
|
200
194
|
<DialogTitle className="truncate text-base">{title}</DialogTitle>
|
package/src/ui/dialog.tsx
CHANGED
|
@@ -95,20 +95,29 @@ function DialogContent({
|
|
|
95
95
|
className,
|
|
96
96
|
children,
|
|
97
97
|
showCloseButton = true,
|
|
98
|
+
fullScreen = false,
|
|
98
99
|
...props
|
|
99
100
|
}: React.ComponentProps<typeof DialogPrimitive.Popup> & {
|
|
100
101
|
showCloseButton?: boolean
|
|
102
|
+
/**
|
|
103
|
+
* Fills the window minus a small gutter, over an opaque overlay — for
|
|
104
|
+
* wizards and editors that replace the page rather than sit on top of it.
|
|
105
|
+
* A see-through overlay would let the page's own panel edges show around
|
|
106
|
+
* the dialog as a second border.
|
|
107
|
+
*/
|
|
108
|
+
fullScreen?: boolean
|
|
101
109
|
}) {
|
|
102
110
|
return (
|
|
103
111
|
<DialogPortal>
|
|
104
|
-
<DialogOverlay />
|
|
112
|
+
<DialogOverlay className={cn(fullScreen && 'bg-background dark:bg-background')} />
|
|
105
113
|
<DialogPrimitive.Popup
|
|
106
114
|
data-slot="dialog-content"
|
|
107
115
|
className={cn(
|
|
108
116
|
'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]',
|
|
117
|
+
fullScreen && 'inset-3 w-auto max-w-none translate-x-0 translate-y-0 sm:max-w-none',
|
|
109
118
|
className,
|
|
110
119
|
)}
|
|
111
|
-
style={{ maxHeight: '90vh' }}
|
|
120
|
+
style={fullScreen ? undefined : { maxHeight: '90vh' }}
|
|
112
121
|
{...props}
|
|
113
122
|
>
|
|
114
123
|
{children}
|
package/src/ui/item.tsx
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { mergeProps } from '@base-ui/react/merge-props'
|
|
2
|
+
import { useRender } from '@base-ui/react/use-render'
|
|
3
|
+
import { cva, type VariantProps } from 'class-variance-authority'
|
|
4
|
+
import * as React from 'react'
|
|
5
|
+
import { cn } from '../lib/utils'
|
|
6
|
+
|
|
7
|
+
import { Separator } from './separator'
|
|
8
|
+
|
|
9
|
+
function ItemGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
|
10
|
+
return (
|
|
11
|
+
<div
|
|
12
|
+
role="list"
|
|
13
|
+
data-slot="item-group"
|
|
14
|
+
className={cn(
|
|
15
|
+
'group/item-group flex w-full flex-col gap-4 has-data-[size=sm]:gap-2.5 has-data-[size=xs]:gap-2',
|
|
16
|
+
className,
|
|
17
|
+
)}
|
|
18
|
+
{...props}
|
|
19
|
+
/>
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function ItemSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {
|
|
24
|
+
return (
|
|
25
|
+
<Separator
|
|
26
|
+
data-slot="item-separator"
|
|
27
|
+
orientation="horizontal"
|
|
28
|
+
className={cn('my-2', className)}
|
|
29
|
+
{...props}
|
|
30
|
+
/>
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const itemVariants = cva(
|
|
35
|
+
'group/item flex w-full flex-wrap items-center rounded-lg border text-sm transition-colors duration-100 outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 [a]:transition-colors [a]:hover:bg-muted',
|
|
36
|
+
{
|
|
37
|
+
variants: {
|
|
38
|
+
variant: {
|
|
39
|
+
default: 'border-transparent',
|
|
40
|
+
outline: 'border-border',
|
|
41
|
+
muted: 'border-transparent bg-muted/50',
|
|
42
|
+
},
|
|
43
|
+
size: {
|
|
44
|
+
default: 'gap-2.5 px-3 py-2.5',
|
|
45
|
+
sm: 'gap-2.5 px-3 py-2.5',
|
|
46
|
+
xs: 'gap-2 px-2.5 py-2 in-data-[slot=dropdown-menu-content]:p-0',
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
defaultVariants: {
|
|
50
|
+
variant: 'default',
|
|
51
|
+
size: 'default',
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
function Item({
|
|
57
|
+
className,
|
|
58
|
+
variant = 'default',
|
|
59
|
+
size = 'default',
|
|
60
|
+
render,
|
|
61
|
+
...props
|
|
62
|
+
}: useRender.ComponentProps<'div'> & VariantProps<typeof itemVariants>) {
|
|
63
|
+
return useRender({
|
|
64
|
+
defaultTagName: 'div',
|
|
65
|
+
props: mergeProps<'div'>(
|
|
66
|
+
{
|
|
67
|
+
className: cn(itemVariants({ variant, size, className })),
|
|
68
|
+
},
|
|
69
|
+
props,
|
|
70
|
+
),
|
|
71
|
+
render,
|
|
72
|
+
state: {
|
|
73
|
+
slot: 'item',
|
|
74
|
+
variant,
|
|
75
|
+
size,
|
|
76
|
+
},
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const itemMediaVariants = cva(
|
|
81
|
+
'flex shrink-0 items-center justify-center gap-2 group-has-data-[slot=item-description]/item:translate-y-0.5 group-has-data-[slot=item-description]/item:self-start [&_svg]:pointer-events-none',
|
|
82
|
+
{
|
|
83
|
+
variants: {
|
|
84
|
+
variant: {
|
|
85
|
+
default: 'bg-transparent',
|
|
86
|
+
icon: "[&_svg:not([class*='size-'])]:size-4",
|
|
87
|
+
image:
|
|
88
|
+
'size-10 overflow-hidden rounded-sm group-data-[size=sm]/item:size-8 group-data-[size=xs]/item:size-6 [&_img]:size-full [&_img]:object-cover',
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
defaultVariants: {
|
|
92
|
+
variant: 'default',
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
function ItemMedia({
|
|
98
|
+
className,
|
|
99
|
+
variant = 'default',
|
|
100
|
+
...props
|
|
101
|
+
}: React.ComponentProps<'div'> & VariantProps<typeof itemMediaVariants>) {
|
|
102
|
+
return (
|
|
103
|
+
<div
|
|
104
|
+
data-slot="item-media"
|
|
105
|
+
data-variant={variant}
|
|
106
|
+
className={cn(itemMediaVariants({ variant, className }))}
|
|
107
|
+
{...props}
|
|
108
|
+
/>
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function ItemContent({ className, ...props }: React.ComponentProps<'div'>) {
|
|
113
|
+
return (
|
|
114
|
+
<div
|
|
115
|
+
data-slot="item-content"
|
|
116
|
+
className={cn(
|
|
117
|
+
'flex flex-1 flex-col gap-1 group-data-[size=xs]/item:gap-0 [&+[data-slot=item-content]]:flex-none',
|
|
118
|
+
className,
|
|
119
|
+
)}
|
|
120
|
+
{...props}
|
|
121
|
+
/>
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function ItemTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
|
126
|
+
return (
|
|
127
|
+
<div
|
|
128
|
+
data-slot="item-title"
|
|
129
|
+
className={cn(
|
|
130
|
+
'line-clamp-1 flex w-fit items-center gap-2 text-sm leading-snug font-medium underline-offset-4',
|
|
131
|
+
className,
|
|
132
|
+
)}
|
|
133
|
+
{...props}
|
|
134
|
+
/>
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function ItemDescription({ className, ...props }: React.ComponentProps<'p'>) {
|
|
139
|
+
return (
|
|
140
|
+
<p
|
|
141
|
+
data-slot="item-description"
|
|
142
|
+
className={cn(
|
|
143
|
+
'line-clamp-2 text-left text-sm leading-normal font-normal text-muted-foreground group-data-[size=xs]/item:text-xs [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary',
|
|
144
|
+
className,
|
|
145
|
+
)}
|
|
146
|
+
{...props}
|
|
147
|
+
/>
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function ItemActions({ className, ...props }: React.ComponentProps<'div'>) {
|
|
152
|
+
return (
|
|
153
|
+
<div data-slot="item-actions" className={cn('flex items-center gap-2', className)} {...props} />
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function ItemHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
|
158
|
+
return (
|
|
159
|
+
<div
|
|
160
|
+
data-slot="item-header"
|
|
161
|
+
className={cn('flex basis-full items-center justify-between gap-2', className)}
|
|
162
|
+
{...props}
|
|
163
|
+
/>
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function ItemFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
|
168
|
+
return (
|
|
169
|
+
<div
|
|
170
|
+
data-slot="item-footer"
|
|
171
|
+
className={cn('flex basis-full items-center justify-between gap-2', className)}
|
|
172
|
+
{...props}
|
|
173
|
+
/>
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export {
|
|
178
|
+
Item,
|
|
179
|
+
ItemActions,
|
|
180
|
+
ItemContent,
|
|
181
|
+
ItemDescription,
|
|
182
|
+
ItemFooter,
|
|
183
|
+
ItemGroup,
|
|
184
|
+
ItemHeader,
|
|
185
|
+
ItemMedia,
|
|
186
|
+
ItemSeparator,
|
|
187
|
+
ItemTitle,
|
|
188
|
+
}
|
package/src/ui/toast.tsx
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
TriangleAlertIcon,
|
|
13
13
|
XIcon,
|
|
14
14
|
} from '../spree/icons'
|
|
15
|
+
import { Button } from './button'
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* App-wide toast manager. Created outside React so a toast can be raised from
|
|
@@ -26,18 +27,89 @@ import {
|
|
|
26
27
|
export const toastManager = ToastPrimitive.createToastManager()
|
|
27
28
|
|
|
28
29
|
const ICONS: Record<string, React.ReactNode> = {
|
|
29
|
-
success: <CircleCheckIcon className="
|
|
30
|
-
error: <OctagonXIcon className="
|
|
31
|
-
warning: <TriangleAlertIcon className="
|
|
32
|
-
info: <InfoIcon className="
|
|
33
|
-
loading: <Loader2Icon className="
|
|
30
|
+
success: <CircleCheckIcon className="text-emerald-600 dark:text-emerald-400" />,
|
|
31
|
+
error: <OctagonXIcon className="text-destructive" />,
|
|
32
|
+
warning: <TriangleAlertIcon className="text-amber-600 dark:text-amber-400" />,
|
|
33
|
+
info: <InfoIcon className="text-muted-foreground" />,
|
|
34
|
+
loading: <Loader2Icon className="animate-spin text-muted-foreground" />,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function ToastViewport({ className, ...props }: ToastPrimitive.Viewport.Props) {
|
|
38
|
+
return (
|
|
39
|
+
<ToastPrimitive.Viewport
|
|
40
|
+
data-slot="toast-viewport"
|
|
41
|
+
className={cn(
|
|
42
|
+
// `z-40`, below the overlay layer (`z-50`) rather than above
|
|
43
|
+
// everything: the stack sits bottom-right, which is exactly where a
|
|
44
|
+
// side sheet's footer buttons are, and a toast drifting over Save is
|
|
45
|
+
// both unclickable and a click the merchant did not intend.
|
|
46
|
+
'pointer-events-none fixed inset-x-4 bottom-[max(1rem,env(safe-area-inset-bottom))] z-40 mx-auto w-auto max-w-sm outline-none sm:right-4 sm:left-auto sm:mx-0 sm:w-full',
|
|
47
|
+
className,
|
|
48
|
+
)}
|
|
49
|
+
{...props}
|
|
50
|
+
/>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function Toast({ className, ...props }: ToastPrimitive.Root.Props) {
|
|
55
|
+
return (
|
|
56
|
+
<ToastPrimitive.Root
|
|
57
|
+
data-slot="toast"
|
|
58
|
+
className={cn(
|
|
59
|
+
'group/toast pointer-events-auto absolute right-0 bottom-0 z-[calc(1000-var(--toast-index))] w-full origin-bottom rounded-2xl border bg-popover text-popover-foreground shadow-lg will-change-transform outline-none select-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50',
|
|
60
|
+
'[--gap:0.75rem] [--height:var(--toast-frontmost-height,var(--toast-height))] [--offset-y:calc(var(--toast-offset-y)*-1+calc(var(--toast-index)*var(--gap)*-1)+var(--toast-swipe-movement-y))] [--peek:0.75rem] [--scale:calc(max(0,1-(var(--toast-index)*0.1)))] [--shrink:calc(1-var(--scale))]',
|
|
61
|
+
'h-(--height) [transform:translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)-(var(--toast-index)*var(--peek))-(var(--shrink)*var(--height))))_scale(var(--scale))] [transition:transform_500ms_cubic-bezier(0.22,1,0.36,1),opacity_500ms,height_150ms]',
|
|
62
|
+
"after:absolute after:top-full after:left-0 after:h-[calc(var(--gap)+1px)] after:w-full after:content-['']",
|
|
63
|
+
'data-expanded:h-(--toast-height) data-expanded:[transform:translateX(var(--toast-swipe-movement-x))_translateY(var(--offset-y))]',
|
|
64
|
+
'data-limited:opacity-0 data-starting-style:[transform:translateY(150%)]',
|
|
65
|
+
'[&[data-ending-style]:not([data-limited]):not([data-swipe-direction])]:[transform:translateY(150%)]',
|
|
66
|
+
'data-ending-style:data-[swipe-direction=down]:[transform:translateY(calc(var(--toast-swipe-movement-y)+150%))]',
|
|
67
|
+
'data-ending-style:data-[swipe-direction=left]:[transform:translateX(calc(var(--toast-swipe-movement-x)-150%))_translateY(var(--offset-y))]',
|
|
68
|
+
'data-ending-style:data-[swipe-direction=right]:[transform:translateX(calc(var(--toast-swipe-movement-x)+150%))_translateY(var(--offset-y))]',
|
|
69
|
+
'data-ending-style:data-[swipe-direction=up]:[transform:translateY(calc(var(--toast-swipe-movement-y)-150%))]',
|
|
70
|
+
'data-expanded:data-ending-style:data-[swipe-direction=down]:[transform:translateY(calc(var(--toast-swipe-movement-y)+150%))]',
|
|
71
|
+
'data-expanded:data-ending-style:data-[swipe-direction=left]:[transform:translateX(calc(var(--toast-swipe-movement-x)-150%))_translateY(var(--offset-y))]',
|
|
72
|
+
'data-expanded:data-ending-style:data-[swipe-direction=right]:[transform:translateX(calc(var(--toast-swipe-movement-x)+150%))_translateY(var(--offset-y))]',
|
|
73
|
+
'data-expanded:data-ending-style:data-[swipe-direction=up]:[transform:translateY(calc(var(--toast-swipe-movement-y)-150%))]',
|
|
74
|
+
className,
|
|
75
|
+
)}
|
|
76
|
+
{...props}
|
|
77
|
+
/>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function ToastContent({ className, ...props }: ToastPrimitive.Content.Props) {
|
|
82
|
+
return (
|
|
83
|
+
<ToastPrimitive.Content
|
|
84
|
+
data-slot="toast-content"
|
|
85
|
+
className={cn(
|
|
86
|
+
'flex items-center gap-3 overflow-hidden p-4 transition-opacity duration-250 ease-[cubic-bezier(0.22,1,0.36,1)] data-behind:opacity-0 data-expanded:opacity-100',
|
|
87
|
+
className,
|
|
88
|
+
)}
|
|
89
|
+
{...props}
|
|
90
|
+
/>
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function ToastIcon({ type }: { type: string | undefined }) {
|
|
95
|
+
const icon = type ? ICONS[type] : null
|
|
96
|
+
if (!icon) return null
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<span
|
|
100
|
+
data-slot="toast-icon"
|
|
101
|
+
className="shrink-0 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4"
|
|
102
|
+
>
|
|
103
|
+
{icon}
|
|
104
|
+
</span>
|
|
105
|
+
)
|
|
34
106
|
}
|
|
35
107
|
|
|
36
108
|
function ToastList() {
|
|
37
109
|
const { toasts } = ToastPrimitive.useToastManager()
|
|
38
110
|
|
|
39
111
|
return toasts.map((toast) => (
|
|
40
|
-
<
|
|
112
|
+
<Toast
|
|
41
113
|
key={toast.id}
|
|
42
114
|
toast={toast}
|
|
43
115
|
// Base UI types a toast as `dialog`, which puts every transient
|
|
@@ -45,37 +117,31 @@ function ToastList() {
|
|
|
45
117
|
// lookup, ours or a screen reader user's, matches whatever toast happens
|
|
46
118
|
// to be on screen. `status` is what a passive notification should expose.
|
|
47
119
|
role={toast.type === 'error' ? 'alert' : 'status'}
|
|
48
|
-
// `--toast-index` / `--toast-offset-y` drive the stacked-card effect:
|
|
49
|
-
// toasts behind the front one scale down and sit slightly higher.
|
|
50
|
-
className={cn(
|
|
51
|
-
// Normal flow inside a bottom-anchored column, not absolute
|
|
52
|
-
// positioning: a zero-height viewport gives an absolutely-positioned
|
|
53
|
-
// child nothing to sit on, and it renders below the fold.
|
|
54
|
-
'pointer-events-auto w-full',
|
|
55
|
-
'rounded-xl border border-border bg-popover p-4 text-popover-foreground shadow-lg',
|
|
56
|
-
'transition-all duration-200 ease-out',
|
|
57
|
-
'data-[starting-style]:translate-y-2 data-[starting-style]:opacity-0',
|
|
58
|
-
'data-[ending-style]:translate-y-2 data-[ending-style]:opacity-0',
|
|
59
|
-
)}
|
|
60
120
|
>
|
|
61
|
-
<
|
|
62
|
-
{toast.type
|
|
63
|
-
<span className="mt-0.5 shrink-0">{ICONS[toast.type]}</span>
|
|
64
|
-
)}
|
|
121
|
+
<ToastContent>
|
|
122
|
+
<ToastIcon type={toast.type} />
|
|
65
123
|
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
|
66
|
-
<ToastPrimitive.Title className="font-medium text-sm
|
|
67
|
-
<ToastPrimitive.Description
|
|
124
|
+
<ToastPrimitive.Title data-slot="toast-title" className="font-medium text-sm" />
|
|
125
|
+
<ToastPrimitive.Description
|
|
126
|
+
data-slot="toast-description"
|
|
127
|
+
className="text-muted-foreground text-sm"
|
|
128
|
+
/>
|
|
68
129
|
</div>
|
|
130
|
+
<ToastPrimitive.Action
|
|
131
|
+
data-slot="toast-action"
|
|
132
|
+
render={<Button variant="outline" size="sm" />}
|
|
133
|
+
className="shrink-0"
|
|
134
|
+
/>
|
|
69
135
|
<ToastPrimitive.Close
|
|
70
|
-
|
|
71
|
-
// rest of the app follows for icon-only controls.
|
|
72
|
-
className="-mt-1 -mr-1 inline-flex size-7 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
|
136
|
+
data-slot="toast-close"
|
|
73
137
|
aria-label={i18n.t('admin.actions.close')}
|
|
138
|
+
render={<Button variant="ghost" size="icon-sm" />}
|
|
139
|
+
className="relative shrink-0 text-muted-foreground after:absolute after:-inset-2 after:content-[''] hover:text-foreground"
|
|
74
140
|
>
|
|
75
|
-
<XIcon
|
|
141
|
+
<XIcon aria-hidden="true" />
|
|
76
142
|
</ToastPrimitive.Close>
|
|
77
|
-
</
|
|
78
|
-
</
|
|
143
|
+
</ToastContent>
|
|
144
|
+
</Toast>
|
|
79
145
|
))
|
|
80
146
|
}
|
|
81
147
|
|
|
@@ -90,22 +156,10 @@ export function Toaster({ children }: { children?: React.ReactNode }) {
|
|
|
90
156
|
return (
|
|
91
157
|
<ToastPrimitive.Provider toastManager={toastManager}>
|
|
92
158
|
{children}
|
|
93
|
-
<ToastPrimitive.Portal>
|
|
94
|
-
<
|
|
95
|
-
className={cn(
|
|
96
|
-
// Below the overlay layer (`z-50`) rather than above everything:
|
|
97
|
-
// the stack sits bottom-right, which is exactly where a side
|
|
98
|
-
// sheet's footer buttons are, and a toast drifting over Save is
|
|
99
|
-
// both unclickable and a click the merchant did not intend.
|
|
100
|
-
'fixed right-4 z-40 flex w-[min(22rem,calc(100vw-2rem))] flex-col gap-2',
|
|
101
|
-
// `pointer-events-none` on the column so the empty space above the
|
|
102
|
-
// stack never blocks the page; each toast takes them back.
|
|
103
|
-
'pointer-events-none',
|
|
104
|
-
'bottom-[max(1rem,env(safe-area-inset-bottom))]',
|
|
105
|
-
)}
|
|
106
|
-
>
|
|
159
|
+
<ToastPrimitive.Portal data-slot="toast-portal">
|
|
160
|
+
<ToastViewport>
|
|
107
161
|
<ToastList />
|
|
108
|
-
</
|
|
162
|
+
</ToastViewport>
|
|
109
163
|
</ToastPrimitive.Portal>
|
|
110
164
|
</ToastPrimitive.Provider>
|
|
111
165
|
)
|