@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.
- package/CHANGELOG.md +239 -0
- package/bin/cli.mjs +5 -0
- package/bin/lib/check.mjs +198 -10
- package/bin/lib/copy.mjs +29 -0
- package/bin/lib/mcp.mjs +1 -1
- package/docs/adr/0020-icon-tiles-are-explicitly-composed.md +8 -0
- package/docs/adr/0021-content-carries-structure-and-semantic-element.md +7 -0
- package/docs/adr/0023-surfaces-share-one-anatomy-and-two-layouts.md +19 -1
- package/docs/adr/0024-fill-height-propagates-by-context.md +118 -0
- package/docs/adr/0025-content-frame-treatment-does-not-change-semantics.md +134 -0
- package/docs/data-products.md +15 -9
- package/docs/protocol.md +3 -4
- package/docs/releasing.md +2 -2
- package/package.json +2 -2
- package/registry/skills/create-opus-action/scripts/scaffold.mjs +2 -1
- package/registry/templates/app/package.json +1 -1
- package/src/core/runtime.ts +96 -8
- package/src/core/types.ts +5 -2
- package/src/ui/components/patterns/action-form-card.tsx +11 -1
- package/src/ui/components/patterns/content-header.tsx +109 -18
- package/src/ui/components/patterns/fill-region.ts +150 -0
- package/src/ui/components/patterns/list.tsx +91 -92
- package/src/ui/components/patterns/page.tsx +20 -4
- package/src/ui/components/patterns/presentation.tsx +52 -37
- package/src/ui/components/patterns/split.tsx +44 -4
- package/src/ui/components/patterns/surface-anatomy.ts +35 -8
- package/src/ui/components/patterns/surface-header.tsx +58 -25
- package/src/ui/components/patterns/trigger.tsx +12 -2
- package/src/ui/components/primitives/alert.tsx +9 -1
- package/src/ui/components/primitives/avatar.tsx +7 -3
- package/src/ui/components/primitives/card.tsx +1 -1
- package/src/ui/components/primitives/carousel.tsx +34 -5
- package/src/ui/components/primitives/checkbox.tsx +11 -3
- package/src/ui/components/primitives/command.tsx +2 -1
- package/src/ui/components/primitives/copyable.tsx +120 -10
- package/src/ui/components/primitives/dialog.tsx +42 -11
- package/src/ui/components/primitives/drawer.tsx +10 -2
- package/src/ui/components/primitives/empty.tsx +1 -1
- package/src/ui/components/primitives/icon-picker.tsx +6 -2
- package/src/ui/components/primitives/icon-tile.tsx +7 -0
- package/src/ui/components/primitives/input-group.tsx +9 -2
- package/src/ui/components/primitives/input-otp.tsx +1 -1
- package/src/ui/components/primitives/kbd.tsx +1 -1
- package/src/ui/components/primitives/label.tsx +3 -0
- package/src/ui/components/primitives/menu.tsx +5 -0
- package/src/ui/components/primitives/popover.tsx +1 -1
- package/src/ui/components/primitives/progress.tsx +9 -1
- package/src/ui/components/primitives/slider.tsx +62 -11
- package/src/ui/components/primitives/truncate.tsx +10 -1
- package/src/ui/docs/content/accordion.md +74 -27
- package/src/ui/docs/content/action-form-card.md +24 -15
- package/src/ui/docs/content/action-form-dialog.md +17 -7
- package/src/ui/docs/content/action-form.md +92 -77
- package/src/ui/docs/content/action-list-dialog.md +75 -38
- package/src/ui/docs/content/action-list.md +141 -58
- package/src/ui/docs/content/action-trigger.md +27 -10
- package/src/ui/docs/content/action-view.md +14 -14
- package/src/ui/docs/content/actions.md +161 -66
- package/src/ui/docs/content/ai.md +43 -44
- package/src/ui/docs/content/alert.md +83 -29
- package/src/ui/docs/content/ask.md +51 -26
- package/src/ui/docs/content/aspect-ratio.md +22 -24
- package/src/ui/docs/content/audit.md +23 -19
- package/src/ui/docs/content/auth.md +38 -19
- package/src/ui/docs/content/avatar.md +54 -5
- package/src/ui/docs/content/badge.md +29 -9
- package/src/ui/docs/content/breadcrumb.md +21 -8
- package/src/ui/docs/content/button.md +73 -43
- package/src/ui/docs/content/calendar.md +134 -26
- package/src/ui/docs/content/card.md +41 -15
- package/src/ui/docs/content/carousel.md +37 -6
- package/src/ui/docs/content/chat.md +118 -37
- package/src/ui/docs/content/checkbox.md +73 -7
- package/src/ui/docs/content/cli.md +94 -55
- package/src/ui/docs/content/collapsible.md +43 -19
- package/src/ui/docs/content/command.md +30 -8
- package/src/ui/docs/content/composer.md +23 -8
- package/src/ui/docs/content/content.md +119 -27
- package/src/ui/docs/content/copyable.md +39 -8
- package/src/ui/docs/content/customization.md +55 -46
- package/src/ui/docs/content/cycle.md +33 -18
- package/src/ui/docs/content/data-products.md +115 -0
- package/src/ui/docs/content/data-state.md +20 -6
- package/src/ui/docs/content/data.md +20 -19
- package/src/ui/docs/content/detail.md +12 -5
- package/src/ui/docs/content/dialog.md +107 -24
- package/src/ui/docs/content/dictionary-value.md +4 -4
- package/src/ui/docs/content/dock.md +54 -19
- package/src/ui/docs/content/dot.md +35 -5
- package/src/ui/docs/content/drawer.md +63 -19
- package/src/ui/docs/content/empty-value.md +20 -6
- package/src/ui/docs/content/empty.md +24 -3
- package/src/ui/docs/content/events.md +76 -16
- package/src/ui/docs/content/executions.md +9 -9
- package/src/ui/docs/content/field.md +21 -6
- package/src/ui/docs/content/getting-started.md +44 -34
- package/src/ui/docs/content/icon-picker.md +19 -13
- package/src/ui/docs/content/icon-tile.md +38 -15
- package/src/ui/docs/content/input-otp.md +58 -7
- package/src/ui/docs/content/input.md +22 -3
- package/src/ui/docs/content/item.md +53 -6
- package/src/ui/docs/content/kbd.md +5 -1
- package/src/ui/docs/content/label.md +13 -4
- package/src/ui/docs/content/log.md +5 -5
- package/src/ui/docs/content/markdown.md +5 -4
- package/src/ui/docs/content/mcp.md +49 -23
- package/src/ui/docs/content/menu.md +95 -10
- package/src/ui/docs/content/metric-card.md +12 -4
- package/src/ui/docs/content/observability.md +10 -9
- package/src/ui/docs/content/page.md +118 -12
- package/src/ui/docs/content/pagination.md +13 -5
- package/src/ui/docs/content/pane.md +55 -5
- package/src/ui/docs/content/popover.md +42 -5
- package/src/ui/docs/content/presentation.md +91 -47
- package/src/ui/docs/content/progress.md +27 -14
- package/src/ui/docs/content/queue.md +39 -21
- package/src/ui/docs/content/radio-group.md +26 -4
- package/src/ui/docs/content/router.md +29 -10
- package/src/ui/docs/content/runtime.md +90 -54
- package/src/ui/docs/content/scheduler.md +53 -21
- package/src/ui/docs/content/scroll-area.md +42 -35
- package/src/ui/docs/content/select.md +78 -33
- package/src/ui/docs/content/semantic-context.md +21 -2
- package/src/ui/docs/content/separator.md +15 -9
- package/src/ui/docs/content/sidebar.md +3 -2
- package/src/ui/docs/content/skeleton.md +6 -1
- package/src/ui/docs/content/slider.md +37 -9
- package/src/ui/docs/content/spinner.md +4 -3
- package/src/ui/docs/content/split.md +19 -0
- package/src/ui/docs/content/storage.md +8 -9
- package/src/ui/docs/content/switch.md +10 -7
- package/src/ui/docs/content/table.md +46 -8
- package/src/ui/docs/content/tabs.md +14 -4
- package/src/ui/docs/content/testing.md +76 -31
- package/src/ui/docs/content/textarea.md +5 -4
- package/src/ui/docs/content/toast.md +48 -7
- package/src/ui/docs/content/toggle.md +3 -3
- package/src/ui/docs/content/tokens.md +15 -26
- package/src/ui/docs/content/tooltip.md +18 -2
- package/src/ui/docs/content/truncate.md +36 -6
- package/src/ui/docs/content/ui.md +74 -22
- package/src/ui/docs/content/upgrading.md +45 -36
- package/src/ui/docs/plugin.ts +36 -2
- package/src/ui/docs/registry.tsx +6 -0
- package/src/ui/drivers/react.tsx +16 -8
- package/src/ui/meta.ts +32 -32
- package/src/ui/react.tsx +1 -0
- 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 &&
|
|
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
|
-
|
|
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 &&
|
|
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
|
-
<
|
|
254
|
-
|
|
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
|
-
</
|
|
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
|
+
}
|