@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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@spree/dashboard-ui",
3
3
  "license": "MIT",
4
- "version": "1.0.0-rc.3",
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.2.8",
64
- "@fontsource-variable/geist-mono": "5.2.7",
65
- "@tanstack/react-table": "8.21.3",
66
- "@tiptap/core": "3.31.3",
67
- "@tiptap/extension-image": "3.31.3",
68
- "@tiptap/extension-link": "3.31.3",
69
- "@tiptap/extension-placeholder": "3.31.3",
70
- "@tiptap/pm": "3.31.3",
71
- "@tiptap/react": "3.31.3",
72
- "@tiptap/starter-kit": "3.31.3",
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.1.0",
79
+ "date-fns": "4.4.0",
78
80
  "date-fns-tz": "3.2.0",
79
- "flag-icons": "7.5.0",
80
- "lucide-react": "1.47.0",
81
- "react-colorful": "5.7.0",
82
- "react-day-picker": "9.14.0",
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.21.0",
85
- "tailwind-merge": "3.5.0",
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": "~6.0.3",
93
- "vite": "^8.0.16"
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 { Button } from '../ui/button'
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 { EllipsisVerticalIcon, PencilIcon, TrashIcon } from './icons'
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
- <div className="flex items-start justify-between gap-2 rounded-md border p-3">
49
- <div className="flex min-w-0 flex-col gap-1">
50
- <span className="flex flex-wrap items-center gap-2">
51
- {address.label && (
52
- <span className="font-medium text-foreground text-sm">{address.label}</span>
53
- )}
54
- {address.is_default_billing && (
55
- <Badge variant="outline">{t('admin.address_book.default_billing')}</Badge>
56
- )}
57
- {address.is_default_shipping && (
58
- <Badge variant="outline">{t('admin.address_book.default_shipping')}</Badge>
59
- )}
60
- </span>
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
- </div>
56
+ </ItemContent>
63
57
 
64
58
  {canEdit && (
65
- <DropdownMenu>
66
- <DropdownMenuTrigger asChild>
67
- <Button variant="ghost" size="icon-xs" className="shrink-0">
68
- <EllipsisVerticalIcon className="size-4" />
69
- <span className="sr-only">{t('admin.actions.actions_menu')}</span>
70
- </Button>
71
- </DropdownMenuTrigger>
72
- <DropdownMenuContent align="end">
73
- <DropdownMenuItem onClick={onEdit}>
74
- <PencilIcon className="size-4" />
75
- {t('admin.actions.edit')}
76
- </DropdownMenuItem>
77
- {onSetDefaultBilling && !address.is_default_billing && (
78
- <DropdownMenuItem onClick={onSetDefaultBilling}>
79
- {t('admin.address_book.set_default_billing')}
80
- </DropdownMenuItem>
81
- )}
82
- {onSetDefaultShipping && !address.is_default_shipping && (
83
- <DropdownMenuItem onClick={onSetDefaultShipping}>
84
- {t('admin.address_book.set_default_shipping')}
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
- </div>
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 { DataGrid, DecimalCell, editableRowIndex, MoneyCell, ReadOnlyCell } from './data-grid'
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<ColumnDef<BulkPriceRow>[]>(
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 ColumnDef<BulkPriceRow>,
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 ColumnDef<BulkPriceRow>,
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
- <div
316
- role="alert"
317
- className="mx-3 shrink-0 rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-destructive text-sm"
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 { DataGrid, DecimalCell, ReadOnlyCell, SelectCell, SwitchCell, TextCell } from './data-grid'
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<ColumnDef<BulkVariantsRow>[]>(() => {
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: ColumnDef<BulkVariantsRow>[] = [
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<ColumnDef<BulkVariantsRow>>(({ field, col }) => ({
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
- <div
182
- key={line.id}
183
- className="flex items-center justify-between gap-4 rounded-lg border border-border-subtle p-3"
184
- >
185
- <span className="truncate text-sm">
186
- {line.label}
187
- <span className="text-muted-foreground"> ×{line.quantity}</span>
188
- </span>
189
- <Switch
190
- checked={replacing[line.id] ?? false}
191
- onCheckedChange={(checked) =>
192
- setReplacing({ ...replacing, [line.id]: checked })
193
- }
194
- />
195
- </div>
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
- type TableOptions,
7
- useReactTable,
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
- interface DataGridProps<T> {
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: TableOptions<T>['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 = useReactTable<T>({
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.getVisibleCells().map((cell) => (
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
- <div className="flex items-center gap-3 p-3">
34
+ <Item className="flex-nowrap gap-3 rounded-none border-0 p-3">
34
35
  <Thumbnail src={row.thumbnailUrl} fallback={<PackageIcon />} />
35
36
 
36
- <div className="min-w-0 flex-1">
37
- <div className="truncate text-sm font-medium">{row.name}</div>
37
+ <ItemContent className="min-w-0 gap-0">
38
+ <ItemTitle className="block truncate">{row.name}</ItemTitle>
38
39
  {row.optionsText && (
39
- <div className="truncate text-xs text-muted-foreground">{row.optionsText}</div>
40
+ <ItemDescription className="line-clamp-1 text-xs">{row.optionsText}</ItemDescription>
40
41
  )}
41
- </div>
42
+ </ItemContent>
42
43
 
43
- <div className="min-w-0 shrink text-right text-muted-foreground text-sm">
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
- </div>
53
- </div>
52
+ {row.priceSource && <div className="max-w-full truncate text-xs">{row.priceSource}</div>}
53
+ </ItemActions>
54
+ </Item>
54
55
  )
55
56
  }
56
57
 
@@ -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
- <div
35
- key={unit.id}
36
- className="flex items-center justify-between gap-4 rounded-lg border border-border-subtle p-3"
37
- >
38
- <span className="min-w-0 truncate text-sm">{unit.label}</span>
39
- <Input
40
- type="number"
41
- min={0}
42
- max={unit.quantity}
43
- className="w-20"
44
- value={selection[unit.id] ?? 0}
45
- aria-label={unit.label}
46
- onChange={(event) =>
47
- onChange({
48
- ...selection,
49
- [unit.id]: Math.max(0, Math.min(Number(event.target.value), unit.quantity)),
50
- })
51
- }
52
- />
53
- </div>
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 { ReactNode } from 'react'
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
- const selected = items.find((r) => r.id === value) ?? null
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
- onValueChange={(record: T | null) => onChange(record?.id, record)}
94
- onInputValueChange={onInputChange}
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 { Input } from '../ui/input'
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
- <Input
97
- id={id}
98
- type="password"
99
- autoComplete="new-password"
100
- placeholder={
101
- replacing ? t('admin.components.secret_input.new_value_placeholder') : placeholder
102
- }
103
- value={(value as string) ?? ''}
104
- onChange={(e) => onChange(e.target.value)}
105
- className="flex-1"
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, EllipsisVerticalIcon, PencilIcon, PlusIcon, TrashIcon } from './icons'
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 className="flex flex-col gap-3">
157
- {identifiers.map((identifier) => (
158
- <div
159
- key={identifier.id}
160
- className="flex items-start justify-between gap-3 rounded-md border p-3"
161
- >
162
- <div className="flex min-w-0 flex-col gap-1">
163
- <div className="flex flex-wrap items-center gap-2">
164
- <span className="font-medium text-sm">{identifier.value}</span>
165
- <Badge variant="outline">
166
- {t(`admin.tax_identifiers.kinds.${identifier.kind}`, {
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
- </div>
184
- {identifier.validated_at && (
185
- <span className="text-muted-foreground text-xs">
186
- <RelativeTime
187
- iso={identifier.validated_at}
188
- prefix={t('admin.tax_identifiers.checked_prefix')}
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
- </span>
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
- </div>
198
-
199
- {canEdit && (
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
 
@@ -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}
@@ -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="size-4 text-emerald-600 dark:text-emerald-400" />,
30
- error: <OctagonXIcon className="size-4 text-destructive" />,
31
- warning: <TriangleAlertIcon className="size-4 text-amber-600 dark:text-amber-400" />,
32
- info: <InfoIcon className="size-4 text-muted-foreground" />,
33
- loading: <Loader2Icon className="size-4 animate-spin text-muted-foreground" />,
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
- <ToastPrimitive.Root
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
- <div className="flex items-start gap-3">
62
- {toast.type && ICONS[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 leading-snug" />
67
- <ToastPrimitive.Description className="text-muted-foreground text-sm leading-snug" />
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
- // 44px tap target on touch, compact for a pointer — the same rule the
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 className="size-4" />
141
+ <XIcon aria-hidden="true" />
76
142
  </ToastPrimitive.Close>
77
- </div>
78
- </ToastPrimitive.Root>
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
- <ToastPrimitive.Viewport
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
- </ToastPrimitive.Viewport>
162
+ </ToastViewport>
109
163
  </ToastPrimitive.Portal>
110
164
  </ToastPrimitive.Provider>
111
165
  )