@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,166 @@
1
+ import type { UseFormReturn } from 'react-hook-form'
2
+ import { Controller } from 'react-hook-form'
3
+ import { useTranslation } from 'react-i18next'
4
+ import { Button } from '../ui/button'
5
+ import {
6
+ Combobox,
7
+ ComboboxButtonTrigger,
8
+ ComboboxContent,
9
+ ComboboxEmpty,
10
+ ComboboxItem,
11
+ ComboboxList,
12
+ ComboboxSearch,
13
+ ComboboxTriggerPlaceholder,
14
+ ComboboxValue,
15
+ } from '../ui/combobox'
16
+ import {
17
+ Dialog,
18
+ DialogBody,
19
+ DialogContent,
20
+ DialogDescription,
21
+ DialogFooter,
22
+ DialogHeader,
23
+ DialogTitle,
24
+ } from '../ui/dialog'
25
+ import { Field, FieldError, FieldLabel } from '../ui/field'
26
+ import { Input } from '../ui/input'
27
+
28
+ /** The form shape both panels bind for one consignment. */
29
+ export type DeliveryFormFields = {
30
+ tracking_number: string
31
+ carrier: string
32
+ }
33
+
34
+ /**
35
+ * Records a tracked consignment on a parcel, or corrects one. A parcel can
36
+ * carry several — three boxes, or a freight PRO number covering a pallet —
37
+ * and each travels on its own carrier status.
38
+ *
39
+ * Shared by the operator's order page and the seller's. Headless: the caller
40
+ * owns the form and the submit, so each panel keeps its own mutation, while
41
+ * the carrier field's contract lives here — the value stored is the carrier's
42
+ * key rather than its display name, and anything off the list stays as typed
43
+ * because a forwarder's own name has to be enterable.
44
+ */
45
+ export function DeliveryFormDialog({
46
+ form,
47
+ open,
48
+ onOpenChange,
49
+ editing,
50
+ carrierKeys,
51
+ carrierLabel,
52
+ onSubmit,
53
+ pending = false,
54
+ }: {
55
+ form: UseFormReturn<DeliveryFormFields>
56
+ open: boolean
57
+ onOpenChange: (open: boolean) => void
58
+ /** Correcting an existing consignment rather than adding one. */
59
+ editing: boolean
60
+ carrierKeys: string[]
61
+ carrierLabel: (key: string) => string
62
+ onSubmit: (values: DeliveryFormFields) => void | Promise<void>
63
+ pending?: boolean
64
+ }) {
65
+ const { t } = useTranslation()
66
+ const { errors } = form.formState
67
+
68
+ return (
69
+ <Dialog open={open} onOpenChange={onOpenChange}>
70
+ <DialogContent>
71
+ <form
72
+ onSubmit={(event) => {
73
+ // The order page renders its cards inside their own forms — without
74
+ // stopping the bubble the browser submits the outer one.
75
+ form.handleSubmit(onSubmit)(event)
76
+ event.stopPropagation()
77
+ }}
78
+ >
79
+ <DialogHeader>
80
+ <DialogTitle>
81
+ {editing
82
+ ? t('admin.orders.detail.fulfillments.edit_tracking_title')
83
+ : t('admin.orders.detail.fulfillments.add_tracking_title')}
84
+ </DialogTitle>
85
+ <DialogDescription>
86
+ {t('admin.orders.detail.fulfillments.tracking_description')}
87
+ </DialogDescription>
88
+ </DialogHeader>
89
+
90
+ <DialogBody className="flex flex-col gap-4">
91
+ {errors.root?.message && (
92
+ <p className="text-destructive text-sm" role="alert">
93
+ {errors.root.message}
94
+ </p>
95
+ )}
96
+
97
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
98
+ <Field>
99
+ <FieldLabel htmlFor="delivery-tracking-number">
100
+ {t('admin.orders.fulfill.tracking_label')}
101
+ </FieldLabel>
102
+ <Input
103
+ id="delivery-tracking-number"
104
+ placeholder={t('admin.orders.fulfill.tracking_placeholder')}
105
+ aria-invalid={!!errors.tracking_number}
106
+ {...form.register('tracking_number')}
107
+ />
108
+ <FieldError errors={[errors.tracking_number]} />
109
+ </Field>
110
+
111
+ <Field>
112
+ <FieldLabel htmlFor="delivery-carrier">
113
+ {t('admin.orders.detail.fulfillments.carrier_label')}
114
+ </FieldLabel>
115
+ <Controller
116
+ control={form.control}
117
+ name="carrier"
118
+ render={({ field }) => (
119
+ <Combobox
120
+ items={carrierKeys}
121
+ value={field.value}
122
+ onValueChange={(value: string | null) => field.onChange(value ?? '')}
123
+ itemToStringLabel={carrierLabel}
124
+ >
125
+ <ComboboxButtonTrigger id="delivery-carrier" onBlur={field.onBlur}>
126
+ {field.value ? (
127
+ <ComboboxValue />
128
+ ) : (
129
+ <ComboboxTriggerPlaceholder>
130
+ {t('admin.orders.detail.fulfillments.carrier_auto')}
131
+ </ComboboxTriggerPlaceholder>
132
+ )}
133
+ </ComboboxButtonTrigger>
134
+ <ComboboxContent>
135
+ <ComboboxSearch
136
+ placeholder={t('admin.orders.detail.fulfillments.carrier_search')}
137
+ />
138
+ <ComboboxEmpty>{t('admin.common.no_results')}</ComboboxEmpty>
139
+ <ComboboxList>
140
+ {(value: string) => (
141
+ <ComboboxItem key={value} value={value}>
142
+ {carrierLabel(value)}
143
+ </ComboboxItem>
144
+ )}
145
+ </ComboboxList>
146
+ </ComboboxContent>
147
+ </Combobox>
148
+ )}
149
+ />
150
+ </Field>
151
+ </div>
152
+ </DialogBody>
153
+
154
+ <DialogFooter>
155
+ <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
156
+ {t('admin.actions.cancel')}
157
+ </Button>
158
+ <Button type="submit" disabled={pending}>
159
+ {pending ? t('admin.actions.saving') : t('admin.actions.save')}
160
+ </Button>
161
+ </DialogFooter>
162
+ </form>
163
+ </DialogContent>
164
+ </Dialog>
165
+ )
166
+ }
@@ -1,8 +1,8 @@
1
1
  import type { DraggableAttributes, DraggableSyntheticListeners } from '@dnd-kit/core'
2
- import { GripVerticalIcon } from 'lucide-react'
3
2
  import type { ComponentProps } from 'react'
4
3
  import { useTranslation } from 'react-i18next'
5
4
  import { cn } from '../lib/utils'
5
+ import { GripVerticalIcon } from './icons'
6
6
 
7
7
  interface DragHandleProps extends Omit<ComponentProps<'button'>, 'type'> {
8
8
  /** `attributes` from `useSortable` / `useDraggable`. */
@@ -38,7 +38,7 @@ export function DragHandle({ attributes, listeners, className, ...props }: DragH
38
38
  type="button"
39
39
  aria-label={t('admin.a11y.drag_to_reorder')}
40
40
  className={cn(
41
- 'flex h-full w-full items-center justify-center rounded-lg p-2 text-muted-foreground cursor-grab active:cursor-grabbing hover:bg-accent',
41
+ 'flex h-full w-full items-center justify-center rounded-lg p-2 text-muted-foreground cursor-grab active:cursor-grabbing hover:bg-accent-strong hover:text-accent-foreground',
42
42
  className,
43
43
  )}
44
44
  {...attributes}
@@ -0,0 +1,103 @@
1
+ import type { ReactNode } from 'react'
2
+ import { useTranslation } from 'react-i18next'
3
+ import { Button } from '../ui/button'
4
+ import { Card, CardAction, CardContent, CardHeader, CardTitle } from '../ui/card'
5
+ import { PencilIcon } from './icons'
6
+
7
+ /**
8
+ * A note on a record: shown as read-only text, edited in place behind a
9
+ * pencil, and reading "None" while empty.
10
+ *
11
+ * Shared by every note card on an order — the buyer's instructions, the
12
+ * merchant's own working note — so they cannot drift into different shapes.
13
+ * The editor itself comes from the caller, because a plain note and a
14
+ * rich-text one need different ones.
15
+ */
16
+ export function EditableNoteCard({
17
+ title,
18
+ editing,
19
+ onEditingChange,
20
+ children,
21
+ editor,
22
+ onSave,
23
+ pending = false,
24
+ }: {
25
+ title: string
26
+ editing: boolean
27
+ onEditingChange: (editing: boolean) => void
28
+ /** The read view. Falsy renders the empty state instead. */
29
+ children?: ReactNode
30
+ editor: ReactNode
31
+ onSave: () => void
32
+ pending?: boolean
33
+ }) {
34
+ const { t } = useTranslation()
35
+
36
+ return (
37
+ <Card>
38
+ <CardHeader>
39
+ <CardTitle>{title}</CardTitle>
40
+ <CardAction>
41
+ <Button
42
+ // Explicit: a card on the order page sits inside that page's own
43
+ // form, and a button with no type submits it.
44
+ type="button"
45
+ variant="ghost"
46
+ size="icon-sm"
47
+ onClick={() => onEditingChange(!editing)}
48
+ aria-label={t('admin.actions.edit')}
49
+ >
50
+ <PencilIcon className="size-4" />
51
+ </Button>
52
+ </CardAction>
53
+ </CardHeader>
54
+ <CardContent>
55
+ {editing ? (
56
+ <form
57
+ className="flex flex-col gap-3"
58
+ onSubmit={(event) => {
59
+ // The order page renders its cards inside their own forms —
60
+ // without stopping the bubble the browser submits the outer one.
61
+ event.preventDefault()
62
+ event.stopPropagation()
63
+ onSave()
64
+ }}
65
+ >
66
+ {editor}
67
+ <div className="flex justify-end gap-2">
68
+ <Button
69
+ type="button"
70
+ variant="outline"
71
+ size="sm"
72
+ onClick={() => onEditingChange(false)}
73
+ >
74
+ {t('admin.actions.cancel')}
75
+ </Button>
76
+ <Button type="submit" size="sm" disabled={pending}>
77
+ {pending ? t('admin.actions.saving') : t('admin.actions.save')}
78
+ </Button>
79
+ </div>
80
+ </form>
81
+ ) : (
82
+ (children ?? <p className="text-muted-foreground text-sm">{t('admin.common.none')}</p>)
83
+ )}
84
+ </CardContent>
85
+ </Card>
86
+ )
87
+ }
88
+
89
+ /** A plain-text note as it reads when not being edited. */
90
+ export function NoteText({ value }: { value: string }) {
91
+ return <p className="whitespace-pre-wrap text-muted-foreground text-sm">{value}</p>
92
+ }
93
+
94
+ /** A rich-text note as it reads when not being edited. */
95
+ export function NoteHtml({ html }: { html: string }) {
96
+ return (
97
+ <div
98
+ className="prose prose-sm dark:prose-invert max-w-none text-muted-foreground"
99
+ // biome-ignore lint/security/noDangerouslySetInnerHtml: HTML is sanitized server-side via the rich-text pipeline
100
+ dangerouslySetInnerHTML={{ __html: html }}
101
+ />
102
+ )
103
+ }
@@ -0,0 +1,56 @@
1
+ import type * as React from 'react'
2
+ import { cn } from '../lib/utils'
3
+ import { ExternalLinkIcon } from './icons'
4
+
5
+ /**
6
+ * A link that leaves the dashboard — documentation, a storefront, a carrier's
7
+ * tracking page, a provider's console.
8
+ *
9
+ * The trailing arrow is the point: it tells the reader this one opens a new tab
10
+ * before they click it, which a bare link cannot. Four hand-rolled versions of
11
+ * this had grown across the two panels, each with its own colour, gap and icon
12
+ * size, so a docs link looked like one thing next to a title and another inside
13
+ * a card.
14
+ *
15
+ * `rel="noopener noreferrer"` is not optional on a `_blank` link: without
16
+ * `noopener` the opened page can reach back through `window.opener` and
17
+ * navigate this one. `target` and `rel` are therefore not part of the public
18
+ * props — a caller cannot spread them back in and undo the isolation.
19
+ */
20
+ export function ExternalLink({
21
+ className,
22
+ children,
23
+ // Pulled out of `props` so they cannot be spread back in below, and typed
24
+ // `never` so a caller is told at compile time too. Types alone would not be
25
+ // enough: they vanish at runtime, so an untyped caller or a plain-JS
26
+ // consumer of this source-shipped package could still land its own
27
+ // `target`/`rel` after ours and undo the isolation.
28
+ target: _target,
29
+ rel: _rel,
30
+ ...props
31
+ }: Omit<React.ComponentProps<'a'>, 'target' | 'rel'> & {
32
+ href: string
33
+ target?: never
34
+ rel?: never
35
+ }) {
36
+ return (
37
+ <a
38
+ data-slot="external-link"
39
+ target="_blank"
40
+ rel="noopener noreferrer"
41
+ className={cn(
42
+ // `--link` rather than the accent: this is body-copy text, and the
43
+ // accent fails 4.5:1 on both the card and the page.
44
+ 'inline-flex items-center gap-1 text-link transition-colors duration-100 ease-out hover:text-link-hover',
45
+ // Sized relative to the text it sits in, so the arrow stays in
46
+ // proportion whether the link is in a heading or a caption.
47
+ '[&>svg]:size-[1em] [&>svg]:shrink-0',
48
+ className,
49
+ )}
50
+ {...props}
51
+ >
52
+ {children}
53
+ <ExternalLinkIcon aria-hidden />
54
+ </a>
55
+ )
56
+ }
@@ -0,0 +1,68 @@
1
+ import type { ComponentType } from 'react'
2
+ import {
3
+ FileArchiveIcon,
4
+ FileAudioIcon,
5
+ FileCodeIcon,
6
+ FileIcon,
7
+ FileImageIcon,
8
+ FileSpreadsheetIcon,
9
+ FileTextIcon,
10
+ FileVideoIcon,
11
+ } from './icons'
12
+
13
+ type IconComponent = ComponentType<{ className?: string }>
14
+
15
+ // Matched on the MIME type where it is specific enough, then on the extension —
16
+ // servers routinely hand back `application/octet-stream` for anything they do
17
+ // not recognise, which would otherwise flatten every row to a blank page icon.
18
+ const BY_SUBTYPE: Array<[RegExp, IconComponent]> = [
19
+ [/^image\//, FileImageIcon],
20
+ [/^audio\//, FileAudioIcon],
21
+ [/^video\//, FileVideoIcon],
22
+ [/(zip|compressed|tar|gzip|rar|7z)/, FileArchiveIcon],
23
+ [/(spreadsheet|excel|csv)/, FileSpreadsheetIcon],
24
+ [/(json|xml|javascript|typescript|html|x-ruby|x-python|x-sh)/, FileCodeIcon],
25
+ [/(pdf|word|document|text|rtf|epub)/, FileTextIcon],
26
+ ]
27
+
28
+ const BY_EXTENSION: Array<[RegExp, IconComponent]> = [
29
+ [/\.(png|jpe?g|gif|webp|svg|avif|heic|tiff?)$/i, FileImageIcon],
30
+ [/\.(mp3|wav|flac|aac|ogg|m4a|aiff?)$/i, FileAudioIcon],
31
+ [/\.(mp4|mov|avi|mkv|webm|m4v)$/i, FileVideoIcon],
32
+ [/\.(zip|rar|7z|tar|gz|bz2|dmg|iso)$/i, FileArchiveIcon],
33
+ [/\.(csv|tsv|xlsx?|numbers|ods)$/i, FileSpreadsheetIcon],
34
+ [/\.(json|xml|ya?ml|js|ts|tsx|rb|py|sh|css|html?)$/i, FileCodeIcon],
35
+ [/\.(pdf|docx?|txt|rtf|md|epub|pages)$/i, FileTextIcon],
36
+ ]
37
+
38
+ /**
39
+ * Picks the icon for a downloadable file. Falls back to a plain page rather
40
+ * than guessing, so an unknown type reads as "a file" instead of the wrong one.
41
+ */
42
+ export function fileTypeIcon(filename?: string | null, contentType?: string | null): IconComponent {
43
+ const type = contentType?.toLowerCase() ?? ''
44
+ for (const [pattern, icon] of BY_SUBTYPE) {
45
+ if (pattern.test(type)) return icon
46
+ }
47
+
48
+ const name = filename ?? ''
49
+ for (const [pattern, icon] of BY_EXTENSION) {
50
+ if (pattern.test(name)) return icon
51
+ }
52
+
53
+ return FileIcon
54
+ }
55
+
56
+ /** Convenience wrapper for the common `<FileTypeIcon .../>` call site. */
57
+ export function FileTypeIcon({
58
+ filename,
59
+ contentType,
60
+ className,
61
+ }: {
62
+ filename?: string | null
63
+ contentType?: string | null
64
+ className?: string
65
+ }) {
66
+ const Icon = fileTypeIcon(filename, contentType)
67
+ return <Icon className={className} />
68
+ }
@@ -1,9 +1,9 @@
1
1
  import { useHotkey } from '@tanstack/react-hotkeys'
2
- import { Loader2Icon } from 'lucide-react'
3
2
  import { useEffect } from 'react'
4
3
  import { type FieldValues, type UseFormReturn, useFormState } from 'react-hook-form'
5
4
  import { useTranslation } from 'react-i18next'
6
5
  import { Button } from '../ui/button'
6
+ import { Loader2Icon } from './icons'
7
7
 
8
8
  /**
9
9
  * Submit button wired to a `react-hook-form` instance. Always rendered,
@@ -31,7 +31,7 @@ export function FormSaveButton<TFieldValues extends FieldValues>({
31
31
  const { isDirty, isSubmitting } = useFormState({ control: form.control })
32
32
 
33
33
  return (
34
- <Button type="submit" size="sm" disabled={!isDirty || isSubmitting}>
34
+ <Button type="submit" disabled={!isDirty || isSubmitting}>
35
35
  {isSubmitting && <Loader2Icon className="animate-spin" />}
36
36
  {isSubmitting
37
37
  ? (savingLabel ?? t('admin.actions.saving'))
@@ -65,7 +65,6 @@ export function FormDiscardButton<TFieldValues extends FieldValues>({
65
65
  return (
66
66
  <Button
67
67
  type="button"
68
- size="sm"
69
68
  variant="outline"
70
69
  disabled={isSubmitting}
71
70
  onClick={onDiscard ?? (() => form.reset())}
@@ -0,0 +1,46 @@
1
+ import type { ReactNode } from 'react'
2
+ import { FieldGroup, FieldLegend, FieldSet } from '../ui/field'
3
+
4
+ interface FormSectionProps {
5
+ /** Names the group. Rendered as a legend, so it labels the fields inside it
6
+ * rather than merely preceding them. */
7
+ title?: string
8
+ description?: string
9
+ /** Right-aligned beside the title — an "Add" button, a count, a toggle. */
10
+ action?: ReactNode
11
+ children: ReactNode
12
+ }
13
+
14
+ /**
15
+ * A titled group of form fields.
16
+ *
17
+ * Built on FieldSet/FieldLegend rather than a heading beside a div: a heading
18
+ * only precedes the inputs, while a legend is announced as the group they
19
+ * belong to — the difference between "Pickup" read once and every control
20
+ * under it being understood as part of pickup.
21
+ */
22
+ export function FormSection({ title, description, action, children }: FormSectionProps) {
23
+ const hasHeading = title || description || action
24
+
25
+ return (
26
+ <FieldSet className="gap-3">
27
+ {hasHeading && (
28
+ <div className="flex items-start justify-between gap-2">
29
+ <div className="flex flex-col gap-0.5">
30
+ {title && (
31
+ <FieldLegend variant="label" className="mb-0">
32
+ {title}
33
+ </FieldLegend>
34
+ )}
35
+ {description && <p className="text-muted-foreground text-xs">{description}</p>}
36
+ </div>
37
+ {action}
38
+ </div>
39
+ )}
40
+ {/* The group paints the card that separates one section from the next.
41
+ Sections are siblings, never nested, so no card ever sits inside
42
+ another. */}
43
+ <FieldGroup>{children}</FieldGroup>
44
+ </FieldSet>
45
+ )
46
+ }