@spree/dashboard-ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +26 -0
  3. package/package.json +86 -0
  4. package/src/hooks/use-copy-to-clipboard.ts +44 -0
  5. package/src/hooks/use-mobile.ts +19 -0
  6. package/src/hooks/use-scrolled.ts +30 -0
  7. package/src/index.ts +99 -0
  8. package/src/lib/date-locale.ts +29 -0
  9. package/src/lib/utils.ts +6 -0
  10. package/src/lib/validation-messages.ts +19 -0
  11. package/src/spree/address-block.tsx +74 -0
  12. package/src/spree/back-button.tsx +48 -0
  13. package/src/spree/bulk-dialog.tsx +71 -0
  14. package/src/spree/bulk-price-table.tsx +254 -0
  15. package/src/spree/calculator-summary.tsx +120 -0
  16. package/src/spree/color-picker.tsx +202 -0
  17. package/src/spree/confirm-dialog.tsx +82 -0
  18. package/src/spree/copy-to-clipboard-button.tsx +90 -0
  19. package/src/spree/country-flag.tsx +30 -0
  20. package/src/spree/data-grid/cells.tsx +415 -0
  21. package/src/spree/data-grid/context.ts +59 -0
  22. package/src/spree/data-grid/data-grid.tsx +214 -0
  23. package/src/spree/data-grid/fill-handle.tsx +153 -0
  24. package/src/spree/data-grid/helpers.ts +18 -0
  25. package/src/spree/data-grid/index.ts +5 -0
  26. package/src/spree/data-grid/types.ts +36 -0
  27. package/src/spree/data-grid/use-data-grid-keyboard.ts +254 -0
  28. package/src/spree/data-grid/use-stable-cell-registration.ts +45 -0
  29. package/src/spree/drag-handle.tsx +51 -0
  30. package/src/spree/form-actions.tsx +140 -0
  31. package/src/spree/json-preview-drawer.tsx +254 -0
  32. package/src/spree/json-value-view.tsx +149 -0
  33. package/src/spree/language-menu-items.tsx +54 -0
  34. package/src/spree/metadata/metadata-card.tsx +62 -0
  35. package/src/spree/relative-time.tsx +49 -0
  36. package/src/spree/resource-combobox.tsx +108 -0
  37. package/src/spree/resource-layout.tsx +31 -0
  38. package/src/spree/resource-multi-autocomplete.tsx +180 -0
  39. package/src/spree/resource-name-cell.tsx +44 -0
  40. package/src/spree/route-error-boundary.tsx +102 -0
  41. package/src/spree/row-actions.tsx +85 -0
  42. package/src/spree/row-click-bridge.tsx +17 -0
  43. package/src/spree/secret-input.tsx +137 -0
  44. package/src/spree/storefront-visible-switch.tsx +58 -0
  45. package/src/spree/tag-list.tsx +26 -0
  46. package/src/spree/theme-provider.tsx +83 -0
  47. package/src/spree/theme-toggle.tsx +35 -0
  48. package/src/styles.css +338 -0
  49. package/src/ui/attachment.tsx +187 -0
  50. package/src/ui/avatar.tsx +94 -0
  51. package/src/ui/badge.tsx +161 -0
  52. package/src/ui/breadcrumb.tsx +109 -0
  53. package/src/ui/button.tsx +60 -0
  54. package/src/ui/calendar.tsx +66 -0
  55. package/src/ui/card.tsx +72 -0
  56. package/src/ui/chart.tsx +94 -0
  57. package/src/ui/checkbox.tsx +50 -0
  58. package/src/ui/collapsible.tsx +18 -0
  59. package/src/ui/combobox.tsx +270 -0
  60. package/src/ui/command.tsx +172 -0
  61. package/src/ui/data-table.tsx +60 -0
  62. package/src/ui/date-picker.tsx +253 -0
  63. package/src/ui/date-range-picker.tsx +150 -0
  64. package/src/ui/dialog.tsx +214 -0
  65. package/src/ui/dropdown-menu.tsx +345 -0
  66. package/src/ui/empty.tsx +94 -0
  67. package/src/ui/field.tsx +221 -0
  68. package/src/ui/input-group.tsx +141 -0
  69. package/src/ui/input.tsx +19 -0
  70. package/src/ui/label.tsx +29 -0
  71. package/src/ui/pagination.tsx +123 -0
  72. package/src/ui/popover.tsx +76 -0
  73. package/src/ui/progress.tsx +73 -0
  74. package/src/ui/radio-group.tsx +42 -0
  75. package/src/ui/rich-text-editor.tsx +263 -0
  76. package/src/ui/select.tsx +213 -0
  77. package/src/ui/separator.tsx +31 -0
  78. package/src/ui/sheet.tsx +208 -0
  79. package/src/ui/sidebar.tsx +679 -0
  80. package/src/ui/skeleton.tsx +13 -0
  81. package/src/ui/slot.tsx +35 -0
  82. package/src/ui/sonner.tsx +47 -0
  83. package/src/ui/switch.tsx +39 -0
  84. package/src/ui/textarea.tsx +18 -0
  85. package/src/ui/tooltip.tsx +100 -0
@@ -0,0 +1,254 @@
1
+ import type { ColumnDef } from '@tanstack/react-table'
2
+ import { useMemo } from 'react'
3
+ import { Button } from '../ui/button'
4
+ import { Input } from '../ui/input'
5
+ import { DataGrid, editableRowIndex, MoneyCell, ReadOnlyCell } from './data-grid'
6
+
7
+ export interface BulkPriceRow {
8
+ // Unique row id. For header rows use a synthetic key (e.g. `header:<groupId>`);
9
+ // the table renders headers via `renderSectionHeader` without an editable cell.
10
+ id: string
11
+ kind: 'header' | 'item'
12
+ // Header rows: the section title (typically the product name).
13
+ groupLabel?: string
14
+ // Item rows.
15
+ variantLabel?: string | null
16
+ sku?: string | null
17
+ // Display strings — the caller decides whether to ship the locale-formatted
18
+ // user input or the API's canonical decimal. The table passes them through.
19
+ amount?: string | null
20
+ compareAt?: string | null
21
+ }
22
+
23
+ export interface BulkPriceTableLabels {
24
+ /** Column header for the variant label column. */
25
+ variant: string
26
+ /** Column header for the SKU column. */
27
+ sku: string
28
+ /** Column header for the amount column. */
29
+ price: string
30
+ /** Column header for the compare-at-amount column. */
31
+ compareAt: string
32
+ /** Placeholder for a variant row with no options text (e.g. "Default"). */
33
+ variantDefault: string
34
+ /** Placeholder text for the search input. Omit to hide the search input. */
35
+ searchPlaceholder?: string
36
+ /** A short message shown above the grid summarising the matching row count. */
37
+ countSummary?: string
38
+ /** Shown when the loading flag is set. */
39
+ loading: string
40
+ /** Page-of indicator template, e.g. "Page {page} of {total}". `{page}` and `{total}` are substituted. */
41
+ pageOf: string
42
+ /** Previous-page button label. */
43
+ prev: string
44
+ /** Next-page button label. */
45
+ next: string
46
+ /** Shown when not loading and rows.length === 0 and no search query. */
47
+ emptyMessage?: string
48
+ /** Shown when not loading and rows.length === 0 and a search query is set. */
49
+ emptySearchMessage?: string
50
+ /** Aria label on the DataGrid for screen readers. */
51
+ gridAriaLabel?: string
52
+ /** Aria-label template for the amount input. `{label}` is replaced with the variant label. */
53
+ priceAriaTemplate?: string
54
+ /** Aria-label template for the compare-at input. `{label}` is replaced with the variant label. */
55
+ compareAtAriaTemplate?: string
56
+ }
57
+
58
+ export interface BulkPriceTableProps {
59
+ rows: BulkPriceRow[]
60
+ /** Currency symbol shown inside the money cells (e.g. "$", "€"). */
61
+ symbol: string
62
+ /** Decimal separator used by the cell formatter (e.g. "." or ","). */
63
+ decimal: string
64
+ /** Localized strings — the caller owns translations. */
65
+ labels: BulkPriceTableLabels
66
+ /** Called when the user commits a value to a cell. The id is the row id. */
67
+ onChange: (rowId: string, field: 'amount' | 'compareAt', value: string | null) => void
68
+
69
+ // Optional state for the toolbar + pagination footer. Omitting all of these
70
+ // hides the toolbar/pagination entirely (use for in-memory single-page lists).
71
+ search?: string
72
+ onSearchChange?: (next: string) => void
73
+ page?: number
74
+ totalPages?: number
75
+ onPageChange?: (next: number) => void
76
+ isLoading?: boolean
77
+ }
78
+
79
+ /**
80
+ * Presentational primitive for any "spreadsheet of variant prices" UI. Owns
81
+ * the DataGrid columns, money-cell wiring, currency-aware formatting, search
82
+ * input, and pagination footer. Owns NO data fetching, edit tracking, or save
83
+ * logic — the caller projects its rows into BulkPriceRow shape and writes
84
+ * edits back through `onChange`.
85
+ */
86
+ export function BulkPriceTable({
87
+ rows,
88
+ symbol,
89
+ decimal,
90
+ labels,
91
+ onChange,
92
+ search,
93
+ onSearchChange,
94
+ page,
95
+ totalPages,
96
+ onPageChange,
97
+ isLoading,
98
+ }: BulkPriceTableProps) {
99
+ const columns = useMemo<ColumnDef<BulkPriceRow>[]>(
100
+ () => [
101
+ {
102
+ id: 'variant',
103
+ header: labels.variant,
104
+ cell: ({ row }) => {
105
+ const r = row.original
106
+ if (r.kind !== 'item') return null
107
+ return (
108
+ <ReadOnlyCell className="text-muted-foreground">
109
+ {r.variantLabel ?? labels.variantDefault}
110
+ </ReadOnlyCell>
111
+ )
112
+ },
113
+ },
114
+ {
115
+ id: 'sku',
116
+ header: labels.sku,
117
+ cell: ({ row }) => {
118
+ const r = row.original
119
+ if (r.kind !== 'item') return null
120
+ return (
121
+ <ReadOnlyCell className="font-mono text-xs text-muted-foreground">
122
+ {r.sku ?? '—'}
123
+ </ReadOnlyCell>
124
+ )
125
+ },
126
+ },
127
+ {
128
+ id: 'amount',
129
+ header: () => <span className="block text-right">{labels.price}</span>,
130
+ cell: ({ row, table }) => {
131
+ const r = row.original
132
+ if (r.kind !== 'item') return null
133
+ const coords = { row: editableRowIndex(table.getRowModel().rows, row.id), col: 2 }
134
+ const label = r.variantLabel ?? labels.variantDefault
135
+ return (
136
+ <MoneyCell
137
+ coords={coords}
138
+ value={r.amount ?? null}
139
+ onChange={(next) => onChange(r.id, 'amount', next)}
140
+ symbol={symbol}
141
+ decimal={decimal}
142
+ ariaLabel={labels.priceAriaTemplate?.replace('{label}', label) ?? label}
143
+ />
144
+ )
145
+ },
146
+ },
147
+ {
148
+ id: 'compare_at',
149
+ header: () => <span className="block text-right">{labels.compareAt}</span>,
150
+ cell: ({ row, table }) => {
151
+ const r = row.original
152
+ if (r.kind !== 'item') return null
153
+ const coords = { row: editableRowIndex(table.getRowModel().rows, row.id), col: 3 }
154
+ const label = r.variantLabel ?? labels.variantDefault
155
+ return (
156
+ <MoneyCell
157
+ coords={coords}
158
+ value={r.compareAt ?? null}
159
+ onChange={(next) => onChange(r.id, 'compareAt', next)}
160
+ symbol={symbol}
161
+ decimal={decimal}
162
+ ariaLabel={labels.compareAtAriaTemplate?.replace('{label}', label) ?? label}
163
+ />
164
+ )
165
+ },
166
+ },
167
+ ],
168
+ [symbol, decimal, onChange, labels],
169
+ )
170
+
171
+ const showToolbar =
172
+ labels.countSummary !== undefined ||
173
+ (onSearchChange !== undefined && labels.searchPlaceholder !== undefined)
174
+ const showPagination =
175
+ page !== undefined && totalPages !== undefined && totalPages > 1 && onPageChange !== undefined
176
+ const isEmpty = !isLoading && rows.length === 0
177
+ const hasSearch = !!search && search.length > 0
178
+
179
+ return (
180
+ <div className="flex h-full flex-col gap-3">
181
+ {showToolbar && (
182
+ // Always-mounted toolbar. Conditionally rendering the search input
183
+ // would unmount it whenever a deferred query refetches into the
184
+ // loading state, blurring the field mid-keystroke.
185
+ <div className="flex shrink-0 items-center justify-between gap-3">
186
+ {labels.countSummary !== undefined && (
187
+ <p className="text-xs text-muted-foreground">{labels.countSummary}</p>
188
+ )}
189
+ {onSearchChange !== undefined && labels.searchPlaceholder !== undefined && (
190
+ <Input
191
+ type="search"
192
+ placeholder={labels.searchPlaceholder}
193
+ value={search ?? ''}
194
+ onChange={(e) => onSearchChange(e.target.value)}
195
+ className="h-9 max-w-sm"
196
+ />
197
+ )}
198
+ </div>
199
+ )}
200
+
201
+ <div className="min-h-0 flex-1 overflow-auto">
202
+ {isLoading ? (
203
+ <p className="text-sm text-muted-foreground">{labels.loading}</p>
204
+ ) : isEmpty ? (
205
+ <p className="text-sm text-muted-foreground">
206
+ {hasSearch
207
+ ? (labels.emptySearchMessage ?? labels.emptyMessage ?? '')
208
+ : (labels.emptyMessage ?? '')}
209
+ </p>
210
+ ) : (
211
+ <DataGrid<BulkPriceRow>
212
+ rows={rows}
213
+ columns={columns}
214
+ getRowId={(row) => row.id}
215
+ renderSectionHeader={(row) =>
216
+ row.kind === 'header' ? (
217
+ <div className="truncate font-medium">{row.groupLabel}</div>
218
+ ) : null
219
+ }
220
+ aria-label={labels.gridAriaLabel}
221
+ />
222
+ )}
223
+ </div>
224
+
225
+ {showPagination && (
226
+ <div className="flex shrink-0 items-center justify-between gap-2 text-xs text-muted-foreground">
227
+ <span>
228
+ {labels.pageOf.replace('{page}', String(page)).replace('{total}', String(totalPages))}
229
+ </span>
230
+ <div className="flex gap-1">
231
+ <Button
232
+ type="button"
233
+ size="sm"
234
+ variant="outline"
235
+ onClick={() => onPageChange(Math.max(1, page - 1))}
236
+ disabled={page === 1 || isLoading}
237
+ >
238
+ {labels.prev}
239
+ </Button>
240
+ <Button
241
+ type="button"
242
+ size="sm"
243
+ variant="outline"
244
+ onClick={() => onPageChange(Math.min(totalPages, page + 1))}
245
+ disabled={page === totalPages || isLoading}
246
+ >
247
+ {labels.next}
248
+ </Button>
249
+ </div>
250
+ </div>
251
+ )}
252
+ </div>
253
+ )
254
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * One-line preview of a calculator's configuration — "Flat Rate · $5.00",
3
+ * "Percent on line item · 10%", etc. Used for promotion-action rows
4
+ * today; intended as the shared row-summary for any calculator-backed
5
+ * model (delivery methods, tax rates, …).
6
+ *
7
+ * The expected payload mirrors the admin API's calculator embed: a
8
+ * `{ label, preferences, preference_schema }` triple. The component is
9
+ * tolerant of partial input — missing schema or preferences just fall
10
+ * back to the label alone.
11
+ *
12
+ * Lives on the frontend (rather than as a server-rendered string) so
13
+ * draft edits update the row immediately, before any save.
14
+ */
15
+ export interface CalculatorPayload {
16
+ /** Human-readable name (e.g. "Flat Rate"). Optional; if missing the type is demodulized. */
17
+ label?: string | null
18
+ /** STI class name (e.g. "Spree::Calculator::FlatRate"). Used as a label fallback. */
19
+ type?: string | null
20
+ preferences?: Record<string, unknown> | null
21
+ preference_schema?: ReadonlyArray<{ key: string; type: string }> | null
22
+ }
23
+
24
+ interface CalculatorSummaryProps {
25
+ calculator: CalculatorPayload | null | undefined
26
+ /**
27
+ * What to show when `calculator` is missing or empty. Use this to
28
+ * differentiate "no calculator picked" from "calculator with no
29
+ * preferences yet." Defaults to nothing (returns null).
30
+ */
31
+ fallback?: React.ReactNode
32
+ className?: string
33
+ }
34
+
35
+ export function CalculatorSummary({
36
+ calculator,
37
+ fallback = null,
38
+ className,
39
+ }: CalculatorSummaryProps) {
40
+ const text = formatCalculatorSummary(calculator)
41
+ if (!text) return <>{fallback}</>
42
+ return <span className={className}>{text}</span>
43
+ }
44
+
45
+ /**
46
+ * Pure formatter — exported for callers that want to embed the string
47
+ * inside a larger sentence (e.g. "Free shipping · {summary}") or use
48
+ * it in a non-React context.
49
+ */
50
+ export function formatCalculatorSummary(
51
+ calculator: CalculatorPayload | null | undefined,
52
+ ): string | null {
53
+ if (!calculator) return null
54
+
55
+ const label =
56
+ calculator.label?.trim() ||
57
+ (calculator.type ? (calculator.type.split('::').pop() ?? calculator.type) : '')
58
+
59
+ const schema = calculator.preference_schema
60
+ const prefs = calculator.preferences
61
+
62
+ if (!schema?.length || !prefs) return label || null
63
+
64
+ const currency = (typeof prefs.currency === 'string' && prefs.currency) || 'USD'
65
+ const details = schema
66
+ .map((field) => formatField(field, prefs[field.key], currency))
67
+ .filter((s): s is string => s !== null)
68
+ .join(', ')
69
+
70
+ if (!details) return label || null
71
+ return label ? `${label} · ${details}` : details
72
+ }
73
+
74
+ /**
75
+ * Formats one preference value by naming convention. Calculators don't
76
+ * carry richer field metadata (no `display_format`, no `unit`), so we
77
+ * fall back to key-name heuristics that match the Ruby preference
78
+ * declarations in `Spree::Calculator::*`.
79
+ *
80
+ * - `amount` / `*_amount` decimals → currency-formatted.
81
+ * - `*_percent` decimals → `N%`.
82
+ * - `currency` strings → omitted (folded into the money formatting).
83
+ * - booleans → humanized key when true; skipped when false.
84
+ * - everything else → `key: value`.
85
+ */
86
+ function formatField(
87
+ field: { key: string; type: string },
88
+ value: unknown,
89
+ currency: string,
90
+ ): string | null {
91
+ if (value === null || value === undefined || value === '') return null
92
+ if (field.key === 'currency') return null
93
+
94
+ if (field.type === 'decimal') {
95
+ if (/(?:^|_)percent$/.test(field.key)) return `${value}%`
96
+ if (field.key === 'amount' || field.key.endsWith('_amount')) {
97
+ return formatMoney(value, currency)
98
+ }
99
+ return `${humanize(field.key)}: ${value}`
100
+ }
101
+ if (field.type === 'boolean') {
102
+ return value ? humanize(field.key) : null
103
+ }
104
+ return `${humanize(field.key)}: ${value}`
105
+ }
106
+
107
+ function formatMoney(value: unknown, currency: string): string {
108
+ const n = typeof value === 'number' ? value : Number(value)
109
+ if (Number.isNaN(n)) return String(value)
110
+ try {
111
+ return new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(n)
112
+ } catch {
113
+ return `${n} ${currency}`
114
+ }
115
+ }
116
+
117
+ function humanize(key: string): string {
118
+ const spaced = key.replace(/_/g, ' ').trim()
119
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1)
120
+ }
@@ -0,0 +1,202 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import { HexColorPicker } from 'react-colorful'
3
+ import { useTranslation } from 'react-i18next'
4
+ import { cn } from '../lib/utils'
5
+ import { Input } from '../ui/input'
6
+
7
+ /** Matches the server-side validation in `Spree::OptionValue#color_code`. */
8
+ const HEX_RE = /^#?[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$/
9
+
10
+ function normalizeHex(input: string | null | undefined): string | null {
11
+ if (!input) return null
12
+ const trimmed = input.trim()
13
+ if (!trimmed) return null
14
+ const withHash = trimmed.startsWith('#') ? trimmed : `#${trimmed}`
15
+ return HEX_RE.test(withHash) ? withHash.toUpperCase() : null
16
+ }
17
+
18
+ export interface ColorPickerProps {
19
+ /** Hex value with `#` prefix (e.g. `#FF0000`). `null` / `undefined` / `''` for empty. */
20
+ value: string | null | undefined
21
+ /** Fires with a normalized `#RRGGBB` value, or `null` when cleared. */
22
+ onChange: (value: string | null) => void
23
+ /** Disables the trigger and locks the panel closed. */
24
+ disabled?: boolean
25
+ /** Forwarded to the text input for label association. */
26
+ id?: string
27
+ /** Forwarded to the text input. */
28
+ 'aria-invalid'?: boolean
29
+ className?: string
30
+ placeholder?: string
31
+ /**
32
+ * Hides the inline hex input — the swatch button alone becomes the trigger
33
+ * and the hex input moves into the picker panel. Use in dense surfaces
34
+ * (table cells, repeated row editors) where the inline input would crowd
35
+ * the layout. Default `false`.
36
+ */
37
+ compact?: boolean
38
+ /**
39
+ * Which edge of the swatch the panel anchors to. `start` (default) grows
40
+ * the panel rightward from the swatch's left edge; `end` grows it leftward
41
+ * from the swatch's right edge — use when the swatch sits near the right
42
+ * edge of its container (e.g. the rightmost cells in a table).
43
+ */
44
+ panelAlign?: 'start' | 'end'
45
+ }
46
+
47
+ /**
48
+ * Hex color picker with a swatch trigger and a free-form text input.
49
+ *
50
+ * The picker panel is rendered **inline** (not via a portal) because Base UI's
51
+ * `<Popover>` Portal mechanism interacts poorly with deeply-nested portal trees
52
+ * (e.g. when the picker lives inside a `<Sheet>` inside a `<TableRow>` inside a
53
+ * `useSortable` context — the Popup component silently fails to mount). The
54
+ * inline approach uses an absolute-positioned panel with click-outside +
55
+ * Escape-to-close handling.
56
+ */
57
+ export function ColorPicker({
58
+ value,
59
+ onChange,
60
+ disabled,
61
+ id,
62
+ 'aria-invalid': ariaInvalid,
63
+ className,
64
+ placeholder = '#000000',
65
+ compact = false,
66
+ panelAlign = 'start',
67
+ }: ColorPickerProps) {
68
+ const { t } = useTranslation()
69
+ const normalizedValue = normalizeHex(value)
70
+ const [open, setOpen] = useState(false)
71
+ // Local text mirrors `value` but lets the user type freely (including
72
+ // invalid intermediate states like `#FF`) without bouncing through onChange.
73
+ const [text, setText] = useState(normalizedValue ?? '')
74
+ const containerRef = useRef<HTMLDivElement | null>(null)
75
+
76
+ // Resync when the form-level value changes (e.g. when the picker writes to
77
+ // a different row, or when the form resets after a save).
78
+ useEffect(() => {
79
+ setText(normalizedValue ?? '')
80
+ }, [normalizedValue])
81
+
82
+ // `pointerdown` (not `click`) fires before the trigger's own click, matching
83
+ // popover-library behavior so reopening doesn't double-toggle.
84
+ useEffect(() => {
85
+ if (!open) return
86
+ function handle(event: PointerEvent) {
87
+ const target = event.target as Node | null
88
+ if (!target || !containerRef.current) return
89
+ if (!containerRef.current.contains(target)) {
90
+ setOpen(false)
91
+ }
92
+ }
93
+ function handleKey(event: KeyboardEvent) {
94
+ if (event.key === 'Escape') setOpen(false)
95
+ }
96
+ document.addEventListener('pointerdown', handle)
97
+ document.addEventListener('keydown', handleKey)
98
+ return () => {
99
+ document.removeEventListener('pointerdown', handle)
100
+ document.removeEventListener('keydown', handleKey)
101
+ }
102
+ }, [open])
103
+
104
+ function commitText(raw: string) {
105
+ const normalized = normalizeHex(raw)
106
+ if (normalized) {
107
+ onChange(normalized)
108
+ setText(normalized)
109
+ } else if (raw.trim() === '') {
110
+ onChange(null)
111
+ setText('')
112
+ }
113
+ // else: invalid input — keep the text, don't propagate
114
+ }
115
+
116
+ const hexInput = (
117
+ <Input
118
+ id={id}
119
+ type="text"
120
+ inputMode="text"
121
+ autoComplete="off"
122
+ spellCheck={false}
123
+ value={text}
124
+ placeholder={placeholder}
125
+ disabled={disabled}
126
+ aria-invalid={ariaInvalid}
127
+ className="font-mono uppercase"
128
+ onChange={(e) => setText(e.target.value)}
129
+ onBlur={(e) => commitText(e.target.value)}
130
+ onKeyDown={(e) => {
131
+ if (e.key === 'Enter') {
132
+ e.preventDefault()
133
+ commitText(e.currentTarget.value)
134
+ }
135
+ }}
136
+ />
137
+ )
138
+
139
+ return (
140
+ <div ref={containerRef} className={cn('relative flex items-center gap-2', className)}>
141
+ <button
142
+ type="button"
143
+ disabled={disabled}
144
+ aria-label={
145
+ normalizedValue
146
+ ? t('admin.a11y.change_color', { value: normalizedValue })
147
+ : t('admin.a11y.pick_color')
148
+ }
149
+ aria-haspopup="dialog"
150
+ aria-expanded={open}
151
+ onClick={() => setOpen((v) => !v)}
152
+ className={cn(
153
+ 'inline-flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-md border border-border shadow-xs transition-shadow',
154
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
155
+ 'disabled:cursor-not-allowed disabled:opacity-50',
156
+ !normalizedValue && 'color-picker-empty-swatch',
157
+ )}
158
+ style={normalizedValue ? { backgroundColor: normalizedValue } : undefined}
159
+ />
160
+ {!compact && hexInput}
161
+ {open && (
162
+ <div
163
+ role="dialog"
164
+ aria-label={t('admin.a11y.pick_color')}
165
+ className={cn(
166
+ 'absolute top-full z-50 mt-2 min-w-min rounded-lg border border-border bg-popover p-3 text-popover-foreground shadow-md',
167
+ panelAlign === 'end' ? 'right-0' : 'left-0',
168
+ )}
169
+ >
170
+ <HexColorPicker
171
+ color={normalizedValue ?? '#000000'}
172
+ onChange={(next) => {
173
+ const normalized = normalizeHex(next)
174
+ if (normalized) {
175
+ setText(normalized)
176
+ onChange(normalized)
177
+ }
178
+ }}
179
+ />
180
+ {compact && <div className="mt-3">{hexInput}</div>}
181
+ <div className="mt-3 flex items-center justify-between gap-2">
182
+ <span className="font-mono text-xs text-muted-foreground">
183
+ {normalizedValue ?? t('admin.color_picker.no_color')}
184
+ </span>
185
+ {normalizedValue && (
186
+ <button
187
+ type="button"
188
+ onClick={() => {
189
+ setText('')
190
+ onChange(null)
191
+ }}
192
+ className="text-xs text-muted-foreground underline-offset-2 hover:underline"
193
+ >
194
+ {t('admin.actions.clear')}
195
+ </button>
196
+ )}
197
+ </div>
198
+ </div>
199
+ )}
200
+ </div>
201
+ )
202
+ }
@@ -0,0 +1,82 @@
1
+ import { createContext, type ReactNode, useCallback, useContext, useRef, useState } from 'react'
2
+ import { useTranslation } from 'react-i18next'
3
+ import { Button } from '../ui/button'
4
+ import {
5
+ Dialog,
6
+ DialogContent,
7
+ DialogDescription,
8
+ DialogFooter,
9
+ DialogHeader,
10
+ DialogTitle,
11
+ } from '../ui/dialog'
12
+
13
+ interface ConfirmOptions {
14
+ title?: string
15
+ message: string
16
+ confirmLabel?: string
17
+ cancelLabel?: string
18
+ variant?: 'default' | 'destructive'
19
+ }
20
+
21
+ type ConfirmFn = (options: ConfirmOptions) => Promise<boolean>
22
+
23
+ const ConfirmContext = createContext<ConfirmFn | null>(null)
24
+
25
+ export function useConfirm(): ConfirmFn {
26
+ const ctx = useContext(ConfirmContext)
27
+ if (!ctx) throw new Error('useConfirm must be used within ConfirmProvider')
28
+ return ctx
29
+ }
30
+
31
+ export function ConfirmProvider({ children }: { children: ReactNode }) {
32
+ const { t } = useTranslation()
33
+ const [open, setOpen] = useState(false)
34
+ const [options, setOptions] = useState<ConfirmOptions>({
35
+ message: '',
36
+ })
37
+ const resolveRef = useRef<((value: boolean) => void) | null>(null)
38
+
39
+ const confirm = useCallback((opts: ConfirmOptions): Promise<boolean> => {
40
+ setOptions(opts)
41
+ setOpen(true)
42
+ return new Promise<boolean>((resolve) => {
43
+ resolveRef.current = resolve
44
+ })
45
+ }, [])
46
+
47
+ const handleClose = useCallback((confirmed: boolean) => {
48
+ setOpen(false)
49
+ resolveRef.current?.(confirmed)
50
+ resolveRef.current = null
51
+ }, [])
52
+
53
+ return (
54
+ <ConfirmContext.Provider value={confirm}>
55
+ {children}
56
+ <Dialog
57
+ open={open}
58
+ onOpenChange={(o) => {
59
+ if (!o) handleClose(false)
60
+ }}
61
+ >
62
+ <DialogContent showCloseButton={false}>
63
+ <DialogHeader>
64
+ <DialogTitle>{options.title ?? t('admin.components.confirm_dialog.title')}</DialogTitle>
65
+ <DialogDescription>{options.message}</DialogDescription>
66
+ </DialogHeader>
67
+ <DialogFooter>
68
+ <Button variant="outline" onClick={() => handleClose(false)}>
69
+ {options.cancelLabel ?? t('admin.actions.cancel')}
70
+ </Button>
71
+ <Button
72
+ variant={options.variant === 'destructive' ? 'destructive' : 'default'}
73
+ onClick={() => handleClose(true)}
74
+ >
75
+ {options.confirmLabel ?? t('admin.actions.confirm')}
76
+ </Button>
77
+ </DialogFooter>
78
+ </DialogContent>
79
+ </Dialog>
80
+ </ConfirmContext.Provider>
81
+ )
82
+ }