@spree/dashboard-ui 0.13.1 → 1.0.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +1 -1
  2. package/package.json +13 -9
  3. package/src/hooks/use-prefers-reduced-motion.ts +26 -0
  4. package/src/hooks/use-scrolled.ts +34 -14
  5. package/src/index.ts +38 -1
  6. package/src/lib/date-range-presets.ts +79 -0
  7. package/src/lib/same-rich-text.ts +145 -0
  8. package/src/spree/address-block.tsx +5 -4
  9. package/src/spree/address-book-row.tsx +96 -0
  10. package/src/spree/back-button.tsx +14 -9
  11. package/src/spree/bulk-dialog.tsx +2 -2
  12. package/src/spree/bulk-price-table.tsx +145 -14
  13. package/src/spree/bulk-variants-table.tsx +434 -0
  14. package/src/spree/calculator-summary.tsx +5 -1
  15. package/src/spree/claim-resolve-dialog.tsx +219 -0
  16. package/src/spree/confirm-dialog.tsx +1 -1
  17. package/src/spree/copy-to-clipboard-button.tsx +1 -1
  18. package/src/spree/data-grid/cells.tsx +199 -25
  19. package/src/spree/data-grid/context.ts +6 -0
  20. package/src/spree/data-grid/data-grid.tsx +149 -7
  21. package/src/spree/data-grid/helpers.ts +6 -2
  22. package/src/spree/data-grid/index.ts +9 -1
  23. package/src/spree/data-grid/use-data-grid-keyboard.ts +4 -2
  24. package/src/spree/delivery-form-dialog.tsx +166 -0
  25. package/src/spree/drag-handle.tsx +2 -2
  26. package/src/spree/editable-note-card.tsx +103 -0
  27. package/src/spree/external-link.tsx +56 -0
  28. package/src/spree/file-type-icon.tsx +68 -0
  29. package/src/spree/form-actions.tsx +2 -3
  30. package/src/spree/form-section.tsx +46 -0
  31. package/src/spree/fulfill-items-form.tsx +282 -0
  32. package/src/spree/fulfillment-edit-dialog.tsx +211 -0
  33. package/src/spree/fulfillment-item-list.tsx +61 -0
  34. package/src/spree/fulfillment-panel.tsx +53 -0
  35. package/src/spree/icons.ts +167 -0
  36. package/src/spree/json-preview-drawer.tsx +4 -4
  37. package/src/spree/label-upload-dialog.tsx +195 -0
  38. package/src/spree/language-menu-items.tsx +37 -30
  39. package/src/spree/metadata/metadata-card.tsx +1 -1
  40. package/src/spree/order-cancel-dialog.tsx +149 -0
  41. package/src/spree/post-sale-create-dialogs.tsx +253 -0
  42. package/src/spree/post-sale-fields.tsx +133 -0
  43. package/src/spree/product-membership-list.tsx +453 -0
  44. package/src/spree/products/status-card.tsx +79 -0
  45. package/src/spree/quantity-tier-editor.tsx +162 -0
  46. package/src/spree/resource-combobox.tsx +12 -1
  47. package/src/spree/resource-name-cell.tsx +21 -4
  48. package/src/spree/return-dialogs.tsx +238 -0
  49. package/src/spree/route-error-boundary.tsx +1 -1
  50. package/src/spree/row-actions.tsx +2 -2
  51. package/src/spree/search-input.tsx +76 -0
  52. package/src/spree/secret-input.tsx +26 -40
  53. package/src/spree/tax-identifiers-card.tsx +418 -0
  54. package/src/spree/theme-toggle.tsx +36 -23
  55. package/src/spree/wizard.tsx +254 -0
  56. package/src/styles.css +750 -75
  57. package/src/ui/accordion.tsx +83 -0
  58. package/src/ui/alert.tsx +79 -0
  59. package/src/ui/attachment.tsx +1 -1
  60. package/src/ui/badge.tsx +198 -56
  61. package/src/ui/breadcrumb.tsx +1 -1
  62. package/src/ui/button.tsx +22 -10
  63. package/src/ui/calendar.tsx +1 -1
  64. package/src/ui/card.tsx +52 -6
  65. package/src/ui/checkbox.tsx +13 -5
  66. package/src/ui/combobox.tsx +125 -6
  67. package/src/ui/command.tsx +149 -23
  68. package/src/ui/context-menu.tsx +99 -0
  69. package/src/ui/data-table.tsx +448 -9
  70. package/src/ui/date-picker.tsx +18 -3
  71. package/src/ui/date-range-picker.tsx +47 -60
  72. package/src/ui/dialog.tsx +6 -6
  73. package/src/ui/dropdown-menu.tsx +18 -11
  74. package/src/ui/field.tsx +10 -2
  75. package/src/ui/input-group.tsx +1 -1
  76. package/src/ui/input.tsx +1 -1
  77. package/src/ui/media-preview.tsx +105 -0
  78. package/src/ui/pagination.tsx +19 -11
  79. package/src/ui/popover.tsx +4 -2
  80. package/src/ui/progress.tsx +5 -2
  81. package/src/ui/radio-group.tsx +4 -4
  82. package/src/ui/rich-text-editor.tsx +214 -88
  83. package/src/ui/scroll-area.tsx +65 -0
  84. package/src/ui/select.tsx +17 -10
  85. package/src/ui/separator.tsx +1 -1
  86. package/src/ui/sheet.tsx +49 -7
  87. package/src/ui/sidebar.tsx +138 -37
  88. package/src/ui/skeleton.tsx +3 -1
  89. package/src/ui/tabs.tsx +84 -0
  90. package/src/ui/textarea.tsx +1 -1
  91. package/src/ui/thumbnail.tsx +80 -0
  92. package/src/ui/toast.tsx +112 -0
  93. package/src/ui/tooltip.tsx +3 -1
  94. package/src/ui/sonner.tsx +0 -47
@@ -0,0 +1,418 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { Controller, type UseFormSetError, useForm } from 'react-hook-form'
3
+ import { useTranslation } from 'react-i18next'
4
+ import {
5
+ Badge,
6
+ Button,
7
+ Card,
8
+ CardAction,
9
+ CardContent,
10
+ CardHeader,
11
+ CardTitle,
12
+ Combobox,
13
+ ComboboxContent,
14
+ ComboboxEmpty,
15
+ ComboboxInput,
16
+ ComboboxItem,
17
+ ComboboxList,
18
+ DropdownMenu,
19
+ DropdownMenuContent,
20
+ DropdownMenuItem,
21
+ DropdownMenuTrigger,
22
+ Field,
23
+ FieldDescription,
24
+ FieldError,
25
+ FieldGroup,
26
+ FieldLabel,
27
+ Input,
28
+ RelativeTime,
29
+ Sheet,
30
+ SheetContent,
31
+ SheetDescription,
32
+ SheetFooter,
33
+ SheetHeader,
34
+ SheetTitle,
35
+ StatusBadge,
36
+ useConfirm,
37
+ } from '../index'
38
+ import { BadgeCheckIcon, EllipsisVerticalIcon, PencilIcon, PlusIcon, TrashIcon } from './icons'
39
+
40
+ /** One selectable registration kind. */
41
+ interface KindOption {
42
+ value: string
43
+ label: string
44
+ }
45
+
46
+ /**
47
+ * A registration as the API returns it. Declared here rather than imported
48
+ * from an SDK: this package is headless, and the admin and seller SDKs
49
+ * describe the same row.
50
+ */
51
+ export interface TaxIdentifier {
52
+ id: string
53
+ kind: string
54
+ value: string
55
+ validation_status?: string | null
56
+ validated_at?: string | null
57
+ validatable?: boolean
58
+ }
59
+
60
+ export interface TaxIdentifierParams {
61
+ kind: string
62
+ value: string
63
+ }
64
+
65
+ interface TaxIdentifierFormValues {
66
+ kind: string
67
+ value: string
68
+ }
69
+
70
+ const TAX_IDENTIFIER_DEFAULTS: TaxIdentifierFormValues = { kind: '', value: '' }
71
+
72
+ /**
73
+ * The registration kinds Spree ships strings for. Any string is accepted by
74
+ * the API — what a kind means is decided by whichever validator is registered
75
+ * for it — so the merchant can type their own.
76
+ */
77
+ export const TAX_IDENTIFIER_KINDS = ['eu_vat', 'gb_vat', 'ch_vat', 'au_abn', 'us_ein'] as const
78
+
79
+ /**
80
+ * Mutations differ by owner (a company's registrations hang off the company,
81
+ * a customer's off the customer) while the panel itself does not — so the
82
+ * owner supplies them and this stays one component.
83
+ */
84
+ export interface TaxIdentifierMutations {
85
+ create: (params: TaxIdentifierParams) => Promise<unknown>
86
+ update: (id: string, params: TaxIdentifierParams) => Promise<unknown>
87
+ remove: (id: string) => Promise<unknown>
88
+ validate: (id: string) => Promise<unknown>
89
+ isValidating?: boolean
90
+ /**
91
+ * Routes a rejected save onto the form. Supplied by the host because it is
92
+ * the host's SDK that defines what a validation error looks like; returning
93
+ * false means "not mine", and the error is rethrown.
94
+ */
95
+ mapErrors: (error: unknown, setError: UseFormSetError<TaxIdentifierFormValues>) => boolean
96
+ }
97
+
98
+ /**
99
+ * The buyer's VAT or business number. What makes EU B2B reverse charge
100
+ * possible, so the verdict matters as much as the number: a row nothing can
101
+ * check reads as such rather than as merely unverified.
102
+ */
103
+ export function TaxIdentifiersCard({
104
+ identifiers,
105
+ isLoading,
106
+ mutations,
107
+ canEdit,
108
+ }: {
109
+ identifiers: TaxIdentifier[]
110
+ isLoading?: boolean
111
+ mutations: TaxIdentifierMutations
112
+ canEdit: boolean
113
+ }) {
114
+ const { t } = useTranslation()
115
+ const confirm = useConfirm()
116
+ const [addOpen, setAddOpen] = useState(false)
117
+ const [editing, setEditing] = useState<TaxIdentifier | null>(null)
118
+
119
+ async function handleDelete(identifier: TaxIdentifier) {
120
+ const ok = await confirm({
121
+ title: t('admin.tax_identifiers.delete_confirm.title'),
122
+ message: t('admin.tax_identifiers.delete_confirm.message', { value: identifier.value }),
123
+ variant: 'destructive',
124
+ confirmLabel: t('admin.actions.delete'),
125
+ })
126
+ if (!ok) return
127
+ await mutations.remove(identifier.id).catch(() => undefined)
128
+ }
129
+
130
+ return (
131
+ <Card>
132
+ <CardHeader>
133
+ <CardTitle>
134
+ {t('admin.tax_identifiers.title')}
135
+ {identifiers.length > 0 && <Badge variant="outline">{identifiers.length}</Badge>}
136
+ </CardTitle>
137
+ {canEdit && (
138
+ <CardAction>
139
+ <Button size="sm" variant="outline" onClick={() => setAddOpen(true)}>
140
+ <PlusIcon className="size-4" />
141
+ {t('admin.tax_identifiers.add_cta')}
142
+ </Button>
143
+ </CardAction>
144
+ )}
145
+ </CardHeader>
146
+
147
+ {isLoading ? (
148
+ <CardContent>
149
+ <p className="text-muted-foreground text-sm">{t('admin.common.loading')}</p>
150
+ </CardContent>
151
+ ) : identifiers.length === 0 ? (
152
+ <CardContent>
153
+ <p className="text-muted-foreground text-sm">{t('admin.tax_identifiers.empty')}</p>
154
+ </CardContent>
155
+ ) : (
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')}
181
+ </Badge>
182
+ )}
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')}
189
+ />
190
+ </span>
191
+ )}
192
+ {!identifier.validatable && (
193
+ <span className="text-muted-foreground text-xs">
194
+ {t('admin.tax_identifiers.not_validatable')}
195
+ </span>
196
+ )}
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
+ ))}
231
+ </CardContent>
232
+ )}
233
+
234
+ {addOpen && (
235
+ <TaxIdentifierSheet
236
+ open
237
+ onOpenChange={setAddOpen}
238
+ mapErrors={mutations.mapErrors}
239
+ onSubmit={(params) => mutations.create(params)}
240
+ />
241
+ )}
242
+ {editing && (
243
+ <TaxIdentifierSheet
244
+ open
245
+ identifier={editing}
246
+ onOpenChange={(next) => {
247
+ if (!next) setEditing(null)
248
+ }}
249
+ mapErrors={mutations.mapErrors}
250
+ onSubmit={(params) => mutations.update(editing.id, params)}
251
+ />
252
+ )}
253
+ </Card>
254
+ )
255
+ }
256
+
257
+ function TaxIdentifierSheet({
258
+ open,
259
+ onOpenChange,
260
+ identifier,
261
+ onSubmit,
262
+ mapErrors,
263
+ }: {
264
+ open: boolean
265
+ onOpenChange: (open: boolean) => void
266
+ identifier?: TaxIdentifier
267
+ onSubmit: (params: TaxIdentifierParams) => Promise<unknown>
268
+ mapErrors: (error: unknown, setError: UseFormSetError<TaxIdentifierFormValues>) => boolean
269
+ }) {
270
+ const { t } = useTranslation()
271
+ const form = useForm<TaxIdentifierFormValues>({ defaultValues: TAX_IDENTIFIER_DEFAULTS })
272
+
273
+ useEffect(() => {
274
+ if (open) {
275
+ form.reset(
276
+ identifier ? { kind: identifier.kind, value: identifier.value } : TAX_IDENTIFIER_DEFAULTS,
277
+ )
278
+ }
279
+ }, [open, identifier, form])
280
+
281
+ async function handleSubmit(values: TaxIdentifierFormValues) {
282
+ try {
283
+ await onSubmit({ kind: values.kind, value: values.value })
284
+ onOpenChange(false)
285
+ } catch (err) {
286
+ // The host maps its own SDK's errors: this package knows nothing about
287
+ // either SDK, and a thrown error it cannot read is the host's to report.
288
+ if (!mapErrors(err, form.setError)) throw err
289
+ }
290
+ }
291
+
292
+ const { errors } = form.formState
293
+ // The mapper always seeds `root` with the server's summary, as a safety net
294
+ // for errors whose field is not on screen. Here both fields are, so showing
295
+ // it as well repeats the same sentence twice in one small form.
296
+ const rootIsRepeated = Boolean(errors.kind || errors.value)
297
+
298
+ return (
299
+ <Sheet open={open} onOpenChange={onOpenChange}>
300
+ <SheetContent>
301
+ <SheetHeader>
302
+ <SheetTitle>
303
+ {identifier
304
+ ? t('admin.tax_identifiers.edit_title')
305
+ : t('admin.tax_identifiers.add_title')}
306
+ </SheetTitle>
307
+ <SheetDescription>{t('admin.tax_identifiers.dialog_description')}</SheetDescription>
308
+ </SheetHeader>
309
+ <form
310
+ onSubmit={(event) => {
311
+ // This sheet can be rendered inside another form; stopping the
312
+ // bubbled submit keeps the outer form from also submitting.
313
+ form.handleSubmit(handleSubmit)(event)
314
+ event.stopPropagation()
315
+ }}
316
+ className="flex min-h-0 flex-1 flex-col"
317
+ >
318
+ <div className="flex flex-1 flex-col gap-4 overflow-y-auto p-4">
319
+ <FieldGroup>
320
+ {errors.root?.message && !rootIsRepeated && (
321
+ <p className="text-destructive text-sm" role="alert">
322
+ {errors.root.message}
323
+ </p>
324
+ )}
325
+ <Field>
326
+ <FieldLabel htmlFor="tax-identifier-kind">
327
+ {t('admin.fields.tax_identifier.kind.label')}
328
+ </FieldLabel>
329
+ {/* The list seeds the picker rather than closing it: any string
330
+ is a valid kind, and only the validator registered for one
331
+ decides what it means. */}
332
+ <Controller
333
+ name="kind"
334
+ control={form.control}
335
+ rules={{ required: t('admin.fields.tax_identifier.kind.required') }}
336
+ render={({ field }) => {
337
+ // Combobox rather than Select: this card renders inside a
338
+ // page card, and Base UI's Select popup does not survive
339
+ // that portal nesting (the country picker below is a
340
+ // Combobox for the same reason).
341
+ const kindOptions = TAX_IDENTIFIER_KINDS.map((kind) => ({
342
+ value: kind,
343
+ label: t(`admin.tax_identifiers.kinds.${kind}`, { defaultValue: kind }),
344
+ }))
345
+ const selected = kindOptions.find((o) => o.value === field.value) ?? null
346
+ return (
347
+ <Combobox
348
+ items={kindOptions}
349
+ value={selected}
350
+ onValueChange={(option: KindOption | null) =>
351
+ field.onChange(option?.value ?? '')
352
+ }
353
+ // The list seeds the picker rather than closing it, so
354
+ // a kind matching no item has to be read from the input
355
+ // itself. Only genuine typing: picking an option also
356
+ // rewrites the input, to the label rather than the key.
357
+ onInputValueChange={(input: string, details: { reason?: string }) => {
358
+ if (details.reason === 'input-change') field.onChange(input)
359
+ }}
360
+ itemToStringLabel={(option: KindOption | null) => option?.label ?? ''}
361
+ itemToStringValue={(option: KindOption | null) => option?.value ?? ''}
362
+ >
363
+ <ComboboxInput
364
+ id="tax-identifier-kind"
365
+ placeholder={t('admin.tax_identifiers.select_kind')}
366
+ />
367
+ <ComboboxContent>
368
+ <ComboboxEmpty>{t('admin.common.no_results')}</ComboboxEmpty>
369
+ <ComboboxList>
370
+ {(option: KindOption) => (
371
+ <ComboboxItem key={option.value} value={option}>
372
+ {option.label}
373
+ </ComboboxItem>
374
+ )}
375
+ </ComboboxList>
376
+ </ComboboxContent>
377
+ </Combobox>
378
+ )
379
+ }}
380
+ />
381
+ <FieldDescription>{t('admin.fields.tax_identifier.kind.help')}</FieldDescription>
382
+ <FieldError errors={[errors.kind]} />
383
+ </Field>
384
+
385
+ <Field>
386
+ <FieldLabel htmlFor="tax-identifier-value">
387
+ {t('admin.fields.tax_identifier.value.label')}
388
+ </FieldLabel>
389
+ <Input
390
+ id="tax-identifier-value"
391
+ placeholder={t('admin.fields.tax_identifier.value.placeholder')}
392
+ aria-invalid={!!errors.value || undefined}
393
+ {...form.register('value', {
394
+ required: t('admin.fields.tax_identifier.value.required'),
395
+ })}
396
+ />
397
+ <FieldError errors={[errors.value]} />
398
+ </Field>
399
+ </FieldGroup>
400
+ </div>
401
+ <SheetFooter>
402
+ <Button
403
+ type="button"
404
+ variant="outline"
405
+ onClick={() => onOpenChange(false)}
406
+ disabled={form.formState.isSubmitting}
407
+ >
408
+ {t('admin.actions.cancel')}
409
+ </Button>
410
+ <Button type="submit" disabled={form.formState.isSubmitting}>
411
+ {form.formState.isSubmitting ? t('admin.actions.saving') : t('admin.actions.save')}
412
+ </Button>
413
+ </SheetFooter>
414
+ </form>
415
+ </SheetContent>
416
+ </Sheet>
417
+ )
418
+ }
@@ -1,35 +1,48 @@
1
- import { CheckIcon, MonitorIcon, MoonIcon, SunIcon } from 'lucide-react'
2
1
  import { useTranslation } from 'react-i18next'
3
- import { DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator } from '../ui/dropdown-menu'
2
+ import { cn } from '../lib/utils'
3
+ import { MonitorIcon, MoonStarIcon, SunIcon } from './icons'
4
4
  import { useTheme } from './theme-provider'
5
5
 
6
+ const OPTIONS = [
7
+ { value: 'system', icon: MonitorIcon, labelKey: 'admin.components.theme_toggle.system' },
8
+ { value: 'light', icon: SunIcon, labelKey: 'admin.components.theme_toggle.light' },
9
+ { value: 'dark', icon: MoonStarIcon, labelKey: 'admin.components.theme_toggle.dark' },
10
+ ] as const
11
+
6
12
  /**
7
- * Theme picker rendered inside the user dropdown menu. Three-way (Light / Dark
8
- * / System) — System follows `prefers-color-scheme`.
13
+ * Theme picker rendered inside the user dropdown's Preferences section as a
14
+ * compact segmented control (System / Light / Dark). System follows
15
+ * `prefers-color-scheme`. The buttons are plain (not menu items) so activating
16
+ * one switches the theme without closing the dropdown.
9
17
  */
10
18
  export function ThemeMenuItems() {
11
19
  const { mode, setMode } = useTheme()
12
20
  const { t } = useTranslation()
13
21
 
14
- const items = [
15
- { value: 'light', label: t('admin.components.theme_toggle.light'), icon: SunIcon },
16
- { value: 'dark', label: t('admin.components.theme_toggle.dark'), icon: MoonIcon },
17
- { value: 'system', label: t('admin.components.theme_toggle.system'), icon: MonitorIcon },
18
- ] as const
19
-
20
22
  return (
21
- <>
22
- <DropdownMenuLabel className="text-xs">
23
- {t('admin.components.theme_toggle.label')}
24
- </DropdownMenuLabel>
25
- {items.map(({ value, label, icon: Icon }) => (
26
- <DropdownMenuItem key={value} closeOnClick={false} onClick={() => setMode(value)}>
27
- <Icon className="size-4" />
28
- {label}
29
- {mode === value && <CheckIcon className="ml-auto size-3.5" />}
30
- </DropdownMenuItem>
31
- ))}
32
- <DropdownMenuSeparator />
33
- </>
23
+ <div className="flex items-center justify-between gap-2 px-2.5 py-1.5">
24
+ <span className="text-sm text-foreground">{t('admin.components.theme_toggle.label')}</span>
25
+ <div className="flex items-center gap-0.5 rounded-lg border border-border bg-muted/40 p-0.5">
26
+ {OPTIONS.map(({ value, icon: Icon, labelKey }) => {
27
+ const active = mode === value
28
+ return (
29
+ <button
30
+ key={value}
31
+ type="button"
32
+ aria-pressed={active}
33
+ aria-label={t(labelKey)}
34
+ title={t(labelKey)}
35
+ onClick={() => setMode(value)}
36
+ className={cn(
37
+ 'flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground hover:bg-accent cursor-pointer',
38
+ active && 'bg-accent-strong text-foreground shadow-sm',
39
+ )}
40
+ >
41
+ <Icon className="size-4" />
42
+ </button>
43
+ )
44
+ })}
45
+ </div>
46
+ </div>
34
47
  )
35
48
  }