@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.
- package/CHANGELOG.md +20 -0
- package/README.md +1 -0
- package/docs/adr/0020-icon-tiles-are-explicitly-composed.md +46 -0
- package/docs/adr/0021-content-carries-structure-and-semantic-element.md +65 -0
- package/docs/adr/0022-execution-monitoring-is-a-projection.md +39 -0
- package/package.json +21 -1
- package/src/executions/drivers/memory.ts +39 -0
- package/src/executions/drivers/pg-changes.ts +104 -0
- package/src/executions/drivers/pg.ts +74 -0
- package/src/executions/index.ts +129 -0
- package/src/executions/stream.ts +123 -0
- package/src/ui/components/patterns/content-header.tsx +168 -41
- package/src/ui/components/patterns/list.tsx +43 -13
- package/src/ui/components/primitives/detail.tsx +1 -1
- package/src/ui/components/primitives/empty.tsx +2 -1
- package/src/ui/components/primitives/field.tsx +38 -16
- package/src/ui/components/primitives/icon-tile.tsx +91 -0
- package/src/ui/components/primitives/item.tsx +2 -1
- package/src/ui/components/primitives/table.tsx +1 -1
- package/src/ui/docs/content/action-list.md +61 -52
- package/src/ui/docs/content/content.md +84 -8
- package/src/ui/docs/content/executions.md +157 -0
- package/src/ui/docs/content/field.md +78 -30
- package/src/ui/docs/content/icon-tile.md +47 -0
- package/src/ui/docs/registry.tsx +13 -0
- package/src/ui/meta.ts +9 -3
- package/src/ui/react.tsx +17 -0
|
@@ -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
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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 = {
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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<
|
|
307
|
+
}: HTMLAttributes<HTMLElement>): ReactElement {
|
|
199
308
|
const context = requiredContext(
|
|
200
309
|
useContext(ContentHeaderContext),
|
|
201
310
|
"ContentTitle",
|
|
202
311
|
"ContentHeader",
|
|
203
312
|
);
|
|
204
|
-
const Heading =
|
|
205
|
-
|
|
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(
|
|
283
|
-
|
|
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 {
|
|
115
|
-
|
|
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
|
-
<
|
|
1157
|
-
<
|
|
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
|
-
</
|
|
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
|
-
<
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
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
|
-
</
|
|
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="
|
|
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-
|
|
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-
|
|
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:
|
|
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
|
-
|
|
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
|
-
<
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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=
|
|
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>
|