@softize/opus 17.2.0 → 18.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.
- package/CHANGELOG.md +55 -1
- package/bin/lib/check.mjs +212 -45
- package/docs/adr/0011-page-shell-coordinates-persistent-page-chrome.md +4 -0
- package/docs/adr/0013-presentation-is-a-portable-action-oriented-artifact.md +14 -2
- package/docs/adr/0016-list-collection-header-belongs-to-content.md +80 -0
- package/package.json +1 -1
- package/registry/skills/build-opus-ui/SKILL.md +30 -20
- package/registry/skills/build-opus-ui/references/evaluations.md +9 -3
- package/registry/skills/build-opus-ui/references/ui-patterns.md +29 -17
- package/src/core/presentation.ts +223 -24
- package/src/core/runtime.ts +3 -0
- package/src/core/types.ts +2 -0
- package/src/mcp/index.ts +1 -0
- package/src/ui/components/patterns/action-form-card.tsx +8 -1
- package/src/ui/components/patterns/confirm.tsx +194 -157
- package/src/ui/components/patterns/content-header.tsx +17 -2
- package/src/ui/components/patterns/form-dialog.tsx +28 -14
- package/src/ui/components/patterns/form.tsx +340 -222
- package/src/ui/components/patterns/list.tsx +43 -44
- package/src/ui/components/patterns/page-heading-context.tsx +34 -0
- package/src/ui/components/patterns/page-state.tsx +2 -0
- package/src/ui/components/patterns/page.tsx +164 -50
- package/src/ui/components/patterns/presentation.tsx +140 -84
- package/src/ui/components/patterns/surface-header.tsx +5 -6
- package/src/ui/components/patterns/trigger.tsx +112 -82
- package/src/ui/components/primitives/button.tsx +2 -2
- package/src/ui/components/primitives/chat.tsx +19 -5
- package/src/ui/components/primitives/control.ts +9 -3
- package/src/ui/components/primitives/dialog.tsx +16 -9
- package/src/ui/components/primitives/drawer.tsx +9 -6
- package/src/ui/docs/content/action-form-card.md +9 -8
- package/src/ui/docs/content/action-form-dialog.md +11 -12
- package/src/ui/docs/content/action-form.md +25 -25
- package/src/ui/docs/content/action-list.md +101 -70
- package/src/ui/docs/content/chat.md +4 -4
- package/src/ui/docs/content/content.md +29 -13
- package/src/ui/docs/content/dialog.md +27 -21
- package/src/ui/docs/content/drawer.md +8 -6
- package/src/ui/docs/content/page.md +43 -50
- package/src/ui/docs/content/presentation.md +39 -28
- package/src/ui/docs/doc-client.tsx +1 -1
- package/src/ui/meta.ts +4 -4
|
@@ -17,49 +17,69 @@
|
|
|
17
17
|
* inline; toast em sucesso/erro. Emite `data-action="<action.name>"` na raiz (selector E2E).
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { createContext, useContext, useEffect, useId } from
|
|
21
|
-
import type { UseFormReturn } from
|
|
20
|
+
import { createContext, useContext, useEffect, useId } from "react";
|
|
21
|
+
import type { UseFormReturn } from "react-hook-form";
|
|
22
22
|
import {
|
|
23
23
|
getLogicalType,
|
|
24
24
|
type FieldWidget,
|
|
25
25
|
type FormContract,
|
|
26
26
|
type OptionsSpec,
|
|
27
|
-
} from
|
|
28
|
-
import {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
import {
|
|
34
|
-
import {
|
|
35
|
-
import {
|
|
36
|
-
import {
|
|
37
|
-
import {
|
|
38
|
-
import {
|
|
39
|
-
import {
|
|
40
|
-
import {
|
|
41
|
-
import {
|
|
42
|
-
import {
|
|
43
|
-
import {
|
|
27
|
+
} from "../../../core/index.ts";
|
|
28
|
+
import {
|
|
29
|
+
useDicts,
|
|
30
|
+
useFormAction,
|
|
31
|
+
type DictLike,
|
|
32
|
+
} from "../../drivers/react.tsx";
|
|
33
|
+
import { z, type ZodTypeAny } from "zod";
|
|
34
|
+
import { cn } from "../../lib/cn.ts";
|
|
35
|
+
import { humanizeActionError } from "../../lib/action-errors.ts";
|
|
36
|
+
import { objectSchemaShape } from "../../lib/object-schema.ts";
|
|
37
|
+
import { toast } from "../primitives/sonner.tsx";
|
|
38
|
+
import { Button, type ButtonVariant } from "../primitives/button.tsx";
|
|
39
|
+
import { ButtonGroup } from "../primitives/button-group.tsx";
|
|
40
|
+
import { Input } from "../primitives/input.tsx";
|
|
41
|
+
import { Textarea } from "../primitives/textarea.tsx";
|
|
42
|
+
import { Checkbox } from "../primitives/checkbox.tsx";
|
|
43
|
+
import { IconPicker } from "../primitives/icon-picker.tsx";
|
|
44
|
+
import {
|
|
45
|
+
Tooltip,
|
|
46
|
+
TooltipContent,
|
|
47
|
+
TooltipProvider,
|
|
48
|
+
TooltipTrigger,
|
|
49
|
+
} from "../primitives/tooltip.tsx";
|
|
50
|
+
import { Info } from "lucide-react";
|
|
51
|
+
import { Select, type SelectOption } from "../primitives/select.tsx";
|
|
52
|
+
import { ToggleGroup, ToggleGroupItem } from "../primitives/toggle-group.tsx";
|
|
53
|
+
import {
|
|
54
|
+
Field,
|
|
55
|
+
FieldError,
|
|
56
|
+
FieldGroup,
|
|
57
|
+
FieldLabel,
|
|
58
|
+
} from "../primitives/field.tsx";
|
|
44
59
|
|
|
45
60
|
// =============================================================================
|
|
46
61
|
// Inferência de tipo de field a partir do Zod schema
|
|
47
62
|
// =============================================================================
|
|
48
63
|
|
|
49
64
|
type FieldKind =
|
|
50
|
-
| { kind:
|
|
51
|
-
| { kind:
|
|
52
|
-
| { kind:
|
|
53
|
-
| { kind:
|
|
54
|
-
| { kind:
|
|
55
|
-
| {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
65
|
+
| { kind: "text"; required: boolean }
|
|
66
|
+
| { kind: "textarea"; required: boolean }
|
|
67
|
+
| { kind: "checkbox"; required: boolean }
|
|
68
|
+
| { kind: "select"; required: boolean; options: string[] }
|
|
69
|
+
| { kind: "multiselect"; required: boolean; options: string[] }
|
|
70
|
+
| {
|
|
71
|
+
kind: "toggle-group";
|
|
72
|
+
required: boolean;
|
|
73
|
+
multiple: boolean;
|
|
74
|
+
options: string[];
|
|
75
|
+
}
|
|
76
|
+
| { kind: "lines"; required: boolean }
|
|
77
|
+
| { kind: "refItems"; required: boolean }
|
|
78
|
+
| { kind: "icon"; required: boolean };
|
|
59
79
|
|
|
60
80
|
function unwrap(schema: ZodTypeAny): { inner: ZodTypeAny; required: boolean } {
|
|
61
|
-
let inner: ZodTypeAny = schema
|
|
62
|
-
let required = true
|
|
81
|
+
let inner: ZodTypeAny = schema;
|
|
82
|
+
let required = true;
|
|
63
83
|
// ZodOptional / ZodDefault / ZodNullable → desce no _def.innerType.
|
|
64
84
|
while (
|
|
65
85
|
inner instanceof z.ZodOptional ||
|
|
@@ -67,42 +87,46 @@ function unwrap(schema: ZodTypeAny): { inner: ZodTypeAny; required: boolean } {
|
|
|
67
87
|
inner instanceof z.ZodNullable
|
|
68
88
|
) {
|
|
69
89
|
if (inner instanceof z.ZodOptional || inner instanceof z.ZodDefault) {
|
|
70
|
-
required = false
|
|
90
|
+
required = false;
|
|
71
91
|
}
|
|
72
|
-
const def = (inner as unknown as { _def: { innerType: ZodTypeAny } })._def
|
|
73
|
-
inner = def.innerType
|
|
92
|
+
const def = (inner as unknown as { _def: { innerType: ZodTypeAny } })._def;
|
|
93
|
+
inner = def.innerType;
|
|
74
94
|
}
|
|
75
|
-
return { inner, required }
|
|
95
|
+
return { inner, required };
|
|
76
96
|
}
|
|
77
97
|
|
|
78
98
|
function inferFieldKind(schema: ZodTypeAny): FieldKind {
|
|
79
|
-
const { inner, required } = unwrap(schema)
|
|
99
|
+
const { inner, required } = unwrap(schema);
|
|
80
100
|
|
|
81
101
|
if (inner instanceof z.ZodBoolean) {
|
|
82
|
-
return { kind:
|
|
102
|
+
return { kind: "checkbox", required };
|
|
83
103
|
}
|
|
84
104
|
|
|
85
105
|
if (inner instanceof z.ZodEnum) {
|
|
86
|
-
return { kind:
|
|
106
|
+
return { kind: "select", required, options: inner.options as string[] };
|
|
87
107
|
}
|
|
88
108
|
|
|
89
109
|
// z.array(...) → multiselect (Select multiple). Opções estáticas só quando o elemento é
|
|
90
110
|
// z.enum; pra z.array(z.string()) (ids em runtime) as opções vêm de fora (spec).
|
|
91
111
|
if (inner instanceof z.ZodArray) {
|
|
92
|
-
const el = (inner._def as { type: ZodTypeAny }).type
|
|
93
|
-
return {
|
|
112
|
+
const el = (inner._def as { type: ZodTypeAny }).type;
|
|
113
|
+
return {
|
|
114
|
+
kind: "multiselect",
|
|
115
|
+
required,
|
|
116
|
+
options: el instanceof z.ZodEnum ? (el.options as string[]) : [],
|
|
117
|
+
};
|
|
94
118
|
}
|
|
95
119
|
|
|
96
120
|
if (inner instanceof z.ZodString) {
|
|
97
121
|
const maxCheck = inner._def.checks.find(
|
|
98
|
-
(c: { kind: string }) => c.kind ===
|
|
99
|
-
) as { value: number } | undefined
|
|
122
|
+
(c: { kind: string }) => c.kind === "max",
|
|
123
|
+
) as { value: number } | undefined;
|
|
100
124
|
if (maxCheck !== undefined && maxCheck.value > 200) {
|
|
101
|
-
return { kind:
|
|
125
|
+
return { kind: "textarea", required };
|
|
102
126
|
}
|
|
103
127
|
}
|
|
104
128
|
|
|
105
|
-
return { kind:
|
|
129
|
+
return { kind: "text", required };
|
|
106
130
|
}
|
|
107
131
|
|
|
108
132
|
// =============================================================================
|
|
@@ -110,10 +134,10 @@ function inferFieldKind(schema: ZodTypeAny): FieldKind {
|
|
|
110
134
|
// =============================================================================
|
|
111
135
|
|
|
112
136
|
interface FieldSpec {
|
|
113
|
-
label?: string
|
|
114
|
-
placeholder?: string
|
|
137
|
+
label?: string;
|
|
138
|
+
placeholder?: string;
|
|
115
139
|
/** Ajuda na label: ícone ⓘ + tooltip no hover. */
|
|
116
|
-
help?: string
|
|
140
|
+
help?: string;
|
|
117
141
|
/** Override explícito do tipo de campo, quando o auto-detect do Zod não basta.
|
|
118
142
|
* Honra 'textarea' (string sem max), 'code' (textarea monoespaçada, ex.: SKILL.md),
|
|
119
143
|
* 'lines' (z.array(z.string()) num textarea, um item por linha), 'refItems'
|
|
@@ -121,13 +145,13 @@ interface FieldSpec {
|
|
|
121
145
|
* `ref` vêm de fieldOptions[campo]) e 'icon' (string com o nome kebab-case da
|
|
122
146
|
* paleta da casa — renderiza o <IconPicker>) e 'toggle-group' para escolhas
|
|
123
147
|
* declarativas com opções ricas. */
|
|
124
|
-
widget?: FieldWidget
|
|
148
|
+
widget?: FieldWidget;
|
|
125
149
|
/** Renderiza o campo só quando true pro input atual (ex.: clientId só se !staff).
|
|
126
150
|
* No modo COMPOSIÇÃO o condicional pode (e deve) ser JSX de quem diagrama. */
|
|
127
|
-
showWhen?: (input: Record<string, unknown>) => boolean
|
|
151
|
+
showWhen?: (input: Record<string, unknown>) => boolean;
|
|
128
152
|
/** Origem declarada das opções (static/dictionary/lookup) — ver a precedência
|
|
129
153
|
* em `ActionFormField`. `dictionary` resolve pelos dicts do OpusProvider. */
|
|
130
|
-
options?: OptionsSpec
|
|
154
|
+
options?: OptionsSpec;
|
|
131
155
|
}
|
|
132
156
|
|
|
133
157
|
/** Opções declaradas no FieldSpec: static direto; dictionary via provider; lookup
|
|
@@ -136,39 +160,42 @@ function optionsFromSpec(
|
|
|
136
160
|
spec: OptionsSpec | undefined,
|
|
137
161
|
dicts: Record<string, DictLike>,
|
|
138
162
|
): SelectOption[] | undefined {
|
|
139
|
-
if (spec === undefined) return undefined
|
|
140
|
-
if (spec.kind ===
|
|
163
|
+
if (spec === undefined) return undefined;
|
|
164
|
+
if (spec.kind === "static") {
|
|
141
165
|
return spec.items.map((i) => ({
|
|
142
166
|
value: i.value,
|
|
143
|
-
label: typeof i.label ===
|
|
144
|
-
}))
|
|
167
|
+
label: typeof i.label === "string" ? i.label : i.label.default,
|
|
168
|
+
}));
|
|
145
169
|
}
|
|
146
|
-
if (spec.kind ===
|
|
147
|
-
const dict = dicts[spec.ref]
|
|
148
|
-
if (dict === undefined) return undefined
|
|
149
|
-
return dict.options().map((o) => ({ value: o.value, label: o.label }))
|
|
170
|
+
if (spec.kind === "dictionary") {
|
|
171
|
+
const dict = dicts[spec.ref];
|
|
172
|
+
if (dict === undefined) return undefined;
|
|
173
|
+
return dict.options().map((o) => ({ value: o.value, label: o.label }));
|
|
150
174
|
}
|
|
151
|
-
return undefined
|
|
175
|
+
return undefined;
|
|
152
176
|
}
|
|
153
177
|
|
|
154
178
|
/** Fallback zero-config: o schema do contrato CARREGA o vocabulário do `t.dict`
|
|
155
179
|
* (meta `params.keys/entries`) — labels sem registry no provider. Cobre o campo
|
|
156
180
|
* dict e o multiselect com elemento dict. */
|
|
157
181
|
function dictMetaOptions(schema: ZodTypeAny): SelectOption[] | undefined {
|
|
158
|
-
const { inner } = unwrap(schema)
|
|
159
|
-
const el =
|
|
160
|
-
|
|
161
|
-
|
|
182
|
+
const { inner } = unwrap(schema);
|
|
183
|
+
const el =
|
|
184
|
+
inner instanceof z.ZodArray
|
|
185
|
+
? (inner._def as { type: ZodTypeAny }).type
|
|
186
|
+
: inner;
|
|
187
|
+
const meta = getLogicalType(el as object);
|
|
188
|
+
if (meta?.logicalType !== "dict") return undefined;
|
|
162
189
|
const params = (meta.params ?? {}) as {
|
|
163
|
-
keys?: unknown
|
|
164
|
-
entries?: Record<string, { label?: unknown }
|
|
165
|
-
}
|
|
166
|
-
const { keys, entries } = params
|
|
167
|
-
if (!Array.isArray(keys) || entries === undefined) return undefined
|
|
190
|
+
keys?: unknown;
|
|
191
|
+
entries?: Record<string, { label?: unknown }>;
|
|
192
|
+
};
|
|
193
|
+
const { keys, entries } = params;
|
|
194
|
+
if (!Array.isArray(keys) || entries === undefined) return undefined;
|
|
168
195
|
return (keys as string[]).map((k) => {
|
|
169
|
-
const label = entries[k]?.label
|
|
170
|
-
return { value: k, label: typeof label ===
|
|
171
|
-
})
|
|
196
|
+
const label = entries[k]?.label;
|
|
197
|
+
return { value: k, label: typeof label === "string" ? label : k };
|
|
198
|
+
});
|
|
172
199
|
}
|
|
173
200
|
|
|
174
201
|
/**
|
|
@@ -182,8 +209,12 @@ function dictMetaOptions(schema: ZodTypeAny): SelectOption[] | undefined {
|
|
|
182
209
|
* o tooltip vem aberto); abre só no hover. preventDefault: clicar o ícone não dispara o label
|
|
183
210
|
* (não foca o input / não toggla).
|
|
184
211
|
*/
|
|
185
|
-
export function LabelHelp({
|
|
186
|
-
|
|
212
|
+
export function LabelHelp({
|
|
213
|
+
help,
|
|
214
|
+
}: {
|
|
215
|
+
help: string | undefined;
|
|
216
|
+
}): React.ReactElement | null {
|
|
217
|
+
if (help === undefined || help.trim().length === 0) return null;
|
|
187
218
|
return (
|
|
188
219
|
<TooltipProvider delayDuration={300}>
|
|
189
220
|
<Tooltip>
|
|
@@ -194,8 +225,8 @@ export function LabelHelp({ help }: { help: string | undefined }): React.ReactEl
|
|
|
194
225
|
aria-label="Ajuda"
|
|
195
226
|
onClick={(e) => {
|
|
196
227
|
// Clicar o ícone não ativa o label (não foca/toggla o campo).
|
|
197
|
-
e.preventDefault()
|
|
198
|
-
e.stopPropagation()
|
|
228
|
+
e.preventDefault();
|
|
229
|
+
e.stopPropagation();
|
|
199
230
|
}}
|
|
200
231
|
className="inline-flex shrink-0 cursor-help text-muted-foreground/50 transition-colors hover:text-muted-foreground"
|
|
201
232
|
>
|
|
@@ -207,12 +238,16 @@ export function LabelHelp({ help }: { help: string | undefined }): React.ReactEl
|
|
|
207
238
|
<TooltipContent>{help}</TooltipContent>
|
|
208
239
|
</Tooltip>
|
|
209
240
|
</TooltipProvider>
|
|
210
|
-
)
|
|
241
|
+
);
|
|
211
242
|
}
|
|
212
243
|
|
|
213
244
|
/** Marca de obrigatório na label — derivada do Zod (campo sem optional/default). */
|
|
214
|
-
function RequiredMark({
|
|
215
|
-
|
|
245
|
+
function RequiredMark({
|
|
246
|
+
required,
|
|
247
|
+
}: {
|
|
248
|
+
required: boolean;
|
|
249
|
+
}): React.ReactElement | null {
|
|
250
|
+
if (!required) return null;
|
|
216
251
|
return (
|
|
217
252
|
<>
|
|
218
253
|
<span aria-hidden className="ml-0.5 text-context-danger-emphasis">
|
|
@@ -220,17 +255,17 @@ function RequiredMark({ required }: { required: boolean }): React.ReactElement |
|
|
|
220
255
|
</span>
|
|
221
256
|
<span className="sr-only"> (obrigatório)</span>
|
|
222
257
|
</>
|
|
223
|
-
)
|
|
258
|
+
);
|
|
224
259
|
}
|
|
225
260
|
|
|
226
261
|
/** action.messages.* pode ser string ou I18nRef ({ key, default }). Resolve pra texto. */
|
|
227
262
|
function msgText(m: unknown, fallback: string): string {
|
|
228
|
-
if (typeof m ===
|
|
229
|
-
if (m !== null && typeof m ===
|
|
230
|
-
const d = (m as { default: unknown }).default
|
|
231
|
-
if (typeof d ===
|
|
263
|
+
if (typeof m === "string") return m;
|
|
264
|
+
if (m !== null && typeof m === "object" && "default" in m) {
|
|
265
|
+
const d = (m as { default: unknown }).default;
|
|
266
|
+
if (typeof d === "string") return d;
|
|
232
267
|
}
|
|
233
|
-
return fallback
|
|
268
|
+
return fallback;
|
|
234
269
|
}
|
|
235
270
|
|
|
236
271
|
// =============================================================================
|
|
@@ -239,13 +274,13 @@ function msgText(m: unknown, fallback: string): string {
|
|
|
239
274
|
|
|
240
275
|
export interface ActionFormContextValue {
|
|
241
276
|
/** RHF do useFormAction. O shape é dinâmico — os paths entram como string. */
|
|
242
|
-
form: UseFormReturn<Record<string, unknown
|
|
243
|
-
shape: Record<string, ZodTypeAny | undefined
|
|
244
|
-
fields: Record<string, FieldSpec
|
|
245
|
-
fieldOptions?: Record<string, SelectOption[]> | undefined
|
|
277
|
+
form: UseFormReturn<Record<string, unknown>>;
|
|
278
|
+
shape: Record<string, ZodTypeAny | undefined>;
|
|
279
|
+
fields: Record<string, FieldSpec>;
|
|
280
|
+
fieldOptions?: Record<string, SelectOption[]> | undefined;
|
|
246
281
|
}
|
|
247
282
|
|
|
248
|
-
const ActionFormContext = createContext<ActionFormContextValue | null>(null)
|
|
283
|
+
const ActionFormContext = createContext<ActionFormContextValue | null>(null);
|
|
249
284
|
|
|
250
285
|
/**
|
|
251
286
|
* O contexto do `<ActionForm>` pai — a válvula de escape pra CONTROLE CUSTOM no
|
|
@@ -256,9 +291,12 @@ const ActionFormContext = createContext<ActionFormContextValue | null>(null)
|
|
|
256
291
|
* zod-resolver, erro inline e submit continuam do contrato.
|
|
257
292
|
*/
|
|
258
293
|
export function useActionFormContext(): ActionFormContextValue {
|
|
259
|
-
const ctx = useContext(ActionFormContext)
|
|
260
|
-
if (ctx === null)
|
|
261
|
-
|
|
294
|
+
const ctx = useContext(ActionFormContext);
|
|
295
|
+
if (ctx === null)
|
|
296
|
+
throw new Error(
|
|
297
|
+
"useActionFormContext precisa estar dentro de um <ActionForm>.",
|
|
298
|
+
);
|
|
299
|
+
return ctx;
|
|
262
300
|
}
|
|
263
301
|
|
|
264
302
|
// =============================================================================
|
|
@@ -267,95 +305,109 @@ export function useActionFormContext(): ActionFormContextValue {
|
|
|
267
305
|
|
|
268
306
|
export interface ActionFormFieldProps {
|
|
269
307
|
/** Nome do campo no input do contrato (a chave em `fields`/schema). */
|
|
270
|
-
name: string
|
|
308
|
+
name: string;
|
|
271
309
|
/** Opções por id de runtime — sobrepõe o fieldOptions do form e o z.enum. */
|
|
272
|
-
options?: SelectOption[]
|
|
310
|
+
options?: SelectOption[];
|
|
273
311
|
/** Classes do invólucro do campo (ex.: col-span-2 numa grid). */
|
|
274
|
-
className?: string
|
|
312
|
+
className?: string;
|
|
275
313
|
}
|
|
276
314
|
|
|
277
|
-
export function ActionFormField({
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
315
|
+
export function ActionFormField({
|
|
316
|
+
name,
|
|
317
|
+
options: optionsProp,
|
|
318
|
+
className,
|
|
319
|
+
}: ActionFormFieldProps): React.ReactElement | null {
|
|
320
|
+
const ctx = useContext(ActionFormContext);
|
|
321
|
+
if (ctx === null)
|
|
322
|
+
throw new Error("ActionFormField precisa estar dentro de um <ActionForm>.");
|
|
323
|
+
const dicts = useDicts();
|
|
324
|
+
const { form, shape, fields, fieldOptions } = ctx;
|
|
282
325
|
// Assina TODOS os valores: alimenta o showWhen e os widgets controlados.
|
|
283
|
-
const formValues = form.watch()
|
|
326
|
+
const formValues = form.watch();
|
|
284
327
|
|
|
285
328
|
const focusControl = (): void => {
|
|
286
|
-
const control = document.getElementById(name)
|
|
287
|
-
if (control?.dataset.slot ===
|
|
288
|
-
control
|
|
289
|
-
|
|
329
|
+
const control = document.getElementById(name);
|
|
330
|
+
if (control?.dataset.slot === "toggle-group") {
|
|
331
|
+
control
|
|
332
|
+
.querySelector<HTMLElement>(
|
|
333
|
+
'[data-slot="toggle-group-item"]:not([disabled])',
|
|
334
|
+
)
|
|
335
|
+
?.focus();
|
|
336
|
+
return;
|
|
290
337
|
}
|
|
291
|
-
control?.focus()
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
const spec: FieldSpec = fields[name] ?? {}
|
|
295
|
-
const fieldError = form.formState.errors[name]
|
|
296
|
-
const errorMessage =
|
|
297
|
-
|
|
298
|
-
const
|
|
338
|
+
control?.focus();
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
const spec: FieldSpec = fields[name] ?? {};
|
|
342
|
+
const fieldError = form.formState.errors[name];
|
|
343
|
+
const errorMessage =
|
|
344
|
+
typeof fieldError?.message === "string" ? fieldError.message : undefined;
|
|
345
|
+
const errorId = errorMessage !== undefined ? `${name}-error` : undefined;
|
|
346
|
+
const describedBy = errorId;
|
|
299
347
|
useEffect(() => {
|
|
300
|
-
if (errorMessage === undefined) return
|
|
301
|
-
const control = document.getElementById(name)
|
|
348
|
+
if (errorMessage === undefined) return;
|
|
349
|
+
const control = document.getElementById(name);
|
|
302
350
|
const firstInvalidToggle = control
|
|
303
|
-
?.closest(
|
|
304
|
-
?.querySelector<HTMLElement>(
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
351
|
+
?.closest("form")
|
|
352
|
+
?.querySelector<HTMLElement>(
|
|
353
|
+
'[data-slot="toggle-group"][aria-invalid="true"]',
|
|
354
|
+
);
|
|
355
|
+
if (firstInvalidToggle?.id === name) focusControl();
|
|
356
|
+
}, [errorMessage, name]);
|
|
357
|
+
|
|
358
|
+
const fieldSchema = shape[name];
|
|
359
|
+
if (fieldSchema === undefined) return null;
|
|
360
|
+
if (typeof spec.showWhen === "function" && !spec.showWhen(formValues))
|
|
361
|
+
return null;
|
|
362
|
+
|
|
363
|
+
const inferred = inferFieldKind(fieldSchema);
|
|
313
364
|
// `widget` no FieldSpec sobrepõe o auto-detect (ex.: textarea pra string sem max).
|
|
314
365
|
const fieldKind: FieldKind =
|
|
315
|
-
spec.widget ===
|
|
316
|
-
? { kind:
|
|
317
|
-
: spec.widget ===
|
|
318
|
-
? { kind:
|
|
319
|
-
: spec.widget ===
|
|
320
|
-
? { kind:
|
|
321
|
-
: spec.widget ===
|
|
322
|
-
? { kind:
|
|
323
|
-
: spec.widget ===
|
|
366
|
+
spec.widget === "textarea" || spec.widget === "code"
|
|
367
|
+
? { kind: "textarea", required: inferred.required }
|
|
368
|
+
: spec.widget === "lines"
|
|
369
|
+
? { kind: "lines", required: inferred.required }
|
|
370
|
+
: spec.widget === "refItems"
|
|
371
|
+
? { kind: "refItems", required: inferred.required }
|
|
372
|
+
: spec.widget === "icon"
|
|
373
|
+
? { kind: "icon", required: inferred.required }
|
|
374
|
+
: spec.widget === "toggle-group"
|
|
324
375
|
? {
|
|
325
|
-
kind:
|
|
376
|
+
kind: "toggle-group",
|
|
326
377
|
required: inferred.required,
|
|
327
|
-
multiple: inferred.kind ===
|
|
378
|
+
multiple: inferred.kind === "multiselect",
|
|
328
379
|
options:
|
|
329
|
-
inferred.kind ===
|
|
380
|
+
inferred.kind === "select" ||
|
|
381
|
+
inferred.kind === "multiselect"
|
|
330
382
|
? inferred.options
|
|
331
383
|
: [],
|
|
332
384
|
}
|
|
333
|
-
: inferred
|
|
385
|
+
: inferred;
|
|
334
386
|
// Precedência das opções: prop do campo > fieldOptions do form > spec.options
|
|
335
387
|
// (dictionary via provider, static direto) > meta do t.dict no schema
|
|
336
388
|
// (zero-config) > chaves cruas do z.enum.
|
|
337
|
-
const runtimeOptions = optionsProp ?? fieldOptions?.[name]
|
|
338
|
-
const specOptions = optionsFromSpec(spec.options, dicts)
|
|
339
|
-
const declaredOptions = runtimeOptions ?? specOptions
|
|
389
|
+
const runtimeOptions = optionsProp ?? fieldOptions?.[name];
|
|
390
|
+
const specOptions = optionsFromSpec(spec.options, dicts);
|
|
391
|
+
const declaredOptions = runtimeOptions ?? specOptions;
|
|
340
392
|
const options: SelectOption[] =
|
|
341
393
|
declaredOptions ??
|
|
342
394
|
dictMetaOptions(fieldSchema) ??
|
|
343
|
-
(fieldKind.kind ===
|
|
344
|
-
fieldKind.kind ===
|
|
345
|
-
fieldKind.kind ===
|
|
395
|
+
(fieldKind.kind === "select" ||
|
|
396
|
+
fieldKind.kind === "multiselect" ||
|
|
397
|
+
fieldKind.kind === "toggle-group"
|
|
346
398
|
? fieldKind.options.map((o) => ({ value: o, label: o }))
|
|
347
|
-
: [])
|
|
399
|
+
: []);
|
|
348
400
|
// Campo texto COM opções declaradas (runtime ou spec) → single-select por-id.
|
|
349
401
|
const effectiveKind: FieldKind =
|
|
350
|
-
fieldKind.kind ===
|
|
351
|
-
? { kind:
|
|
352
|
-
: fieldKind
|
|
402
|
+
fieldKind.kind === "text" && declaredOptions !== undefined
|
|
403
|
+
? { kind: "select", required: fieldKind.required, options: [] }
|
|
404
|
+
: fieldKind;
|
|
353
405
|
|
|
354
406
|
const setValue = (v: unknown): void =>
|
|
355
|
-
form.setValue(name, v, { shouldValidate: true, shouldDirty: true })
|
|
407
|
+
form.setValue(name, v, { shouldValidate: true, shouldDirty: true });
|
|
356
408
|
|
|
357
409
|
// Checkbox tem layout próprio (controle + label na mesma linha).
|
|
358
|
-
if (fieldKind.kind ===
|
|
410
|
+
if (fieldKind.kind === "checkbox") {
|
|
359
411
|
return (
|
|
360
412
|
<Field
|
|
361
413
|
className={className}
|
|
@@ -377,9 +429,11 @@ export function ActionFormField({ name, options: optionsProp, className }: Actio
|
|
|
377
429
|
</span>
|
|
378
430
|
<LabelHelp help={spec.help} />
|
|
379
431
|
</FieldLabel>
|
|
380
|
-
{errorMessage !== undefined &&
|
|
432
|
+
{errorMessage !== undefined && (
|
|
433
|
+
<FieldError id={errorId}>{errorMessage}</FieldError>
|
|
434
|
+
)}
|
|
381
435
|
</Field>
|
|
382
|
-
)
|
|
436
|
+
);
|
|
383
437
|
}
|
|
384
438
|
|
|
385
439
|
return (
|
|
@@ -389,7 +443,12 @@ export function ActionFormField({ name, options: optionsProp, className }: Actio
|
|
|
389
443
|
aria-invalid={errorMessage !== undefined}
|
|
390
444
|
aria-describedby={describedBy}
|
|
391
445
|
>
|
|
392
|
-
<FieldLabel
|
|
446
|
+
<FieldLabel
|
|
447
|
+
id={`${name}-label`}
|
|
448
|
+
htmlFor={name}
|
|
449
|
+
onClick={focusControl}
|
|
450
|
+
className="items-center gap-1.5"
|
|
451
|
+
>
|
|
393
452
|
<span>
|
|
394
453
|
{spec.label ?? name}
|
|
395
454
|
<RequiredMark required={fieldKind.required} />
|
|
@@ -397,7 +456,7 @@ export function ActionFormField({ name, options: optionsProp, className }: Actio
|
|
|
397
456
|
<LabelHelp help={spec.help} />
|
|
398
457
|
</FieldLabel>
|
|
399
458
|
|
|
400
|
-
{effectiveKind.kind ===
|
|
459
|
+
{effectiveKind.kind === "toggle-group" ? (
|
|
401
460
|
effectiveKind.multiple ? (
|
|
402
461
|
<ToggleGroup
|
|
403
462
|
type="multiple"
|
|
@@ -420,7 +479,9 @@ export function ActionFormField({ name, options: optionsProp, className }: Actio
|
|
|
420
479
|
className="h-auto min-h-14 w-full cursor-pointer justify-start whitespace-normal p-3 text-left data-[state=on]:border-primary data-[state=on]:bg-primary/5 data-[state=on]:shadow-sm"
|
|
421
480
|
>
|
|
422
481
|
<span className="min-w-0">
|
|
423
|
-
<span className="block">
|
|
482
|
+
<span className="block">
|
|
483
|
+
{option.content ?? option.label}
|
|
484
|
+
</span>
|
|
424
485
|
{option.hint !== undefined && (
|
|
425
486
|
<span className="mt-1 block text-xs font-normal text-muted-foreground">
|
|
426
487
|
{option.hint}
|
|
@@ -434,10 +495,10 @@ export function ActionFormField({ name, options: optionsProp, className }: Actio
|
|
|
434
495
|
<ToggleGroup
|
|
435
496
|
type="single"
|
|
436
497
|
id={name}
|
|
437
|
-
value={(formValues[name] as string | undefined) ??
|
|
498
|
+
value={(formValues[name] as string | undefined) ?? ""}
|
|
438
499
|
onValueChange={(value) => {
|
|
439
|
-
if (value !==
|
|
440
|
-
setValue(value ===
|
|
500
|
+
if (value !== "" || !effectiveKind.required) {
|
|
501
|
+
setValue(value === "" ? undefined : value);
|
|
441
502
|
}
|
|
442
503
|
}}
|
|
443
504
|
variant="outline"
|
|
@@ -457,7 +518,9 @@ export function ActionFormField({ name, options: optionsProp, className }: Actio
|
|
|
457
518
|
className="h-auto min-h-14 w-full cursor-pointer justify-start whitespace-normal p-3 text-left data-[state=on]:border-primary data-[state=on]:bg-primary/5 data-[state=on]:shadow-sm"
|
|
458
519
|
>
|
|
459
520
|
<span className="min-w-0">
|
|
460
|
-
<span className="block">
|
|
521
|
+
<span className="block">
|
|
522
|
+
{option.content ?? option.label}
|
|
523
|
+
</span>
|
|
461
524
|
{option.hint !== undefined && (
|
|
462
525
|
<span className="mt-1 block text-xs font-normal text-muted-foreground">
|
|
463
526
|
{option.hint}
|
|
@@ -468,7 +531,7 @@ export function ActionFormField({ name, options: optionsProp, className }: Actio
|
|
|
468
531
|
))}
|
|
469
532
|
</ToggleGroup>
|
|
470
533
|
)
|
|
471
|
-
) : effectiveKind.kind ===
|
|
534
|
+
) : effectiveKind.kind === "multiselect" ? (
|
|
472
535
|
<Select
|
|
473
536
|
multiple
|
|
474
537
|
searchable
|
|
@@ -476,16 +539,16 @@ export function ActionFormField({ name, options: optionsProp, className }: Actio
|
|
|
476
539
|
value={(formValues[name] as string[] | undefined) ?? []}
|
|
477
540
|
onChange={(v) => setValue(v)}
|
|
478
541
|
options={options}
|
|
479
|
-
placeholder={spec.placeholder ??
|
|
542
|
+
placeholder={spec.placeholder ?? "Selecione…"}
|
|
480
543
|
className="w-full"
|
|
481
544
|
aria-invalid={errorMessage !== undefined}
|
|
482
545
|
aria-describedby={describedBy}
|
|
483
546
|
/>
|
|
484
|
-
) : effectiveKind.kind ===
|
|
547
|
+
) : effectiveKind.kind === "select" ? (
|
|
485
548
|
// w-full: campo de FORM alinha com os inputs (todos cheios) — coluna uniforme.
|
|
486
549
|
<Select
|
|
487
550
|
id={name}
|
|
488
|
-
value={(formValues[name] as string | undefined) ??
|
|
551
|
+
value={(formValues[name] as string | undefined) ?? ""}
|
|
489
552
|
onChange={(v) => setValue(v)}
|
|
490
553
|
options={options}
|
|
491
554
|
// A régua do próprio Select: lista curta e conhecida se percorre com o olho;
|
|
@@ -494,30 +557,33 @@ export function ActionFormField({ name, options: optionsProp, className }: Actio
|
|
|
494
557
|
// saber se o dicionário dele cresceu. (Veio de patch da Grand Brasil; subiu em
|
|
495
558
|
// 8.6.9 porque patch local morre calado a cada bump.)
|
|
496
559
|
searchable={options.length > 6}
|
|
497
|
-
placeholder={spec.placeholder ??
|
|
560
|
+
placeholder={spec.placeholder ?? "Selecione…"}
|
|
498
561
|
className="w-full"
|
|
499
562
|
aria-invalid={errorMessage !== undefined}
|
|
500
563
|
aria-describedby={describedBy}
|
|
501
564
|
/>
|
|
502
|
-
) : effectiveKind.kind ===
|
|
565
|
+
) : effectiveKind.kind === "lines" ? (
|
|
503
566
|
// Lista de strings num textarea: um item por linha. split/join round-trip
|
|
504
567
|
// estável (linhas vazias do meio da digitação são filtradas no servidor).
|
|
505
568
|
<Textarea
|
|
506
569
|
id={name}
|
|
507
|
-
placeholder={spec.placeholder ??
|
|
570
|
+
placeholder={spec.placeholder ?? ""}
|
|
508
571
|
rows={4}
|
|
509
572
|
className="max-h-80"
|
|
510
|
-
value={((formValues[name] as string[] | undefined) ?? []).join(
|
|
511
|
-
onChange={(e) => setValue(e.target.value.split(
|
|
573
|
+
value={((formValues[name] as string[] | undefined) ?? []).join("\n")}
|
|
574
|
+
onChange={(e) => setValue(e.target.value.split("\n"))}
|
|
512
575
|
aria-invalid={errorMessage !== undefined}
|
|
513
576
|
aria-describedby={describedBy}
|
|
514
577
|
/>
|
|
515
|
-
) : effectiveKind.kind ===
|
|
578
|
+
) : effectiveKind.kind === "refItems" ? (
|
|
516
579
|
// Lista composta {ref, text}: cada linha = select de referência (opções de
|
|
517
580
|
// fieldOptions) + texto. Estruturado de propósito — o ref nunca é texto livre.
|
|
518
581
|
(() => {
|
|
519
|
-
const items =
|
|
520
|
-
|
|
582
|
+
const items =
|
|
583
|
+
(formValues[name] as
|
|
584
|
+
Array<{ ref: string; text: string }> | undefined) ?? [];
|
|
585
|
+
const setItems = (next: Array<{ ref: string; text: string }>): void =>
|
|
586
|
+
setValue(next);
|
|
521
587
|
return (
|
|
522
588
|
<div className="space-y-2">
|
|
523
589
|
{items.map((item, i) => (
|
|
@@ -525,7 +591,13 @@ export function ActionFormField({ name, options: optionsProp, className }: Actio
|
|
|
525
591
|
<Select
|
|
526
592
|
id={i === 0 ? name : `${name}-${i}-ref`}
|
|
527
593
|
value={item.ref}
|
|
528
|
-
onChange={(v) =>
|
|
594
|
+
onChange={(v) =>
|
|
595
|
+
setItems(
|
|
596
|
+
items.map((it, j) =>
|
|
597
|
+
j === i ? { ...it, ref: v } : it,
|
|
598
|
+
),
|
|
599
|
+
)
|
|
600
|
+
}
|
|
529
601
|
options={options}
|
|
530
602
|
placeholder="Papel…"
|
|
531
603
|
className="w-44 shrink-0"
|
|
@@ -535,8 +607,14 @@ export function ActionFormField({ name, options: optionsProp, className }: Actio
|
|
|
535
607
|
<Input
|
|
536
608
|
id={`${name}-${i}-text`}
|
|
537
609
|
value={item.text}
|
|
538
|
-
placeholder={spec.placeholder ??
|
|
539
|
-
onChange={(e) =>
|
|
610
|
+
placeholder={spec.placeholder ?? "Descreva o critério…"}
|
|
611
|
+
onChange={(e) =>
|
|
612
|
+
setItems(
|
|
613
|
+
items.map((it, j) =>
|
|
614
|
+
j === i ? { ...it, text: e.target.value } : it,
|
|
615
|
+
),
|
|
616
|
+
)
|
|
617
|
+
}
|
|
540
618
|
aria-invalid={errorMessage !== undefined}
|
|
541
619
|
aria-describedby={describedBy}
|
|
542
620
|
/>
|
|
@@ -557,33 +635,39 @@ export function ActionFormField({ name, options: optionsProp, className }: Actio
|
|
|
557
635
|
variant="outline"
|
|
558
636
|
size="sm"
|
|
559
637
|
id={items.length === 0 ? name : undefined}
|
|
560
|
-
onClick={() => setItems([...items, { ref:
|
|
561
|
-
aria-invalid={
|
|
638
|
+
onClick={() => setItems([...items, { ref: "", text: "" }])}
|
|
639
|
+
aria-invalid={
|
|
640
|
+
items.length === 0 && errorMessage !== undefined
|
|
641
|
+
? true
|
|
642
|
+
: undefined
|
|
643
|
+
}
|
|
562
644
|
aria-describedby={items.length === 0 ? describedBy : undefined}
|
|
563
645
|
>
|
|
564
646
|
Adicionar item
|
|
565
647
|
</Button>
|
|
566
648
|
</div>
|
|
567
|
-
)
|
|
649
|
+
);
|
|
568
650
|
})()
|
|
569
|
-
) : effectiveKind.kind ===
|
|
651
|
+
) : effectiveKind.kind === "icon" ? (
|
|
570
652
|
// Widget 'icon': o value é o NOME do ícone (kebab-case) — ver <IconPicker>.
|
|
571
653
|
<IconPicker
|
|
572
654
|
id={name}
|
|
573
|
-
value={(formValues[name] as string | undefined) ??
|
|
655
|
+
value={(formValues[name] as string | undefined) ?? ""}
|
|
574
656
|
onChange={(v) => setValue(v)}
|
|
575
|
-
placeholder={spec.placeholder ??
|
|
657
|
+
placeholder={spec.placeholder ?? "Selecione um ícone…"}
|
|
576
658
|
aria-invalid={errorMessage !== undefined}
|
|
577
659
|
aria-describedby={describedBy}
|
|
578
660
|
/>
|
|
579
|
-
) : effectiveKind.kind ===
|
|
661
|
+
) : effectiveKind.kind === "textarea" ? (
|
|
580
662
|
// max-h: o Textarea v4 auto-cresce (field-sizing-content) — capa e scrolla
|
|
581
663
|
// o CAMPO, não o form/dialog inteiro. widget 'code' = monoespaçado (SKILL.md).
|
|
582
664
|
<Textarea
|
|
583
665
|
id={name}
|
|
584
|
-
placeholder={spec.placeholder ??
|
|
666
|
+
placeholder={spec.placeholder ?? ""}
|
|
585
667
|
rows={5}
|
|
586
|
-
className={
|
|
668
|
+
className={
|
|
669
|
+
spec.widget === "code" ? "max-h-80 font-mono text-xs" : "max-h-80"
|
|
670
|
+
}
|
|
587
671
|
aria-invalid={errorMessage !== undefined}
|
|
588
672
|
aria-describedby={describedBy}
|
|
589
673
|
{...form.register(name)}
|
|
@@ -591,7 +675,7 @@ export function ActionFormField({ name, options: optionsProp, className }: Actio
|
|
|
591
675
|
) : (
|
|
592
676
|
<Input
|
|
593
677
|
id={name}
|
|
594
|
-
placeholder={spec.placeholder ??
|
|
678
|
+
placeholder={spec.placeholder ?? ""}
|
|
595
679
|
aria-invalid={errorMessage !== undefined}
|
|
596
680
|
aria-describedby={describedBy}
|
|
597
681
|
{...form.register(name)}
|
|
@@ -602,47 +686,53 @@ export function ActionFormField({ name, options: optionsProp, className }: Actio
|
|
|
602
686
|
<FieldError id={errorId}>{errorMessage}</FieldError>
|
|
603
687
|
)}
|
|
604
688
|
</Field>
|
|
605
|
-
)
|
|
689
|
+
);
|
|
606
690
|
}
|
|
607
691
|
|
|
608
692
|
// =============================================================================
|
|
609
693
|
// ActionForm
|
|
610
694
|
// =============================================================================
|
|
611
695
|
|
|
612
|
-
export interface ActionFormProps<
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
696
|
+
export interface ActionFormProps<
|
|
697
|
+
TInput extends Record<string, unknown>,
|
|
698
|
+
TData,
|
|
699
|
+
> {
|
|
700
|
+
action: FormContract<TInput, TData>;
|
|
701
|
+
defaultValues?: Partial<TInput>;
|
|
702
|
+
onSuccess?: (data: TData) => void;
|
|
703
|
+
submitLabel?: string;
|
|
704
|
+
cancelLabel?: string;
|
|
705
|
+
/** Tratamento visual do cancelamento. Use outline quando ele dividir o footer com a ação principal. */
|
|
706
|
+
cancelVariant?: Extract<ButtonVariant, "ghost" | "outline">;
|
|
707
|
+
onCancel?: () => void;
|
|
619
708
|
/** Bloqueia campos e ações sem desmontar o formulário. */
|
|
620
|
-
disabled?: boolean
|
|
709
|
+
disabled?: boolean;
|
|
621
710
|
/** Informa carregamento para superfícies que coordenam ações como um grupo. */
|
|
622
|
-
onLoadingChange?: (loading: boolean) => void
|
|
711
|
+
onLoadingChange?: (loading: boolean) => void;
|
|
623
712
|
/** Opções por campo pra select/multiselect carregados em runtime (ex.: papéis/skills
|
|
624
713
|
* por id). Sobrepõe as opções estáticas inferidas do z.enum. Chave = nome do campo. */
|
|
625
|
-
fieldOptions?: Record<string, SelectOption[]
|
|
714
|
+
fieldOptions?: Record<string, SelectOption[]>;
|
|
626
715
|
/** Classes do <form> (layout externo — ex.: coluna flex no dialog). */
|
|
627
|
-
className?: string
|
|
716
|
+
className?: string;
|
|
628
717
|
/** Slot do CORPO: recebe os campos montados e decide o invólucro. Default: inline. No
|
|
629
718
|
* dialog, o ActionFormDialog injeta `DialogBody` (a área que rola). */
|
|
630
|
-
body?: (fields: React.ReactNode) => React.ReactNode
|
|
719
|
+
body?: (fields: React.ReactNode) => React.ReactNode;
|
|
631
720
|
/** Slot do RODAPÉ: recebe os botões (Cancelar/Salvar) — com o estado do form e DENTRO
|
|
632
721
|
* do <form> — e decide o invólucro. Default: faixa de ação simples. No dialog, o
|
|
633
722
|
* ActionFormDialog injeta `DialogFooter` (a faixa da casa). */
|
|
634
|
-
footer?: (actions: React.ReactNode) => React.ReactNode
|
|
723
|
+
footer?: (actions: React.ReactNode) => React.ReactNode;
|
|
635
724
|
/** Modo COMPOSIÇÃO: diagrame os campos com <ActionFormField name/> (grid, seções,
|
|
636
725
|
* condicionais em JSX). Sem children, o modo AUTO monta todos na ordem do contrato. */
|
|
637
|
-
children?: React.ReactNode
|
|
726
|
+
children?: React.ReactNode;
|
|
638
727
|
}
|
|
639
728
|
|
|
640
729
|
export function ActionForm<TInput extends Record<string, unknown>, TData>({
|
|
641
730
|
action,
|
|
642
731
|
defaultValues,
|
|
643
732
|
onSuccess,
|
|
644
|
-
submitLabel =
|
|
645
|
-
cancelLabel =
|
|
733
|
+
submitLabel = "Salvar",
|
|
734
|
+
cancelLabel = "Cancelar",
|
|
735
|
+
cancelVariant = "ghost",
|
|
646
736
|
onCancel,
|
|
647
737
|
disabled = false,
|
|
648
738
|
onLoadingChange,
|
|
@@ -652,35 +742,53 @@ export function ActionForm<TInput extends Record<string, unknown>, TData>({
|
|
|
652
742
|
footer,
|
|
653
743
|
children,
|
|
654
744
|
}: ActionFormProps<TInput, TData>) {
|
|
655
|
-
const formId = useId()
|
|
656
|
-
const { form, submit, isLoading, error, isSuccess } = useFormAction<
|
|
657
|
-
|
|
745
|
+
const formId = useId();
|
|
746
|
+
const { form, submit, isLoading, error, isSuccess } = useFormAction<
|
|
747
|
+
TInput,
|
|
748
|
+
TData
|
|
749
|
+
>(action, {
|
|
750
|
+
...(defaultValues !== undefined
|
|
751
|
+
? { defaultValues: defaultValues as never }
|
|
752
|
+
: {}),
|
|
658
753
|
onSuccess: (data) => {
|
|
659
|
-
toast.success(msgText(action.messages?.success,
|
|
660
|
-
onSuccess?.(data)
|
|
754
|
+
toast.success(msgText(action.messages?.success, "Concluído"));
|
|
755
|
+
onSuccess?.(data);
|
|
661
756
|
},
|
|
662
757
|
onError: (err) => {
|
|
663
|
-
toast.error(
|
|
758
|
+
toast.error(
|
|
759
|
+
humanizeActionError(
|
|
760
|
+
err,
|
|
761
|
+
msgText(action.messages?.error, "Não foi possível salvar."),
|
|
762
|
+
),
|
|
763
|
+
);
|
|
664
764
|
},
|
|
665
|
-
})
|
|
765
|
+
});
|
|
666
766
|
|
|
667
767
|
useEffect(() => {
|
|
668
|
-
onLoadingChange?.(isLoading)
|
|
669
|
-
return () => onLoadingChange?.(false)
|
|
670
|
-
}, [isLoading, onLoadingChange])
|
|
768
|
+
onLoadingChange?.(isLoading);
|
|
769
|
+
return () => onLoadingChange?.(false);
|
|
770
|
+
}, [isLoading, onLoadingChange]);
|
|
671
771
|
|
|
672
|
-
const shape = (objectSchemaShape(action.input) ?? {}) as Record<
|
|
673
|
-
|
|
772
|
+
const shape = (objectSchemaShape(action.input) ?? {}) as Record<
|
|
773
|
+
string,
|
|
774
|
+
ZodTypeAny | undefined
|
|
775
|
+
>;
|
|
776
|
+
const fields = action.fields as Record<string, FieldSpec>;
|
|
674
777
|
|
|
675
778
|
// Corpo e rodapé como SLOTS: por padrão renderizam inline; o ActionFormDialog injeta
|
|
676
779
|
// DialogBody/DialogFooter (fonte única do scroll e da faixa). Os botões ficam DENTRO do
|
|
677
780
|
// <form> nos dois casos — o submit exige isso; o que muda é só o invólucro.
|
|
678
|
-
const wrapBody = body ?? ((fields: React.ReactNode) => fields)
|
|
781
|
+
const wrapBody = body ?? ((fields: React.ReactNode) => fields);
|
|
679
782
|
const wrapFooter =
|
|
680
783
|
footer ??
|
|
681
|
-
((actions: React.ReactNode) =>
|
|
682
|
-
|
|
683
|
-
|
|
784
|
+
((actions: React.ReactNode) =>
|
|
785
|
+
onCancel === undefined ? (
|
|
786
|
+
<div className="mt-6 flex shrink-0 justify-end">{actions}</div>
|
|
787
|
+
) : (
|
|
788
|
+
<ButtonGroup mode="spaced" className="mt-6 shrink-0 justify-end">
|
|
789
|
+
{actions}
|
|
790
|
+
</ButtonGroup>
|
|
791
|
+
));
|
|
684
792
|
|
|
685
793
|
// Banner de erro do servidor (chrome do próprio form, nos dois modos). O FieldGroup
|
|
686
794
|
// mantém o mesmo ritmo entre campos, blocos compostos e este estado. Só a frase de
|
|
@@ -691,9 +799,12 @@ export function ActionForm<TInput extends Record<string, unknown>, TData>({
|
|
|
691
799
|
role="alert"
|
|
692
800
|
className="rounded-md border border-context-danger-border bg-context-danger-subtle p-3 text-sm text-context-danger-emphasis"
|
|
693
801
|
>
|
|
694
|
-
{humanizeActionError(
|
|
802
|
+
{humanizeActionError(
|
|
803
|
+
error,
|
|
804
|
+
msgText(action.messages?.error, "Não foi possível salvar."),
|
|
805
|
+
)}
|
|
695
806
|
</div>
|
|
696
|
-
) : null
|
|
807
|
+
) : null;
|
|
697
808
|
|
|
698
809
|
return (
|
|
699
810
|
<ActionFormContext.Provider
|
|
@@ -704,13 +815,20 @@ export function ActionForm<TInput extends Record<string, unknown>, TData>({
|
|
|
704
815
|
fieldOptions,
|
|
705
816
|
}}
|
|
706
817
|
>
|
|
707
|
-
<form
|
|
818
|
+
<form
|
|
819
|
+
id={formId}
|
|
820
|
+
onSubmit={submit}
|
|
821
|
+
className={cn("flex flex-col", className)}
|
|
822
|
+
data-action={action.name}
|
|
823
|
+
>
|
|
708
824
|
{wrapBody(
|
|
709
825
|
<fieldset disabled={disabled || isLoading} className="contents">
|
|
710
826
|
<FieldGroup>
|
|
711
827
|
{children !== undefined
|
|
712
828
|
? children
|
|
713
|
-
: Object.keys(fields).map((name) =>
|
|
829
|
+
: Object.keys(fields).map((name) => (
|
|
830
|
+
<ActionFormField key={name} name={name} />
|
|
831
|
+
))}
|
|
714
832
|
{errorBanner}
|
|
715
833
|
</FieldGroup>
|
|
716
834
|
</fieldset>,
|
|
@@ -722,7 +840,7 @@ export function ActionForm<TInput extends Record<string, unknown>, TData>({
|
|
|
722
840
|
<Button
|
|
723
841
|
type="button"
|
|
724
842
|
form={formId}
|
|
725
|
-
variant=
|
|
843
|
+
variant={cancelVariant}
|
|
726
844
|
disabled={disabled || isLoading}
|
|
727
845
|
onClick={onCancel}
|
|
728
846
|
>
|
|
@@ -743,5 +861,5 @@ export function ActionForm<TInput extends Record<string, unknown>, TData>({
|
|
|
743
861
|
)}
|
|
744
862
|
</form>
|
|
745
863
|
</ActionFormContext.Provider>
|
|
746
|
-
)
|
|
864
|
+
);
|
|
747
865
|
}
|