@podoba/react 0.0.32 → 0.0.35

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 (70) hide show
  1. package/package.json +3 -3
  2. package/src/components/asset-masonry-grid.examples.tsx +9 -0
  3. package/src/components/asset-masonry-grid.tsx +62 -0
  4. package/src/components/asset-selection-surface.examples.tsx +32 -0
  5. package/src/components/asset-selection-surface.tsx +184 -0
  6. package/src/components/brand-page-header.tsx +297 -67
  7. package/src/components/button.tsx +27 -4
  8. package/src/components/combobox.tsx +1 -1
  9. package/src/components/compact-action-button.examples.tsx +7 -0
  10. package/src/components/compact-action-button.tsx +21 -0
  11. package/src/components/compact-settings-dialog.examples.tsx +15 -0
  12. package/src/components/compact-settings-dialog.tsx +48 -0
  13. package/src/components/context-action-glyph.examples.tsx +9 -0
  14. package/src/components/context-action-glyph.tsx +61 -0
  15. package/src/components/context-menu.tsx +191 -107
  16. package/src/components/context-search-panel.examples.tsx +9 -0
  17. package/src/components/context-search-panel.tsx +50 -0
  18. package/src/components/csv-binding-presentation.examples.tsx +14 -0
  19. package/src/components/csv-binding-presentation.tsx +40 -0
  20. package/src/components/cta-pill.tsx +19 -4
  21. package/src/components/dashboard-grid.tsx +1 -1
  22. package/src/components/date-picker.tsx +5 -3
  23. package/src/components/date-selection-calendar.examples.tsx +8 -0
  24. package/src/components/date-selection-calendar.tsx +47 -0
  25. package/src/components/delivery/send-to-print-modal.tsx +351 -84
  26. package/src/components/dialog-action-button.examples.tsx +7 -0
  27. package/src/components/dialog-action-button.tsx +22 -0
  28. package/src/components/dialog.tsx +32 -12
  29. package/src/components/document-upload-panel.examples.tsx +12 -0
  30. package/src/components/document-upload-panel.tsx +48 -0
  31. package/src/components/dropdown-menu.tsx +5 -3
  32. package/src/components/empty-panel-action.examples.tsx +7 -0
  33. package/src/components/empty-panel-action.tsx +8 -0
  34. package/src/components/field-appearance.ts +14 -0
  35. package/src/components/focus-field.tsx +3 -3
  36. package/src/components/icons.tsx +30 -0
  37. package/src/components/input.examples.tsx +8 -0
  38. package/src/components/input.tsx +13 -5
  39. package/src/components/media-asset-panel.examples.tsx +11 -0
  40. package/src/components/media-asset-panel.tsx +58 -0
  41. package/src/components/media-gallery.examples.tsx +12 -0
  42. package/src/components/media-gallery.tsx +53 -0
  43. package/src/components/media-settings-dialog.examples.tsx +21 -0
  44. package/src/components/media-settings-dialog.tsx +83 -0
  45. package/src/components/preview-info-card.examples.tsx +18 -0
  46. package/src/components/preview-info-card.tsx +27 -0
  47. package/src/components/reload-icon.examples.tsx +7 -0
  48. package/src/components/reload-icon.tsx +6 -0
  49. package/src/components/request-changes-modal.examples.tsx +26 -0
  50. package/src/components/request-changes-modal.tsx +36 -19
  51. package/src/components/rich-text-editor.tsx +1 -1
  52. package/src/components/select.examples.tsx +17 -0
  53. package/src/components/select.tsx +78 -22
  54. package/src/components/settings-dialog-surface.examples.tsx +44 -0
  55. package/src/components/settings-dialog-surface.tsx +240 -0
  56. package/src/components/side-panel.examples.tsx +80 -0
  57. package/src/components/side-panel.tsx +140 -0
  58. package/src/components/subtle.tsx +64 -8
  59. package/src/components/table.examples.tsx +9 -0
  60. package/src/components/table.tsx +29 -12
  61. package/src/components/template-catalog-card.examples.tsx +25 -0
  62. package/src/components/template-catalog-card.tsx +178 -0
  63. package/src/components/text.tsx +14 -1
  64. package/src/components/textarea.examples.tsx +8 -0
  65. package/src/components/textarea.tsx +16 -6
  66. package/src/components/tile.tsx +16 -17
  67. package/src/editor/block-editor.tsx +56 -14
  68. package/src/index.ts +20 -0
  69. package/src/layout/app-shell.tsx +13 -9
  70. package/src/layout/topbar.tsx +9 -12
@@ -11,12 +11,16 @@
11
11
  // follow-up `changes` task returned by the command.
12
12
  //
13
13
  // a11y: the textarea is label-associated (htmlFor/id), marked `required` +
14
- // `aria-invalid` while empty; the server error is role="alert"; confirm shows a
15
- // pending state and is disabled while in flight or while the note is empty.
14
+ // the server error is role="alert"; confirm shows a pending state and is disabled
15
+ // while in flight or while the note is empty. The note is marked `isRequired`
16
+ // (→ aria-required), NOT aria-invalid-while-empty as the raw textarea used to be:
17
+ // an untouched required field is incomplete, not invalid, and RAC coupling
18
+ // `isInvalid` to the danger ring would alarm the field before anyone typed.
16
19
 
17
- import { useId, useState } from 'react'
20
+ import { useLayoutEffect, useRef, useState } from 'react'
18
21
  import { Button } from './button'
19
22
  import { Dialog } from './dialog'
23
+ import { Textarea } from './textarea'
20
24
 
21
25
  /** Strings the request-changes modal renders — supplied by the app (i18n). */
22
26
  export interface RequestChangesModalLabels {
@@ -63,14 +67,24 @@ export function RequestChangesModal({
63
67
  labels,
64
68
  'data-testid': testId,
65
69
  }: RequestChangesModalProps): React.ReactNode {
66
- const noteId = useId()
67
70
  // CONTROLLED textarea: the confirm button's disabled state depends on whether
68
71
  // the note is non-empty, so the value drives dependent UI (unlike approve).
69
72
  const [note, setNote] = useState('')
73
+ const noteRootRef = useRef<HTMLDivElement>(null)
70
74
  const isEmpty = note.trim().length === 0
71
75
  const canConfirm = !isEmpty && !isPending
72
76
 
77
+ useLayoutEffect(() => {
78
+ if (!isOpen || isPending) return
79
+ noteRootRef.current?.querySelector('textarea')?.focus()
80
+ const frame = requestAnimationFrame(() => {
81
+ noteRootRef.current?.querySelector('textarea')?.focus()
82
+ })
83
+ return () => cancelAnimationFrame(frame)
84
+ }, [isOpen, isPending])
85
+
73
86
  const handleConfirm = (): void => {
87
+ if (isPending) return
74
88
  const trimmed = note.trim()
75
89
  if (trimmed.length === 0) return
76
90
  onConfirm(trimmed)
@@ -82,27 +96,30 @@ export function RequestChangesModal({
82
96
  data-testid={testId ?? 'request-changes-modal'}
83
97
  isOpen={isOpen}
84
98
  onOpenChange={(open) => {
99
+ if (isPending) return
85
100
  if (!open) setNote('')
86
101
  onOpenChange(open)
87
102
  }}
103
+ isDismissable={!isPending}
104
+ closeLabel={labels.cancel}
88
105
  >
89
106
  <div className="flex flex-col gap-3">
90
107
  <p className="text-small text-fg-muted">{labels.body}</p>
91
- <label htmlFor={noteId} className="text-small font-medium text-fg">
92
- {labels.noteLabel}
93
- </label>
94
- <textarea
95
- id={noteId}
96
- data-testid="request-changes-note"
97
- value={note}
98
- onChange={(event) => setNote(event.target.value)}
99
- maxLength={10_000}
100
- rows={4}
101
- required
102
- aria-invalid={isEmpty ? true : undefined}
103
- placeholder={labels.notePlaceholder}
104
- className="w-full rounded-md border border-border bg-surface px-3 py-2 text-small text-fg outline-none transition-colors placeholder:text-fg-muted focus-visible:ring-2 focus-visible:ring-ring"
105
- />
108
+ <div ref={noteRootRef}>
109
+ <Textarea
110
+ autoFocus
111
+ appearance="filled"
112
+ label={labels.noteLabel}
113
+ data-testid="request-changes-note"
114
+ value={note}
115
+ isDisabled={isPending}
116
+ onChange={setNote}
117
+ maxLength={10_000}
118
+ rows={4}
119
+ isRequired
120
+ placeholder={labels.notePlaceholder}
121
+ />
122
+ </div>
106
123
  {error ? (
107
124
  <p data-testid="request-changes-error" role="alert" className="text-small text-danger">
108
125
  {error}
@@ -145,7 +145,7 @@ export function RichTextEditor({
145
145
  </div>
146
146
  <div
147
147
  ref={ref}
148
- className="prose prose-sm max-w-none px-4 py-3.5 text-small leading-relaxed text-fg outline-none empty:before:text-fg-subtle empty:before:content-[attr(data-placeholder)]"
148
+ className="prose prose-sm max-w-none px-4 py-3.5 text-small leading-relaxed text-fg outline-none empty:before:text-fg-muted empty:before:content-[attr(data-placeholder)]"
149
149
  style={{ minHeight: bodyMinHeight }}
150
150
  contentEditable
151
151
  suppressContentEditableWarning
@@ -0,0 +1,17 @@
1
+ import { Select, SelectItem } from './select'
2
+ import type { FieldAppearance } from './field-appearance'
3
+
4
+ function Example({ appearance = 'outlined', disabled = false, invalid = false }: { appearance?: FieldAppearance; disabled?: boolean; invalid?: boolean }) {
5
+ return <Select appearance={appearance} label="Content behavior" placeholder="Choose behavior" isDisabled={disabled}
6
+ isInvalid={invalid} errorMessage="Choose a behavior." defaultSelectedKey={invalid ? undefined : 'shared'}>
7
+ <SelectItem id="shared">Shared across outputs</SelectItem>
8
+ <SelectItem id="separate">Edited separately per output</SelectItem>
9
+ <SelectItem id="unavailable" isDisabled>Unavailable behavior</SelectItem>
10
+ </Select>
11
+ }
12
+ export const examples = {
13
+ default: () => <Example />,
14
+ variants: () => <><Example /><Example appearance="filled" /></>,
15
+ states: () => <><Example appearance="filled" disabled /><Example appearance="filled" invalid /></>,
16
+ }
17
+ export const meta = { category: 'Form', description: 'Outlined and Manager-style filled selects with keyboard/typeahead navigation. Filled options retain a keyboard outline and larger narrow-screen hit targets for accessibility.' }
@@ -1,4 +1,4 @@
1
- import type { ReactNode } from 'react'
1
+ import { createContext, useContext, type CSSProperties, type ReactNode } from 'react'
2
2
  import {
3
3
  Button as RACButton,
4
4
  FieldError,
@@ -14,6 +14,12 @@ import {
14
14
  } from 'react-aria-components'
15
15
  import { uic } from '../utils/uic'
16
16
  import { useInFocusOverlay } from './focus-context'
17
+ import type { FieldAppearance } from './field-appearance'
18
+
19
+ const SelectAppearanceContext = createContext<FieldAppearance>('outlined')
20
+ const filledPopoverStyle: CSSProperties & { '--select-popup-max-width': string } = {
21
+ '--select-popup-max-width': 'calc(100vw - var(--spacing) * 6)',
22
+ }
17
23
 
18
24
  /**
19
25
  * Select — accessible dropdown built on React Aria Components `Select`.
@@ -29,32 +35,77 @@ import { useInFocusOverlay } from './focus-context'
29
35
  */
30
36
  const SelectTrigger = uic(RACButton, {
31
37
  displayName: 'SelectTrigger',
32
- // gs source control geometry (58px tall, 20px inline padding, 8px radius) with
33
- // the shared bordered fill: gs draws it borderless on cream, but our Card is
34
- // also surface-card and dark theme collapses surface-card onto surface, so a
35
- // borderless cream trigger disappears. White fill + border keeps it visible and
36
- // consistent with the other form controls; hover darkens the border.
38
+ // Filled follows the Manager's borderless control; outlined preserves the
39
+ // existing default for consumers which have not opted into that appearance.
37
40
  baseClass:
38
- 'flex h-control-tall w-full items-center justify-between gap-2.5 rounded-lg border border-border bg-surface px-5 ' +
39
- 'text-small text-fg outline-none transition-colors ' +
41
+ 'flex w-full items-center justify-between gap-2.5 rounded-lg px-5 text-small text-fg outline-none transition-colors',
42
+ variants: {
43
+ appearance: {
44
+ filled: 'min-h-control-tall border-0 bg-surface-card py-5 font-normal leading-4.5 duration-200 motion-reduce:transition-none ' +
45
+ 'data-[hovered]:bg-surface-muted group-data-[open]:bg-surface-card ' +
46
+ 'data-[focus-visible]:outline-2 data-[focus-visible]:outline-ring data-[focus-visible]:outline-offset-2 ' +
47
+ 'group-data-[invalid]:ring-1 group-data-[invalid]:ring-danger ' +
48
+ 'data-[disabled]:bg-surface-card data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed',
49
+ outlined: 'h-control-tall border border-border bg-surface ' +
40
50
  'data-[hovered]:border-fg-subtle ' +
41
51
  'data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring ' +
42
52
  'group-data-[invalid]:border-danger group-data-[invalid]:ring-2 group-data-[invalid]:ring-danger ' +
43
53
  'data-[disabled]:bg-surface-muted data-[disabled]:opacity-60 data-[disabled]:pointer-events-none',
54
+ },
55
+ },
56
+ defaultVariants: { appearance: 'outlined' },
44
57
  })
45
58
 
46
- export const SelectItem = uic(ListBoxItem, {
59
+ const StyledSelectItem = uic(ListBoxItem, {
47
60
  displayName: 'SelectItem',
48
- // gs item: 0/20px padding (px-5, no vertical pad), 6px radius, selected =
49
- // medium weight. We add a `surface-muted` focus background (gs highlights with
50
- // weight only) so keyboard focus stays clearly visible on the cream content.
61
+ // Filled highlights through weight, as in Manager. Retain a keyboard outline
62
+ // and larger narrow-screen hit targets as explicit accessibility exceptions.
51
63
  baseClass:
52
- 'flex cursor-pointer select-none items-center rounded-md px-3 py-2 text-small text-fg outline-none ' +
64
+ 'flex cursor-pointer select-none items-center rounded-md text-small text-fg outline-none data-[disabled]:opacity-50 data-[disabled]:pointer-events-none',
65
+ variants: {
66
+ appearance: {
67
+ filled: 'min-w-0 overflow-hidden px-5 py-0 font-normal leading-4.5 max-md:min-h-11 ' +
68
+ 'data-[hovered]:font-medium data-[focused]:font-medium data-[selected]:font-medium ' +
69
+ 'data-[focus-visible]:outline-2 data-[focus-visible]:outline-ring data-[focus-visible]:-outline-offset-2',
70
+ outlined: 'px-3 py-2 ' +
53
71
  'data-[hovered]:bg-surface-muted data-[focused]:bg-surface-muted data-[selected]:font-medium ' +
54
72
  'data-[disabled]:opacity-50 data-[disabled]:pointer-events-none',
55
- }) as (props: ListBoxItemProps) => ReactNode
73
+ },
74
+ },
75
+ defaultVariants: { appearance: 'outlined' },
76
+ }) as (props: ListBoxItemProps & { appearance?: FieldAppearance }) => ReactNode
77
+
78
+ export const SelectItem = (props: ListBoxItemProps): ReactNode => {
79
+ const appearance = useContext(SelectAppearanceContext)
80
+ if (appearance === 'outlined') return <StyledSelectItem {...props} appearance={appearance} />
81
+ // Manager's ItemText is a single ellipsized line. Keep the full value in the
82
+ // label/textValue so keyboard search and assistive technology don't see a cut-off label.
83
+ const { children, textValue, ...rest } = props
84
+ return <StyledSelectItem {...rest} appearance={appearance} textValue={textValue ?? (typeof children === 'string' ? children : undefined)}>
85
+ {state => <Text slot="label" className="min-w-0 truncate">{typeof children === 'function' ? children(state) : children}</Text>}
86
+ </StyledSelectItem>
87
+ }
88
+
89
+ const SelectListBox = uic(ListBox, {
90
+ displayName: 'SelectListBox',
91
+ baseClass: 'flex flex-col overflow-auto overscroll-contain outline-none',
92
+ variants: { appearance: { outlined: 'max-h-72 gap-0.5 p-1', filled: 'gap-3 px-0 py-5' } },
93
+ defaultVariants: { appearance: 'outlined' },
94
+ })
95
+ const SelectPopover = uic(Popover, {
96
+ displayName: 'SelectPopover',
97
+ baseClass: 'overflow-hidden rounded-lg bg-surface-card shadow-lg',
98
+ variants: { appearance: {
99
+ outlined: 'min-w-(--trigger-width)',
100
+ filled: 'w-(--trigger-width) max-w-(--select-popup-max-width)',
101
+ } },
102
+ defaultVariants: { appearance: 'outlined' },
103
+ })
56
104
 
57
105
  export type SelectProps<T extends object> = RACSelectProps<T> & {
106
+ appearance?: FieldAppearance
107
+ /** Keep the accessible label without reserving an empty label row. */
108
+ isLabelHidden?: boolean
58
109
  /** Visible label (required for accessibility). */
59
110
  label: ReactNode
60
111
  description?: ReactNode
@@ -80,14 +131,16 @@ export const Select = <T extends object>({
80
131
  children,
81
132
  rootClassName,
82
133
  triggerClassName,
134
+ appearance = 'outlined',
135
+ isLabelHidden = false,
83
136
  ...props
84
137
  }: SelectProps<T>) => {
85
138
  // In a focus overlay, show the options inline (seamless) instead of a popover.
86
139
  const inFocus = useInFocusOverlay()
87
140
  const listbox = (
88
- <ListBox className="flex max-h-72 flex-col gap-0.5 overflow-auto overscroll-contain p-1 outline-none">
141
+ <SelectListBox appearance={appearance} style={appearance === 'filled' ? { maxHeight: 'inherit' } : undefined}>
89
142
  {children}
90
- </ListBox>
143
+ </SelectListBox>
91
144
  )
92
145
  const desc = description ? (
93
146
  <Text slot="description" className="text-label text-fg-muted">
@@ -97,8 +150,9 @@ export const Select = <T extends object>({
97
150
  const err = <FieldError className="text-label text-danger">{errorMessage}</FieldError>
98
151
 
99
152
  return (
153
+ <SelectAppearanceContext.Provider value={appearance}>
100
154
  <RACSelect {...props} placeholder={placeholder} className={`group flex flex-col gap-3 ${rootClassName ?? ''}`}>
101
- <Label className="text-panel-heading font-medium text-fg">{label}</Label>
155
+ <Label className={isLabelHidden ? 'sr-only' : 'text-panel-heading font-medium text-fg'}>{label}</Label>
102
156
  {inFocus ? (
103
157
  <>
104
158
  {listbox}
@@ -107,7 +161,7 @@ export const Select = <T extends object>({
107
161
  </>
108
162
  ) : (
109
163
  <>
110
- <SelectTrigger className={triggerClassName}>
164
+ <SelectTrigger className={triggerClassName} appearance={appearance}>
111
165
  <SelectValue className="data-[placeholder]:text-fg-muted" />
112
166
  {/* gs chevron: 9.5px caret, dark (neutral-400 → fg), non-interactive. */}
113
167
  <svg
@@ -123,13 +177,15 @@ export const Select = <T extends object>({
123
177
  </SelectTrigger>
124
178
  {desc}
125
179
  {err}
126
- {/* Cream fill, 8px radius, shadow-lg, NO border. 4px inset so each option's
127
- highlight sits as a padded pill; small gap for an even list rhythm. */}
128
- <Popover className="min-w-[var(--trigger-width)] overflow-hidden rounded-lg bg-surface-card shadow-lg">
180
+ {/* Filled uses the trigger width and Manager's 4px popup offset. */}
181
+ <SelectPopover appearance={appearance} offset={appearance === 'filled' ? 4 : undefined}
182
+ containerPadding={appearance === 'filled' ? 12 : undefined}
183
+ style={appearance === 'filled' ? filledPopoverStyle : undefined}>
129
184
  {listbox}
130
- </Popover>
185
+ </SelectPopover>
131
186
  </>
132
187
  )}
133
188
  </RACSelect>
189
+ </SelectAppearanceContext.Provider>
134
190
  )
135
191
  }
@@ -0,0 +1,44 @@
1
+ import { useState } from 'react'
2
+ import { Button } from './button'
3
+ import { Input } from './input'
4
+ import { WizardForm, WizardBrief, WizardFormDialog, ProductionSettingsDialog, ProductionSettingsAction, SettingsDialogDisclosure, SettingsDialogProductionColumns } from './settings-dialog-surface'
5
+ import { DialogActionButton } from './dialog-action-button'
6
+ import { SettingsDialogSurface, SettingsDialogIdentity, SettingsDialogAction, SettingsDialogEmphasis, SettingsDialogDescription, SettingsDialogLabel, SettingsDialogHint, type SettingsDialogVariant } from './settings-dialog-surface'
7
+
8
+ function Example({ variant = 'create', pending = false, error = false, wide = false }: { variant?: SettingsDialogVariant; pending?: boolean; error?: boolean; wide?: boolean }) {
9
+ const [open, setOpen] = useState(false)
10
+ const Action = variant === 'basic' ? DialogActionButton : SettingsDialogAction
11
+ return <>
12
+ <Button onPress={() => setOpen(true)}>Open {variant} dialog{pending ? ' (pending)' : error ? ' (error)' : ''}</Button>
13
+ <SettingsDialogSurface variant={variant} isOpen={open} onOpenChange={setOpen} isPending={pending}
14
+ isWide={wide} description={variant === 'csv' ? 'Review the column-to-field mapping before importing.' : undefined}
15
+ label="Settings example" closeLabel="Close settings"
16
+ title={variant === 'basic' ? <><SettingsDialogEmphasis>{'Set the project name\nand describe '}</SettingsDialogEmphasis>the brief.</> : variant === 'catalog' ? <>Choose an output template<br />to add to this section.</> : <>Create <span className="text-fg">a new section</span> for your <span className="text-fg">production workspace</span>.</>}
17
+ footer={<><Action variant="secondary" isDisabled={pending} onPress={() => setOpen(false)}>Cancel</Action><Action isPending={pending} isDisabled={pending}>Save</Action></>}
18
+ >
19
+ {variant === 'basic' ? <SettingsDialogDescription><SettingsDialogLabel>Basic information</SettingsDialogLabel><SettingsDialogHint>Keep the project name clear and add a short brief so the team understands what this work is about.</SettingsDialogHint></SettingsDialogDescription> : null}
20
+ <SettingsDialogIdentity><Input appearance="filled" label="Section name" defaultValue="Spring campaign" isDisabled={pending} /></SettingsDialogIdentity>
21
+ {error ? <p role="alert" className="text-small text-danger">The changes could not be saved. Please try again.</p> : null}
22
+ </SettingsDialogSurface>
23
+ </>
24
+ }
25
+ function ProductionExample() {
26
+ const [open, setOpen] = useState(false)
27
+ return <><Button onPress={() => setOpen(true)}>Open production settings</Button><ProductionSettingsDialog isOpen={open} onOpenChange={setOpen} prefix="Template settings" title="Poster" description="Configure production details." closeLabel="Close settings" preview={<span>Artwork preview</span>} footer={<><ProductionSettingsAction variant="secondary" onPress={() => setOpen(false)}>Close</ProductionSettingsAction><ProductionSettingsAction isDisabled>Save</ProductionSettingsAction></>}><SettingsDialogDisclosure title="General"><Input label="Name" appearance="filled" defaultValue="Poster" /></SettingsDialogDisclosure></ProductionSettingsDialog></>
28
+ }
29
+ function WizardExample({ expanded = false, pending = false }: { expanded?: boolean; pending?: boolean }) {
30
+ const [open, setOpen] = useState(false)
31
+ return <><Button onPress={() => setOpen(true)}>Open wizard {expanded ? 'catalog' : 'form'}{pending ? ' pending' : ''}</Button><WizardFormDialog isOpen={open} onOpenChange={setOpen} isExpanded={expanded} isPending={pending} closeLabel="Close wizard" title={<>Create a <SettingsDialogEmphasis>new preset</SettingsDialogEmphasis><br />and define its details.</>} footer={<><Button variant="secondary" isDisabled={pending} onPress={() => setOpen(false)}>Cancel</Button><Button isPending={pending} isDisabled={pending}>Next</Button></>}><WizardForm><Input label="Name" appearance="filled" isDisabled={pending} defaultValue="Campaign" /><WizardBrief label="Scenario brief">Prepare the selected templates and shared content.</WizardBrief></WizardForm></WizardFormDialog></>
32
+ }
33
+ export const examples = {
34
+ wizard: () => <WizardExample />,
35
+ wizardSizes: () => <><WizardExample /><WizardExample expanded /></>,
36
+ wizardPending: () => <WizardExample pending />,
37
+ production: () => <ProductionExample />,
38
+ default: () => <Example />,
39
+ productionColumns: () => <SettingsDialogProductionColumns><Input label="Material" appearance="filled" /><Input label="Surface finish" appearance="filled" /></SettingsDialogProductionColumns>,
40
+ disclosures: () => <><SettingsDialogDisclosure title="Settings" description="Campaign templates"><Input label="Name" appearance="filled" defaultValue="Poster" /></SettingsDialogDisclosure><SettingsDialogDisclosure title="Print" description="Available for Print templates" defaultExpanded={false}><Input label="Material" appearance="filled" isDisabled /></SettingsDialogDisclosure></>,
41
+ variants: () => <><Example variant="create" /><Example variant="edit" /><Example variant="catalog" /><Example variant="csv" /><Example variant="csv" wide /><Example variant="basic" /></>,
42
+ states: () => <><Example pending /><Example error /><Example variant="basic" pending /><Example variant="basic" error /></>,
43
+ }
44
+ export const meta = { category: 'Layout', description: 'Responsive settings and catalog frames with fixed headings/actions and a scrolling form body.' }
@@ -0,0 +1,240 @@
1
+ import { createContext, useContext, useEffect, useId, useState, type ComponentProps, type CSSProperties, type ReactNode, type RefObject } from 'react'
2
+ import { Button } from './button'
3
+ import { Button as AriaButton, Disclosure as AriaDisclosure, DisclosurePanel as AriaDisclosurePanel } from 'react-aria-components'
4
+ import { ModalDialog, ModalOverlay, ModalSurface } from './dialog'
5
+ import { DisplayHeading, Heading } from './text'
6
+ import { uic } from '../utils/uic'
7
+
8
+ export const SETTINGS_DIALOG_VARIANTS = ['create', 'edit', 'catalog', 'csv', 'basic'] as const
9
+ export type SettingsDialogVariant = (typeof SETTINGS_DIALOG_VARIANTS)[number]
10
+
11
+ const LayoutContext = createContext({ variant: 'edit' as SettingsDialogVariant, compact: false })
12
+ const Panel = uic(ModalSurface, { displayName: 'SettingsDialogPanel', baseClass: 'box-border flex flex-col overflow-hidden p-0' })
13
+ const Layout = uic(ModalDialog, { displayName: 'SettingsDialogLayout', baseClass: 'relative flex min-h-0 flex-1 flex-col outline-none' })
14
+ const Container = uic('div', { displayName: 'SettingsDialogContainer', baseClass: 'box-border mx-auto w-full' })
15
+ const ScrollBody = uic('div', { displayName: 'SettingsDialogScrollBody', baseClass: 'min-h-0 flex-1 overflow-y-auto' })
16
+ const Body = uic('div', { displayName: 'SettingsDialogBody', baseClass: 'flex w-full flex-col' })
17
+ const Group = uic('div', { displayName: 'SettingsDialogGroup', baseClass: 'flex min-w-0 flex-col' })
18
+ const Columns = uic('div', { displayName: 'SettingsDialogColumns', baseClass: 'grid' })
19
+
20
+ /** Label-driven settings/catalog envelope. State, requests and selections belong to callers.
21
+ * Widths use the viewport-sized, unpadded overlay; rem caps use the shared spacing scale.
22
+ * Create and edit deliberately have different geometry, including below the 900px breakpoint.
23
+ */
24
+ export function SettingsDialogSurface({ variant, isOpen, onOpenChange, isPending = false,
25
+ label, title, closeLabel, children, footer, filters, bodyRef, bodyTestId, description, describedBy, isWide = false, headerBottomPadding = false,
26
+ }: {
27
+ variant: SettingsDialogVariant
28
+ isOpen: boolean
29
+ onOpenChange: (open: boolean) => void
30
+ isPending?: boolean
31
+ label?: string
32
+ title: ReactNode
33
+ description?: ReactNode
34
+ /** IDs of caller-owned concise descriptions, e.g. a dynamic calendar hint. */
35
+ describedBy?: string
36
+ /** CSV switches from an upload frame to a wide data preview without remounting. */
37
+ isWide?: boolean
38
+ /** Adds the source Basic Information title-to-body separation without changing other basic dialogs. */
39
+ headerBottomPadding?: boolean
40
+ closeLabel: string
41
+ children: ReactNode
42
+ footer: ReactNode
43
+ filters?: ReactNode
44
+ bodyRef?: RefObject<HTMLDivElement | null>
45
+ bodyTestId?: string
46
+ }) {
47
+ const titleId = useId()
48
+ const descriptionId = useId()
49
+ const [compact, setCompact] = useState(() => typeof window !== 'undefined' && window.matchMedia('(max-width: 900px)').matches)
50
+ useEffect(() => {
51
+ const media = window.matchMedia('(max-width: 900px)')
52
+ const change = () => setCompact(media.matches)
53
+ change()
54
+ media.addEventListener('change', change)
55
+ return () => media.removeEventListener('change', change)
56
+ }, [])
57
+ const create = variant === 'create'
58
+ const catalog = variant === 'catalog'
59
+ const csv = variant === 'csv'
60
+ const basic = variant === 'basic'
61
+ const containerClass = csv && isWide ? compact ? 'px-5' : 'px-6' : catalog ? '' : compact
62
+ ? create ? 'px-(--settings-dialog-gutter-inline)' : 'px-5'
63
+ : create ? 'max-w-200 px-(--settings-dialog-gutter-inline)' : 'max-w-192 px-6'
64
+ // Source uses spacing8 × .6 (spacing6 × .6 on narrow screens). Tailwind's
65
+ // numeric spacing utilities do not emit .8/.6 steps, so derive a named value
66
+ // from the shared spacing token rather than silently emitting no padding.
67
+ const layoutStyle: CSSProperties & { '--settings-dialog-gutter-inline': string } = {
68
+ letterSpacing: 0,
69
+ '--settings-dialog-gutter-inline': compact ? 'calc(var(--spacing) * 3.6)' : 'calc(var(--spacing) * 4.8)',
70
+ }
71
+ const panelClass = compact
72
+ ? catalog || (csv && isWide) ? 'h-23/25 w-24/25' : 'h-23/25 w-47/50'
73
+ : catalog ? 'h-9/10 max-h-324 w-9/10 max-w-432'
74
+ : csv && isWide ? 'h-9/10 max-h-264 w-9/10 max-w-432'
75
+ : create ? 'h-9/10 max-h-288 w-1/2 max-w-288' : 'h-9/10 max-h-264 w-9/10 max-w-204'
76
+ const layoutClass = catalog ? compact ? 'gap-3 p-4' : 'gap-4 p-6'
77
+ : csv || basic ? compact ? 'gap-8 py-6' : 'gap-10 py-10'
78
+ : create ? compact ? 'gap-4 py-7.5' : 'gap-5 py-10'
79
+ : compact ? 'py-6' : 'py-10'
80
+ const bodyClass = catalog ? '' : basic ? 'gap-8' : create ? compact ? 'gap-4' : 'gap-5' : compact ? 'gap-8' : 'gap-10'
81
+ return <LayoutContext.Provider value={{ variant, compact }}>
82
+ <ModalOverlay className="p-0" isOpen={isOpen} onOpenChange={open => { if (!isPending) onOpenChange(open) }} isDismissable={!isPending} isKeyboardDismissDisabled={isPending}>
83
+ <Panel className={panelClass} data-settings-variant={variant} data-settings-compact={compact}>
84
+ <Layout aria-label={label} aria-labelledby={label ? undefined : titleId} aria-describedby={[description ? descriptionId : undefined, describedBy].filter(Boolean).join(' ') || undefined} className={layoutClass} style={layoutStyle}>
85
+ <Button variant="ghost" aria-label={closeLabel} isDisabled={isPending} onPress={() => onOpenChange(false)} className="absolute end-4 top-3.5 z-10 h-8 w-8 rounded-md p-0 text-fg-subtle">
86
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18" /></svg>
87
+ </Button>
88
+ <header className={catalog ? 'flex shrink-0 flex-col gap-2' : basic ? headerBottomPadding ? 'shrink-0 pb-4' : 'shrink-0' : create || csv ? 'shrink-0 pb-4' : 'shrink-0 pb-10 max-md:pb-8'}>
89
+ <Container className={containerClass}>
90
+ {catalog ? <Heading id={titleId} level="1" className="m-0 mb-4 max-w-5/6 whitespace-pre-line" style={{ letterSpacing: 0 }}>{title}</Heading>
91
+ : <DisplayHeading id={titleId} className={`m-0 whitespace-pre-line pb-0.5 text-fg-subtle ${create ? 'mb-4 max-w-5/6' : ''}`} style={{ letterSpacing: 0, ...(basic ? { maxWidth: '18ch' } : csv && isWide ? { maxWidth: '34ch' } : {}) }}>{title}</DisplayHeading>}
92
+ {description ? <p id={descriptionId} className="mb-0 mt-3 text-small font-normal text-fg-workflow-muted" style={{ maxWidth: '48ch' }}>{description}</p> : null}
93
+ </Container>
94
+ {filters ? <div className="w-full max-w-80">{filters}</div> : null}
95
+ </header>
96
+ <ScrollBody style={{ scrollbarGutter: 'stable' }}>
97
+ <Container className={containerClass}>
98
+ <Body ref={bodyRef} className={bodyClass} data-testid={bodyTestId}>{children}</Body>
99
+ </Container>
100
+ </ScrollBody>
101
+ <footer className={catalog ? 'shrink-0 border-t border-border bg-surface pt-3' : create ? 'shrink-0 border-t border-border bg-surface pt-4' : 'shrink-0 border-t border-border bg-surface pt-5'}>
102
+ <Container className={containerClass}>
103
+ <div className={`flex justify-end ${csv || basic ? 'items-center gap-1' : variant === 'edit' ? 'gap-3' : 'gap-2'}`}>{footer}</div>
104
+ </Container>
105
+ </footer>
106
+ </Layout>
107
+ </Panel>
108
+ </ModalOverlay>
109
+ </LayoutContext.Provider>
110
+ }
111
+
112
+ /** Multi-step form/catalog envelope. Switching width preserves mounted drafts. */
113
+ export const WizardForm = uic('form', { displayName: 'WizardForm', baseClass: 'flex flex-col gap-8' })
114
+ const BriefText = uic('p', { displayName: 'WizardBriefText', baseClass: 'm-0 min-h-32 rounded-lg bg-surface-card p-4 text-small text-fg-workflow-muted' })
115
+ export function WizardBrief({ label, children }: { label: ReactNode; children: ReactNode }) {
116
+ const id = useId()
117
+ return <Group className="gap-2" role="group" aria-labelledby={id}><SettingsDialogLabel id={id}>{label}</SettingsDialogLabel><BriefText>{children}</BriefText></Group>
118
+ }
119
+
120
+ export function WizardFormDialog({ isOpen, onOpenChange, isPending = false, isExpanded = false, title, closeLabel, children, footer }: {
121
+ isOpen: boolean; onOpenChange: (open: boolean) => void; isPending?: boolean; isExpanded?: boolean
122
+ title: ReactNode; closeLabel: string; children: ReactNode; footer: ReactNode
123
+ }) {
124
+ const titleId = useId()
125
+ return <ModalOverlay className="p-0" isOpen={isOpen} onOpenChange={open => { if (!isPending) onOpenChange(open) }} isDismissable={!isPending} isKeyboardDismissDisabled={isPending}>
126
+ <Panel data-wizard-expanded={isExpanded} className={`h-23/25 max-h-23/25 w-24/25 max-w-24/25 min-[901px]:h-9/10 min-[901px]:max-h-288 min-[901px]:w-9/10 ${isExpanded ? 'min-[901px]:max-w-432' : 'min-[901px]:max-w-204'}`}>
127
+ <Layout aria-labelledby={titleId} className="gap-8 py-6 min-[901px]:gap-10 min-[901px]:py-10">
128
+ <Button variant="ghost" aria-label={closeLabel} isDisabled={isPending} onPress={() => onOpenChange(false)} className="absolute end-4 top-3.5 z-10 h-8 w-8 rounded-md p-0 text-fg-subtle">
129
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18" /></svg>
130
+ </Button>
131
+ <header className="shrink-0"><Container className="max-w-384 px-5 min-[901px]:px-6"><DisplayHeading id={titleId} className="m-0 whitespace-pre-line pb-0.5 text-fg-subtle" style={{ letterSpacing: 0 }}>{title}</DisplayHeading></Container></header>
132
+ <ScrollBody style={{ scrollbarGutter: 'stable' }}><Container className="max-w-384 px-5 min-[901px]:px-6"><Body className="gap-8">{children}</Body></Container></ScrollBody>
133
+ <footer className="shrink-0 border-t border-border bg-surface pt-5"><Container className="max-w-384 px-5 min-[901px]:px-6"><div className="flex flex-wrap justify-end gap-2">{footer}</div></Container></footer>
134
+ </Layout>
135
+ </Panel>
136
+ </ModalOverlay>
137
+ }
138
+
139
+ export function SettingsDialogIdentity({ children }: { children: ReactNode }) {
140
+ const { variant } = useContext(LayoutContext)
141
+ return <Group className={variant === 'create' ? 'gap-4 border-b border-border pb-3' : 'gap-8'}>{children}</Group>
142
+ }
143
+ export function SettingsDialogDescription({ children }: { children: ReactNode }) {
144
+ const { variant } = useContext(LayoutContext)
145
+ return <Group className={variant === 'create' ? 'mt-2' : variant === 'basic' ? 'gap-1' : undefined}>{children}</Group>
146
+ }
147
+ export function SettingsDialogColumns({ children, metadata = false }: { children: ReactNode; metadata?: boolean }) {
148
+ const { variant, compact } = useContext(LayoutContext)
149
+ if (metadata) return <Columns className={`${compact ? 'grid-cols-1' : 'grid-cols-3'} gap-3`}>{children}</Columns>
150
+ return <Columns className={`${compact ? 'grid-cols-1' : 'grid-cols-2'} ${variant === 'create' ? compact ? 'gap-3' : 'gap-4' : 'gap-8'}`}>{children}</Columns>
151
+ }
152
+ export function SettingsDialogGroup({ children }: { children: ReactNode }) {
153
+ const { variant } = useContext(LayoutContext)
154
+ return <Group className={variant === 'create' ? 'gap-3 border-b border-border pb-3' : undefined}>{children}</Group>
155
+ }
156
+
157
+ /** Two-column production metadata grid from the split settings family.
158
+ * Independent of SettingsDialogSurface's 900px breakpoint; this source uses1100px.
159
+ */
160
+ export function SettingsDialogProductionColumns({ children }: { children: ReactNode }) {
161
+ const [compact, setCompact] = useState(() => typeof window !== 'undefined' && window.matchMedia('(max-width: 1100px)').matches)
162
+ useEffect(() => {
163
+ const media = window.matchMedia('(max-width: 1100px)')
164
+ const change = () => setCompact(media.matches)
165
+ change()
166
+ media.addEventListener('change', change)
167
+ return () => media.removeEventListener('change', change)
168
+ }, [])
169
+ return <Columns data-settings-production-columns className={`${compact ? 'grid-cols-1' : 'grid-cols-2'} min-w-0 gap-4`}>{children}</Columns>
170
+ }
171
+ export function SettingsDialogControl({ children }: { children: ReactNode }) {
172
+ const { variant } = useContext(LayoutContext)
173
+ return <Group className={variant === 'edit' ? 'mt-6' : undefined}>{children}</Group>
174
+ }
175
+ export const SettingsDialogLabel = uic('p', { displayName: 'SettingsDialogLabel', baseClass: 'm-0 text-panel-heading font-medium text-fg' })
176
+ export const SettingsDialogEmphasis = uic('span', { displayName: 'SettingsDialogEmphasis', baseClass: 'text-fg' })
177
+ const Hint = uic('p', { displayName: 'SettingsDialogHint', baseClass: 'm-0 text-small font-normal text-fg-workflow-muted' })
178
+ export function SettingsDialogHint({ style, ...props }: ComponentProps<typeof Hint>) {
179
+ const { variant } = useContext(LayoutContext)
180
+ return <Hint {...props} style={{ ...(variant === 'basic' ? { maxWidth: '48ch' } : {}), ...style }} />
181
+ }
182
+ export const SettingsDialogAction = uic(Button, { displayName: 'SettingsDialogAction', baseClass: 'h-11.5 rounded-full px-6 text-base font-medium leading-5', style: { letterSpacing: 0 } })
183
+
184
+ const ProductionLayout = uic(ModalDialog, { displayName: 'ProductionSettingsLayout', baseClass: 'relative grid h-full min-h-0 w-full overflow-hidden outline-none' })
185
+ const ProductionHeader = uic('header', { displayName: 'ProductionSettingsHeader', baseClass: 'flex shrink-0 flex-col items-start gap-4 px-8 pb-6 pt-8' })
186
+ const ProductionTitle = uic('h1', { displayName: 'ProductionSettingsTitle', baseClass: 'm-0 max-w-132 text-display-large font-medium text-fg-muted', style: { letterSpacing: '-0.02em' } })
187
+ const ProductionDescription = uic('p', { displayName: 'ProductionSettingsDescription', baseClass: 'm-0 max-w-132 text-body font-normal text-fg' })
188
+ const ProductionFields = uic('div', { displayName: 'ProductionSettingsFields', baseClass: 'flex min-h-0 flex-col gap-6 px-8 pb-8' })
189
+ const ProductionFooter = uic('footer', { displayName: 'ProductionSettingsFooter', baseClass: 'relative z-10 flex shrink-0 flex-wrap items-center gap-2 bg-surface px-8 pb-8 pt-4' })
190
+ const ProductionPreview = uic('aside', { displayName: 'ProductionSettingsPreview', baseClass: 'relative grid min-h-0 min-w-0 place-items-center overflow-hidden p-10', style: { background: 'radial-gradient(circle at 50% 42%, color-mix(in srgb, var(--color-surface) 8%, transparent) 0, transparent 42%), color-mix(in srgb, var(--color-fg) 84%, var(--color-surface))' } })
191
+ const ProductionArtwork = uic('div', { displayName: 'ProductionSettingsArtwork', baseClass: 'min-h-0 min-w-0 overflow-hidden', style: { width: '80%', height: '80%' } })
192
+ export const ProductionSettingsAction = uic(Button, { displayName: 'ProductionSettingsAction', baseClass: 'min-h-10 rounded-full px-6 text-body font-medium' })
193
+
194
+ /** Wide production settings, not the content editor's independently tuned split. */
195
+ export function ProductionSettingsDialog({ isOpen, onOpenChange, prefix, title, description, closeLabel, children, preview, footer, testId = 'production-settings' }: {
196
+ isOpen: boolean; onOpenChange: (open: boolean) => void; prefix: ReactNode; title: ReactNode; description: ReactNode; closeLabel: string
197
+ children: ReactNode; preview: ReactNode; footer: ReactNode; testId?: string
198
+ }) {
199
+ const titleId = useId(), descriptionId = useId()
200
+ const query = '(max-width: 1100px), (orientation: portrait)'
201
+ const [compact, setCompact] = useState(() => typeof window !== 'undefined' && window.matchMedia(query).matches)
202
+ useEffect(() => {
203
+ const media = window.matchMedia(query), change = () => setCompact(media.matches)
204
+ change(); media.addEventListener('change', change)
205
+ return () => media.removeEventListener('change', change)
206
+ }, [])
207
+ return <ModalOverlay isOpen={isOpen} onOpenChange={onOpenChange} isDismissable className="p-0">
208
+ <Panel style={{ width: compact ? '96vw' : 'min(90vw, 110rem)', maxWidth: compact ? '96vw' : 'min(90vw, 110rem)', height: compact ? '92vh' : 'min(82vh, 63.125rem)', maxHeight: compact ? '92vh' : 'min(82vh, 63.125rem)' }}>
209
+ <ProductionLayout aria-labelledby={titleId} aria-describedby={descriptionId} data-testid={testId} data-production-compact={compact} style={{ gridTemplateColumns: compact ? 'minmax(0,1fr)' : 'minmax(calc(var(--spacing) * 90),2fr) minmax(0,4fr)', gridTemplateRows: compact ? 'minmax(18rem,44%) minmax(0,1fr)' : 'minmax(0,1fr)' }}>
210
+ <section className={`flex min-h-0 min-w-0 flex-col bg-surface ${compact ? 'row-start-2 overflow-y-auto' : 'overflow-hidden'}`} aria-labelledby={titleId}>
211
+ <ProductionHeader><ProductionTitle id={titleId}>{prefix}{' '}<span className="text-fg">{title}</span></ProductionTitle><ProductionDescription id={descriptionId}>{description}</ProductionDescription></ProductionHeader>
212
+ <ProductionFields data-testid={`${testId}-form`} className={compact ? 'shrink-0' : 'flex-1 overflow-y-auto'} style={{ scrollbarGutter: 'stable' }}>{children}</ProductionFields>
213
+ <ProductionFooter>{footer}</ProductionFooter>
214
+ </section>
215
+ <ProductionPreview className={compact ? 'col-start-1 row-start-1' : undefined} data-testid={`${testId}-preview`}><ProductionArtwork data-testid={`${testId}-artwork`}>{preview}</ProductionArtwork></ProductionPreview>
216
+ <Button variant="ghost" aria-label={closeLabel} onPress={() => onOpenChange(false)} className="absolute right-4 top-4 z-20 h-8 w-8 rounded-md p-0 text-white/60 hover:bg-transparent hover:text-white"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18" /></svg></Button>
217
+ </ProductionLayout>
218
+ </Panel>
219
+ </ModalOverlay>
220
+ }
221
+
222
+ const DisclosureRoot = uic(AriaDisclosure, { displayName: 'SettingsDisclosureRoot', baseClass: 'group/settings-disclosure flex min-w-0 flex-col gap-3' })
223
+ const DisclosureTrigger = uic(AriaButton, { displayName: 'SettingsDisclosureTrigger', baseClass: 'flex w-full cursor-pointer items-start justify-between gap-3 bg-transparent p-0 text-left outline-none data-[focus-visible]:outline-2 data-[focus-visible]:outline-ring data-[focus-visible]:outline-offset-2' })
224
+ const DisclosureCopy = uic('span', { displayName: 'SettingsDisclosureCopy', baseClass: 'flex min-w-0 flex-col gap-1' })
225
+ const DisclosureTitle = uic('strong', { displayName: 'SettingsDisclosureTitle', baseClass: 'text-body font-medium leading-normal text-fg' })
226
+ const DisclosureHint = uic('span', { displayName: 'SettingsDisclosureHint', baseClass: 'text-body font-normal leading-normal text-fg-workflow-muted' })
227
+ const DisclosureBody = uic(AriaDisclosurePanel, { displayName: 'SettingsDisclosureBody', baseClass: 'flex min-w-0 flex-col gap-5 pt-3' })
228
+
229
+ /** Source outputTaskDropdownPanel: labelled, initially expanded settings group. */
230
+ export function SettingsDialogDisclosure({ title, description, children, defaultExpanded = true }: {
231
+ title: ReactNode; description?: ReactNode; children: ReactNode; defaultExpanded?: boolean
232
+ }) {
233
+ return <DisclosureRoot defaultExpanded={defaultExpanded}>
234
+ <DisclosureTrigger slot="trigger">
235
+ <DisclosureCopy><DisclosureTitle>{title}</DisclosureTitle>{description ? <DisclosureHint>{description}</DisclosureHint> : null}</DisclosureCopy>
236
+ <svg aria-hidden="true" width="15" height="15" viewBox="0 0 15 15" fill="none" className="mt-1 shrink-0 text-fg-workflow-muted transition-transform duration-150 group-data-[expanded]/settings-disclosure:rotate-180 motion-reduce:transition-none"><path d="m3.5 5.5 4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></svg>
237
+ </DisclosureTrigger>
238
+ <DisclosureBody>{children}</DisclosureBody>
239
+ </DisclosureRoot>
240
+ }