@softize/opus 18.2.0 → 18.3.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.
@@ -164,7 +164,10 @@ export function PageShell({
164
164
  <PageShellContext.Provider value>
165
165
  <PageNavigationTargetContext.Provider value={navigationTarget}>
166
166
  <PageActionsTarget target={actionsTarget}>
167
- <div data-slot="page-shell-content" className="min-h-0 flex-1">
167
+ <div
168
+ data-slot="page-shell-content"
169
+ className="min-h-0 min-w-0 flex-1 overflow-auto [&>*]:min-w-[64rem]"
170
+ >
168
171
  {children}
169
172
  </div>
170
173
  </PageActionsTarget>
@@ -202,6 +205,8 @@ interface PageBaseProps extends Omit<
202
205
  > {
203
206
  /** Classes do container. Default: centralizado com `max-w-7xl` (80rem). */
204
207
  className?: string;
208
+ /** `fill` limita a página à altura do shell para que regiões internas assumam a rolagem. */
209
+ layout?: "flow" | "fill";
205
210
  }
206
211
 
207
212
  interface PageShorthandProps extends PageBaseProps {
@@ -252,8 +257,7 @@ function countPageBodyHeadings(node: ReactNode): {
252
257
  total.content +
253
258
  (child.type === Content && props.level === 1 ? 1 : 0) +
254
259
  nested.content,
255
- state:
256
- total.state + nested.state,
260
+ state: total.state + nested.state,
257
261
  };
258
262
  },
259
263
  { content: 0, state: 0 },
@@ -275,6 +279,7 @@ function hasOpaquePageBodyContent(node: ReactNode): boolean {
275
279
  export function Page({
276
280
  title,
277
281
  actions,
282
+ layout = "flow",
278
283
  className,
279
284
  children,
280
285
  ...props
@@ -372,7 +377,8 @@ export function Page({
372
377
  (total, intro) =>
373
378
  total + countDirectChildrenOfType(intro as ReactElement, PageTitle),
374
379
  0,
375
- ) + bodyHeadingSources.content;
380
+ ) +
381
+ bodyHeadingSources.content;
376
382
  const headingSources =
377
383
  bodyHeadingSources.state > 0
378
384
  ? bodyHeadingSources.state + bodyHeadingSources.content
@@ -429,9 +435,12 @@ export function Page({
429
435
  <PageHeadingPresenceProvider register={registerHeading}>
430
436
  <main
431
437
  data-slot="page"
438
+ data-layout={layout}
432
439
  className={cn(
433
- "min-w-0 flex-1",
440
+ "min-w-[64rem] flex-1",
434
441
  insideShell && "min-h-full",
442
+ layout === "fill" &&
443
+ "flex h-full min-h-0 flex-col overflow-hidden",
435
444
  integralState && "flex min-h-full flex-col",
436
445
  )}
437
446
  {...props}
@@ -439,6 +448,8 @@ export function Page({
439
448
  <div
440
449
  className={cn(
441
450
  "mx-auto max-w-7xl space-y-6 px-8 py-8",
451
+ layout === "fill" &&
452
+ "flex h-full min-h-0 w-full flex-1 flex-col",
442
453
  integralState &&
443
454
  "flex min-h-full w-full flex-1 flex-col space-y-0",
444
455
  className,
@@ -82,6 +82,7 @@ import { SurfaceAssistant } from "./surface-assistant.tsx";
82
82
 
83
83
  interface PresentationFrameProps {
84
84
  surface: PresentationSurface;
85
+ layout?: "flow" | "fill";
85
86
  title: ReactNode;
86
87
  navigation?: ReactNode;
87
88
  headerActions?: ReactNode;
@@ -121,6 +122,7 @@ function PresentationActions({
121
122
  /** A mesma Presentation em Page, Dialog ou Drawer; a superfície não redefine o recurso. */
122
123
  function PresentationFrame({
123
124
  surface,
125
+ layout = "flow",
124
126
  title,
125
127
  navigation,
126
128
  headerActions,
@@ -162,7 +164,11 @@ function PresentationFrame({
162
164
  const body = (
163
165
  <div
164
166
  data-slot="presentation-body"
165
- className={cn(bodyClassName, bodyClassNameProp)}
167
+ className={cn(
168
+ bodyClassName,
169
+ layout === "fill" && "flex min-h-0 flex-1 flex-col",
170
+ bodyClassNameProp,
171
+ )}
166
172
  >
167
173
  {typeof children === "function" ? children(footerTarget) : children}
168
174
  </div>
@@ -198,7 +204,10 @@ function PresentationFrame({
198
204
  </Content>
199
205
  );
200
206
  return (
201
- <Page className={cn("flex min-h-full flex-col", className)}>
207
+ <Page
208
+ layout={layout}
209
+ className={cn("flex min-h-full flex-col", className)}
210
+ >
202
211
  {navigation === undefined && assistantAction === undefined ? null : (
203
212
  <PageHeader>
204
213
  {navigation === undefined ? null : (
@@ -211,7 +220,9 @@ function PresentationFrame({
211
220
  )}
212
221
  <PageBody className="min-h-0 flex-1 overflow-hidden">
213
222
  {withAssistant(
214
- <div className="h-full overflow-y-auto">{content}</div>,
223
+ <div className="h-full overflow-x-hidden overflow-y-auto">
224
+ {content}
225
+ </div>,
215
226
  )}
216
227
  </PageBody>
217
228
  {hasFooter ? <PageFooter>{renderFooterActions()}</PageFooter> : null}
@@ -220,7 +231,10 @@ function PresentationFrame({
220
231
  }
221
232
  if (insidePageShell) {
222
233
  return (
223
- <Page className={cn("flex min-h-full flex-col", className)}>
234
+ <Page
235
+ layout={layout}
236
+ className={cn("flex min-h-full flex-col", className)}
237
+ >
224
238
  {navigation === undefined &&
225
239
  headerActions === undefined &&
226
240
  assistantAction === undefined ? null : (
@@ -241,7 +255,12 @@ function PresentationFrame({
241
255
  )}
242
256
  <PageBody className="min-h-0 flex-1 overflow-hidden">
243
257
  {withAssistant(
244
- <div className="flex h-full min-h-0 flex-col overflow-y-auto">
258
+ <div
259
+ className={cn(
260
+ "flex h-full min-h-0 flex-col overflow-x-hidden",
261
+ layout === "fill" ? "overflow-y-hidden" : "overflow-y-auto",
262
+ )}
263
+ >
245
264
  {contentOwnsHeading ? null : (
246
265
  <PageIntro>
247
266
  <PageTitle>{title}</PageTitle>
@@ -257,6 +276,7 @@ function PresentationFrame({
257
276
  }
258
277
  return (
259
278
  <Page
279
+ layout={layout}
260
280
  className={cn(
261
281
  "flex min-h-full max-w-none flex-col space-y-0 px-0 py-0",
262
282
  className,
@@ -279,7 +299,9 @@ function PresentationFrame({
279
299
  </PageHeader>
280
300
  <PageBody className="min-h-0 flex-1 overflow-hidden">
281
301
  {withAssistant(
282
- <div className="h-full overflow-y-auto px-8 py-8">{body}</div>,
302
+ <div className="h-full overflow-x-hidden overflow-y-auto px-8 py-8">
303
+ {body}
304
+ </div>,
283
305
  )}
284
306
  </PageBody>
285
307
  {hasFooter ? <PageFooter>{renderFooterActions()}</PageFooter> : null}
@@ -405,6 +427,8 @@ export interface PresentationProps {
405
427
  onOpenChange?: (open: boolean) => void;
406
428
  className?: string;
407
429
  bodyClassName?: string;
430
+ /** `fill` limita a página ao shell e delega a rolagem ao conteúdo React. */
431
+ layout?: "flow" | "fill";
408
432
  /** Conteúdo React da boundary declarada por `body.component`. */
409
433
  children?: ReactNode;
410
434
  /** Painel runtime; a página fecha sobre os dados do recurso que ela carregou. */
@@ -478,6 +502,7 @@ export function Presentation({
478
502
  onOpenChange,
479
503
  className,
480
504
  bodyClassName: bodyClassNameProp,
505
+ layout = "flow",
481
506
  children,
482
507
  assistant,
483
508
  }: PresentationProps): ReactElement {
@@ -776,6 +801,7 @@ export function Presentation({
776
801
  return (
777
802
  <PresentationFrame
778
803
  surface={invocation.surface}
804
+ layout={layout}
779
805
  title={definition.title}
780
806
  navigation={navigation}
781
807
  headerActions={headerActions.length === 0 ? undefined : headerActions}
@@ -22,20 +22,41 @@ function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
22
22
  function FieldLegend({
23
23
  className,
24
24
  variant = "legend",
25
+ icon,
26
+ children,
25
27
  ...props
26
- }: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
28
+ }: React.ComponentProps<"legend"> & {
29
+ variant?: "legend" | "label" | "section"
30
+ icon?: React.ReactNode
31
+ }) {
27
32
  return (
28
33
  <legend
29
34
  data-slot="field-legend"
30
35
  data-variant={variant}
31
36
  className={cn(
32
37
  "mb-3 font-medium",
38
+ icon !== undefined && "flex items-center gap-2",
33
39
  "data-[variant=legend]:text-base",
34
40
  "data-[variant=label]:text-sm",
41
+ "data-[variant=section]:w-full data-[variant=section]:border-b data-[variant=section]:border-border/70 data-[variant=section]:pb-2 data-[variant=section]:text-sm data-[variant=section]:font-semibold",
35
42
  className
36
43
  )}
37
44
  {...props}
38
- />
45
+ >
46
+ {icon !== undefined ? (
47
+ <span
48
+ data-slot="field-legend-icon"
49
+ aria-hidden="true"
50
+ className={cn(
51
+ "inline-flex shrink-0 items-center justify-center text-muted-foreground [&_svg]:size-4",
52
+ variant === "section" && "size-7 rounded-md bg-muted",
53
+ )}
54
+ >
55
+ {icon}
56
+ </span>
57
+ ) : null}
58
+ {children}
59
+ </legend>
39
60
  )
40
61
  }
41
62
 
@@ -8,6 +8,15 @@ import { Skeleton } from './skeleton.tsx'
8
8
  export type MetricCardTone = 'default' | 'warning'
9
9
  export type MetricCardContext = Exclude<UiContext, 'primary'>
10
10
 
11
+ export interface MetricCardSecondaryMetric {
12
+ /** Nome curto da medida complementar. */
13
+ label: React.ReactNode
14
+ /** Valor complementar já formatado pelo consumidor. */
15
+ value: React.ReactNode
16
+ /** Controle complementar próprio, como uma explicação em tooltip. */
17
+ labelAction?: React.ReactNode
18
+ }
19
+
11
20
  const iconContextClasses: Record<MetricCardContext, string> = {
12
21
  neutral: 'bg-context-neutral-subtle text-context-neutral-emphasis',
13
22
  info: 'bg-context-info-subtle text-context-info-emphasis',
@@ -16,7 +25,7 @@ const iconContextClasses: Record<MetricCardContext, string> = {
16
25
  danger: 'bg-context-danger-subtle text-context-danger-emphasis',
17
26
  }
18
27
 
19
- interface MetricCardBaseProps extends Omit<React.ComponentProps<typeof Card>, 'children'> {
28
+ interface MetricCardBaseProps extends Omit<React.ComponentProps<typeof Card>, 'children' | 'asChild'> {
20
29
  /** Controle complementar exibido ao lado do rótulo, como uma explicação em tooltip. */
21
30
  labelAction?: React.ReactNode
22
31
  /** Contexto que explica recorte, proporção ou significado do valor. */
@@ -29,6 +38,8 @@ interface MetricCardBaseProps extends Omit<React.ComponentProps<typeof Card>, 'c
29
38
  tone?: MetricCardTone
30
39
  /** Ação relacionada diretamente à medida. */
31
40
  action?: React.ReactNode
41
+ /** Medida complementar exibida como uma lâmina posterior sob o card principal. */
42
+ secondaryMetric?: MetricCardSecondaryMetric
32
43
  }
33
44
 
34
45
  export type MetricCardProps = MetricCardBaseProps &
@@ -63,6 +74,7 @@ export function MetricCard({
63
74
  context,
64
75
  tone,
65
76
  action,
77
+ secondaryMetric,
66
78
  loading = false,
67
79
  className,
68
80
  ...props
@@ -73,12 +85,16 @@ export function MetricCard({
73
85
  const hasIcon = icon !== undefined && icon !== null
74
86
  const resolvedContext = context ?? (tone === 'warning' ? 'warning' : 'neutral')
75
87
 
76
- return (
88
+ const primaryCard = (
77
89
  <Card
78
90
  data-slot="metric-card"
79
- aria-busy={loading || undefined}
80
- className={cn('min-w-0 p-4', className)}
81
- {...props}
91
+ aria-busy={secondaryMetric === undefined ? loading || undefined : undefined}
92
+ className={cn(
93
+ 'min-w-0 p-4',
94
+ secondaryMetric !== undefined && 'relative z-10',
95
+ secondaryMetric === undefined && className,
96
+ )}
97
+ {...(secondaryMetric === undefined ? props : {})}
82
98
  >
83
99
  {loading ? (
84
100
  <div className="space-y-3">
@@ -130,4 +146,47 @@ export function MetricCard({
130
146
  )}
131
147
  </Card>
132
148
  )
149
+
150
+ if (secondaryMetric === undefined) return primaryCard
151
+
152
+ return (
153
+ <div
154
+ {...props}
155
+ data-slot="metric-card-stack"
156
+ aria-busy={loading || undefined}
157
+ className={cn('relative isolate min-w-0', className)}
158
+ >
159
+ {primaryCard}
160
+ <Card
161
+ data-slot="metric-card-secondary"
162
+ className="relative z-0 -mt-8 flex w-full min-w-0 items-center gap-1.5 bg-[color-mix(in_oklab,var(--card)_88%,black)] px-4 pt-9 pb-1"
163
+ >
164
+ {loading ? (
165
+ <Skeleton className="h-5 w-full" />
166
+ ) : (
167
+ <>
168
+ <div
169
+ data-slot="metric-card-secondary-value"
170
+ className="shrink-0 text-xs font-medium text-muted-foreground tabular-nums"
171
+ >
172
+ {secondaryMetric.value}
173
+ </div>
174
+ <div className="flex min-w-0 items-center gap-1.5">
175
+ <div
176
+ data-slot="metric-card-secondary-label"
177
+ className="min-w-0 truncate text-xs font-medium text-muted-foreground"
178
+ >
179
+ {secondaryMetric.label}
180
+ </div>
181
+ {secondaryMetric.labelAction !== undefined && (
182
+ <div data-slot="metric-card-secondary-label-action" className="shrink-0">
183
+ {secondaryMetric.labelAction}
184
+ </div>
185
+ )}
186
+ </div>
187
+ </>
188
+ )}
189
+ </Card>
190
+ </div>
191
+ )
133
192
  }
@@ -27,6 +27,7 @@ import { Command as CommandPrimitive } from 'cmdk'
27
27
  import { cn } from '../../lib/cn.ts'
28
28
  import { focusRing, focusRingWithin, type ControlShape, type ControlSize } from './control.ts'
29
29
  import { CommandEmpty, CommandGroup, CommandItem, CommandList } from './command.tsx'
30
+ import { InputGroupButton } from './input-group.tsx'
30
31
  import { Popover, PopoverAnchor, PopoverContent } from './popover.tsx'
31
32
 
32
33
  export interface SelectOption {
@@ -451,25 +452,6 @@ function CustomSelect(props: Exclude<SelectProps, SelectNativeProps>): React.Rea
451
452
  placeholder={props.multiple && selectedValues.length > 0 ? '' : placeholder}
452
453
  className="sr-only"
453
454
  />
454
- {clearable === true && selectedValues.length > 0 && disabled !== true && (
455
- <button
456
- type="button"
457
- data-slot="select-clear"
458
- aria-label="Limpar seleção"
459
- onPointerDown={(e) => e.preventDefault()}
460
- onClick={(e) => {
461
- e.stopPropagation()
462
- if (props.multiple) props.onChange([])
463
- else {
464
- props.onChange('')
465
- setQuery('')
466
- }
467
- }}
468
- className="flex shrink-0 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
469
- >
470
- <X className="size-3.5" />
471
- </button>
472
- )}
473
455
  {/* Ação custom (ex.: botão que age sobre o valor) — o clique NÃO abre a lista:
474
456
  o slot para a propagação pro controle. */}
475
457
  {trailing !== undefined && (
@@ -492,16 +474,38 @@ function CustomSelect(props: Exclude<SelectProps, SelectNativeProps>): React.Rea
492
474
  affordance que diz isso. Antes ele sumia no buscável, porque ali o sinal
493
475
  era o cursor de texto — que não existe mais no campo. */}
494
476
  <ChevronDownIcon data-slot="select-icon" className={cn('size-4 shrink-0 opacity-50', ghost ? 'h-3.5 w-3.5 opacity-60' : '')} aria-hidden="true" />
477
+ {clearable === true && selectedValues.length > 0 && disabled !== true && (
478
+ <InputGroupButton
479
+ type="button"
480
+ size="icon-xs"
481
+ data-slot="select-clear"
482
+ aria-label="Limpar seleção"
483
+ onPointerDown={(e) => e.preventDefault()}
484
+ onClick={(e) => {
485
+ e.stopPropagation()
486
+ if (props.multiple) props.onChange([])
487
+ else {
488
+ props.onChange('')
489
+ setQuery('')
490
+ }
491
+ }}
492
+ className="-ml-1 -mr-1.5 text-muted-foreground hover:text-foreground"
493
+ >
494
+ <X className="size-3.5" />
495
+ </InputGroupButton>
496
+ )}
495
497
  </div>
496
498
  </PopoverAnchor>
497
499
  <PopoverContent
498
500
  align="start"
499
- // No campo de form a lista acompanha a largura do controle; no ghost, NÃO — o
500
- // gatilho é do tamanho do rótulo (uma chave curta), e a lista precisa caber o
501
- // título inteiro.
501
+ // No campo comum a lista parte da largura do controle, mas pode crescer para
502
+ // preservar rótulos maiores sem alargar o gatilho. No ghost, o gatilho já abraça
503
+ // o conteúdo e a lista mantém sua faixa compacta própria.
502
504
  className={cn(
503
505
  'pointer-events-auto overflow-hidden p-0',
504
- compact ? 'w-auto min-w-56 max-w-80' : 'w-(--radix-popover-trigger-width) min-w-40',
506
+ compact
507
+ ? 'w-auto min-w-56 max-w-80'
508
+ : 'w-max min-w-(--radix-popover-trigger-width) max-w-lg',
505
509
  )}
506
510
  /*
507
511
  * O foco ao abrir é SEMPRE nosso — o Radix nunca mexe.
@@ -8,9 +8,11 @@ const TableVariantContext = React.createContext<TableVariant>('plain')
8
8
  export interface TableProps extends React.ComponentProps<"table"> {
9
9
  /** `framed` aplica a moldura canônica de datagrid no contêiner da tabela. */
10
10
  variant?: TableVariant
11
+ /** Classes do contêiner responsável pela rolagem e pela moldura da tabela. */
12
+ containerClassName?: string
11
13
  }
12
14
 
13
- function Table({ className, variant = 'plain', ...props }: TableProps) {
15
+ function Table({ className, containerClassName, variant = 'plain', ...props }: TableProps) {
14
16
  return (
15
17
  <TableVariantContext.Provider value={variant}>
16
18
  <div
@@ -19,6 +21,7 @@ function Table({ className, variant = 'plain', ...props }: TableProps) {
19
21
  className={cn(
20
22
  "relative w-full overflow-x-auto",
21
23
  variant === 'framed' && 'rounded-lg border border-border',
24
+ containerClassName,
22
25
  )}
23
26
  >
24
27
  <table