@softize/opus 18.5.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 (32) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/docs/adr/0023-surfaces-share-one-anatomy-and-two-layouts.md +293 -0
  3. package/package.json +1 -1
  4. package/src/ui/components/patterns/content-header.tsx +1 -9
  5. package/src/ui/components/patterns/heading-level.ts +16 -0
  6. package/src/ui/components/patterns/list.tsx +34 -15
  7. package/src/ui/components/patterns/pane.tsx +294 -0
  8. package/src/ui/components/patterns/presentation.tsx +29 -17
  9. package/src/ui/components/patterns/sidebar.tsx +13 -72
  10. package/src/ui/components/patterns/split.tsx +8 -35
  11. package/src/ui/components/patterns/surface-anatomy.ts +219 -0
  12. package/src/ui/components/patterns/surface-header.tsx +51 -6
  13. package/src/ui/components/patterns/surface-media.tsx +69 -0
  14. package/src/ui/components/primitives/card.tsx +91 -29
  15. package/src/ui/components/primitives/command.tsx +12 -4
  16. package/src/ui/components/primitives/detail.tsx +10 -13
  17. package/src/ui/components/primitives/dialog.tsx +138 -35
  18. package/src/ui/components/primitives/drawer.tsx +122 -23
  19. package/src/ui/components/primitives/metric-card.tsx +98 -39
  20. package/src/ui/docs/content/card.md +221 -30
  21. package/src/ui/docs/content/content.md +6 -0
  22. package/src/ui/docs/content/dialog.md +115 -7
  23. package/src/ui/docs/content/drawer.md +136 -9
  24. package/src/ui/docs/content/metric-card.md +57 -18
  25. package/src/ui/docs/content/page.md +8 -0
  26. package/src/ui/docs/content/pane.md +250 -0
  27. package/src/ui/docs/content/sidebar.md +22 -43
  28. package/src/ui/docs/content/skeleton.md +7 -3
  29. package/src/ui/docs/content/split.md +16 -21
  30. package/src/ui/docs/registry.tsx +8 -2
  31. package/src/ui/meta.ts +8 -2
  32. package/src/ui/react.tsx +14 -0
@@ -54,9 +54,7 @@ function DetailGroup({
54
54
  columnClasses[columns],
55
55
  variant === 'plain' && !dividers && 'gap-x-6 gap-y-4',
56
56
  dividers && 'gap-px overflow-hidden',
57
- (variant === 'framed' || dividers) &&
58
- !splitHorizontalField &&
59
- '[&>[data-slot=detail-field]]:p-3',
57
+ (variant === 'framed' || dividers) && !splitHorizontalField && '[&>[data-slot=detail-field]]:p-3',
60
58
  variant === 'framed' &&
61
59
  'overflow-hidden rounded-lg border border-border bg-transparent text-foreground',
62
60
  // Default da coluna de rótulos como CLASSE, antes do `className`: assim uma classe do
@@ -74,10 +72,7 @@ function DetailGroup({
74
72
  )
75
73
  }
76
74
 
77
- export interface DetailFieldProps extends Omit<
78
- React.ComponentProps<'div'>,
79
- 'children'
80
- > {
75
+ export interface DetailFieldProps extends Omit<React.ComponentProps<'div'>, 'children'> {
81
76
  label: React.ReactNode
82
77
  /** `null`, `undefined`, string vazia ou só com espaços renderizam a ausência
83
78
  * como travessão; “Não informado” ou `empty` preservam o significado para
@@ -99,11 +94,13 @@ function DetailField({
99
94
  ...props
100
95
  }: DetailFieldProps): React.ReactElement {
101
96
  const { splitHorizontalField } = React.useContext(DetailGroupLayoutContext)
102
- const content = !isAbsentValue(value)
103
- ? value
104
- : empty === undefined || typeof empty === 'string'
105
- ? <EmptyValue compact {...(empty !== undefined ? { label: empty } : {})} />
106
- : empty
97
+ const content = !isAbsentValue(value) ? (
98
+ value
99
+ ) : empty === undefined || typeof empty === 'string' ? (
100
+ <EmptyValue compact {...(empty !== undefined ? { label: empty } : {})} />
101
+ ) : (
102
+ empty
103
+ )
107
104
  return (
108
105
  <div
109
106
  data-slot="detail-field"
@@ -131,7 +128,7 @@ function DetailField({
131
128
  <dt
132
129
  data-slot="detail-field-label"
133
130
  className={cn(
134
- 'flex items-center gap-2.5 text-sm leading-snug font-medium text-muted-foreground',
131
+ 'flex items-center gap-1.5 text-sm leading-snug font-medium text-muted-foreground',
135
132
  splitHorizontalField && 'bg-muted/30 p-3 dark:bg-input/30',
136
133
  )}
137
134
  >
@@ -2,6 +2,21 @@ import * as React from "react";
2
2
  import * as DialogPrimitive from "@radix-ui/react-dialog";
3
3
  import { AlertDialog as AlertDialogPrimitive } from "radix-ui";
4
4
  import { cn } from "../../lib/cn.ts";
5
+ import {
6
+ SurfaceLayoutProvider,
7
+ surfaceActionsClasses,
8
+ surfaceHeaderRowClasses,
9
+ surfaceRegionClasses,
10
+ surfaceTitleClasses,
11
+ useSurfaceLayout,
12
+ type SurfaceLayout,
13
+ } from "../patterns/surface-anatomy.ts";
14
+ import { headingTags, type HeadingLevel } from "../patterns/heading-level.ts";
15
+ import { SurfaceHeader } from "../patterns/surface-header.tsx";
16
+ import {
17
+ SurfaceMedia,
18
+ type SurfaceMediaProps,
19
+ } from "../patterns/surface-media.tsx";
5
20
  import {
6
21
  CloseButton,
7
22
  type CloseButtonSize,
@@ -175,6 +190,7 @@ export function DialogContent({
175
190
  showCloseButton,
176
191
  closeDisabled = false,
177
192
  closeSize = "sm",
193
+ layout = "sectioned",
178
194
  "aria-describedby": ariaDescribedBy,
179
195
  ...props
180
196
  }: React.ComponentProps<typeof DialogPrimitive.Content> & {
@@ -184,6 +200,8 @@ export function DialogContent({
184
200
  closeDisabled?: boolean;
185
201
  /** Tamanho da ação circular de fechar no cabeçalho. */
186
202
  closeSize?: CloseButtonSize;
203
+ /** Anatomia da superfície: `sectioned` (default) ou `continuous` — ADR 0023. */
204
+ layout?: SurfaceLayout;
187
205
  }) {
188
206
  const { mode } = useDialogContext();
189
207
  const showClose = showCloseButton ?? mode !== "alert";
@@ -221,6 +239,7 @@ export function DialogContent({
221
239
  <DialogPrimitive.Content
222
240
  data-slot="dialog-content"
223
241
  data-mode="default"
242
+ data-layout={layout}
224
243
  className={cn(
225
244
  contentClasses,
226
245
  "flex max-h-[85vh] max-w-lg flex-col",
@@ -229,11 +248,13 @@ export function DialogContent({
229
248
  aria-describedby={ariaDescribedBy}
230
249
  {...props}
231
250
  >
232
- <DialogContentContext.Provider
233
- value={{ showCloseButton: showClose, closeDisabled, closeSize }}
234
- >
235
- {children}
236
- </DialogContentContext.Provider>
251
+ <SurfaceLayoutProvider value={layout}>
252
+ <DialogContentContext.Provider
253
+ value={{ showCloseButton: showClose, closeDisabled, closeSize }}
254
+ >
255
+ {children}
256
+ </DialogContentContext.Provider>
257
+ </SurfaceLayoutProvider>
237
258
  </DialogPrimitive.Content>
238
259
  </DialogPortal>
239
260
  );
@@ -245,40 +266,99 @@ export function DialogHeader({
245
266
  ...props
246
267
  }: React.ComponentProps<"div">) {
247
268
  const { mode } = useDialogContext();
269
+ const layout = useSurfaceLayout("dialog");
248
270
  const { showCloseButton, closeDisabled, closeSize } =
249
271
  React.useContext(DialogContentContext);
272
+
273
+ // O modo de alerta é a exceção declarada da ADR 0023: a resposta obrigatória empilha
274
+ // mídia, título e corpo centralizados, e o padding mora na raiz do conteúdo.
275
+ if (mode === "alert") {
276
+ return (
277
+ <div
278
+ data-slot="dialog-header"
279
+ className={cn(
280
+ "grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=dialog-media]:grid-rows-[auto_auto_1fr]",
281
+ className,
282
+ )}
283
+ {...props}
284
+ >
285
+ {children}
286
+ </div>
287
+ );
288
+ }
289
+
250
290
  return (
251
- <div
252
- data-slot="dialog-header"
291
+ <SurfaceHeader
292
+ name="DialogHeader"
293
+ slot="dialog"
294
+ slots={{
295
+ leading: DialogNavigation,
296
+ media: DialogMedia,
297
+ title: DialogTitle,
298
+ actions: DialogActions,
299
+ }}
300
+ leadingPlacement="inline"
301
+ titleRequired={false}
302
+ rowClassName={surfaceHeaderRowClasses("dialog")}
253
303
  className={cn(
254
- mode === "alert"
255
- ? "grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=dialog-media]:grid-rows-[auto_auto_1fr]"
256
- : "flex shrink-0 items-center gap-4 border-b p-4 text-left [&:has(+[data-slot=dialog-footer])]:border-b-0",
304
+ "text-left",
305
+ surfaceRegionClasses("dialog", layout, "header"),
257
306
  className,
258
307
  )}
308
+ trailing={
309
+ showCloseButton ? (
310
+ <DialogCloseButton disabled={closeDisabled} size={closeSize} />
311
+ ) : undefined
312
+ }
259
313
  {...props}
260
314
  >
261
315
  {children}
262
- {mode !== "alert" && showCloseButton && (
263
- <DialogCloseButton disabled={closeDisabled} size={closeSize} />
264
- )}
265
- </div>
316
+ </SurfaceHeader>
317
+ );
318
+ }
319
+
320
+ /** Região introdutória do header — o voltar de um fluxo modal encadeado. */
321
+ export function DialogNavigation({ className, ...props }: React.ComponentProps<"div">) {
322
+ return (
323
+ <div
324
+ data-slot="dialog-navigation-content"
325
+ className={cn("flex min-w-0 shrink-0 items-center", className)}
326
+ {...props}
327
+ />
266
328
  );
267
329
  }
268
330
 
269
- export function DialogMedia({
331
+ /** Ações da tela no header, antes do botão de fechar. */
332
+ export function DialogActions({
270
333
  className,
271
334
  ...props
272
335
  }: React.ComponentProps<"div">) {
273
336
  return (
274
337
  <div
275
- data-slot="dialog-media"
338
+ data-slot="dialog-actions"
339
+ className={cn(surfaceActionsClasses, className)}
340
+ {...props}
341
+ />
342
+ );
343
+ }
344
+
345
+ export function DialogMedia({ className, ...props }: SurfaceMediaProps) {
346
+ const { mode } = useDialogContext();
347
+ // No alerta a mídia é o elemento de reconhecimento da resposta e ocupa a moldura
348
+ // maior — exceção declarada da ADR 0023, coberta por teste. Fora dele, a mesma
349
+ // moldura das demais famílias.
350
+ return mode === "alert" ? (
351
+ <SurfaceMedia
352
+ slot="dialog-media"
353
+ size="lg"
276
354
  className={cn(
277
- 'mb-2 inline-flex size-16 items-center justify-center rounded-md bg-muted *:[svg:not([class*="size-"])]:size-8',
355
+ 'mb-2 size-16 *:[svg:not([class*="size-"])]:size-8',
278
356
  className,
279
357
  )}
280
358
  {...props}
281
359
  />
360
+ ) : (
361
+ <SurfaceMedia slot="dialog-media" {...props} className={className} />
282
362
  );
283
363
  }
284
364
 
@@ -288,11 +368,17 @@ export function DialogBody({
288
368
  ...props
289
369
  }: React.ComponentProps<"div">) {
290
370
  const { mode } = useDialogContext();
371
+ const layout = useSurfaceLayout("dialog");
291
372
  return (
292
373
  <div
293
374
  data-slot="dialog-body"
294
375
  className={cn(
295
- mode === "alert" ? "min-w-0" : "min-h-0 flex-1 overflow-y-auto p-4",
376
+ mode === "alert"
377
+ ? "min-w-0"
378
+ : cn(
379
+ "min-h-0 flex-1 overflow-y-auto",
380
+ surfaceRegionClasses("dialog", layout, "body"),
381
+ ),
296
382
  className,
297
383
  )}
298
384
  style={
@@ -310,13 +396,17 @@ export function DialogFooter({
310
396
  ...props
311
397
  }: React.ComponentProps<"div">) {
312
398
  const { mode } = useDialogContext();
399
+ const layout = useSurfaceLayout("dialog");
313
400
  return (
314
401
  <div
315
402
  data-slot="dialog-footer"
316
403
  className={cn(
317
404
  mode === "alert"
318
405
  ? "w-full"
319
- : "flex shrink-0 flex-row justify-end gap-2 border-t bg-muted/20 p-4 dark:bg-input/20",
406
+ : cn(
407
+ "flex shrink-0 flex-row justify-end gap-2",
408
+ surfaceRegionClasses("dialog", layout, "footer"),
409
+ ),
320
410
  className,
321
411
  )}
322
412
  {...props}
@@ -326,23 +416,36 @@ export function DialogFooter({
326
416
 
327
417
  export function DialogTitle({
328
418
  className,
419
+ level,
420
+ children,
329
421
  ...props
330
- }: React.ComponentProps<typeof DialogPrimitive.Title>) {
422
+ }: React.ComponentProps<typeof DialogPrimitive.Title> & {
423
+ /** Hierarquia do documento. Sem ele vale o `h2` que a primitiva emite. */
424
+ level?: HeadingLevel;
425
+ }) {
331
426
  const { mode } = useDialogContext();
332
- return mode === "alert" ? (
333
- <AlertDialogPrimitive.Title
334
- data-slot="dialog-title"
335
- className={cn("text-lg font-semibold", className)}
336
- {...props}
337
- />
338
- ) : (
339
- <DialogPrimitive.Title
340
- data-slot="dialog-title"
341
- className={cn(
342
- "min-w-0 flex-1 text-lg font-semibold leading-none",
343
- className,
344
- )}
345
- {...props}
346
- />
427
+ const layout = useSurfaceLayout("dialog");
428
+ const Title = mode === "alert" ? AlertDialogPrimitive.Title : DialogPrimitive.Title;
429
+ const classes = cn(
430
+ mode === "alert" ? undefined : "min-w-0 truncate",
431
+ surfaceTitleClasses("dialog", layout),
432
+ className,
433
+ );
434
+ // O nível troca a TAG do mesmo nó, via `asChild` — nunca aninha um heading dentro
435
+ // de outro, e a escala continua vindo do layout (ADR 0023).
436
+ if (level === undefined) {
437
+ return (
438
+ <Title data-slot="dialog-title" className={classes} {...props}>
439
+ {children}
440
+ </Title>
441
+ );
442
+ }
443
+ const Heading = headingTags[level];
444
+ return (
445
+ <Title asChild {...props}>
446
+ <Heading data-slot="dialog-title" className={classes}>
447
+ {children}
448
+ </Heading>
449
+ </Title>
347
450
  );
348
451
  }
@@ -2,6 +2,21 @@ import * as React from "react";
2
2
  import { Dialog as DrawerPrimitive } from "radix-ui";
3
3
 
4
4
  import { cn } from "../../lib/cn.ts";
5
+ import {
6
+ SurfaceLayoutProvider,
7
+ surfaceActionsClasses,
8
+ surfaceHeaderRowClasses,
9
+ surfaceRegionClasses,
10
+ surfaceTitleClasses,
11
+ useSurfaceLayout,
12
+ type SurfaceLayout,
13
+ } from "../patterns/surface-anatomy.ts";
14
+ import { headingTags, type HeadingLevel } from "../patterns/heading-level.ts";
15
+ import { SurfaceHeader } from "../patterns/surface-header.tsx";
16
+ import {
17
+ SurfaceMedia,
18
+ type SurfaceMediaProps,
19
+ } from "../patterns/surface-media.tsx";
5
20
  import {
6
21
  CloseButton,
7
22
  type CloseButtonSize,
@@ -78,6 +93,7 @@ function DrawerContent({
78
93
  showCloseButton = true,
79
94
  closeDisabled = false,
80
95
  closeSize = "sm",
96
+ layout = "sectioned",
81
97
  "aria-describedby": ariaDescribedBy,
82
98
  ...props
83
99
  }: React.ComponentProps<typeof DrawerPrimitive.Content> & {
@@ -85,12 +101,15 @@ function DrawerContent({
85
101
  showCloseButton?: boolean;
86
102
  closeDisabled?: boolean;
87
103
  closeSize?: CloseButtonSize;
104
+ /** Anatomia da superfície: `sectioned` (default) ou `continuous` — ADR 0023. */
105
+ layout?: SurfaceLayout;
88
106
  }) {
89
107
  return (
90
108
  <DrawerPortal>
91
109
  <DrawerOverlay />
92
110
  <DrawerPrimitive.Content
93
111
  data-slot="drawer-content"
112
+ data-layout={layout}
94
113
  className={cn(
95
114
  "fixed z-50 flex flex-col bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500",
96
115
  side === "right" &&
@@ -106,11 +125,13 @@ function DrawerContent({
106
125
  aria-describedby={ariaDescribedBy}
107
126
  {...props}
108
127
  >
109
- <DrawerContentContext.Provider
110
- value={{ showCloseButton, closeDisabled, closeSize }}
111
- >
112
- {children}
113
- </DrawerContentContext.Provider>
128
+ <SurfaceLayoutProvider value={layout}>
129
+ <DrawerContentContext.Provider
130
+ value={{ showCloseButton, closeDisabled, closeSize }}
131
+ >
132
+ {children}
133
+ </DrawerContentContext.Provider>
134
+ </SurfaceLayoutProvider>
114
135
  </DrawerPrimitive.Content>
115
136
  </DrawerPortal>
116
137
  );
@@ -121,27 +142,76 @@ function DrawerHeader({
121
142
  children,
122
143
  ...props
123
144
  }: React.ComponentProps<"div">) {
145
+ const layout = useSurfaceLayout("drawer");
124
146
  const { showCloseButton, closeDisabled, closeSize } =
125
147
  React.useContext(DrawerContentContext);
126
148
  return (
127
- <div
128
- data-slot="drawer-header"
129
- className={cn("flex shrink-0 items-center gap-4 border-b p-4", className)}
149
+ <SurfaceHeader
150
+ name="DrawerHeader"
151
+ slot="drawer"
152
+ slots={{
153
+ leading: DrawerNavigation,
154
+ media: DrawerMedia,
155
+ title: DrawerTitle,
156
+ actions: DrawerActions,
157
+ }}
158
+ leadingPlacement="inline"
159
+ titleRequired={false}
160
+ rowClassName={surfaceHeaderRowClasses("drawer")}
161
+ className={cn(
162
+ "text-left",
163
+ surfaceRegionClasses("drawer", layout, "header"),
164
+ className,
165
+ )}
166
+ trailing={
167
+ showCloseButton ? (
168
+ <DrawerCloseButton disabled={closeDisabled} size={closeSize} />
169
+ ) : undefined
170
+ }
130
171
  {...props}
131
172
  >
132
173
  {children}
133
- {showCloseButton && (
134
- <DrawerCloseButton disabled={closeDisabled} size={closeSize} />
135
- )}
136
- </div>
174
+ </SurfaceHeader>
175
+ );
176
+ }
177
+
178
+ /** Moldura de ícone ou imagem que abre a linha do header. */
179
+ function DrawerMedia(props: SurfaceMediaProps) {
180
+ return <SurfaceMedia slot="drawer-media" {...props} />;
181
+ }
182
+
183
+ /** Região introdutória do header — o voltar de um fluxo modal encadeado. */
184
+ function DrawerNavigation({ className, ...props }: React.ComponentProps<"div">) {
185
+ return (
186
+ <div
187
+ data-slot="drawer-navigation-content"
188
+ className={cn("flex min-w-0 shrink-0 items-center", className)}
189
+ {...props}
190
+ />
191
+ );
192
+ }
193
+
194
+ /** Ações da tela no header, antes do botão de fechar. */
195
+ function DrawerActions({ className, ...props }: React.ComponentProps<"div">) {
196
+ return (
197
+ <div
198
+ data-slot="drawer-actions"
199
+ className={cn(surfaceActionsClasses, className)}
200
+ {...props}
201
+ />
137
202
  );
138
203
  }
139
204
 
140
205
  function DrawerBody({ className, style, ...props }: React.ComponentProps<"div">) {
206
+ const layout = useSurfaceLayout("drawer");
141
207
  return (
142
208
  <div
143
209
  data-slot="drawer-body"
144
- className={cn("min-h-0 flex-1 overflow-y-auto p-4", className)}
210
+ className={cn(
211
+ "min-h-0 flex-1 overflow-y-auto",
212
+ surfaceRegionClasses("drawer", layout, "body"),
213
+ className,
214
+ )}
145
215
  style={{ "--opus-inline-gutter": "1rem", ...style } as React.CSSProperties}
146
216
  {...props}
147
217
  />
@@ -149,11 +219,16 @@ function DrawerBody({ className, style, ...props }: React.ComponentProps<"div">)
149
219
  }
150
220
 
151
221
  function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
222
+ const layout = useSurfaceLayout("drawer");
152
223
  return (
153
224
  <div
154
225
  data-slot="drawer-footer"
155
226
  className={cn(
156
- "mt-auto flex shrink-0 flex-row justify-end gap-2 border-t p-4",
227
+ // `mt-auto` é a divergência declarada do Drawer: o conteúdo lateral quase nunca
228
+ // preenche a altura da janela, e sem isso o rodapé subiria atrás do corpo. No
229
+ // Dialog a altura é do conteúdo, então ele não precisa (ADR 0023).
230
+ "mt-auto flex shrink-0 flex-row justify-end gap-2",
231
+ surfaceRegionClasses("drawer", layout, "footer"),
157
232
  className,
158
233
  )}
159
234
  {...props}
@@ -163,21 +238,45 @@ function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
163
238
 
164
239
  function DrawerTitle({
165
240
  className,
241
+ level,
242
+ children,
166
243
  ...props
167
- }: React.ComponentProps<typeof DrawerPrimitive.Title>) {
244
+ }: React.ComponentProps<typeof DrawerPrimitive.Title> & {
245
+ /** Hierarquia do documento. Sem ele vale o `h2` que a primitiva emite. */
246
+ level?: HeadingLevel;
247
+ }) {
248
+ const layout = useSurfaceLayout("drawer");
249
+ const classes = cn(
250
+ "min-w-0 truncate",
251
+ surfaceTitleClasses("drawer", layout),
252
+ className,
253
+ );
254
+ // O nível troca a TAG do mesmo nó, via `asChild` — a escala continua vindo do layout.
255
+ if (level === undefined) {
256
+ return (
257
+ <DrawerPrimitive.Title
258
+ data-slot="drawer-title"
259
+ className={classes}
260
+ {...props}
261
+ >
262
+ {children}
263
+ </DrawerPrimitive.Title>
264
+ );
265
+ }
266
+ const Heading = headingTags[level];
168
267
  return (
169
- <DrawerPrimitive.Title
170
- data-slot="drawer-title"
171
- className={cn(
172
- "min-w-0 flex-1 text-lg font-semibold leading-none text-foreground",
173
- className,
174
- )}
175
- {...props}
176
- />
268
+ <DrawerPrimitive.Title asChild {...props}>
269
+ <Heading data-slot="drawer-title" className={classes}>
270
+ {children}
271
+ </Heading>
272
+ </DrawerPrimitive.Title>
177
273
  );
178
274
  }
179
275
 
180
276
  export {
277
+ DrawerActions,
278
+ DrawerMedia,
279
+ DrawerNavigation,
181
280
  Drawer,
182
281
  DrawerTrigger,
183
282
  DrawerClose,