@softize/opus 19.0.0 → 20.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/CHANGELOG.md +239 -0
  2. package/bin/cli.mjs +5 -0
  3. package/bin/lib/check.mjs +198 -10
  4. package/bin/lib/copy.mjs +29 -0
  5. package/bin/lib/mcp.mjs +1 -1
  6. package/docs/adr/0020-icon-tiles-are-explicitly-composed.md +8 -0
  7. package/docs/adr/0021-content-carries-structure-and-semantic-element.md +7 -0
  8. package/docs/adr/0023-surfaces-share-one-anatomy-and-two-layouts.md +19 -1
  9. package/docs/adr/0024-fill-height-propagates-by-context.md +118 -0
  10. package/docs/adr/0025-content-frame-treatment-does-not-change-semantics.md +134 -0
  11. package/docs/data-products.md +15 -9
  12. package/docs/protocol.md +3 -4
  13. package/docs/releasing.md +2 -2
  14. package/package.json +2 -2
  15. package/registry/skills/create-opus-action/scripts/scaffold.mjs +2 -1
  16. package/registry/templates/app/package.json +1 -1
  17. package/src/core/runtime.ts +96 -8
  18. package/src/core/types.ts +5 -2
  19. package/src/ui/components/patterns/action-form-card.tsx +11 -1
  20. package/src/ui/components/patterns/content-header.tsx +109 -18
  21. package/src/ui/components/patterns/fill-region.ts +150 -0
  22. package/src/ui/components/patterns/list.tsx +91 -92
  23. package/src/ui/components/patterns/page.tsx +20 -4
  24. package/src/ui/components/patterns/presentation.tsx +52 -37
  25. package/src/ui/components/patterns/split.tsx +44 -4
  26. package/src/ui/components/patterns/surface-anatomy.ts +35 -8
  27. package/src/ui/components/patterns/surface-header.tsx +58 -25
  28. package/src/ui/components/patterns/trigger.tsx +12 -2
  29. package/src/ui/components/primitives/alert.tsx +9 -1
  30. package/src/ui/components/primitives/avatar.tsx +7 -3
  31. package/src/ui/components/primitives/card.tsx +1 -1
  32. package/src/ui/components/primitives/carousel.tsx +34 -5
  33. package/src/ui/components/primitives/checkbox.tsx +11 -3
  34. package/src/ui/components/primitives/command.tsx +2 -1
  35. package/src/ui/components/primitives/copyable.tsx +120 -10
  36. package/src/ui/components/primitives/dialog.tsx +42 -11
  37. package/src/ui/components/primitives/drawer.tsx +10 -2
  38. package/src/ui/components/primitives/empty.tsx +1 -1
  39. package/src/ui/components/primitives/icon-picker.tsx +6 -2
  40. package/src/ui/components/primitives/icon-tile.tsx +7 -0
  41. package/src/ui/components/primitives/input-group.tsx +9 -2
  42. package/src/ui/components/primitives/input-otp.tsx +1 -1
  43. package/src/ui/components/primitives/kbd.tsx +1 -1
  44. package/src/ui/components/primitives/label.tsx +3 -0
  45. package/src/ui/components/primitives/menu.tsx +5 -0
  46. package/src/ui/components/primitives/popover.tsx +1 -1
  47. package/src/ui/components/primitives/progress.tsx +9 -1
  48. package/src/ui/components/primitives/slider.tsx +62 -11
  49. package/src/ui/components/primitives/truncate.tsx +10 -1
  50. package/src/ui/docs/content/accordion.md +74 -27
  51. package/src/ui/docs/content/action-form-card.md +24 -15
  52. package/src/ui/docs/content/action-form-dialog.md +17 -7
  53. package/src/ui/docs/content/action-form.md +92 -77
  54. package/src/ui/docs/content/action-list-dialog.md +75 -38
  55. package/src/ui/docs/content/action-list.md +141 -58
  56. package/src/ui/docs/content/action-trigger.md +27 -10
  57. package/src/ui/docs/content/action-view.md +14 -14
  58. package/src/ui/docs/content/actions.md +161 -66
  59. package/src/ui/docs/content/ai.md +43 -44
  60. package/src/ui/docs/content/alert.md +83 -29
  61. package/src/ui/docs/content/ask.md +51 -26
  62. package/src/ui/docs/content/aspect-ratio.md +22 -24
  63. package/src/ui/docs/content/audit.md +23 -19
  64. package/src/ui/docs/content/auth.md +38 -19
  65. package/src/ui/docs/content/avatar.md +54 -5
  66. package/src/ui/docs/content/badge.md +29 -9
  67. package/src/ui/docs/content/breadcrumb.md +21 -8
  68. package/src/ui/docs/content/button.md +73 -43
  69. package/src/ui/docs/content/calendar.md +134 -26
  70. package/src/ui/docs/content/card.md +41 -15
  71. package/src/ui/docs/content/carousel.md +37 -6
  72. package/src/ui/docs/content/chat.md +118 -37
  73. package/src/ui/docs/content/checkbox.md +73 -7
  74. package/src/ui/docs/content/cli.md +94 -55
  75. package/src/ui/docs/content/collapsible.md +43 -19
  76. package/src/ui/docs/content/command.md +30 -8
  77. package/src/ui/docs/content/composer.md +23 -8
  78. package/src/ui/docs/content/content.md +119 -27
  79. package/src/ui/docs/content/copyable.md +39 -8
  80. package/src/ui/docs/content/customization.md +55 -46
  81. package/src/ui/docs/content/cycle.md +33 -18
  82. package/src/ui/docs/content/data-products.md +115 -0
  83. package/src/ui/docs/content/data-state.md +20 -6
  84. package/src/ui/docs/content/data.md +20 -19
  85. package/src/ui/docs/content/detail.md +12 -5
  86. package/src/ui/docs/content/dialog.md +107 -24
  87. package/src/ui/docs/content/dictionary-value.md +4 -4
  88. package/src/ui/docs/content/dock.md +54 -19
  89. package/src/ui/docs/content/dot.md +35 -5
  90. package/src/ui/docs/content/drawer.md +63 -19
  91. package/src/ui/docs/content/empty-value.md +20 -6
  92. package/src/ui/docs/content/empty.md +24 -3
  93. package/src/ui/docs/content/events.md +76 -16
  94. package/src/ui/docs/content/executions.md +9 -9
  95. package/src/ui/docs/content/field.md +21 -6
  96. package/src/ui/docs/content/getting-started.md +44 -34
  97. package/src/ui/docs/content/icon-picker.md +19 -13
  98. package/src/ui/docs/content/icon-tile.md +38 -15
  99. package/src/ui/docs/content/input-otp.md +58 -7
  100. package/src/ui/docs/content/input.md +22 -3
  101. package/src/ui/docs/content/item.md +53 -6
  102. package/src/ui/docs/content/kbd.md +5 -1
  103. package/src/ui/docs/content/label.md +13 -4
  104. package/src/ui/docs/content/log.md +5 -5
  105. package/src/ui/docs/content/markdown.md +5 -4
  106. package/src/ui/docs/content/mcp.md +49 -23
  107. package/src/ui/docs/content/menu.md +95 -10
  108. package/src/ui/docs/content/metric-card.md +12 -4
  109. package/src/ui/docs/content/observability.md +10 -9
  110. package/src/ui/docs/content/page.md +118 -12
  111. package/src/ui/docs/content/pagination.md +13 -5
  112. package/src/ui/docs/content/pane.md +55 -5
  113. package/src/ui/docs/content/popover.md +42 -5
  114. package/src/ui/docs/content/presentation.md +91 -47
  115. package/src/ui/docs/content/progress.md +27 -14
  116. package/src/ui/docs/content/queue.md +39 -21
  117. package/src/ui/docs/content/radio-group.md +26 -4
  118. package/src/ui/docs/content/router.md +29 -10
  119. package/src/ui/docs/content/runtime.md +90 -54
  120. package/src/ui/docs/content/scheduler.md +53 -21
  121. package/src/ui/docs/content/scroll-area.md +42 -35
  122. package/src/ui/docs/content/select.md +78 -33
  123. package/src/ui/docs/content/semantic-context.md +21 -2
  124. package/src/ui/docs/content/separator.md +15 -9
  125. package/src/ui/docs/content/sidebar.md +3 -2
  126. package/src/ui/docs/content/skeleton.md +6 -1
  127. package/src/ui/docs/content/slider.md +37 -9
  128. package/src/ui/docs/content/spinner.md +4 -3
  129. package/src/ui/docs/content/split.md +19 -0
  130. package/src/ui/docs/content/storage.md +8 -9
  131. package/src/ui/docs/content/switch.md +10 -7
  132. package/src/ui/docs/content/table.md +46 -8
  133. package/src/ui/docs/content/tabs.md +14 -4
  134. package/src/ui/docs/content/testing.md +76 -31
  135. package/src/ui/docs/content/textarea.md +5 -4
  136. package/src/ui/docs/content/toast.md +48 -7
  137. package/src/ui/docs/content/toggle.md +3 -3
  138. package/src/ui/docs/content/tokens.md +15 -26
  139. package/src/ui/docs/content/tooltip.md +18 -2
  140. package/src/ui/docs/content/truncate.md +36 -6
  141. package/src/ui/docs/content/ui.md +74 -22
  142. package/src/ui/docs/content/upgrading.md +45 -36
  143. package/src/ui/docs/plugin.ts +36 -2
  144. package/src/ui/docs/registry.tsx +6 -0
  145. package/src/ui/drivers/react.tsx +16 -8
  146. package/src/ui/meta.ts +32 -32
  147. package/src/ui/react.tsx +1 -0
  148. package/src/vite/design.ts +16 -4
@@ -13,6 +13,7 @@ import {
13
13
  CardTitle,
14
14
  } from "../primitives/card.tsx";
15
15
  import { ActionForm, type ActionFormProps } from "./form.tsx";
16
+ import type { HeadingLevel } from "./heading-level.ts";
16
17
  import { ButtonGroup } from "../primitives/button-group.tsx";
17
18
 
18
19
  export interface ActionFormCardProps<
@@ -21,6 +22,12 @@ export interface ActionFormCardProps<
21
22
  > extends Omit<ActionFormProps<TInput, TData>, "body" | "footer"> {
22
23
  /** Título do header do card. Sem ele, o card começa direto no conteúdo. */
23
24
  title?: string;
25
+ /**
26
+ * Nível semântico do título (`h1`–`h6`), repassado ao `CardTitle` como `level`. Sem
27
+ * ele vale o `h3` do Card. Muda só a hierarquia do documento; a escala visual continua
28
+ * a do Card.
29
+ */
30
+ titleLevel?: HeadingLevel;
24
31
  /** Subtítulo opcional, abaixo do título. */
25
32
  description?: string;
26
33
  /** Classes do Card (a superfície). `className` vai pro <form>. */
@@ -29,6 +36,7 @@ export interface ActionFormCardProps<
29
36
 
30
37
  export function ActionFormCard<TInput extends Record<string, unknown>, TData>({
31
38
  title,
39
+ titleLevel,
32
40
  description,
33
41
  cardClassName,
34
42
  ...rest
@@ -37,7 +45,9 @@ export function ActionFormCard<TInput extends Record<string, unknown>, TData>({
37
45
  <Card className={cardClassName}>
38
46
  {(title !== undefined || description !== undefined) && (
39
47
  <CardHeader>
40
- {title !== undefined && <CardTitle>{title}</CardTitle>}
48
+ {title !== undefined && (
49
+ <CardTitle level={titleLevel}>{title}</CardTitle>
50
+ )}
41
51
  {description !== undefined && (
42
52
  <CardDescription>{description}</CardDescription>
43
53
  )}
@@ -10,8 +10,14 @@ import {
10
10
  type ReactNode,
11
11
  } from "react";
12
12
  import { cn } from "../../lib/cn.ts";
13
+ import { IconTile, type IconTileProps } from "../primitives/icon-tile.tsx";
14
+ import { fillRegionClassName, useFillRegion } from "./fill-region.ts";
13
15
  import { headingTags } from "./heading-level.ts";
14
16
  import { usePageHeadingPresence } from "./page-heading-context.tsx";
17
+ import {
18
+ SurfaceMedia,
19
+ type SurfaceMediaProps,
20
+ } from "./surface-media.tsx";
15
21
  import {
16
22
  SurfaceHeader,
17
23
  surfaceHeaderClasses,
@@ -58,7 +64,8 @@ interface ContentSectionBaseProps
58
64
  Omit<ComponentProps<"section">, "children" | "title"> {
59
65
  /** Elemento semântico da região. */
60
66
  as?: "section";
61
- frameVariant?: "default";
67
+ /** Tratamento visual da moldura: contínua (default) ou integrada ao header. */
68
+ frameVariant?: ContentFrameVariant;
62
69
  /** Nível semântico do heading, independente de seu destaque visual. */
63
70
  level?: ContentHeaderLevel;
64
71
  }
@@ -77,6 +84,8 @@ interface ContentFieldsetBaseProps
77
84
 
78
85
  interface ContentSectionShorthandProps extends ContentSectionBaseProps {
79
86
  title: ReactNode;
87
+ /** Ícone ou imagem que identifica a região, antes do título. */
88
+ media?: ReactNode;
80
89
  /** Total de itens ao lado do título da seção. A página não carrega contador (ADR 0009). */
81
90
  count?: number;
82
91
  description?: ReactNode;
@@ -86,6 +95,8 @@ interface ContentSectionShorthandProps extends ContentSectionBaseProps {
86
95
 
87
96
  interface ContentFieldsetShorthandProps extends ContentFieldsetBaseProps {
88
97
  title: ReactNode;
98
+ /** Ícone ou imagem decorativa que acompanha o título dentro da legenda. */
99
+ media?: ReactNode;
89
100
  count?: never;
90
101
  description?: never;
91
102
  actions?: never;
@@ -94,6 +105,7 @@ interface ContentFieldsetShorthandProps extends ContentFieldsetBaseProps {
94
105
 
95
106
  interface ContentComposedProps {
96
107
  title?: never;
108
+ media?: never;
97
109
  count?: never;
98
110
  description?: never;
99
111
  actions?: never;
@@ -110,6 +122,7 @@ export type ContentProps =
110
122
  export function Content({
111
123
  as = "section",
112
124
  title,
125
+ media,
113
126
  count,
114
127
  description,
115
128
  actions,
@@ -122,6 +135,7 @@ export function Content({
122
135
  ...props
123
136
  }: ContentProps): ReactElement {
124
137
  const titleId = useId();
138
+ const fill = useFillRegion();
125
139
  const context = {
126
140
  element: as,
127
141
  frameVariant,
@@ -162,6 +176,7 @@ export function Content({
162
176
  const anatomy = shorthand ? (
163
177
  <>
164
178
  <ContentHeader>
179
+ {hasContentMedia(media) && <ContentMedia>{media}</ContentMedia>}
165
180
  <ContentTitle>{title}</ContentTitle>
166
181
  {count !== undefined && <ContentMeta>{count}</ContentMeta>}
167
182
  {description !== undefined && (
@@ -185,6 +200,7 @@ export function Content({
185
200
  data-as="fieldset"
186
201
  data-framed={framed}
187
202
  data-frame-variant={frameVariant}
203
+ {...fill.attributes}
188
204
  aria-labelledby={ariaLabelledBy}
189
205
  className={cn(
190
206
  !framed && "space-y-3",
@@ -194,11 +210,12 @@ export function Content({
194
210
  framed &&
195
211
  frameVariant === "legend" &&
196
212
  "space-y-3 rounded-lg border border-border px-4 pb-4",
213
+ fill.filling && fillRegionClassName,
197
214
  className,
198
215
  )}
199
216
  {...(props as ComponentProps<"fieldset">)}
200
217
  >
201
- {anatomy}
218
+ {fill.provide(anatomy)}
202
219
  </fieldset>
203
220
  ) : (
204
221
  <section
@@ -206,15 +223,22 @@ export function Content({
206
223
  data-as="section"
207
224
  data-framed={framed}
208
225
  data-frame-variant={frameVariant}
226
+ {...fill.attributes}
209
227
  aria-labelledby={ariaLabelledBy}
210
228
  className={cn(
211
229
  "space-y-3",
212
- framed && "rounded-lg border border-border p-4",
230
+ framed &&
231
+ frameVariant === "default" &&
232
+ "rounded-lg border border-border p-4",
233
+ framed &&
234
+ frameVariant === "legend" &&
235
+ "rounded-lg border border-border px-4 pb-4",
236
+ fill.filling && fillRegionClassName,
213
237
  className,
214
238
  )}
215
239
  {...(props as ComponentProps<"section">)}
216
240
  >
217
- {anatomy}
241
+ {fill.provide(anatomy)}
218
242
  </section>
219
243
  )}
220
244
  </ContentContext.Provider>
@@ -238,20 +262,15 @@ export function ContentHeader({
238
262
  "Content",
239
263
  );
240
264
  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
265
  return (
252
266
  <ContentHeaderContext.Provider value={content}>
253
- <legend
254
- data-slot="content-header"
267
+ <SurfaceHeader
268
+ as="legend"
269
+ name="ContentHeader"
270
+ slot="content"
271
+ slots={{ media: ContentMedia, title: ContentTitle }}
272
+ mediaPlacement="heading"
273
+ invalidMessage="ContentHeader de fieldset aceita somente ContentMedia e ContentTitle; contador, descrição e ações ficam fora da legenda."
255
274
  className={cn(
256
275
  content.framed &&
257
276
  content.frameVariant === "default" &&
@@ -264,7 +283,7 @@ export function ContentHeader({
264
283
  {...props}
265
284
  >
266
285
  {children}
267
- </legend>
286
+ </SurfaceHeader>
268
287
  </ContentHeaderContext.Provider>
269
288
  );
270
289
  }
@@ -274,12 +293,25 @@ export function ContentHeader({
274
293
  name="ContentHeader"
275
294
  slot="content"
276
295
  slots={{
296
+ media: ContentMedia,
277
297
  title: ContentTitle,
278
298
  count: ContentMeta,
279
299
  description: ContentDescription,
280
300
  actions: ContentActions,
281
301
  }}
302
+ mediaPlacement="heading"
282
303
  className={cn(
304
+ content.framed &&
305
+ content.frameVariant === "legend" &&
306
+ "flex items-center [&>[data-slot=content-actions]]:-mr-2 [&>[data-slot=content-actions]]:bg-[var(--surface-background,var(--background))] [&>[data-slot=content-actions]]:px-2 [&>[data-slot=content-heading]]:-ml-2 [&>[data-slot=content-heading]]:w-fit [&>[data-slot=content-heading]]:max-w-full [&>[data-slot=content-heading]]:bg-[var(--surface-background,var(--background))] [&>[data-slot=content-heading]]:px-2",
307
+ content.framed &&
308
+ content.frameVariant === "legend" &&
309
+ content.variant === "section" &&
310
+ "-mt-4 min-h-8",
311
+ content.framed &&
312
+ content.frameVariant === "legend" &&
313
+ content.variant === "page" &&
314
+ "-mt-5 min-h-10",
283
315
  content.variant === "page" &&
284
316
  "flex-row items-center justify-start gap-3",
285
317
  className,
@@ -292,6 +324,59 @@ export function ContentHeader({
292
324
  );
293
325
  }
294
326
 
327
+ /** `null`, `false` e `undefined` não desenham mídia: uma moldura vazia não identifica nada. */
328
+ function hasContentMedia(media: ReactNode): boolean {
329
+ return media !== undefined && media !== null && media !== false;
330
+ }
331
+
332
+ /**
333
+ * Um `IconTile` recebido como filho não vira uma segunda moldura: o slot assume o lugar dele e
334
+ * herda o que ele declara (`size`, `context`, `variant`, demais props), somando as classes.
335
+ */
336
+ function adoptIconTile(props: SurfaceMediaProps): SurfaceMediaProps {
337
+ const { children, asChild, className } = props;
338
+ if (asChild) return props;
339
+ // `toArray`, não `only`: um array com um único item (de um `map`/`filter`) é válido aqui.
340
+ const nodes = Children.toArray(children);
341
+ if (nodes.length !== 1) return props;
342
+ const child = nodes[0];
343
+ if (!isValidElement<IconTileProps>(child) || child.type !== IconTile) {
344
+ return props;
345
+ }
346
+ // `null` em size/context/variant significa "use o padrão" no IconTile: fica o do slot.
347
+ const declared = Object.fromEntries(
348
+ Object.entries(child.props).filter(([, value]) => value != null),
349
+ ) as SurfaceMediaProps;
350
+ return {
351
+ ...props,
352
+ ...declared,
353
+ className: cn(className, declared.className),
354
+ children: declared.children,
355
+ };
356
+ }
357
+
358
+ /** Moldura de ícone ou imagem que identifica a região nomeada. */
359
+ export function ContentMedia({
360
+ size = "xs",
361
+ ...rest
362
+ }: SurfaceMediaProps): ReactElement {
363
+ const context = requiredContext(
364
+ useContext(ContentHeaderContext),
365
+ "ContentMedia",
366
+ "ContentHeader",
367
+ );
368
+ const props = adoptIconTile({ size, ...rest });
369
+ return (
370
+ <SurfaceMedia
371
+ slot="content-media"
372
+ {...props}
373
+ aria-hidden={
374
+ context.element === "fieldset" ? true : props["aria-hidden"]
375
+ }
376
+ />
377
+ );
378
+ }
379
+
295
380
  export function ContentTitle({
296
381
  className,
297
382
  id,
@@ -379,6 +464,7 @@ export function ContentActions({
379
464
 
380
465
  export function ContentBody({
381
466
  className,
467
+ children,
382
468
  ...props
383
469
  }: HTMLAttributes<HTMLDivElement>): ReactElement {
384
470
  const content = requiredContext(
@@ -386,18 +472,23 @@ export function ContentBody({
386
472
  "ContentBody",
387
473
  "Content",
388
474
  );
475
+ const fill = useFillRegion();
389
476
  return (
390
477
  <div
391
478
  data-slot="content-body"
479
+ {...fill.attributes}
392
480
  className={cn(
393
481
  content.element === "fieldset" && "space-y-3",
394
482
  content.element === "fieldset" &&
395
483
  content.framed &&
396
484
  content.frameVariant === "default" &&
397
485
  "clear-left pt-3",
486
+ fill.filling && fillRegionClassName,
398
487
  className,
399
488
  )}
400
489
  {...props}
401
- />
490
+ >
491
+ {fill.provide(children)}
492
+ </div>
402
493
  );
403
494
  }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Propagação da ocupação de altura (interno) — ADR 0024.
3
+ *
4
+ * `Page layout="fill"` limita a página à altura do shell; a `Presentation` em página herda
5
+ * esse limite pela `Page` que ela monta. Para uma região interna ocupar a folga e rolar
6
+ * sozinha, cada elemento entre a raiz limitada e essa região precisa repassar a altura como
7
+ * coluna flexível. Este módulo faz essa cadeia se formar sem classes no consumidor:
8
+ *
9
+ * - a raiz (`FillRoot`) publica o contexto;
10
+ * - os elos canônicos (`PageBody`, `Content`, `ContentBody`, o corpo da `Presentation`)
11
+ * leem o contexto com `useFillRegion` e o republicam para os filhos;
12
+ * - a folha (`ActionList`) usa `useFillLeaf`: assume `fill` por padrão quando o elemento
13
+ * pai é um elo e se registra na cadeia;
14
+ * - um elo só vira coluna flexível quando uma folha se registrou abaixo dele. Irmãos sem
15
+ * folha mantêm a altura do próprio conteúdo, e uma página `fill` sem lista não muda;
16
+ * - superfícies com anatomia própria (Card, Dialog, Drawer, Pane) cortam a cadeia com
17
+ * `FillBoundary`: a altura delas é decisão delas, inclusive quando um portal as renderiza
18
+ * dentro de uma página `fill`.
19
+ *
20
+ * O registro acontece em `useLayoutEffect`, então as classes chegam antes da primeira pintura.
21
+ */
22
+
23
+ import {
24
+ createContext,
25
+ createElement,
26
+ useContext,
27
+ useLayoutEffect,
28
+ useMemo,
29
+ useRef,
30
+ useState,
31
+ type ReactElement,
32
+ type ReactNode,
33
+ type RefObject,
34
+ } from "react";
35
+
36
+ export type FillLayout = "flow" | "fill";
37
+
38
+ interface FillChain {
39
+ /** Anuncia uma folha abaixo deste elo; devolve a retirada do anúncio. */
40
+ register(): () => void;
41
+ }
42
+
43
+ const FillChainContext = createContext<FillChain | null>(null);
44
+
45
+ /** A raiz já tem altura limitada: não há o que propagar acima dela. */
46
+ const rootChain: FillChain = { register: () => () => {} };
47
+
48
+ /** Classes de um elo que repassa a altura à folha registrada abaixo dele. */
49
+ export const fillRegionClassName = "flex min-h-0 flex-1 flex-col";
50
+
51
+ /**
52
+ * Atributo que marca um elo da cadeia no DOM: `"true"` quando repassa a altura, `"false"`
53
+ * quando está na cadeia sem folha abaixo. A folha só assume `fill` implícito quando o
54
+ * elemento pai o carrega — um contêiner próprio no meio não repassa a altura.
55
+ */
56
+ export const fillRegionAttribute = "data-fill";
57
+
58
+ /** Publica a cadeia quando `active`; caso contrário a interrompe. */
59
+ export function FillRoot({
60
+ active,
61
+ children,
62
+ }: {
63
+ active: boolean;
64
+ children?: ReactNode;
65
+ }): ReactElement {
66
+ return createElement(
67
+ FillChainContext.Provider,
68
+ { value: active ? rootChain : null },
69
+ children,
70
+ );
71
+ }
72
+
73
+ /** Interrompe a cadeia: o conteúdo abaixo decide a própria altura. */
74
+ export function FillBoundary({
75
+ children,
76
+ }: {
77
+ children?: ReactNode;
78
+ }): ReactElement {
79
+ return createElement(FillChainContext.Provider, { value: null }, children);
80
+ }
81
+
82
+ export interface FillRegion {
83
+ /** O elo está em uma cadeia `fill`, com ou sem folha abaixo. */
84
+ inChain: boolean;
85
+ /** Uma folha se registrou abaixo: o elo deve repassar a altura. */
86
+ filling: boolean;
87
+ /** Atributos do elemento do elo. */
88
+ attributes: { "data-fill"?: "true" | "false" };
89
+ /** Republica a cadeia para os filhos do elo. */
90
+ provide(children: ReactNode): ReactElement;
91
+ }
92
+
93
+ /** Elo da cadeia: lê o contexto, conta as folhas abaixo e o republica. */
94
+ export function useFillRegion(): FillRegion {
95
+ const parent = useContext(FillChainContext);
96
+ const [leaves, setLeaves] = useState(0);
97
+ const chain = useMemo<FillChain | null>(
98
+ () =>
99
+ parent === null
100
+ ? null
101
+ : {
102
+ register() {
103
+ setLeaves((count) => count + 1);
104
+ const releaseParent = parent.register();
105
+ let registered = true;
106
+ return () => {
107
+ if (!registered) return;
108
+ registered = false;
109
+ setLeaves((count) => Math.max(0, count - 1));
110
+ releaseParent();
111
+ };
112
+ },
113
+ },
114
+ [parent],
115
+ );
116
+ const inChain = parent !== null;
117
+ const filling = inChain && leaves > 0;
118
+ return {
119
+ inChain,
120
+ filling,
121
+ attributes: inChain ? { "data-fill": filling ? "true" : "false" } : {},
122
+ provide: (children) =>
123
+ createElement(FillChainContext.Provider, { value: chain }, children),
124
+ };
125
+ }
126
+
127
+ /**
128
+ * Folha da cadeia. Sem `layout` explícito, assume `fill` quando o elemento pai é um elo;
129
+ * com `layout="fill"` explícito, registra-se mesmo sob um contêiner próprio, que passa a
130
+ * ser responsabilidade de quem o escreveu. `layout="flow"` nunca se registra.
131
+ */
132
+ export function useFillLeaf<TElement extends HTMLElement>(
133
+ layout: FillLayout | undefined,
134
+ ): { layout: FillLayout; ref: RefObject<TElement | null> } {
135
+ const chain = useContext(FillChainContext);
136
+ const ref = useRef<TElement | null>(null);
137
+ const [linked, setLinked] = useState(false);
138
+ useLayoutEffect(() => {
139
+ setLinked(
140
+ chain !== null &&
141
+ ref.current?.parentElement?.hasAttribute(fillRegionAttribute) === true,
142
+ );
143
+ }, [chain]);
144
+ const resolved = layout ?? (linked ? "fill" : "flow");
145
+ useLayoutEffect(() => {
146
+ if (chain === null || resolved !== "fill") return undefined;
147
+ return chain.register();
148
+ }, [chain, resolved]);
149
+ return { layout: resolved, ref };
150
+ }