@softize/opus 18.3.0 → 18.5.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.
@@ -0,0 +1,123 @@
1
+ import type { ExecutionChanges, ExecutionNotice } from './index.ts'
2
+
3
+ export interface ExecutionStreamOptions {
4
+ changes: ExecutionChanges
5
+ /** null: sessão e direito de acompanhar; notice: também autorizar a execução exata. */
6
+ authorize: (notice: ExecutionNotice | null) => Promise<boolean>
7
+ signal?: AbortSignal
8
+ heartbeatMs?: number
9
+ /** Renova a conexão e a autorização mesmo se não houver atividade. */
10
+ maxDurationMs?: number
11
+ onError: (error: unknown) => void
12
+ }
13
+
14
+ /**
15
+ * SSE sem dados de negócio. Ler a fotografia por actions autorizadas após cada invalidate.
16
+ * Assinar antes da primeira invalidação evita a lacuna entre fotografia e assinatura.
17
+ */
18
+ export async function executionStream(options: ExecutionStreamOptions): Promise<Response> {
19
+ const heartbeatMs = options.heartbeatMs ?? 15_000
20
+ const maxDurationMs = options.maxDurationMs ?? 300_000
21
+ if (!Number.isFinite(heartbeatMs) || heartbeatMs < 100 || !Number.isFinite(maxDurationMs) || maxDurationMs < heartbeatMs) {
22
+ throw new Error('Invalid execution stream timing')
23
+ }
24
+ if (options.signal?.aborted) return new Response(null, { status: 204 })
25
+ if (!await options.authorize(null)) return new Response(null, { status: 403 })
26
+ if (options.signal?.aborted) return new Response(null, { status: 204 })
27
+
28
+ const encoder = new TextEncoder()
29
+ let close = () => {}
30
+ let cancelled = false
31
+ const stream = new ReadableStream<Uint8Array>({
32
+ start(controller) {
33
+ let closed = false
34
+ let busy = false
35
+ let pendingReset = false
36
+ let pinging = false
37
+ let sessionCheck: Promise<boolean> | undefined
38
+ const pending = new Map<string, ExecutionNotice>()
39
+ let unsubscribe: (() => void) | undefined
40
+ let heartbeat: ReturnType<typeof setInterval> | undefined
41
+ let deadline: ReturnType<typeof setTimeout> | undefined
42
+ close = () => {
43
+ if (closed) return
44
+ closed = true
45
+ pending.clear()
46
+ unsubscribe?.()
47
+ clearInterval(heartbeat)
48
+ clearTimeout(deadline)
49
+ options.signal?.removeEventListener('abort', close)
50
+ if (!cancelled) controller.close()
51
+ }
52
+ const send = (message: string) => {
53
+ if (closed) return
54
+ // Uma conexão lenta reconecta e refaz a fotografia; nunca acumula progresso sem limite.
55
+ if ((controller.desiredSize ?? 0) <= 0) { close(); return }
56
+ controller.enqueue(encoder.encode(message))
57
+ }
58
+ const invalidate = () => send('event: invalidate\ndata: {}\n\n')
59
+ const report = (error: unknown) => { try { options.onError(error) } catch { /* não vazar erro no SSE */ } }
60
+
61
+ function sessionAllowed(): Promise<boolean> {
62
+ sessionCheck ??= Promise.resolve().then(() => options.authorize(null)).finally(() => { sessionCheck = undefined })
63
+ return sessionCheck
64
+ }
65
+
66
+ // A fila de autorizações por recurso não pode segurar a renovação da sessão ou o heartbeat.
67
+ async function ping(): Promise<void> {
68
+ if (pinging || closed) return
69
+ pinging = true
70
+ try {
71
+ if (!await sessionAllowed()) close()
72
+ else send(': heartbeat\n\n')
73
+ } catch (error) { report(error); close() }
74
+ finally { pinging = false }
75
+ }
76
+
77
+ async function flush(): Promise<void> {
78
+ if (busy || closed) return
79
+ busy = true
80
+ try {
81
+ while (!closed && (pending.size > 0 || pendingReset)) {
82
+ const notices = [...pending.values()]
83
+ pending.clear()
84
+ let allowed = pendingReset
85
+ pendingReset = false
86
+ if (!await sessionAllowed()) { close(); break }
87
+ for (const notice of notices) {
88
+ if (closed) break
89
+ if (await options.authorize(notice)) allowed = true
90
+ }
91
+ if (allowed && !closed) {
92
+ if (!await sessionAllowed()) { close(); break }
93
+ invalidate()
94
+ }
95
+ }
96
+ } catch (error) { report(error); close() }
97
+ finally { busy = false }
98
+ }
99
+
100
+ const dispose = options.changes.subscribe((notice) => {
101
+ if (closed) return
102
+ if (notice === null) pendingReset = true
103
+ else pending.set(notice.id, notice)
104
+ if (pending.size > 128) { close(); return }
105
+ void flush()
106
+ }, close)
107
+ unsubscribe = dispose
108
+ if (closed) { dispose(); return }
109
+ options.signal?.addEventListener('abort', close, { once: true })
110
+ if (options.signal?.aborted) { close(); return }
111
+ heartbeat = setInterval(() => { void ping() }, heartbeatMs)
112
+ deadline = setTimeout(close, maxDurationMs)
113
+ pendingReset = true
114
+ void flush()
115
+ },
116
+ cancel() { cancelled = true; close() },
117
+ }, { highWaterMark: 16 })
118
+ return new Response(stream, { headers: {
119
+ 'Content-Type': 'text/event-stream',
120
+ 'Cache-Control': 'no-cache, no-store',
121
+ 'X-Accel-Buffering': 'no',
122
+ } })
123
+ }
@@ -4,6 +4,7 @@ import {
4
4
  isValidElement,
5
5
  useContext,
6
6
  useId,
7
+ type ComponentProps,
7
8
  type HTMLAttributes,
8
9
  type ReactElement,
9
10
  type ReactNode,
@@ -18,8 +19,13 @@ import {
18
19
 
19
20
  export type ContentHeaderLevel = 1 | 2 | 3 | 4 | 5 | 6;
20
21
  export type ContentHeaderVariant = SurfaceHeaderVariant;
22
+ export type ContentElement = "section" | "fieldset";
23
+ export type ContentFrameVariant = "default" | "legend";
21
24
 
22
25
  interface ContentContextValue {
26
+ element: ContentElement;
27
+ frameVariant: ContentFrameVariant;
28
+ framed: boolean;
23
29
  level: ContentHeaderLevel;
24
30
  titleId: string;
25
31
  variant: ContentHeaderVariant;
@@ -38,17 +44,37 @@ function requiredContext<T>(
38
44
  return value;
39
45
  }
40
46
 
41
- interface ContentBaseProps extends Omit<
42
- HTMLAttributes<HTMLElement>,
43
- "children" | "title"
44
- > {
45
- /** Nível semântico do heading, independente de seu destaque visual. */
46
- level?: ContentHeaderLevel;
47
+ interface ContentSharedProps {
48
+ /** Aplica a moldura estrutural da região sem transferi-la ao conteúdo interno. */
49
+ framed?: boolean;
47
50
  /** Hierarquia visual: `section` (default) ou `page`, a mesma de PageHeader. */
48
51
  variant?: ContentHeaderVariant;
49
52
  }
50
53
 
51
- interface ContentShorthandProps extends ContentBaseProps {
54
+ interface ContentSectionBaseProps
55
+ extends
56
+ ContentSharedProps,
57
+ Omit<ComponentProps<"section">, "children" | "title"> {
58
+ /** Elemento semântico da região. */
59
+ as?: "section";
60
+ frameVariant?: "default";
61
+ /** Nível semântico do heading, independente de seu destaque visual. */
62
+ level?: ContentHeaderLevel;
63
+ }
64
+
65
+ interface ContentFieldsetBaseProps
66
+ extends
67
+ ContentSharedProps,
68
+ Omit<ComponentProps<"fieldset">, "children" | "title"> {
69
+ /** Usa a semântica nativa de grupo de campos; o título vira `legend`. */
70
+ as: "fieldset";
71
+ /** Tratamento visual da moldura: contínua (default) ou recortada pela legenda. */
72
+ frameVariant?: ContentFrameVariant;
73
+ level?: never;
74
+ variant?: "section";
75
+ }
76
+
77
+ interface ContentSectionShorthandProps extends ContentSectionBaseProps {
52
78
  title: ReactNode;
53
79
  /** Total de itens ao lado do título da seção. A página não carrega contador (ADR 0009). */
54
80
  count?: number;
@@ -57,7 +83,15 @@ interface ContentShorthandProps extends ContentBaseProps {
57
83
  children: ReactNode;
58
84
  }
59
85
 
60
- interface ContentComposedProps extends ContentBaseProps {
86
+ interface ContentFieldsetShorthandProps extends ContentFieldsetBaseProps {
87
+ title: ReactNode;
88
+ count?: never;
89
+ description?: never;
90
+ actions?: never;
91
+ children: ReactNode;
92
+ }
93
+
94
+ interface ContentComposedProps {
61
95
  title?: never;
62
96
  count?: never;
63
97
  description?: never;
@@ -65,22 +99,36 @@ interface ContentComposedProps extends ContentBaseProps {
65
99
  children: ReactNode;
66
100
  }
67
101
 
68
- export type ContentProps = ContentShorthandProps | ContentComposedProps;
102
+ export type ContentProps =
103
+ | ContentSectionShorthandProps
104
+ | ContentFieldsetShorthandProps
105
+ | ((ContentSectionBaseProps | ContentFieldsetBaseProps) &
106
+ ContentComposedProps);
69
107
 
70
108
  /** Região de conteúdo nomeada. A forma curta e a explícita produzem a mesma anatomia. */
71
109
  export function Content({
110
+ as = "section",
72
111
  title,
73
112
  count,
74
113
  description,
75
114
  actions,
76
115
  level = 2,
77
116
  variant = "section",
117
+ framed = false,
118
+ frameVariant = "default",
78
119
  children,
79
120
  className,
80
121
  ...props
81
122
  }: ContentProps): ReactElement {
82
123
  const titleId = useId();
83
- const context = { level, titleId, variant };
124
+ const context = {
125
+ element: as,
126
+ frameVariant,
127
+ framed,
128
+ level: level as ContentHeaderLevel,
129
+ titleId,
130
+ variant,
131
+ };
84
132
  const shorthand = title !== undefined;
85
133
  const nodes = Children.toArray(children);
86
134
 
@@ -110,34 +158,64 @@ export function Content({
110
158
  }
111
159
  }
112
160
 
161
+ const anatomy = shorthand ? (
162
+ <>
163
+ <ContentHeader>
164
+ <ContentTitle>{title}</ContentTitle>
165
+ {count !== undefined && <ContentMeta>{count}</ContentMeta>}
166
+ {description !== undefined && (
167
+ <ContentDescription>{description}</ContentDescription>
168
+ )}
169
+ {actions !== undefined && <ContentActions>{actions}</ContentActions>}
170
+ </ContentHeader>
171
+ <ContentBody>{children}</ContentBody>
172
+ </>
173
+ ) : (
174
+ children
175
+ );
176
+ const ariaLabelledBy =
177
+ props["aria-label"] === undefined ? titleId : undefined;
178
+
113
179
  return (
114
180
  <ContentContext.Provider value={context}>
115
- <section
116
- data-slot="content"
117
- aria-labelledby={
118
- props["aria-label"] === undefined ? titleId : undefined
119
- }
120
- className={cn("space-y-3", className)}
121
- {...props}
122
- >
123
- {shorthand ? (
124
- <>
125
- <ContentHeader>
126
- <ContentTitle>{title}</ContentTitle>
127
- {count !== undefined && <ContentMeta>{count}</ContentMeta>}
128
- {description !== undefined && (
129
- <ContentDescription>{description}</ContentDescription>
130
- )}
131
- {actions !== undefined && (
132
- <ContentActions>{actions}</ContentActions>
133
- )}
134
- </ContentHeader>
135
- <ContentBody>{children}</ContentBody>
136
- </>
137
- ) : (
138
- children
139
- )}
140
- </section>
181
+ {as === "fieldset" ? (
182
+ <fieldset
183
+ data-slot="content"
184
+ data-as="fieldset"
185
+ data-framed={framed}
186
+ data-frame-variant={frameVariant}
187
+ aria-labelledby={ariaLabelledBy}
188
+ className={cn(
189
+ !framed && "space-y-3",
190
+ framed &&
191
+ frameVariant === "default" &&
192
+ "rounded-lg border border-border p-4",
193
+ framed &&
194
+ frameVariant === "legend" &&
195
+ "space-y-3 rounded-lg border border-border px-4 pb-4",
196
+ className,
197
+ )}
198
+ {...(props as ComponentProps<"fieldset">)}
199
+ >
200
+ {anatomy}
201
+ </fieldset>
202
+ ) : (
203
+ <section
204
+ data-slot="content"
205
+ data-as="section"
206
+ data-framed={framed}
207
+ data-frame-variant={frameVariant}
208
+ aria-labelledby={ariaLabelledBy}
209
+ className={cn(
210
+ "space-y-3",
211
+ framed && "rounded-lg border border-border p-4",
212
+ className,
213
+ )}
214
+ {...(props as ComponentProps<"section">)}
215
+ >
216
+ {anatomy}
217
+ </section>
218
+ )}
141
219
  </ContentContext.Provider>
142
220
  );
143
221
  }
@@ -158,6 +236,37 @@ export function ContentHeader({
158
236
  "ContentHeader",
159
237
  "Content",
160
238
  );
239
+ if (content.element === "fieldset") {
240
+ const nodes = Children.toArray(children);
241
+ if (
242
+ nodes.length !== 1 ||
243
+ !isValidElement(nodes[0]) ||
244
+ nodes[0].type !== ContentTitle
245
+ ) {
246
+ throw new Error(
247
+ "ContentHeader de fieldset exige somente um ContentTitle; contador, descrição e ações ficam fora da legenda.",
248
+ );
249
+ }
250
+ return (
251
+ <ContentHeaderContext.Provider value={content}>
252
+ <legend
253
+ data-slot="content-header"
254
+ className={cn(
255
+ content.framed &&
256
+ content.frameVariant === "default" &&
257
+ "float-left w-full",
258
+ content.framed &&
259
+ content.frameVariant === "legend" &&
260
+ "-ml-2 w-auto px-2",
261
+ className,
262
+ )}
263
+ {...props}
264
+ >
265
+ {children}
266
+ </legend>
267
+ </ContentHeaderContext.Provider>
268
+ );
269
+ }
161
270
  return (
162
271
  <ContentHeaderContext.Provider value={content}>
163
272
  <SurfaceHeader
@@ -195,14 +304,15 @@ export function ContentTitle({
195
304
  className,
196
305
  id,
197
306
  ...props
198
- }: HTMLAttributes<HTMLHeadingElement>): ReactElement {
307
+ }: HTMLAttributes<HTMLElement>): ReactElement {
199
308
  const context = requiredContext(
200
309
  useContext(ContentHeaderContext),
201
310
  "ContentTitle",
202
311
  "ContentHeader",
203
312
  );
204
- const Heading = headingTags[context.level];
205
- usePageHeadingPresence(context.level === 1);
313
+ const Heading =
314
+ context.element === "fieldset" ? "span" : headingTags[context.level];
315
+ usePageHeadingPresence(context.element !== "fieldset" && context.level === 1);
206
316
  return (
207
317
  <Heading
208
318
  id={id ?? context.titleId}
@@ -279,6 +389,23 @@ export function ContentBody({
279
389
  className,
280
390
  ...props
281
391
  }: HTMLAttributes<HTMLDivElement>): ReactElement {
282
- requiredContext(useContext(ContentContext), "ContentBody", "Content");
283
- return <div data-slot="content-body" className={className} {...props} />;
392
+ const content = requiredContext(
393
+ useContext(ContentContext),
394
+ "ContentBody",
395
+ "Content",
396
+ );
397
+ return (
398
+ <div
399
+ data-slot="content-body"
400
+ className={cn(
401
+ content.element === "fieldset" && "space-y-3",
402
+ content.element === "fieldset" &&
403
+ content.framed &&
404
+ content.frameVariant === "default" &&
405
+ "clear-left pt-3",
406
+ className,
407
+ )}
408
+ {...props}
409
+ />
410
+ );
284
411
  }
@@ -111,8 +111,13 @@ import {
111
111
  InputGroupInput,
112
112
  } from "../primitives/input-group.tsx";
113
113
  import { Separator } from "../primitives/separator.tsx";
114
- import { Label } from "../primitives/label.tsx";
115
- import { FieldGroup, FieldLegend, FieldSet } from "../primitives/field.tsx";
114
+ import {
115
+ Field,
116
+ FieldGroup,
117
+ FieldLabel,
118
+ type FieldSetVariant,
119
+ } from "../primitives/field.tsx";
120
+ import { Content } from "./content-header.tsx";
116
121
  import { Search } from "lucide-react";
117
122
  import { Select, type SelectOption } from "../primitives/select.tsx";
118
123
  import {
@@ -307,6 +312,8 @@ export interface AdvancedFiltersLayout {
307
312
  columns?: 1 | 2 | 3;
308
313
  /** Ícone de cada seção, indexado pelo mesmo rótulo declarado nos filtros. */
309
314
  sectionIcons?: Record<string, ReactNode>;
315
+ /** Tratamento visual de cada seção. `framed` integra a legenda à moldura do conjunto. */
316
+ sectionVariant?: FieldSetVariant;
310
317
  }
311
318
 
312
319
  /** Uma view alternativa da MESMA listagem (board, galeria, lista, calendário…). A
@@ -1105,6 +1112,7 @@ function AdvancedFiltersDialog({
1105
1112
  filterDefaults,
1106
1113
  filterIcons,
1107
1114
  sectionIcons,
1115
+ sectionVariant = "plain",
1108
1116
  cascade,
1109
1117
  columns = 1,
1110
1118
  }: {
@@ -1117,6 +1125,7 @@ function AdvancedFiltersDialog({
1117
1125
  filterDefaults?: Record<string, string> | undefined;
1118
1126
  filterIcons?: Record<string, ReactNode> | undefined;
1119
1127
  sectionIcons?: Record<string, ReactNode> | undefined;
1128
+ sectionVariant?: FieldSetVariant | undefined;
1120
1129
  columns?: 1 | 2 | 3;
1121
1130
  /** A cascata do `depends` do pai — mudar um campo limpa os dependentes no draft. */
1122
1131
  cascade: (
@@ -1153,8 +1162,10 @@ function AdvancedFiltersDialog({
1153
1162
  }[columns];
1154
1163
 
1155
1164
  const field = ([name, spec]: [string, FilterSpec]): React.ReactElement => (
1156
- <div key={name} className="space-y-2">
1157
- <Label htmlFor={`filter-${name}`}>{text(spec.label, name)}</Label>
1165
+ <Field key={name}>
1166
+ <FieldLabel htmlFor={`filter-${name}`}>
1167
+ {text(spec.label, name)}
1168
+ </FieldLabel>
1158
1169
  <FilterField
1159
1170
  name={name}
1160
1171
  spec={spec}
@@ -1166,7 +1177,7 @@ function AdvancedFiltersDialog({
1166
1177
  defaultValue={filterDefaults?.[name]}
1167
1178
  icon={filterIcons?.[name]}
1168
1179
  />
1169
- </div>
1180
+ </Field>
1170
1181
  );
1171
1182
 
1172
1183
  const clear = (): void => {
@@ -1194,14 +1205,32 @@ function AdvancedFiltersDialog({
1194
1205
  <DialogBody className="space-y-4">
1195
1206
  {sectioned ? (
1196
1207
  sections.map(([section, entries]) => (
1197
- <FieldSet key={section} className="gap-0">
1198
- <FieldLegend variant="section" icon={sectionIcons?.[section]}>
1199
- {section}
1200
- </FieldLegend>
1208
+ <Content
1209
+ key={section}
1210
+ as="fieldset"
1211
+ framed={sectionVariant === "framed"}
1212
+ frameVariant={
1213
+ sectionVariant === "framed" ? "legend" : "default"
1214
+ }
1215
+ title={
1216
+ <span className="flex items-center gap-2">
1217
+ {sectionIcons?.[section] !== undefined && (
1218
+ <span
1219
+ data-slot="content-title-icon"
1220
+ aria-hidden="true"
1221
+ className="inline-flex shrink-0 items-center justify-center text-muted-foreground [&_svg]:size-4"
1222
+ >
1223
+ {sectionIcons[section]}
1224
+ </span>
1225
+ )}
1226
+ {section}
1227
+ </span>
1228
+ }
1229
+ >
1201
1230
  <FieldGroup className={cn("grid gap-4", gridColumns)}>
1202
1231
  {entries.map(field)}
1203
1232
  </FieldGroup>
1204
- </FieldSet>
1233
+ </Content>
1205
1234
  ))
1206
1235
  ) : (
1207
1236
  <FieldGroup className={cn("grid gap-4", gridColumns)}>
@@ -1597,6 +1626,7 @@ export function ActionFilterBar({
1597
1626
  filterIcons={filterIcons}
1598
1627
  columns={advancedFilters?.columns}
1599
1628
  sectionIcons={advancedFilters?.sectionIcons}
1629
+ sectionVariant={advancedFilters?.sectionVariant}
1600
1630
  />
1601
1631
  )}
1602
1632
  </div>
@@ -2111,7 +2141,7 @@ export function ActionList<TInput, TItem>({
2111
2141
  } else {
2112
2142
  content = (
2113
2143
  <Table
2114
- variant="framed"
2144
+ variant="plain"
2115
2145
  className={tableStateActive ? "h-full" : undefined}
2116
2146
  containerClassName={
2117
2147
  tableFillsAvailableHeight
@@ -2122,7 +2152,7 @@ export function ActionList<TInput, TItem>({
2122
2152
  <TableHeader
2123
2153
  className={
2124
2154
  tableFillsAvailableHeight
2125
- ? "sticky top-0 z-10 bg-muted/30 dark:bg-input/30"
2155
+ ? "sticky top-0 z-10 bg-background shadow-[inset_0_-0.0625rem_0_var(--border)] [&_tr]:border-b-0"
2126
2156
  : undefined
2127
2157
  }
2128
2158
  >
@@ -2270,7 +2300,7 @@ export function ActionList<TInput, TItem>({
2270
2300
  total !== undefined && !isError ? (
2271
2301
  <div
2272
2302
  data-slot="action-list-footer"
2273
- className="grid grid-cols-[1fr_auto_1fr] items-center gap-3 px-3 py-1.5 text-xs text-muted-foreground"
2303
+ className="grid grid-cols-[1fr_auto_1fr] items-center gap-3 border-t border-border px-3 py-1.5 text-xs text-muted-foreground"
2274
2304
  >
2275
2305
  <span>
2276
2306
  Página {state.page} de {pages}
@@ -131,7 +131,7 @@ function DetailField({
131
131
  <dt
132
132
  data-slot="detail-field-label"
133
133
  className={cn(
134
- 'flex items-center gap-2.5 text-xs leading-snug font-medium text-muted-foreground group-data-[orientation=horizontal]/detail-group:text-sm',
134
+ 'flex items-center gap-2.5 text-sm leading-snug font-medium text-muted-foreground',
135
135
  splitHorizontalField && 'bg-muted/30 p-3 dark:bg-input/30',
136
136
  )}
137
137
  >
@@ -1,6 +1,7 @@
1
1
  import { cva, type VariantProps } from "class-variance-authority"
2
2
 
3
3
  import { cn } from '../../lib/cn.ts'
4
+ import { iconTileVariants } from './icon-tile.tsx'
4
5
 
5
6
  function Empty({ className, ...props }: React.ComponentProps<"div">) {
6
7
  return (
@@ -34,7 +35,7 @@ const emptyMediaVariants = cva(
34
35
  variants: {
35
36
  variant: {
36
37
  default: "bg-transparent",
37
- icon: "flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-6",
38
+ icon: iconTileVariants({ size: "lg", variant: "subtle" }),
38
39
  },
39
40
  },
40
41
  defaultVariants: {
@@ -1,21 +1,41 @@
1
- import { useMemo } from "react"
1
+ import { createContext, useContext, useMemo } from "react"
2
2
  import { cva, type VariantProps } from "class-variance-authority"
3
3
 
4
4
  import { cn } from '../../lib/cn.ts'
5
5
  import { Label } from './label.tsx'
6
6
  import { Separator } from './separator.tsx'
7
7
 
8
- function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
8
+ export type FieldSetVariant = "plain" | "framed"
9
+
10
+ export interface FieldSetProps extends React.ComponentProps<"fieldset"> {
11
+ /** `framed` envolve o conjunto com a moldura canônica e integra a legenda à borda. */
12
+ variant?: FieldSetVariant
13
+ }
14
+
15
+ const FieldSetVariantContext = createContext<FieldSetVariant>("plain")
16
+
17
+ function FieldSet({
18
+ className,
19
+ children,
20
+ variant = "plain",
21
+ ...props
22
+ }: FieldSetProps) {
9
23
  return (
10
- <fieldset
11
- data-slot="field-set"
12
- className={cn(
13
- "flex flex-col gap-6",
14
- "has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
15
- className
16
- )}
17
- {...props}
18
- />
24
+ <FieldSetVariantContext.Provider value={variant}>
25
+ <fieldset
26
+ data-slot="field-set"
27
+ data-variant={variant}
28
+ className={cn(
29
+ "flex flex-col gap-6",
30
+ "has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
31
+ variant === "framed" && "rounded-lg border border-border px-4 pb-4",
32
+ className
33
+ )}
34
+ {...props}
35
+ >
36
+ {children}
37
+ </fieldset>
38
+ </FieldSetVariantContext.Provider>
19
39
  )
20
40
  }
21
41
 
@@ -29,6 +49,8 @@ function FieldLegend({
29
49
  variant?: "legend" | "label" | "section"
30
50
  icon?: React.ReactNode
31
51
  }) {
52
+ const fieldSetVariant = useContext(FieldSetVariantContext)
53
+
32
54
  return (
33
55
  <legend
34
56
  data-slot="field-legend"
@@ -38,7 +60,10 @@ function FieldLegend({
38
60
  icon !== undefined && "flex items-center gap-2",
39
61
  "data-[variant=legend]:text-base",
40
62
  "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",
63
+ fieldSetVariant === "plain" &&
64
+ "data-[variant=section]:w-full data-[variant=section]:border-b data-[variant=section]:border-border/70 data-[variant=section]:pb-2",
65
+ "data-[variant=section]:text-sm data-[variant=section]:font-semibold",
66
+ fieldSetVariant === "framed" && "-ml-2 w-auto px-2",
42
67
  className
43
68
  )}
44
69
  {...props}
@@ -47,10 +72,7 @@ function FieldLegend({
47
72
  <span
48
73
  data-slot="field-legend-icon"
49
74
  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
- )}
75
+ className="inline-flex shrink-0 items-center justify-center text-muted-foreground [&_svg]:size-4"
54
76
  >
55
77
  {icon}
56
78
  </span>