@softize/opus 18.4.0 → 19.0.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 (42) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/docs/adr/0020-icon-tiles-are-explicitly-composed.md +46 -0
  3. package/docs/adr/0021-content-carries-structure-and-semantic-element.md +65 -0
  4. package/docs/adr/0023-surfaces-share-one-anatomy-and-two-layouts.md +293 -0
  5. package/package.json +1 -1
  6. package/src/ui/components/patterns/content-header.tsx +169 -50
  7. package/src/ui/components/patterns/heading-level.ts +16 -0
  8. package/src/ui/components/patterns/list.tsx +76 -27
  9. package/src/ui/components/patterns/pane.tsx +294 -0
  10. package/src/ui/components/patterns/presentation.tsx +29 -17
  11. package/src/ui/components/patterns/sidebar.tsx +13 -72
  12. package/src/ui/components/patterns/split.tsx +8 -35
  13. package/src/ui/components/patterns/surface-anatomy.ts +219 -0
  14. package/src/ui/components/patterns/surface-header.tsx +51 -6
  15. package/src/ui/components/patterns/surface-media.tsx +69 -0
  16. package/src/ui/components/primitives/card.tsx +91 -29
  17. package/src/ui/components/primitives/command.tsx +12 -4
  18. package/src/ui/components/primitives/detail.tsx +10 -13
  19. package/src/ui/components/primitives/dialog.tsx +138 -35
  20. package/src/ui/components/primitives/drawer.tsx +122 -23
  21. package/src/ui/components/primitives/empty.tsx +2 -1
  22. package/src/ui/components/primitives/field.tsx +38 -16
  23. package/src/ui/components/primitives/icon-tile.tsx +91 -0
  24. package/src/ui/components/primitives/item.tsx +2 -1
  25. package/src/ui/components/primitives/metric-card.tsx +98 -39
  26. package/src/ui/components/primitives/table.tsx +1 -1
  27. package/src/ui/docs/content/action-list.md +61 -52
  28. package/src/ui/docs/content/card.md +221 -30
  29. package/src/ui/docs/content/content.md +90 -8
  30. package/src/ui/docs/content/dialog.md +115 -7
  31. package/src/ui/docs/content/drawer.md +136 -9
  32. package/src/ui/docs/content/field.md +78 -30
  33. package/src/ui/docs/content/icon-tile.md +47 -0
  34. package/src/ui/docs/content/metric-card.md +57 -18
  35. package/src/ui/docs/content/page.md +8 -0
  36. package/src/ui/docs/content/pane.md +250 -0
  37. package/src/ui/docs/content/sidebar.md +22 -43
  38. package/src/ui/docs/content/skeleton.md +7 -3
  39. package/src/ui/docs/content/split.md +16 -21
  40. package/src/ui/docs/registry.tsx +14 -2
  41. package/src/ui/meta.ts +17 -5
  42. package/src/ui/react.tsx +31 -0
@@ -4,11 +4,13 @@ import {
4
4
  isValidElement,
5
5
  useContext,
6
6
  useId,
7
+ type ComponentProps,
7
8
  type HTMLAttributes,
8
9
  type ReactElement,
9
10
  type ReactNode,
10
11
  } from "react";
11
12
  import { cn } from "../../lib/cn.ts";
13
+ import { headingTags } from "./heading-level.ts";
12
14
  import { usePageHeadingPresence } from "./page-heading-context.tsx";
13
15
  import {
14
16
  SurfaceHeader,
@@ -18,8 +20,13 @@ import {
18
20
 
19
21
  export type ContentHeaderLevel = 1 | 2 | 3 | 4 | 5 | 6;
20
22
  export type ContentHeaderVariant = SurfaceHeaderVariant;
23
+ export type ContentElement = "section" | "fieldset";
24
+ export type ContentFrameVariant = "default" | "legend";
21
25
 
22
26
  interface ContentContextValue {
27
+ element: ContentElement;
28
+ frameVariant: ContentFrameVariant;
29
+ framed: boolean;
23
30
  level: ContentHeaderLevel;
24
31
  titleId: string;
25
32
  variant: ContentHeaderVariant;
@@ -38,17 +45,37 @@ function requiredContext<T>(
38
45
  return value;
39
46
  }
40
47
 
41
- interface ContentBaseProps extends Omit<
42
- HTMLAttributes<HTMLElement>,
43
- "children" | "title"
44
- > {
45
- /** Nível semântico do heading, independente de seu destaque visual. */
46
- level?: ContentHeaderLevel;
48
+ interface ContentSharedProps {
49
+ /** Aplica a moldura estrutural da região sem transferi-la ao conteúdo interno. */
50
+ framed?: boolean;
47
51
  /** Hierarquia visual: `section` (default) ou `page`, a mesma de PageHeader. */
48
52
  variant?: ContentHeaderVariant;
49
53
  }
50
54
 
51
- interface ContentShorthandProps extends ContentBaseProps {
55
+ interface ContentSectionBaseProps
56
+ extends
57
+ ContentSharedProps,
58
+ Omit<ComponentProps<"section">, "children" | "title"> {
59
+ /** Elemento semântico da região. */
60
+ as?: "section";
61
+ frameVariant?: "default";
62
+ /** Nível semântico do heading, independente de seu destaque visual. */
63
+ level?: ContentHeaderLevel;
64
+ }
65
+
66
+ interface ContentFieldsetBaseProps
67
+ extends
68
+ ContentSharedProps,
69
+ Omit<ComponentProps<"fieldset">, "children" | "title"> {
70
+ /** Usa a semântica nativa de grupo de campos; o título vira `legend`. */
71
+ as: "fieldset";
72
+ /** Tratamento visual da moldura: contínua (default) ou recortada pela legenda. */
73
+ frameVariant?: ContentFrameVariant;
74
+ level?: never;
75
+ variant?: "section";
76
+ }
77
+
78
+ interface ContentSectionShorthandProps extends ContentSectionBaseProps {
52
79
  title: ReactNode;
53
80
  /** Total de itens ao lado do título da seção. A página não carrega contador (ADR 0009). */
54
81
  count?: number;
@@ -57,7 +84,15 @@ interface ContentShorthandProps extends ContentBaseProps {
57
84
  children: ReactNode;
58
85
  }
59
86
 
60
- interface ContentComposedProps extends ContentBaseProps {
87
+ interface ContentFieldsetShorthandProps extends ContentFieldsetBaseProps {
88
+ title: ReactNode;
89
+ count?: never;
90
+ description?: never;
91
+ actions?: never;
92
+ children: ReactNode;
93
+ }
94
+
95
+ interface ContentComposedProps {
61
96
  title?: never;
62
97
  count?: never;
63
98
  description?: never;
@@ -65,22 +100,36 @@ interface ContentComposedProps extends ContentBaseProps {
65
100
  children: ReactNode;
66
101
  }
67
102
 
68
- export type ContentProps = ContentShorthandProps | ContentComposedProps;
103
+ export type ContentProps =
104
+ | ContentSectionShorthandProps
105
+ | ContentFieldsetShorthandProps
106
+ | ((ContentSectionBaseProps | ContentFieldsetBaseProps) &
107
+ ContentComposedProps);
69
108
 
70
109
  /** Região de conteúdo nomeada. A forma curta e a explícita produzem a mesma anatomia. */
71
110
  export function Content({
111
+ as = "section",
72
112
  title,
73
113
  count,
74
114
  description,
75
115
  actions,
76
116
  level = 2,
77
117
  variant = "section",
118
+ framed = false,
119
+ frameVariant = "default",
78
120
  children,
79
121
  className,
80
122
  ...props
81
123
  }: ContentProps): ReactElement {
82
124
  const titleId = useId();
83
- const context = { level, titleId, variant };
125
+ const context = {
126
+ element: as,
127
+ frameVariant,
128
+ framed,
129
+ level: level as ContentHeaderLevel,
130
+ titleId,
131
+ variant,
132
+ };
84
133
  const shorthand = title !== undefined;
85
134
  const nodes = Children.toArray(children);
86
135
 
@@ -110,34 +159,64 @@ export function Content({
110
159
  }
111
160
  }
112
161
 
162
+ const anatomy = shorthand ? (
163
+ <>
164
+ <ContentHeader>
165
+ <ContentTitle>{title}</ContentTitle>
166
+ {count !== undefined && <ContentMeta>{count}</ContentMeta>}
167
+ {description !== undefined && (
168
+ <ContentDescription>{description}</ContentDescription>
169
+ )}
170
+ {actions !== undefined && <ContentActions>{actions}</ContentActions>}
171
+ </ContentHeader>
172
+ <ContentBody>{children}</ContentBody>
173
+ </>
174
+ ) : (
175
+ children
176
+ );
177
+ const ariaLabelledBy =
178
+ props["aria-label"] === undefined ? titleId : undefined;
179
+
113
180
  return (
114
181
  <ContentContext.Provider value={context}>
115
- <section
116
- data-slot="content"
117
- aria-labelledby={
118
- props["aria-label"] === undefined ? titleId : undefined
119
- }
120
- className={cn("space-y-3", className)}
121
- {...props}
122
- >
123
- {shorthand ? (
124
- <>
125
- <ContentHeader>
126
- <ContentTitle>{title}</ContentTitle>
127
- {count !== undefined && <ContentMeta>{count}</ContentMeta>}
128
- {description !== undefined && (
129
- <ContentDescription>{description}</ContentDescription>
130
- )}
131
- {actions !== undefined && (
132
- <ContentActions>{actions}</ContentActions>
133
- )}
134
- </ContentHeader>
135
- <ContentBody>{children}</ContentBody>
136
- </>
137
- ) : (
138
- children
139
- )}
140
- </section>
182
+ {as === "fieldset" ? (
183
+ <fieldset
184
+ data-slot="content"
185
+ data-as="fieldset"
186
+ data-framed={framed}
187
+ data-frame-variant={frameVariant}
188
+ aria-labelledby={ariaLabelledBy}
189
+ className={cn(
190
+ !framed && "space-y-3",
191
+ framed &&
192
+ frameVariant === "default" &&
193
+ "rounded-lg border border-border p-4",
194
+ framed &&
195
+ frameVariant === "legend" &&
196
+ "space-y-3 rounded-lg border border-border px-4 pb-4",
197
+ className,
198
+ )}
199
+ {...(props as ComponentProps<"fieldset">)}
200
+ >
201
+ {anatomy}
202
+ </fieldset>
203
+ ) : (
204
+ <section
205
+ data-slot="content"
206
+ data-as="section"
207
+ data-framed={framed}
208
+ data-frame-variant={frameVariant}
209
+ aria-labelledby={ariaLabelledBy}
210
+ className={cn(
211
+ "space-y-3",
212
+ framed && "rounded-lg border border-border p-4",
213
+ className,
214
+ )}
215
+ {...(props as ComponentProps<"section">)}
216
+ >
217
+ {anatomy}
218
+ </section>
219
+ )}
141
220
  </ContentContext.Provider>
142
221
  );
143
222
  }
@@ -158,6 +237,37 @@ export function ContentHeader({
158
237
  "ContentHeader",
159
238
  "Content",
160
239
  );
240
+ if (content.element === "fieldset") {
241
+ const nodes = Children.toArray(children);
242
+ if (
243
+ nodes.length !== 1 ||
244
+ !isValidElement(nodes[0]) ||
245
+ nodes[0].type !== ContentTitle
246
+ ) {
247
+ throw new Error(
248
+ "ContentHeader de fieldset exige somente um ContentTitle; contador, descrição e ações ficam fora da legenda.",
249
+ );
250
+ }
251
+ return (
252
+ <ContentHeaderContext.Provider value={content}>
253
+ <legend
254
+ data-slot="content-header"
255
+ className={cn(
256
+ content.framed &&
257
+ content.frameVariant === "default" &&
258
+ "float-left w-full",
259
+ content.framed &&
260
+ content.frameVariant === "legend" &&
261
+ "-ml-2 w-auto px-2",
262
+ className,
263
+ )}
264
+ {...props}
265
+ >
266
+ {children}
267
+ </legend>
268
+ </ContentHeaderContext.Provider>
269
+ );
270
+ }
161
271
  return (
162
272
  <ContentHeaderContext.Provider value={content}>
163
273
  <SurfaceHeader
@@ -182,27 +292,19 @@ export function ContentHeader({
182
292
  );
183
293
  }
184
294
 
185
- const headingTags = {
186
- 1: "h1",
187
- 2: "h2",
188
- 3: "h3",
189
- 4: "h4",
190
- 5: "h5",
191
- 6: "h6",
192
- } as const;
193
-
194
295
  export function ContentTitle({
195
296
  className,
196
297
  id,
197
298
  ...props
198
- }: HTMLAttributes<HTMLHeadingElement>): ReactElement {
299
+ }: HTMLAttributes<HTMLElement>): ReactElement {
199
300
  const context = requiredContext(
200
301
  useContext(ContentHeaderContext),
201
302
  "ContentTitle",
202
303
  "ContentHeader",
203
304
  );
204
- const Heading = headingTags[context.level];
205
- usePageHeadingPresence(context.level === 1);
305
+ const Heading =
306
+ context.element === "fieldset" ? "span" : headingTags[context.level];
307
+ usePageHeadingPresence(context.element !== "fieldset" && context.level === 1);
206
308
  return (
207
309
  <Heading
208
310
  id={id ?? context.titleId}
@@ -279,6 +381,23 @@ export function ContentBody({
279
381
  className,
280
382
  ...props
281
383
  }: HTMLAttributes<HTMLDivElement>): ReactElement {
282
- requiredContext(useContext(ContentContext), "ContentBody", "Content");
283
- return <div data-slot="content-body" className={className} {...props} />;
384
+ const content = requiredContext(
385
+ useContext(ContentContext),
386
+ "ContentBody",
387
+ "Content",
388
+ );
389
+ return (
390
+ <div
391
+ data-slot="content-body"
392
+ className={cn(
393
+ content.element === "fieldset" && "space-y-3",
394
+ content.element === "fieldset" &&
395
+ content.framed &&
396
+ content.frameVariant === "default" &&
397
+ "clear-left pt-3",
398
+ className,
399
+ )}
400
+ {...props}
401
+ />
402
+ );
284
403
  }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Nível de heading (interno): hierarquia do documento, separada do tamanho visual.
3
+ *
4
+ * A ADR 0023 fixa a regra: `h1`–`h6` expressam hierarquia; a escala do título vem da
5
+ * família e do layout. As duas decisões nunca se misturam num mesmo valor.
6
+ */
7
+ export type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
8
+
9
+ export const headingTags = {
10
+ 1: "h1",
11
+ 2: "h2",
12
+ 3: "h3",
13
+ 4: "h4",
14
+ 5: "h5",
15
+ 6: "h6",
16
+ } as const satisfies Record<HeadingLevel, `h${HeadingLevel}`>;
@@ -111,8 +111,13 @@ import {
111
111
  InputGroupInput,
112
112
  } from "../primitives/input-group.tsx";
113
113
  import { Separator } from "../primitives/separator.tsx";
114
- import { Label } from "../primitives/label.tsx";
115
- import { FieldGroup, FieldLegend, FieldSet } from "../primitives/field.tsx";
114
+ import {
115
+ Field,
116
+ FieldGroup,
117
+ FieldLabel,
118
+ type FieldSetVariant,
119
+ } from "../primitives/field.tsx";
120
+ import { Content } from "./content-header.tsx";
116
121
  import { Search } from "lucide-react";
117
122
  import { Select, type SelectOption } from "../primitives/select.tsx";
118
123
  import {
@@ -307,6 +312,8 @@ export interface AdvancedFiltersLayout {
307
312
  columns?: 1 | 2 | 3;
308
313
  /** Ícone de cada seção, indexado pelo mesmo rótulo declarado nos filtros. */
309
314
  sectionIcons?: Record<string, ReactNode>;
315
+ /** Tratamento visual de cada seção. `framed` integra a legenda à moldura do conjunto. */
316
+ sectionVariant?: FieldSetVariant;
310
317
  }
311
318
 
312
319
  /** Uma view alternativa da MESMA listagem (board, galeria, lista, calendário…). A
@@ -1105,6 +1112,7 @@ function AdvancedFiltersDialog({
1105
1112
  filterDefaults,
1106
1113
  filterIcons,
1107
1114
  sectionIcons,
1115
+ sectionVariant = "plain",
1108
1116
  cascade,
1109
1117
  columns = 1,
1110
1118
  }: {
@@ -1117,6 +1125,7 @@ function AdvancedFiltersDialog({
1117
1125
  filterDefaults?: Record<string, string> | undefined;
1118
1126
  filterIcons?: Record<string, ReactNode> | undefined;
1119
1127
  sectionIcons?: Record<string, ReactNode> | undefined;
1128
+ sectionVariant?: FieldSetVariant | undefined;
1120
1129
  columns?: 1 | 2 | 3;
1121
1130
  /** A cascata do `depends` do pai — mudar um campo limpa os dependentes no draft. */
1122
1131
  cascade: (
@@ -1153,8 +1162,10 @@ function AdvancedFiltersDialog({
1153
1162
  }[columns];
1154
1163
 
1155
1164
  const field = ([name, spec]: [string, FilterSpec]): React.ReactElement => (
1156
- <div key={name} className="space-y-2">
1157
- <Label htmlFor={`filter-${name}`}>{text(spec.label, name)}</Label>
1165
+ <Field key={name}>
1166
+ <FieldLabel htmlFor={`filter-${name}`}>
1167
+ {text(spec.label, name)}
1168
+ </FieldLabel>
1158
1169
  <FilterField
1159
1170
  name={name}
1160
1171
  spec={spec}
@@ -1166,7 +1177,7 @@ function AdvancedFiltersDialog({
1166
1177
  defaultValue={filterDefaults?.[name]}
1167
1178
  icon={filterIcons?.[name]}
1168
1179
  />
1169
- </div>
1180
+ </Field>
1170
1181
  );
1171
1182
 
1172
1183
  const clear = (): void => {
@@ -1194,14 +1205,32 @@ function AdvancedFiltersDialog({
1194
1205
  <DialogBody className="space-y-4">
1195
1206
  {sectioned ? (
1196
1207
  sections.map(([section, entries]) => (
1197
- <FieldSet key={section} className="gap-0">
1198
- <FieldLegend variant="section" icon={sectionIcons?.[section]}>
1199
- {section}
1200
- </FieldLegend>
1208
+ <Content
1209
+ key={section}
1210
+ as="fieldset"
1211
+ framed={sectionVariant === "framed"}
1212
+ frameVariant={
1213
+ sectionVariant === "framed" ? "legend" : "default"
1214
+ }
1215
+ title={
1216
+ <span className="flex items-center gap-2">
1217
+ {sectionIcons?.[section] !== undefined && (
1218
+ <span
1219
+ data-slot="content-title-icon"
1220
+ aria-hidden="true"
1221
+ className="inline-flex shrink-0 items-center justify-center text-muted-foreground [&_svg]:size-4"
1222
+ >
1223
+ {sectionIcons[section]}
1224
+ </span>
1225
+ )}
1226
+ {section}
1227
+ </span>
1228
+ }
1229
+ >
1201
1230
  <FieldGroup className={cn("grid gap-4", gridColumns)}>
1202
1231
  {entries.map(field)}
1203
1232
  </FieldGroup>
1204
- </FieldSet>
1233
+ </Content>
1205
1234
  ))
1206
1235
  ) : (
1207
1236
  <FieldGroup className={cn("grid gap-4", gridColumns)}>
@@ -1597,6 +1626,7 @@ export function ActionFilterBar({
1597
1626
  filterIcons={filterIcons}
1598
1627
  columns={advancedFilters?.columns}
1599
1628
  sectionIcons={advancedFilters?.sectionIcons}
1629
+ sectionVariant={advancedFilters?.sectionVariant}
1600
1630
  />
1601
1631
  )}
1602
1632
  </div>
@@ -2111,18 +2141,21 @@ export function ActionList<TInput, TItem>({
2111
2141
  } else {
2112
2142
  content = (
2113
2143
  <Table
2114
- variant="framed"
2144
+ variant="plain"
2115
2145
  className={tableStateActive ? "h-full" : undefined}
2116
2146
  containerClassName={
2147
+ // Quem rola é a linha de resultados, acima: aqui o contêiner abre mão da
2148
+ // rolagem horizontal para não virar a referência do `sticky` do cabeçalho.
2149
+ // `h-full` só no estado sem linhas, para a moldura vazia ocupar a altura.
2117
2150
  tableFillsAvailableHeight
2118
- ? "absolute inset-0 overflow-auto"
2151
+ ? cn("overflow-x-visible", tableStateActive && "h-full")
2119
2152
  : undefined
2120
2153
  }
2121
2154
  >
2122
2155
  <TableHeader
2123
2156
  className={
2124
2157
  tableFillsAvailableHeight
2125
- ? "sticky top-0 z-10 bg-muted/30 dark:bg-input/30"
2158
+ ? "sticky top-0 z-10 bg-background shadow-[inset_0_-0.0625rem_0_var(--border)] [&_tr]:border-b-0"
2126
2159
  : undefined
2127
2160
  }
2128
2161
  >
@@ -2260,7 +2293,14 @@ export function ActionList<TInput, TItem>({
2260
2293
  ) : null;
2261
2294
 
2262
2295
  // — Rodapé (o estilo da casa): Página X de Y à esquerda · pager CENTRALIZADO ·
2263
- // total à direita (grid de 3 colunas com laterais 1fr = centro geométrico) —
2296
+ // total à direita (grid de 3 colunas com laterais 1fr = centro geométrico).
2297
+ // Encosta na última linha: a borda de cima é o fecho da tabela, não a de um
2298
+ // bloco solto, então o rodapé não entra no `gap` da pilha.
2299
+ // Altura: 2.5rem de piso (a mesma do `TableHead`) para a faixa não mudar de
2300
+ // tamanho quando o pager aparece — ele ocupa 1.75rem e cabe dentro do piso.
2301
+ // O padding fica em 0.25rem só para o dia em que o conteúdo passar disso:
2302
+ // com `py-2` o padding empurraria o pager para fora e a faixa voltaria a
2303
+ // crescer —
2264
2304
  const pages =
2265
2305
  total !== undefined
2266
2306
  ? Math.max(1, Math.ceil(total / (state.limit ?? pageSize)))
@@ -2270,7 +2310,7 @@ export function ActionList<TInput, TItem>({
2270
2310
  total !== undefined && !isError ? (
2271
2311
  <div
2272
2312
  data-slot="action-list-footer"
2273
- className="grid grid-cols-[1fr_auto_1fr] items-center gap-3 px-3 py-1.5 text-xs text-muted-foreground"
2313
+ className="grid min-h-10 grid-cols-[1fr_auto_1fr] items-center gap-3 border-t border-border px-3 py-1 text-xs text-muted-foreground"
2274
2314
  >
2275
2315
  <span>
2276
2316
  Página {state.page} de {pages}
@@ -2338,19 +2378,28 @@ export function ActionList<TInput, TItem>({
2338
2378
  {toolbar}
2339
2379
  {beforeContent}
2340
2380
  {selectionBar}
2341
- {/* Refetch com a lista já na tela: esmaece e trava interação até chegar. */}
2342
- <div
2343
- aria-busy={busy}
2344
- className={cn(
2345
- layout === "fill" && "relative flex min-h-0 flex-1 flex-col",
2346
- layout === "fill" &&
2347
- (tableFillsAvailableHeight ? "overflow-hidden" : "overflow-auto"),
2348
- busy && items.length > 0 && "pointer-events-none opacity-60",
2349
- )}
2350
- >
2351
- {body}
2381
+ {/* Tabela e rodapé formam um bloco só: o `gap` da pilha separa a barra de
2382
+ ferramentas do conteúdo, nunca o conteúdo do seu próprio rodapé. Duas
2383
+ linhas: a dos resultados absorve a altura que sobra, a do rodapé se mede
2384
+ pelo conteúdo. Em `flow` a primeira linha também é `1fr`, mas o bloco não
2385
+ tem altura própria e ela vira a altura do conteúdo. */}
2386
+ <div className={cn("grid min-h-0 grid-rows-[1fr_auto]", layout === "fill" && "flex-1")}>
2387
+ {/* A rolagem mora aqui, nos dois eixos: é este elemento que o `sticky` do
2388
+ cabeçalho enxerga. Um contêiner de rolagem entre ele e a tabela roubaria
2389
+ essa referência e o cabeçalho voltaria a subir junto com as linhas.
2390
+ Refetch com a lista já na tela: esmaece e trava interação até chegar. */}
2391
+ <div
2392
+ aria-busy={busy}
2393
+ className={cn(
2394
+ "min-h-0",
2395
+ layout === "fill" && "overflow-auto",
2396
+ busy && items.length > 0 && "pointer-events-none opacity-60",
2397
+ )}
2398
+ >
2399
+ {body}
2400
+ </div>
2401
+ {footer}
2352
2402
  </div>
2353
- {footer}
2354
2403
  {confirming !== null && (
2355
2404
  <Dialog
2356
2405
  mode="alert"