@softize/opus 22.2.0 → 24.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 (115) hide show
  1. package/CHANGELOG.md +120 -1
  2. package/bin/cli.mjs +3 -9
  3. package/bin/lib/check.mjs +242 -49
  4. package/bin/lib/copy.mjs +17 -35
  5. package/bin/lib/gen-runner.mjs +2 -0
  6. package/bin/lib/gen-stubs.mjs +3 -1
  7. package/docs/adr/0005-structural-surfaces-share-an-explicit-anatomy.md +1 -1
  8. package/docs/adr/0023-surfaces-share-one-anatomy-and-two-layouts.md +6 -0
  9. package/docs/adr/0032-tab-baseline-respects-the-container-inset.md +16 -12
  10. package/docs/adr/0034-tabs-can-own-a-frame-without-owning-a-surface.md +8 -6
  11. package/docs/adr/0036-tabs-declares-trigger-distribution.md +11 -8
  12. package/docs/adr/0037-design-data-lives-outside-the-contract.md +1 -1
  13. package/docs/adr/0038-content-level-defines-primary-presentation.md +62 -0
  14. package/docs/adr/0039-filter-presentation-is-independent-from-filter-type.md +62 -0
  15. package/docs/adr/0040-tabs-use-a-single-navigation-presentation.md +59 -0
  16. package/docs/adr/0042-toggle-group-shares-the-grouped-control-grammar.md +79 -0
  17. package/docs/adr/0043-neutral-surfaces-and-interactions-use-opaque-role-tokens.md +73 -0
  18. package/docs/adr/0044-neutral-appearance-derives-from-bounded-axes-per-mode.md +133 -0
  19. package/docs/adr/0045-surface-replaces-content-and-card.md +230 -0
  20. package/docs/adr/0046-structural-body-owns-content-rhythm.md +103 -0
  21. package/docs/adr/0047-pane-is-only-a-split-layout-unit.md +71 -0
  22. package/docs/adr/0048-modal-intro-separates-content-from-chrome.md +34 -0
  23. package/docs/code-style.md +1 -1
  24. package/package.json +5 -1
  25. package/registry/skills/build-opus-ui/SKILL.md +11 -11
  26. package/registry/skills/build-opus-ui/references/evaluations.md +10 -9
  27. package/registry/skills/build-opus-ui/references/ui-patterns.md +17 -16
  28. package/registry/skills/create-opus-action/references/contract-and-binding.md +3 -3
  29. package/registry/templates/app/src/App.tsx +24 -12
  30. package/src/core/contracts.ts +1 -1
  31. package/src/core/runtime.ts +4 -37
  32. package/src/core/types.ts +5 -23
  33. package/src/design/index.ts +14 -3
  34. package/src/testing/index.ts +6 -26
  35. package/src/ui/appearance.ts +149 -0
  36. package/src/ui/components/patterns/action-form-card.tsx +46 -33
  37. package/src/ui/components/patterns/action-list-dialog.tsx +2 -4
  38. package/src/ui/components/patterns/confirm.tsx +3 -1
  39. package/src/ui/components/patterns/fill-region.ts +8 -7
  40. package/src/ui/components/patterns/form-dialog.tsx +1 -3
  41. package/src/ui/components/patterns/form.tsx +8 -7
  42. package/src/ui/components/patterns/list.tsx +64 -14
  43. package/src/ui/components/patterns/page.tsx +15 -11
  44. package/src/ui/components/patterns/pane.tsx +9 -240
  45. package/src/ui/components/patterns/presentation.tsx +14 -14
  46. package/src/ui/components/patterns/sidebar.tsx +7 -17
  47. package/src/ui/components/patterns/split.tsx +111 -59
  48. package/src/ui/components/patterns/{surface-header.tsx → structural-header.tsx} +50 -19
  49. package/src/ui/components/patterns/structural-media.tsx +26 -0
  50. package/src/ui/components/patterns/surface-anatomy.ts +71 -61
  51. package/src/ui/components/patterns/surface-assistant.tsx +2 -4
  52. package/src/ui/components/patterns/surface.tsx +620 -0
  53. package/src/ui/components/patterns/trigger.tsx +11 -9
  54. package/src/ui/components/primitives/ask.tsx +155 -83
  55. package/src/ui/components/primitives/button.tsx +11 -9
  56. package/src/ui/components/primitives/checkbox.tsx +8 -8
  57. package/src/ui/components/primitives/command.tsx +1 -1
  58. package/src/ui/components/primitives/composer.tsx +47 -45
  59. package/src/ui/components/primitives/detail.tsx +9 -2
  60. package/src/ui/components/primitives/dialog.tsx +60 -26
  61. package/src/ui/components/primitives/drawer.tsx +58 -12
  62. package/src/ui/components/primitives/icon-picker.tsx +93 -70
  63. package/src/ui/components/primitives/input-group.tsx +35 -28
  64. package/src/ui/components/primitives/input-otp.tsx +16 -16
  65. package/src/ui/components/primitives/input.tsx +20 -10
  66. package/src/ui/components/primitives/item.tsx +9 -12
  67. package/src/ui/components/primitives/menu.tsx +44 -65
  68. package/src/ui/components/primitives/metric-card.tsx +75 -59
  69. package/src/ui/components/primitives/radio-group.tsx +9 -9
  70. package/src/ui/components/primitives/select.tsx +344 -209
  71. package/src/ui/components/primitives/switch.tsx +10 -10
  72. package/src/ui/components/primitives/table.tsx +29 -24
  73. package/src/ui/components/primitives/tabs.tsx +58 -88
  74. package/src/ui/components/primitives/textarea.tsx +6 -6
  75. package/src/ui/components/primitives/toggle-group-context.ts +24 -0
  76. package/src/ui/components/primitives/toggle-group.tsx +116 -74
  77. package/src/ui/components/primitives/toggle.tsx +82 -16
  78. package/src/ui/docs/DocBrowser.tsx +77 -69
  79. package/src/ui/docs/content/action-form-card.md +9 -9
  80. package/src/ui/docs/content/action-list.md +85 -70
  81. package/src/ui/docs/content/action-view.md +33 -18
  82. package/src/ui/docs/content/actions.md +6 -7
  83. package/src/ui/docs/content/button.md +1 -1
  84. package/src/ui/docs/content/communication.md +1 -1
  85. package/src/ui/docs/content/customization.md +64 -11
  86. package/src/ui/docs/content/detail.md +8 -6
  87. package/src/ui/docs/content/dialog.md +100 -58
  88. package/src/ui/docs/content/drawer.md +63 -19
  89. package/src/ui/docs/content/field.md +14 -15
  90. package/src/ui/docs/content/icon-tile.md +14 -13
  91. package/src/ui/docs/content/page.md +34 -26
  92. package/src/ui/docs/content/pane.md +41 -290
  93. package/src/ui/docs/content/presentation.md +3 -3
  94. package/src/ui/docs/content/semantic-context.md +0 -1
  95. package/src/ui/docs/content/separator.md +6 -6
  96. package/src/ui/docs/content/sidebar.md +68 -266
  97. package/src/ui/docs/content/skeleton.md +20 -18
  98. package/src/ui/docs/content/split.md +14 -14
  99. package/src/ui/docs/content/surface.md +513 -0
  100. package/src/ui/docs/content/tabs.md +82 -83
  101. package/src/ui/docs/content/testing.md +2 -3
  102. package/src/ui/docs/content/toggle.md +120 -63
  103. package/src/ui/docs/content/tokens.md +124 -40
  104. package/src/ui/docs/registry.tsx +4 -6
  105. package/src/ui/drivers/react.tsx +4 -3
  106. package/src/ui/index.ts +30 -27
  107. package/src/ui/meta.ts +15 -27
  108. package/src/ui/react.tsx +42 -46
  109. package/src/ui/theme.css +311 -64
  110. package/src/vite/design.ts +23 -21
  111. package/src/ui/components/patterns/content-header.tsx +0 -532
  112. package/src/ui/components/patterns/surface-media.tsx +0 -69
  113. package/src/ui/components/primitives/card.tsx +0 -191
  114. package/src/ui/docs/content/card.md +0 -292
  115. package/src/ui/docs/content/content.md +0 -276
@@ -88,8 +88,8 @@ export interface OpusDesignOptions {
88
88
 
89
89
  /**
90
90
  * Cenários de design (ADR 0037): módulos que exportam `defineDesignScenario(contrato, resposta)`.
91
- * Aceita caminhos e globs, como `entry`. O cenário vence o `mockHandler` e a resposta padrão
92
- * gerada pelo `fake`; cenário de action inexistente impede o boot, com a lista no erro.
91
+ * Aceita caminhos e globs, como `entry`. O cenário vence a resposta padrão gerada pelo `fake`;
92
+ * cenário de action inexistente impede o boot, com a lista no erro.
93
93
  * Sem default: o projeto decide onde os cenários ficam e se são versionados.
94
94
  */
95
95
  scenarios?: string | string[]
@@ -116,7 +116,7 @@ export interface OpusDesignOptions {
116
116
  stubs?: Record<string, DesignStubHandler>
117
117
 
118
118
  /**
119
- * Monta o /api também no dev NORMAL (handler real, sem OPUS_MODE).
119
+ * Monta o /api também no dev NORMAL, com os handlers reais.
120
120
  * Default false — devBackend puro não é design (sem sonda, proxy intacto).
121
121
  */
122
122
  devBackend?: boolean
@@ -156,7 +156,7 @@ const PROBE_PATH = '/__opus/design'
156
156
  const RUNTIME_ENDPOINT_PATHS = new Set(['/health', '/ready', '/openapi.json'])
157
157
 
158
158
  /** De onde vem a resposta de cada action no modo design, na ordem de precedência. */
159
- type DesignSource = 'scenario' | 'mock' | 'handler' | 'generated'
159
+ type DesignSource = 'scenario' | 'handler' | 'generated'
160
160
 
161
161
  interface DesignState {
162
162
  handle: NodeServerHandle
@@ -214,9 +214,8 @@ export function opusDesign(options: OpusDesignOptions = {}): Plugin {
214
214
  exProxyPrefixes = Object.keys(config.server?.proxy ?? {})
215
215
  // 2) Mata o proxy: fecha o vazamento preview→prod pra QUALQUER prefixo.
216
216
  if (config.server?.proxy !== undefined) config.server.proxy = undefined
217
- // 3) O execute() roteia pro mockHandler lendo OPUS_MODE (isOpusDesignMode); o boot
218
- // liga em cada action a resposta de design, e o preview fica autossuficiente.
219
- process.env.OPUS_MODE = 'design'
217
+ // 3) O boot liga em cada contrato um handler de cenário ou resposta gerada,
218
+ // e o preview fica autossuficiente sem alterar o runtime de produção.
220
219
  },
221
220
 
222
221
  configureServer(server) {
@@ -362,15 +361,14 @@ export function opusDesign(options: OpusDesignOptions = {}): Plugin {
362
361
  const named = (source: DesignSource): string[] =>
363
362
  state.actions.filter((a) => state.sources.get(a.name) === source).map((a) => a.name)
364
363
  const scenarios = named('scenario')
365
- const mocks = named('mock')
366
364
  payload.runtime = {
367
365
  prefix: apiPrefix,
368
366
  actions: state.actions.length,
369
- // `mocked`/`unmocked` mantêm o formato anterior da sonda: resposta escrita à mão
370
- // (cenário ou mockHandler) contra o resto, que responde por handler real ou `fake`.
371
- mocked: scenarios.length + mocks.length,
367
+ // `mocked`/`unmocked` preservam o formato histórico da sonda: cenário escrito
368
+ // à mão contra respostas vindas de handler real ou geradas pelo `fake`.
369
+ mocked: scenarios.length,
372
370
  unmocked: state.actions
373
- .filter((a) => !['scenario', 'mock'].includes(state.sources.get(a.name) ?? ''))
371
+ .filter((a) => state.sources.get(a.name) !== 'scenario')
374
372
  .map((a) => a.name),
375
373
  scenarios,
376
374
  generated: named('generated'),
@@ -687,8 +685,8 @@ function scenariosOfModule(mod: Record<string, unknown>): DesignScenario[] {
687
685
  }
688
686
 
689
687
  /**
690
- * Liga a cada action a resposta de design, nesta precedência: cenário, `mockHandler` declarado,
691
- * handler real (`bindAction`) e, por fim, a resposta padrão gerada pelo `fake`.
688
+ * Liga a cada action a resposta de design, nesta precedência: cenário, handler real
689
+ * (`bindAction`) e, por fim, a resposta padrão gerada pelo `fake`.
692
690
  */
693
691
  function prepareDesignActions(
694
692
  actions: ActionDef[],
@@ -699,18 +697,23 @@ function prepareDesignActions(
699
697
  const scenario = scenarios.get(action.name)
700
698
  if (scenario !== undefined) {
701
699
  sources.set(action.name, 'scenario')
702
- return { ...action, mockHandler: (ctx: ActionContext, input: unknown) => scenario.respond(input, ctx) } as ActionDef
703
- }
704
- if (action.mockHandler !== undefined) {
705
- sources.set(action.name, 'mock')
706
- return action
700
+ return {
701
+ ...action,
702
+ // Resposta simulada não representa exposição real de dado pessoal (ADR 0041).
703
+ personalData: false,
704
+ handler: (ctx: ActionContext, input: unknown) => scenario.respond(input, ctx),
705
+ } as ActionDef
707
706
  }
708
707
  if (typeof (action as { handler?: unknown }).handler === 'function') {
709
708
  sources.set(action.name, 'handler')
710
709
  return action
711
710
  }
712
711
  sources.set(action.name, 'generated')
713
- return { ...action, mockHandler: async (_ctx: ActionContext, input: unknown) => designResponse(action, input) } as ActionDef
712
+ return {
713
+ ...action,
714
+ personalData: false,
715
+ handler: async (_ctx: ActionContext, input: unknown) => designResponse(action, input),
716
+ } as ActionDef
714
717
  })
715
718
  return { actions: prepared, sources }
716
719
  }
@@ -759,4 +762,3 @@ function sendEnvelope(res: ServerResponse, status: number, input: ErrorInput): v
759
762
  ),
760
763
  )
761
764
  }
762
-
@@ -1,532 +0,0 @@
1
- import {
2
- Children,
3
- createContext,
4
- isValidElement,
5
- useContext,
6
- useId,
7
- type ComponentProps,
8
- type HTMLAttributes,
9
- type ReactElement,
10
- type ReactNode,
11
- } from "react";
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";
15
- import { headingTags } from "./heading-level.ts";
16
- import { usePageHeadingPresence } from "./page-heading-context.tsx";
17
- import type { SurfaceLayout } from "./surface-anatomy.ts";
18
- import { SurfaceMedia, type SurfaceMediaProps } from "./surface-media.tsx";
19
- import {
20
- SurfaceHeader,
21
- surfaceHeaderClasses,
22
- type SurfaceHeaderVariant,
23
- } from "./surface-header.tsx";
24
-
25
- export type ContentHeaderLevel = 1 | 2 | 3 | 4 | 5 | 6;
26
- export type ContentHeaderVariant = SurfaceHeaderVariant;
27
- export type ContentElement = "section" | "fieldset";
28
- export type ContentFrameVariant = "default" | "legend";
29
-
30
- interface ContentContextValue {
31
- element: ContentElement;
32
- frameVariant: ContentFrameVariant;
33
- framed: boolean;
34
- layout: SurfaceLayout;
35
- level: ContentHeaderLevel;
36
- titleId: string;
37
- variant: ContentHeaderVariant;
38
- }
39
-
40
- const ContentContext = createContext<ContentContextValue | null>(null);
41
- const ContentHeaderContext = createContext<ContentContextValue | null>(null);
42
-
43
- function requiredContext<T>(
44
- value: T | null,
45
- component: string,
46
- parent: string,
47
- ): T {
48
- if (value === null)
49
- throw new Error(`${component} deve estar dentro de ${parent}.`);
50
- return value;
51
- }
52
-
53
- interface ContentSharedProps {
54
- /** Aplica a moldura estrutural da região sem transferi-la ao conteúdo interno. */
55
- framed?: boolean;
56
- /** Anatomia interna: contínua (default) ou com header separado do corpo. */
57
- layout?: SurfaceLayout;
58
- /** Hierarquia visual: `section` (default) ou `page`, a mesma de PageHeader. */
59
- variant?: ContentHeaderVariant;
60
- }
61
-
62
- interface ContentSectionBaseProps
63
- extends
64
- ContentSharedProps,
65
- Omit<ComponentProps<"section">, "children" | "title"> {
66
- /** Elemento semântico da região. */
67
- as?: "section";
68
- /** Tratamento visual da moldura: contínua (default) ou integrada ao header. */
69
- frameVariant?: ContentFrameVariant;
70
- /** Nível semântico do heading, independente de seu destaque visual. */
71
- level?: ContentHeaderLevel;
72
- }
73
-
74
- interface ContentFieldsetBaseProps
75
- extends
76
- ContentSharedProps,
77
- Omit<ComponentProps<"fieldset">, "children" | "title"> {
78
- /** Usa a semântica nativa de grupo de campos; o título vira `legend`. */
79
- as: "fieldset";
80
- /** Tratamento visual da moldura: contínua (default) ou recortada pela legenda. */
81
- frameVariant?: ContentFrameVariant;
82
- level?: never;
83
- variant?: "section";
84
- }
85
-
86
- interface ContentSectionShorthandProps extends ContentSectionBaseProps {
87
- title: ReactNode;
88
- /** Ícone ou imagem que identifica a região, antes do título. */
89
- media?: ReactNode;
90
- /** Total de itens ao lado do título da seção. A página não carrega contador (ADR 0009). */
91
- count?: number;
92
- description?: ReactNode;
93
- actions?: ReactNode;
94
- children: ReactNode;
95
- }
96
-
97
- interface ContentFieldsetShorthandProps extends ContentFieldsetBaseProps {
98
- title: ReactNode;
99
- /** Ícone ou imagem decorativa que acompanha o título dentro da legenda. */
100
- media?: ReactNode;
101
- count?: never;
102
- description?: never;
103
- actions?: never;
104
- children: ReactNode;
105
- }
106
-
107
- interface ContentComposedProps {
108
- title?: never;
109
- media?: never;
110
- count?: never;
111
- description?: never;
112
- actions?: never;
113
- children: ReactNode;
114
- }
115
-
116
- export type ContentProps =
117
- | ContentSectionShorthandProps
118
- | ContentFieldsetShorthandProps
119
- | ((ContentSectionBaseProps | ContentFieldsetBaseProps) &
120
- ContentComposedProps);
121
-
122
- /** Região de conteúdo nomeada. A forma curta e a explícita produzem a mesma anatomia. */
123
- export function Content({
124
- as = "section",
125
- title,
126
- media,
127
- count,
128
- description,
129
- actions,
130
- level = 2,
131
- variant = "section",
132
- framed = false,
133
- layout = "continuous",
134
- frameVariant = "default",
135
- children,
136
- className,
137
- ...props
138
- }: ContentProps): ReactElement {
139
- const titleId = useId();
140
- const fill = useFillRegion();
141
- const context = {
142
- element: as,
143
- frameVariant,
144
- framed,
145
- layout,
146
- level: level as ContentHeaderLevel,
147
- titleId,
148
- variant,
149
- };
150
- const shorthand = title !== undefined;
151
- const nodes = Children.toArray(children);
152
-
153
- if (layout === "sectioned" && !framed) {
154
- throw new Error('Content layout="sectioned" exige framed.');
155
- }
156
- if (layout === "sectioned" && frameVariant === "legend") {
157
- throw new Error(
158
- 'Content não combina layout="sectioned" com frameVariant="legend".',
159
- );
160
- }
161
-
162
- if (
163
- shorthand &&
164
- nodes.some((node) => isValidElement(node) && node.type === ContentHeader)
165
- ) {
166
- throw new Error(
167
- "Content não permite misturar propriedades de shorthand com ContentHeader explícito.",
168
- );
169
- }
170
- if (!shorthand) {
171
- const headers = nodes.filter(
172
- (node) => isValidElement(node) && node.type === ContentHeader,
173
- );
174
- const bodies = nodes.filter(
175
- (node) => isValidElement(node) && node.type === ContentBody,
176
- );
177
- if (
178
- headers.length !== 1 ||
179
- bodies.length !== 1 ||
180
- headers.length + bodies.length !== nodes.length
181
- ) {
182
- throw new Error(
183
- "Content explícito exige exatamente um ContentHeader e um ContentBody como filhos diretos.",
184
- );
185
- }
186
- }
187
-
188
- const anatomy = shorthand ? (
189
- <>
190
- <ContentHeader>
191
- {hasContentMedia(media) && <ContentMedia>{media}</ContentMedia>}
192
- <ContentTitle>{title}</ContentTitle>
193
- {count !== undefined && <ContentMeta>{count}</ContentMeta>}
194
- {description !== undefined && (
195
- <ContentDescription>{description}</ContentDescription>
196
- )}
197
- {actions !== undefined && <ContentActions>{actions}</ContentActions>}
198
- </ContentHeader>
199
- <ContentBody>{children}</ContentBody>
200
- </>
201
- ) : (
202
- children
203
- );
204
- const ariaLabelledBy =
205
- props["aria-label"] === undefined ? titleId : undefined;
206
-
207
- return (
208
- <ContentContext.Provider value={context}>
209
- {as === "fieldset" ? (
210
- <fieldset
211
- data-slot="content"
212
- data-as="fieldset"
213
- data-framed={framed}
214
- data-frame-variant={frameVariant}
215
- data-layout={layout}
216
- {...fill.attributes}
217
- aria-labelledby={ariaLabelledBy}
218
- className={cn(
219
- !framed && layout === "continuous" && "space-y-3",
220
- framed &&
221
- layout === "continuous" &&
222
- frameVariant === "default" &&
223
- "rounded-xl border border-border p-4",
224
- framed &&
225
- layout === "continuous" &&
226
- frameVariant === "legend" &&
227
- "space-y-3 rounded-xl border border-border px-4 pb-4",
228
- framed &&
229
- layout === "sectioned" &&
230
- "overflow-hidden rounded-xl border border-border",
231
- fill.filling && fillRegionClassName,
232
- className,
233
- )}
234
- {...(props as ComponentProps<"fieldset">)}
235
- >
236
- {fill.provide(anatomy)}
237
- </fieldset>
238
- ) : (
239
- <section
240
- data-slot="content"
241
- data-as="section"
242
- data-framed={framed}
243
- data-frame-variant={frameVariant}
244
- data-layout={layout}
245
- {...fill.attributes}
246
- aria-labelledby={ariaLabelledBy}
247
- className={cn(
248
- layout === "continuous" && "space-y-3",
249
- framed &&
250
- layout === "continuous" &&
251
- frameVariant === "default" &&
252
- "rounded-xl border border-border p-4",
253
- framed &&
254
- layout === "continuous" &&
255
- frameVariant === "legend" &&
256
- "rounded-xl border border-border px-4 pb-4",
257
- framed &&
258
- layout === "sectioned" &&
259
- "overflow-hidden rounded-xl border border-border",
260
- fill.filling && fillRegionClassName,
261
- className,
262
- )}
263
- {...(props as ComponentProps<"section">)}
264
- >
265
- {fill.provide(anatomy)}
266
- </section>
267
- )}
268
- </ContentContext.Provider>
269
- );
270
- }
271
-
272
- export type ContentHeaderProps = Omit<HTMLAttributes<HTMLDivElement>, "title">;
273
-
274
- /**
275
- * Header estrutural de Content; não é uma região autônoma. A anatomia é a de
276
- * `SurfaceHeader`, a mesma de `PageHeader`: só os nomes dos slots e a hierarquia mudam.
277
- */
278
- export function ContentHeader({
279
- className,
280
- children,
281
- ...props
282
- }: ContentHeaderProps): ReactElement {
283
- const content = requiredContext(
284
- useContext(ContentContext),
285
- "ContentHeader",
286
- "Content",
287
- );
288
- if (content.element === "fieldset") {
289
- return (
290
- <ContentHeaderContext.Provider value={content}>
291
- <SurfaceHeader
292
- as="legend"
293
- name="ContentHeader"
294
- slot="content"
295
- slots={{ media: ContentMedia, title: ContentTitle }}
296
- mediaPlacement="heading"
297
- invalidMessage="ContentHeader de fieldset aceita somente ContentMedia e ContentTitle; contador, descrição e ações ficam fora da legenda."
298
- className={cn(
299
- content.framed &&
300
- content.layout === "continuous" &&
301
- content.frameVariant === "default" &&
302
- "float-left w-full",
303
- content.framed &&
304
- content.layout === "continuous" &&
305
- content.frameVariant === "legend" &&
306
- "-ml-2 w-auto px-2",
307
- content.framed &&
308
- content.layout === "sectioned" &&
309
- "float-left w-full border-b border-border p-3",
310
- className,
311
- )}
312
- {...props}
313
- >
314
- {children}
315
- </SurfaceHeader>
316
- </ContentHeaderContext.Provider>
317
- );
318
- }
319
- return (
320
- <ContentHeaderContext.Provider value={content}>
321
- <SurfaceHeader
322
- name="ContentHeader"
323
- slot="content"
324
- slots={{
325
- media: ContentMedia,
326
- title: ContentTitle,
327
- count: ContentMeta,
328
- description: ContentDescription,
329
- actions: ContentActions,
330
- }}
331
- mediaPlacement="heading"
332
- className={cn(
333
- content.framed &&
334
- content.layout === "continuous" &&
335
- content.frameVariant === "legend" &&
336
- "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",
337
- content.framed &&
338
- content.layout === "continuous" &&
339
- content.frameVariant === "legend" &&
340
- content.variant === "section" &&
341
- "-mt-4 min-h-8",
342
- content.framed &&
343
- content.layout === "continuous" &&
344
- content.frameVariant === "legend" &&
345
- content.variant === "page" &&
346
- "-mt-5 min-h-10",
347
- content.framed &&
348
- content.layout === "sectioned" &&
349
- "border-b border-border p-3",
350
- content.variant === "page" &&
351
- "flex-row items-center justify-start gap-3",
352
- className,
353
- )}
354
- {...props}
355
- >
356
- {children}
357
- </SurfaceHeader>
358
- </ContentHeaderContext.Provider>
359
- );
360
- }
361
-
362
- /** `null`, `false` e `undefined` não desenham mídia: uma moldura vazia não identifica nada. */
363
- function hasContentMedia(media: ReactNode): boolean {
364
- return media !== undefined && media !== null && media !== false;
365
- }
366
-
367
- /**
368
- * Um `IconTile` recebido como filho não vira uma segunda moldura: o slot assume o lugar dele e
369
- * herda o que ele declara (`size`, `context`, `variant`, demais props), somando as classes.
370
- */
371
- function adoptIconTile(props: SurfaceMediaProps): SurfaceMediaProps {
372
- const { children, asChild, className } = props;
373
- if (asChild) return props;
374
- // `toArray`, não `only`: um array com um único item (de um `map`/`filter`) é válido aqui.
375
- const nodes = Children.toArray(children);
376
- if (nodes.length !== 1) return props;
377
- const child = nodes[0];
378
- if (!isValidElement<IconTileProps>(child) || child.type !== IconTile) {
379
- return props;
380
- }
381
- // `null` em size/context/variant significa "use o padrão" no IconTile: fica o do slot.
382
- const declared = Object.fromEntries(
383
- Object.entries(child.props).filter(([, value]) => value != null),
384
- ) as SurfaceMediaProps;
385
- return {
386
- ...props,
387
- ...declared,
388
- className: cn(className, declared.className),
389
- children: declared.children,
390
- };
391
- }
392
-
393
- /** Moldura de ícone ou imagem que identifica a região nomeada. */
394
- export function ContentMedia({
395
- size = "xs",
396
- ...rest
397
- }: SurfaceMediaProps): ReactElement {
398
- const context = requiredContext(
399
- useContext(ContentHeaderContext),
400
- "ContentMedia",
401
- "ContentHeader",
402
- );
403
- const props = adoptIconTile({ size, ...rest });
404
- return (
405
- <SurfaceMedia
406
- slot="content-media"
407
- {...props}
408
- aria-hidden={context.element === "fieldset" ? true : props["aria-hidden"]}
409
- />
410
- );
411
- }
412
-
413
- export function ContentTitle({
414
- className,
415
- id,
416
- ...props
417
- }: HTMLAttributes<HTMLElement>): ReactElement {
418
- const context = requiredContext(
419
- useContext(ContentHeaderContext),
420
- "ContentTitle",
421
- "ContentHeader",
422
- );
423
- const Heading =
424
- context.element === "fieldset" ? "span" : headingTags[context.level];
425
- usePageHeadingPresence(context.element !== "fieldset" && context.level === 1);
426
- return (
427
- <Heading
428
- id={id ?? context.titleId}
429
- data-slot="content-title"
430
- className={cn(surfaceHeaderClasses[context.variant].title, className)}
431
- {...props}
432
- />
433
- );
434
- }
435
-
436
- export function ContentDescription({
437
- className,
438
- ...props
439
- }: HTMLAttributes<HTMLParagraphElement>): ReactElement {
440
- const context = requiredContext(
441
- useContext(ContentHeaderContext),
442
- "ContentDescription",
443
- "ContentHeader",
444
- );
445
- return (
446
- <p
447
- data-slot="content-description"
448
- className={cn(
449
- surfaceHeaderClasses[context.variant].description,
450
- className,
451
- )}
452
- {...props}
453
- />
454
- );
455
- }
456
-
457
- /** O contador ao lado do título — o slot `count` do shorthand. */
458
- export function ContentMeta({
459
- className,
460
- ...props
461
- }: HTMLAttributes<HTMLSpanElement>): ReactElement {
462
- const context = requiredContext(
463
- useContext(ContentHeaderContext),
464
- "ContentMeta",
465
- "ContentHeader",
466
- );
467
- return (
468
- <span
469
- data-slot="content-meta"
470
- className={cn(surfaceHeaderClasses[context.variant].count, className)}
471
- {...props}
472
- />
473
- );
474
- }
475
-
476
- export function ContentActions({
477
- className,
478
- ...props
479
- }: HTMLAttributes<HTMLDivElement>): ReactElement {
480
- const context = requiredContext(
481
- useContext(ContentHeaderContext),
482
- "ContentActions",
483
- "ContentHeader",
484
- );
485
- return (
486
- <div
487
- data-slot="content-actions"
488
- className={cn(
489
- surfaceHeaderClasses[context.variant].actions,
490
- context.variant === "page" && "ml-auto w-auto",
491
- className,
492
- )}
493
- {...props}
494
- />
495
- );
496
- }
497
-
498
- export function ContentBody({
499
- className,
500
- children,
501
- ...props
502
- }: HTMLAttributes<HTMLDivElement>): ReactElement {
503
- const content = requiredContext(
504
- useContext(ContentContext),
505
- "ContentBody",
506
- "Content",
507
- );
508
- const fill = useFillRegion();
509
- return (
510
- <div
511
- data-slot="content-body"
512
- {...fill.attributes}
513
- className={cn(
514
- content.element === "fieldset" && "space-y-3",
515
- content.element === "fieldset" &&
516
- content.framed &&
517
- content.layout === "continuous" &&
518
- content.frameVariant === "default" &&
519
- "clear-left pt-3",
520
- content.element === "fieldset" &&
521
- content.framed &&
522
- content.layout === "sectioned" &&
523
- "clear-left",
524
- fill.filling && fillRegionClassName,
525
- className,
526
- )}
527
- {...props}
528
- >
529
- {fill.provide(children)}
530
- </div>
531
- );
532
- }