@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
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Anatomia de superfície (interno): as medidas que Card, Dialog, Drawer e Pane
3
+ * compartilham — ADR 0023.
4
+ *
5
+ * Aqui mora o que antes era escrito seis vezes: recuo de cada região, ritmo vertical,
6
+ * escala do título, posição das ações e a regra dos divisores. As famílias públicas
7
+ * consomem estas tabelas; nenhuma reescreve as classes.
8
+ *
9
+ * Dois layouts, e cada um é um pacote indivisível:
10
+ *
11
+ * - `continuous` — sem divisores. O ritmo separa: o header se aproxima do corpo e a
12
+ * última região fecha o espaço de baixo. É o card de leitura.
13
+ * - `sectioned` — regiões simétricas, com divisor entre as que forem adjacentes. É o
14
+ * modal, o drawer e o painel.
15
+ *
16
+ * O recuo lateral é o mesmo nas três regiões da mesma superfície; é isso que a suíte
17
+ * transversal verifica. Entre famílias diferentes ele pode divergir: o card de leitura
18
+ * é mais folgado que um painel de operação.
19
+ */
20
+
21
+ import { createContext, useContext } from "react";
22
+
23
+ export type SurfaceFamily = "card" | "dialog" | "drawer" | "pane";
24
+ export type SurfaceLayout = "continuous" | "sectioned";
25
+ export type SurfaceRegion = "header" | "body" | "footer";
26
+
27
+ /**
28
+ * Divisores: a região desenha a própria linha quando existe uma vizinha do outro lado.
29
+ * Um header sozinho não fecha nada; um footer que é a única região também não.
30
+ *
31
+ * O header suprime a própria linha quando o vizinho imediato é um footer — os dois
32
+ * juntos desenhariam duas bordas encostadas, uma sobre a outra.
33
+ */
34
+ const headerDivider = [
35
+ "[&:not(:last-child)]:border-b",
36
+ "[&:not(:last-child)]:border-border",
37
+ // A supressão precisa VENCER a regra acima na cascata, e não só vir depois dela: as
38
+ // duas nascem com a mesma especificidade e a ordem na folha é do Tailwind, não nossa.
39
+ // Repetir o `:not(:last-child)` aqui acrescenta uma pseudoclasse e desempata.
40
+ "[&:not(:last-child):has(+[data-slot$=-footer])]:border-b-0",
41
+ ].join(" ");
42
+ const footerDivider =
43
+ "[&:not(:first-child)]:border-t [&:not(:first-child)]:border-border";
44
+
45
+ interface RegionAnatomy {
46
+ header: string;
47
+ body: string;
48
+ footer: string;
49
+ }
50
+
51
+ interface FamilyAnatomy {
52
+ /** Recuo e ritmo por layout. O recuo lateral é comum às três regiões. */
53
+ regions: Record<SurfaceLayout, RegionAnatomy>;
54
+ /** Escala visual do título. O nível semântico do heading é escolhido à parte. */
55
+ title: Record<SurfaceLayout, string>;
56
+ /** Descrição no header, quando a família a oferece (ADRs 0014 e 0018 excluem modais). */
57
+ description: string;
58
+ /** Espaço entre mídia, título e ações na linha do header. */
59
+ headerGap: string;
60
+ /** Faixa do rodapé no layout seccionado, quando a família declara uma. */
61
+ footerBand: string;
62
+ }
63
+
64
+ /**
65
+ * O recuo seccionado de Card, Dialog e Drawer: 1rem, a densidade das superfícies de
66
+ * operação, e o que permite um card seccionado conviver com um drawer sem degrau
67
+ * visível. O pane tem a sua (`px-3`, a do chrome), declarada na tabela abaixo.
68
+ */
69
+ const sectionedRegions: RegionAnatomy = {
70
+ header: "p-4",
71
+ body: "p-4",
72
+ footer: "p-4",
73
+ };
74
+
75
+ export const surfaceAnatomy: Record<SurfaceFamily, FamilyAnatomy> = {
76
+ card: {
77
+ regions: {
78
+ // O `pb-3` do header e do body encurtam a distância até a região seguinte; quando
79
+ // o body é a última região, ele reassume o respiro de baixo (`[&:last-child]:pb-5`).
80
+ continuous: {
81
+ header: "px-5 pt-5 pb-3",
82
+ body: "px-5 py-3 [&:last-child]:pb-5 [&:first-child]:pt-5",
83
+ footer: "px-5 pt-3 pb-5",
84
+ },
85
+ sectioned: sectionedRegions,
86
+ },
87
+ title: {
88
+ continuous: "text-base font-semibold leading-none",
89
+ sectioned: "text-sm font-semibold leading-none",
90
+ },
91
+ description: "text-sm text-muted-foreground",
92
+ headerGap: "gap-3",
93
+ footerBand: "",
94
+ },
95
+ dialog: {
96
+ regions: {
97
+ continuous: {
98
+ header: "p-4 pb-2",
99
+ body: "p-4 py-2 [&:last-child]:pb-4 [&:first-child]:pt-4",
100
+ footer: "p-4 pt-2",
101
+ },
102
+ sectioned: sectionedRegions,
103
+ },
104
+ title: {
105
+ continuous: "text-lg font-semibold leading-none",
106
+ sectioned: "text-lg font-semibold leading-none",
107
+ },
108
+ description: "text-sm text-muted-foreground",
109
+ headerGap: "gap-4",
110
+ footerBand: "bg-muted/20 dark:bg-input/20",
111
+ },
112
+ drawer: {
113
+ regions: {
114
+ continuous: {
115
+ header: "p-4 pb-2",
116
+ body: "p-4 py-2 [&:last-child]:pb-4 [&:first-child]:pt-4",
117
+ footer: "p-4 pt-2",
118
+ },
119
+ sectioned: sectionedRegions,
120
+ },
121
+ title: {
122
+ continuous: "text-lg font-semibold leading-none",
123
+ sectioned: "text-lg font-semibold leading-none",
124
+ },
125
+ description: "text-sm text-muted-foreground",
126
+ headerGap: "gap-4",
127
+ footerBand: "bg-muted/20 dark:bg-input/20",
128
+ },
129
+ pane: {
130
+ regions: {
131
+ continuous: {
132
+ header: "flex shrink-0 items-center px-3 pt-3 pb-2",
133
+ body: "p-3 [&:first-child]:pt-3",
134
+ footer: "px-3 pt-2 pb-3",
135
+ },
136
+ sectioned: {
137
+ // A faixa tem a altura da barra do shell; `flex items-center` centraliza a linha
138
+ // dentro dela, e `shrink-0` impede que um corpo longo comprima a faixa.
139
+ header: "flex h-[3.75rem] shrink-0 items-center px-3",
140
+ body: "p-3",
141
+ footer: "px-3 py-2",
142
+ },
143
+ },
144
+ title: {
145
+ continuous: "text-sm font-semibold leading-none",
146
+ sectioned: "text-sm font-semibold leading-none",
147
+ },
148
+ description: "text-xs text-muted-foreground",
149
+ headerGap: "gap-2",
150
+ footerBand: "",
151
+ },
152
+ };
153
+
154
+ /** Classes da região, já com o divisor que o layout deriva da vizinhança. */
155
+ export function surfaceRegionClasses(
156
+ family: SurfaceFamily,
157
+ layout: SurfaceLayout,
158
+ region: SurfaceRegion,
159
+ ): string {
160
+ const anatomy = surfaceAnatomy[family];
161
+ const base = anatomy.regions[layout][region];
162
+ if (layout !== "sectioned") return base;
163
+ if (region === "header") return `${base} ${headerDivider}`;
164
+ if (region === "footer")
165
+ return [base, footerDivider, anatomy.footerBand].filter(Boolean).join(" ");
166
+ return base;
167
+ }
168
+
169
+ /** Escala visual do título — o nível do heading vem da composição, nunca daqui. */
170
+ export function surfaceTitleClasses(
171
+ family: SurfaceFamily,
172
+ layout: SurfaceLayout,
173
+ ): string {
174
+ return surfaceAnatomy[family].title[layout];
175
+ }
176
+
177
+ export function surfaceDescriptionClasses(family: SurfaceFamily): string {
178
+ return surfaceAnatomy[family].description;
179
+ }
180
+
181
+ /**
182
+ * A linha do header: mídia, heading que absorve a folga, ações no extremo oposto.
183
+ *
184
+ * `flex-1` não é decoração: quando a própria região é flex — o pane, que tem altura de
185
+ * barra —, a linha vira um item que se mede pelo conteúdo e as ações param no meio.
186
+ */
187
+ export function surfaceHeaderRowClasses(family: SurfaceFamily): string {
188
+ return `flex min-w-0 flex-1 items-center ${surfaceAnatomy[family].headerGap}`;
189
+ }
190
+
191
+ /** Ações do header e do footer: fora do fluxo de compressão, na escala densa. */
192
+ export const surfaceActionsClasses = "flex shrink-0 items-center gap-2";
193
+
194
+ /**
195
+ * O layout é do root e chega aos slots por contexto — ADR 0023. Slot não recebe
196
+ * `layout`: o divisor depende de quem está do outro lado, então a decisão precisa valer
197
+ * para a superfície inteira.
198
+ *
199
+ * O default por família preserva o que ela já entregava antes desta ADR.
200
+ */
201
+ const SurfaceLayoutContext = createContext<SurfaceLayout | null>(null);
202
+
203
+ export const SurfaceLayoutProvider = SurfaceLayoutContext.Provider;
204
+
205
+ const familyDefaultLayout: Record<SurfaceFamily, SurfaceLayout> = {
206
+ card: "continuous",
207
+ dialog: "sectioned",
208
+ drawer: "sectioned",
209
+ pane: "sectioned",
210
+ };
211
+
212
+ /** O layout vigente: o declarado pelo root da superfície, ou o default da família. */
213
+ export function useSurfaceLayout(family: SurfaceFamily): SurfaceLayout {
214
+ return useContext(SurfaceLayoutContext) ?? familyDefaultLayout[family];
215
+ }
216
+
217
+ export function defaultSurfaceLayout(family: SurfaceFamily): SurfaceLayout {
218
+ return familyDefaultLayout[family];
219
+ }
@@ -40,6 +40,8 @@ export const surfaceHeaderClasses: Record<
40
40
 
41
41
  interface SurfaceHeaderSlots {
42
42
  leading?: ElementType | readonly ElementType[];
43
+ /** Ícone ou imagem que abre a linha, antes do heading (ADR 0023). */
44
+ media?: ElementType;
43
45
  title: ElementType;
44
46
  count?: ElementType;
45
47
  description?: ElementType;
@@ -59,6 +61,16 @@ export interface SurfaceHeaderProps extends HTMLAttributes<HTMLDivElement> {
59
61
  contentClassName?: string;
60
62
  /** Permite uma superfície composta apenas pelas regiões leading/actions. */
61
63
  titleRequired?: boolean;
64
+ /** Classes da linha `inline` — as famílias com anatomia própria trazem a sua (ADR 0023). */
65
+ rowClassName?: string;
66
+ /** Nó interno acrescentado ao fim da linha, depois das ações (o botão de fechar). */
67
+ trailing?: ReactNode;
68
+ /**
69
+ * Aceita conteúdo livre além dos slots. Vale para a superfície genérica — um pane
70
+ * hospeda marca, busca ou chrome que não são título nem ação. As famílias que sempre
71
+ * NOMEIAM algo (Card, Dialog, Drawer, Page, Content) continuam estritas.
72
+ */
73
+ allowFreeContent?: boolean;
62
74
  }
63
75
 
64
76
  /** Expande fragments de primeiro nível: o shorthand monta os slots dentro de um `<>`. */
@@ -77,6 +89,9 @@ export function SurfaceHeader({
77
89
  leadingPlacement = "above",
78
90
  contentClassName,
79
91
  titleRequired = true,
92
+ rowClassName,
93
+ trailing,
94
+ allowFreeContent = false,
80
95
  className,
81
96
  children,
82
97
  ...props
@@ -91,13 +106,30 @@ export function SurfaceHeader({
91
106
  ? slots.leading
92
107
  : [slots.leading as ElementType];
93
108
  const leading = leadingTypes.flatMap(of);
109
+ const medias = slots.media === undefined ? [] : of(slots.media);
94
110
  const titles = of(slots.title);
95
111
  const counts = slots.count === undefined ? [] : of(slots.count);
96
112
  const descriptions =
97
113
  slots.description === undefined ? [] : of(slots.description);
98
114
  const actionSlots = of(slots.actions);
115
+ const slotTypes = [
116
+ ...leadingTypes,
117
+ slots.media,
118
+ slots.title,
119
+ slots.count,
120
+ slots.description,
121
+ slots.actions,
122
+ ].filter((type): type is ElementType => type !== undefined);
123
+ const free = allowFreeContent
124
+ ? nodes.filter(
125
+ (node) =>
126
+ !(isValidElement(node) && slotTypes.includes(node.type as ElementType)),
127
+ )
128
+ : [];
99
129
  const recognized =
130
+ free.length +
100
131
  leading.length +
132
+ medias.length +
101
133
  titles.length +
102
134
  counts.length +
103
135
  descriptions.length +
@@ -106,6 +138,7 @@ export function SurfaceHeader({
106
138
  if (
107
139
  (titleRequired ? titles.length !== 1 : titles.length > 1) ||
108
140
  leading.length > 1 ||
141
+ medias.length > 1 ||
109
142
  counts.length > 1 ||
110
143
  descriptions.length > 1 ||
111
144
  actionSlots.length > 1 ||
@@ -117,6 +150,7 @@ export function SurfaceHeader({
117
150
  : ((type as { name?: string }).name ?? "");
118
151
  const optionalSlots = [
119
152
  ...leadingTypes,
153
+ slots.media,
120
154
  slots.description,
121
155
  slots.count,
122
156
  slots.actions,
@@ -140,16 +174,23 @@ export function SurfaceHeader({
140
174
  );
141
175
  }
142
176
 
143
- const heading = titles.length + counts.length + descriptions.length > 0 && (
177
+ const heading = titles.length +
178
+ counts.length +
179
+ descriptions.length +
180
+ free.length >
181
+ 0 && (
144
182
  <div
145
183
  data-slot={`${slot}-heading`}
146
184
  className={cn("min-w-0", leadingPlacement === "inline" && "flex-1")}
147
185
  >
148
- <div className="flex items-baseline gap-2">
149
- {titles}
150
- {counts}
151
- </div>
186
+ {titles.length + counts.length > 0 && (
187
+ <div className="flex items-baseline gap-2">
188
+ {titles}
189
+ {counts}
190
+ </div>
191
+ )}
152
192
  {descriptions}
193
+ {free}
153
194
  </div>
154
195
  );
155
196
  // A região introdutória carrega o mesmo `data-slot` nas duas apresentações: seletor de teste
@@ -166,13 +207,17 @@ export function SurfaceHeader({
166
207
  {leading}
167
208
  </div>
168
209
  )}
210
+ {medias}
169
211
  {heading}
170
212
  {actionSlots}
213
+ {trailing}
171
214
  </>
172
215
  );
173
216
  const layout =
174
217
  leadingPlacement === "inline" ? (
175
- <div className="flex min-w-0 items-center gap-3">{row}</div>
218
+ <div className={cn("flex min-w-0 items-center gap-3", rowClassName)}>
219
+ {row}
220
+ </div>
176
221
  ) : leading.length > 0 ? (
177
222
  <>
178
223
  <div data-slot={`${slot}-navigation`} className="mb-4">
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Mídia de superfície (interno): a moldura que abre a linha do header — ADR 0023.
3
+ *
4
+ * Reusa `IconTile`; nenhuma família cria outra escala de molduras. O slot público de
5
+ * cada família (`CardMedia`, `DialogMedia`, …) delega para cá, escolhendo apenas o
6
+ * `data-slot` e a escala default.
7
+ */
8
+ import type { ReactElement } from "react";
9
+
10
+ import { Slot } from "@radix-ui/react-slot";
11
+
12
+ import {
13
+ IconTile,
14
+ iconTileVariants,
15
+ type IconTileContext,
16
+ type IconTileSize,
17
+ type IconTileVariant,
18
+ } from "../primitives/icon-tile.tsx";
19
+ import { cn } from "../../lib/cn.ts";
20
+
21
+ export interface SurfaceMediaProps
22
+ extends Omit<React.ComponentProps<"span">, "children"> {
23
+ /** Escala da moldura; a família define o default. */
24
+ size?: IconTileSize;
25
+ context?: IconTileContext;
26
+ variant?: IconTileVariant;
27
+ /**
28
+ * Renderiza como o filho: para a marca que já tem visual próprio — um orbe, um
29
+ * logotipo — ocupar o slot sem ganhar a moldura por cima.
30
+ */
31
+ asChild?: boolean;
32
+ children?: React.ReactNode;
33
+ }
34
+
35
+ export function SurfaceMedia({
36
+ slot,
37
+ size = "sm",
38
+ context,
39
+ variant,
40
+ asChild = false,
41
+ className,
42
+ ...props
43
+ }: SurfaceMediaProps & { slot: string }): ReactElement {
44
+ if (asChild) {
45
+ return (
46
+ <Slot
47
+ data-slot={slot}
48
+ className={cn(
49
+ iconTileVariants({ size, context: null, variant: null }),
50
+ // Sem fundo nem borda: quem compõe traz o próprio visual.
51
+ "bg-transparent",
52
+ "shrink-0",
53
+ className,
54
+ )}
55
+ {...props}
56
+ />
57
+ );
58
+ }
59
+ return (
60
+ <IconTile
61
+ data-slot={slot}
62
+ size={size}
63
+ {...(context !== undefined ? { context } : {})}
64
+ {...(variant !== undefined ? { variant } : {})}
65
+ className={cn("shrink-0", className)}
66
+ {...props}
67
+ />
68
+ );
69
+ }
@@ -1,61 +1,116 @@
1
1
  import * as React from "react";
2
2
  import { Slot } from "@radix-ui/react-slot";
3
3
  import { cn } from "../../lib/cn.ts";
4
+ import {
5
+ SurfaceLayoutProvider,
6
+ surfaceActionsClasses,
7
+ surfaceDescriptionClasses,
8
+ surfaceHeaderRowClasses,
9
+ surfaceRegionClasses,
10
+ surfaceTitleClasses,
11
+ useSurfaceLayout,
12
+ type SurfaceLayout,
13
+ } from "../patterns/surface-anatomy.ts";
14
+ import { SurfaceHeader } from "../patterns/surface-header.tsx";
15
+ import {
16
+ SurfaceMedia,
17
+ type SurfaceMediaProps,
18
+ } from "../patterns/surface-media.tsx";
19
+ import { headingTags, type HeadingLevel } from "../patterns/heading-level.ts";
4
20
 
5
21
  /**
6
22
  * Card — a superfície da casa: SÓ a casca (borda + raio + bg). Divergência DECLARADA vs
7
- * shadcn: FLAT (sem `shadow-sm`) e SEM `flex/gap/py` forçados. O upstream embute
8
- * `py-6 gap-6` + slots `px-6`, o que briga com card-box
9
- * simples (o `gap-6` do Card + o `space-y` do consumidor = espaçamento dobrado) — por isso
10
- * a casa hand-rollava. Aqui o padding mora no SLOT (Header/Body/Footer) ou no `className`
11
- * do consumidor (box simples) — mesmo modelo do Dialog.
23
+ * shadcn: FLAT (sem `shadow-sm`) e SEM `flex/gap/py` forçados. O padding mora no SLOT
24
+ * (Header/Body/Footer) ou no `className` do consumidor (box simples).
12
25
  *
13
26
  * Box simples: <Card className="p-4">…</Card>
14
- * Estruturado: <Card><CardHeader>…</CardHeader><CardBody>…</CardBody><CardFooter>…</CardFooter></Card>
27
+ * Estruturado: <Card><CardHeader>…</CardHeader><CardBody>…</CardBody></Card>
28
+ *
29
+ * `layout` é o pacote de anatomia da ADR 0023 e vale para a superfície inteira:
30
+ *
31
+ * contínuo (default): sem divisores; o ritmo separa as regiões.
32
+ * seccionado: regiões simétricas, com divisor entre as adjacentes.
33
+ *
34
+ * O divisor é derivado da presença das regiões — o consumidor não escreve `border-b`.
15
35
  */
16
36
  export function Card({
17
37
  className,
18
38
  asChild = false,
39
+ layout = "continuous",
19
40
  ...props
20
41
  }: React.ComponentProps<"div"> & {
21
42
  /** Renderiza como o filho (Radix Slot) — pra card CLICÁVEL manter a semântica do filho
22
43
  * (ex.: <button>) com a superfície do Card: `<Card asChild><button onClick>…</button></Card>`. */
23
44
  asChild?: boolean;
45
+ /** Anatomia da superfície: `continuous` (default) ou `sectioned`. */
46
+ layout?: SurfaceLayout;
24
47
  }): React.ReactElement {
25
48
  const Comp = asChild ? Slot : "div";
26
49
  return (
27
- <Comp
28
- data-slot="card"
29
- className={cn(
30
- "rounded-xl border border-border bg-card text-card-foreground",
31
- className,
32
- )}
33
- {...props}
34
- />
50
+ <SurfaceLayoutProvider value={layout}>
51
+ <Comp
52
+ data-slot="card"
53
+ data-layout={layout}
54
+ className={cn(
55
+ "rounded-xl border border-border bg-card text-card-foreground",
56
+ // Seccionado: o divisor encosta na borda, então o raio precisa recortar.
57
+ layout === "sectioned" && "overflow-hidden",
58
+ className,
59
+ )}
60
+ {...props}
61
+ />
62
+ </SurfaceLayoutProvider>
35
63
  );
36
64
  }
37
65
 
38
66
  export function CardHeader({
39
67
  className,
68
+ children,
40
69
  ...props
41
70
  }: React.ComponentProps<"div">): React.ReactElement {
71
+ const layout = useSurfaceLayout("card");
42
72
  return (
43
- <div
44
- data-slot="card-header"
45
- className={cn("flex flex-col gap-1.5 px-5 pt-5 pb-3", className)}
73
+ <SurfaceHeader
74
+ name="CardHeader"
75
+ slot="card"
76
+ slots={{
77
+ media: CardMedia,
78
+ title: CardTitle,
79
+ description: CardDescription,
80
+ actions: CardAction,
81
+ }}
82
+ leadingPlacement="inline"
83
+ titleRequired={false}
84
+ rowClassName={surfaceHeaderRowClasses("card")}
85
+ className={cn(surfaceRegionClasses("card", layout, "header"), className)}
46
86
  {...props}
47
- />
87
+ >
88
+ {children}
89
+ </SurfaceHeader>
48
90
  );
49
91
  }
50
92
 
93
+ /** Moldura de ícone ou imagem que abre a linha do header. */
94
+ export function CardMedia(props: SurfaceMediaProps): React.ReactElement {
95
+ return <SurfaceMedia slot="card-media" {...props} />;
96
+ }
97
+
51
98
  export function CardTitle({
52
99
  className,
100
+ level = 3,
101
+ asChild = false,
53
102
  ...props
54
- }: React.ComponentProps<"div">): React.ReactElement {
103
+ }: React.ComponentProps<"h3"> & {
104
+ /** Hierarquia do documento (`h1`–`h6`). O tamanho vem do layout, nunca do nível. */
105
+ level?: HeadingLevel;
106
+ asChild?: boolean;
107
+ }): React.ReactElement {
108
+ const layout = useSurfaceLayout("card");
109
+ const Comp = asChild ? Slot : headingTags[level];
55
110
  return (
56
- <div
111
+ <Comp
57
112
  data-slot="card-title"
58
- className={cn("font-semibold leading-none", className)}
113
+ className={cn(surfaceTitleClasses("card", layout), className)}
59
114
  {...props}
60
115
  />
61
116
  );
@@ -64,17 +119,17 @@ export function CardTitle({
64
119
  export function CardDescription({
65
120
  className,
66
121
  ...props
67
- }: React.ComponentProps<"div">): React.ReactElement {
122
+ }: React.ComponentProps<"p">): React.ReactElement {
68
123
  return (
69
- <div
124
+ <p
70
125
  data-slot="card-description"
71
- className={cn("text-sm text-muted-foreground", className)}
126
+ className={cn(surfaceDescriptionClasses("card"), className)}
72
127
  {...props}
73
128
  />
74
129
  );
75
130
  }
76
131
 
77
- /** Slot de ação no header (botão/menu) — o consumidor posiciona (ex.: header em flex-row). */
132
+ /** Ações do header — a família posiciona no extremo oposto ao título. */
78
133
  export function CardAction({
79
134
  className,
80
135
  ...props
@@ -82,7 +137,7 @@ export function CardAction({
82
137
  return (
83
138
  <div
84
139
  data-slot="card-action"
85
- className={cn("shrink-0", className)}
140
+ className={cn(surfaceActionsClasses, className)}
86
141
  {...props}
87
142
  />
88
143
  );
@@ -92,10 +147,11 @@ export function CardBody({
92
147
  className,
93
148
  ...props
94
149
  }: React.ComponentProps<"div">): React.ReactElement {
150
+ const layout = useSurfaceLayout("card");
95
151
  return (
96
152
  <div
97
153
  data-slot="card-body"
98
- className={cn("px-5 py-3", className)}
154
+ className={cn(surfaceRegionClasses("card", layout, "body"), className)}
99
155
  {...props}
100
156
  />
101
157
  );
@@ -106,10 +162,11 @@ export function CardContent({
106
162
  className,
107
163
  ...props
108
164
  }: React.ComponentProps<"div">): React.ReactElement {
165
+ const layout = useSurfaceLayout("card");
109
166
  return (
110
167
  <div
111
168
  data-slot="card-content"
112
- className={cn("px-5 py-3", className)}
169
+ className={cn(surfaceRegionClasses("card", layout, "body"), className)}
113
170
  {...props}
114
171
  />
115
172
  );
@@ -119,10 +176,15 @@ export function CardFooter({
119
176
  className,
120
177
  ...props
121
178
  }: React.ComponentProps<"div">): React.ReactElement {
179
+ const layout = useSurfaceLayout("card");
122
180
  return (
123
181
  <div
124
182
  data-slot="card-footer"
125
- className={cn("flex items-center px-5 pt-3 pb-5", className)}
183
+ className={cn(
184
+ "flex items-center justify-end gap-2",
185
+ surfaceRegionClasses("card", layout, "footer"),
186
+ className,
187
+ )}
126
188
  {...props}
127
189
  />
128
190
  );
@@ -38,15 +38,23 @@ function CommandDialog({
38
38
  return (
39
39
  <Dialog {...props}>
40
40
  <DialogContent
41
+ // A paleta é uma superfície contínua: a lista já separa os grupos, e uma faixa
42
+ // sob o cabeçalho competiria com ela.
43
+ layout="continuous"
41
44
  className={cn("overflow-hidden p-0", className)}
42
45
  showCloseButton={showCloseButton}
43
46
  aria-describedby={descriptionId}
44
47
  >
45
- <DialogHeader className="border-b-0 px-3 py-2">
48
+ {/* A descrição só existe para o `aria-describedby`; fora do header, que só
49
+ nomeia a superfície (ADRs 0018 e 0023). */}
50
+ <p id={descriptionId} className="sr-only">
51
+ {description}
52
+ </p>
53
+ {/* O recuo e a escala são os da paleta, não os da família: a caixa de busca
54
+ manda na densidade, e o título aqui é um rótulo acima dela. É a única
55
+ superfície do SDK que diverge nos dois, e por isso está escrito aqui. */}
56
+ <DialogHeader className="px-3 py-2">
46
57
  <DialogTitle className="text-sm">{title}</DialogTitle>
47
- <p id={descriptionId} className="sr-only">
48
- {description}
49
- </p>
50
58
  </DialogHeader>
51
59
  <Command className="**:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
52
60
  {children}