@softize/opus 17.2.0 → 18.0.1
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 +62 -1
- package/bin/lib/check.mjs +212 -45
- package/docs/adr/0010-page-header-owns-page-chrome.md +2 -2
- 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/0015-action-size-follows-interaction-density.md +1 -1
- 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 +30 -18
- 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 +165 -51
- 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 +113 -83
- 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/action-trigger.md +2 -2
- package/src/ui/docs/content/button.md +1 -1
- 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
|
@@ -19,8 +19,8 @@
|
|
|
19
19
|
* de cada tipo (void, `false`, `null`).
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import * as React from
|
|
23
|
-
import type { UiContext } from
|
|
22
|
+
import * as React from "react";
|
|
23
|
+
import type { UiContext } from "../../../core/ui-context.ts";
|
|
24
24
|
import {
|
|
25
25
|
Dialog,
|
|
26
26
|
DialogBody,
|
|
@@ -30,19 +30,24 @@ import {
|
|
|
30
30
|
DialogHeader,
|
|
31
31
|
DialogMedia,
|
|
32
32
|
DialogTitle,
|
|
33
|
-
} from
|
|
34
|
-
import {
|
|
35
|
-
|
|
33
|
+
} from "../primitives/dialog.tsx";
|
|
34
|
+
import {
|
|
35
|
+
Button,
|
|
36
|
+
type ButtonContext,
|
|
37
|
+
type ButtonVariant,
|
|
38
|
+
} from "../primitives/button.tsx";
|
|
39
|
+
import { Input } from "../primitives/input.tsx";
|
|
40
|
+
import { ButtonGroup } from "../primitives/button-group.tsx";
|
|
36
41
|
|
|
37
42
|
/** Campos comuns aos quatro modos imperativos. */
|
|
38
43
|
interface DialogBase {
|
|
39
|
-
title: React.ReactNode
|
|
44
|
+
title: React.ReactNode;
|
|
40
45
|
/** Ícone grande acima do título (o quadro do DialogMedia). */
|
|
41
|
-
media?: React.ReactNode
|
|
46
|
+
media?: React.ReactNode;
|
|
42
47
|
/** Conteúdo próprio entre a descrição e os botões (lista, detalhe destacado). */
|
|
43
|
-
body?: React.ReactNode
|
|
48
|
+
body?: React.ReactNode;
|
|
44
49
|
/** Rótulo do botão que confirma. */
|
|
45
|
-
action?: string
|
|
50
|
+
action?: string;
|
|
46
51
|
}
|
|
47
52
|
|
|
48
53
|
/** `alert` — reconhecimento obrigatório, um botão só. */
|
|
@@ -50,276 +55,308 @@ export interface AlertOptions extends DialogBase {}
|
|
|
50
55
|
|
|
51
56
|
/** `confirm` — pergunta de sim/não. */
|
|
52
57
|
export interface ConfirmOptions extends DialogBase {
|
|
53
|
-
cancel?: string
|
|
58
|
+
cancel?: string;
|
|
54
59
|
/** Contexto do botão de confirmação. */
|
|
55
|
-
context?: Extract<UiContext,
|
|
60
|
+
context?: Extract<UiContext, "primary" | "danger">;
|
|
56
61
|
}
|
|
57
62
|
|
|
58
63
|
/** `prompt` — uma string livre. Form de verdade (campos, contrato) → ActionFormDialog. */
|
|
59
64
|
export interface PromptOptions extends DialogBase {
|
|
60
|
-
cancel?: string
|
|
61
|
-
context?: Extract<UiContext,
|
|
62
|
-
placeholder?: string
|
|
63
|
-
defaultValue?: string
|
|
65
|
+
cancel?: string;
|
|
66
|
+
context?: Extract<UiContext, "primary" | "danger">;
|
|
67
|
+
placeholder?: string;
|
|
68
|
+
defaultValue?: string;
|
|
64
69
|
}
|
|
65
70
|
|
|
66
71
|
export interface DialogChoice<TResult extends string = string> {
|
|
67
72
|
/** Valor devolvido quando esta ação for escolhida. Deve ser único na coleção. */
|
|
68
|
-
result: TResult
|
|
69
|
-
label: React.ReactNode
|
|
70
|
-
context?: ButtonContext
|
|
71
|
-
variant?: ButtonVariant
|
|
72
|
-
initialFocus?: boolean
|
|
73
|
-
disabled?: boolean
|
|
73
|
+
result: TResult;
|
|
74
|
+
label: React.ReactNode;
|
|
75
|
+
context?: ButtonContext;
|
|
76
|
+
variant?: ButtonVariant;
|
|
77
|
+
initialFocus?: boolean;
|
|
78
|
+
disabled?: boolean;
|
|
74
79
|
}
|
|
75
80
|
|
|
76
81
|
/** `choose` — uma escolha simples entre resultados explícitos. */
|
|
77
|
-
export interface ChooseOptions<TResult extends string = string> extends Omit<
|
|
78
|
-
|
|
82
|
+
export interface ChooseOptions<TResult extends string = string> extends Omit<
|
|
83
|
+
DialogBase,
|
|
84
|
+
"action"
|
|
85
|
+
> {
|
|
86
|
+
actions: readonly DialogChoice<TResult>[];
|
|
79
87
|
}
|
|
80
88
|
|
|
81
89
|
type Pending =
|
|
82
|
-
| ({ kind:
|
|
90
|
+
| ({ kind: "alert"; id: number; resolve: () => void } & AlertOptions)
|
|
83
91
|
| ({
|
|
84
|
-
kind:
|
|
85
|
-
id: number
|
|
86
|
-
resolve: (ok: boolean) => void
|
|
92
|
+
kind: "confirm";
|
|
93
|
+
id: number;
|
|
94
|
+
resolve: (ok: boolean) => void;
|
|
87
95
|
} & ConfirmOptions)
|
|
88
96
|
| ({
|
|
89
|
-
kind:
|
|
90
|
-
id: number
|
|
91
|
-
resolve: (value: string | null) => void
|
|
97
|
+
kind: "prompt";
|
|
98
|
+
id: number;
|
|
99
|
+
resolve: (value: string | null) => void;
|
|
92
100
|
} & PromptOptions)
|
|
93
101
|
| ({
|
|
94
|
-
kind:
|
|
95
|
-
id: number
|
|
96
|
-
resolve: (result: string | null) => void
|
|
97
|
-
} & ChooseOptions)
|
|
102
|
+
kind: "choose";
|
|
103
|
+
id: number;
|
|
104
|
+
resolve: (result: string | null) => void;
|
|
105
|
+
} & ChooseOptions);
|
|
98
106
|
|
|
99
|
-
let nextId = 0
|
|
100
|
-
let listener: ((p: Pending) => void) | null = null
|
|
107
|
+
let nextId = 0;
|
|
108
|
+
let listener: ((p: Pending) => void) | null = null;
|
|
101
109
|
|
|
102
110
|
/** Sem `<DialogHost />` montado, LANÇA em vez de deixar a promise pendurada (clique sem
|
|
103
111
|
* efeito é o pior desfecho de uma interrupção que exige resposta). */
|
|
104
112
|
function requireHost(fn: string): Error | null {
|
|
105
|
-
if (listener !== null) return null
|
|
113
|
+
if (listener !== null) return null;
|
|
106
114
|
return new Error(
|
|
107
115
|
`${fn} foi chamado sem <DialogHost /> montado — ponha o host no shell do app (ao lado do <Toaster />).`,
|
|
108
|
-
)
|
|
116
|
+
);
|
|
109
117
|
}
|
|
110
118
|
|
|
111
|
-
function validateChoose<TResult extends string>(
|
|
119
|
+
function validateChoose<TResult extends string>(
|
|
120
|
+
options: ChooseOptions<TResult>,
|
|
121
|
+
): Error | null {
|
|
112
122
|
if (options.actions.length === 0) {
|
|
113
|
-
return new Error(
|
|
123
|
+
return new Error("dialog.choose() exige pelo menos uma ação.");
|
|
114
124
|
}
|
|
115
|
-
const focused = options.actions.filter(
|
|
125
|
+
const focused = options.actions.filter(
|
|
126
|
+
(action) => action.initialFocus === true,
|
|
127
|
+
);
|
|
116
128
|
if (focused.length !== 1) {
|
|
117
|
-
return new Error(
|
|
129
|
+
return new Error("dialog.choose() exige uma única ação com initialFocus.");
|
|
118
130
|
}
|
|
119
131
|
if (focused[0]?.disabled === true) {
|
|
120
|
-
return new Error(
|
|
132
|
+
return new Error(
|
|
133
|
+
"dialog.choose() não permite initialFocus em uma ação desabilitada.",
|
|
134
|
+
);
|
|
121
135
|
}
|
|
122
|
-
if (
|
|
123
|
-
|
|
136
|
+
if (
|
|
137
|
+
new Set(options.actions.map((action) => action.result)).size !==
|
|
138
|
+
options.actions.length
|
|
139
|
+
) {
|
|
140
|
+
return new Error("dialog.choose() exige resultados únicos.");
|
|
124
141
|
}
|
|
125
|
-
return null
|
|
142
|
+
return null;
|
|
126
143
|
}
|
|
127
144
|
|
|
128
145
|
export const dialog = {
|
|
129
146
|
/** Avisa e espera o "OK". `Promise<void>` — só reconhecimento, sem decisão. */
|
|
130
147
|
alert(options: AlertOptions): Promise<void> {
|
|
131
|
-
const err = requireHost(
|
|
132
|
-
if (err !== null) return Promise.reject(err)
|
|
148
|
+
const err = requireHost("dialog.alert()");
|
|
149
|
+
if (err !== null) return Promise.reject(err);
|
|
133
150
|
return new Promise<void>((resolve) => {
|
|
134
|
-
listener?.({ ...options, kind:
|
|
135
|
-
})
|
|
151
|
+
listener?.({ ...options, kind: "alert", id: nextId++, resolve });
|
|
152
|
+
});
|
|
136
153
|
},
|
|
137
154
|
/** Pergunta sim/não. `Promise<boolean>` — fechar/ESC resolve `false`. */
|
|
138
155
|
confirm(options: ConfirmOptions): Promise<boolean> {
|
|
139
|
-
const err = requireHost(
|
|
140
|
-
if (err !== null) return Promise.reject(err)
|
|
156
|
+
const err = requireHost("dialog.confirm()");
|
|
157
|
+
if (err !== null) return Promise.reject(err);
|
|
141
158
|
return new Promise<boolean>((resolve) => {
|
|
142
|
-
listener?.({ ...options, kind:
|
|
143
|
-
})
|
|
159
|
+
listener?.({ ...options, kind: "confirm", id: nextId++, resolve });
|
|
160
|
+
});
|
|
144
161
|
},
|
|
145
162
|
/** Pede uma string. `Promise<string | null>` — fechar/ESC/Cancelar resolve `null`. */
|
|
146
163
|
prompt(options: PromptOptions): Promise<string | null> {
|
|
147
|
-
const err = requireHost(
|
|
148
|
-
if (err !== null) return Promise.reject(err)
|
|
164
|
+
const err = requireHost("dialog.prompt()");
|
|
165
|
+
if (err !== null) return Promise.reject(err);
|
|
149
166
|
return new Promise<string | null>((resolve) => {
|
|
150
|
-
listener?.({ ...options, kind:
|
|
151
|
-
})
|
|
167
|
+
listener?.({ ...options, kind: "prompt", id: nextId++, resolve });
|
|
168
|
+
});
|
|
152
169
|
},
|
|
153
170
|
/** Oferece resultados explícitos. Fechar/ESC sem escolher resolve `null`. */
|
|
154
|
-
choose<const TResult extends string>(
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
const
|
|
158
|
-
if (
|
|
171
|
+
choose<const TResult extends string>(
|
|
172
|
+
options: ChooseOptions<TResult>,
|
|
173
|
+
): Promise<TResult | null> {
|
|
174
|
+
const invalid = validateChoose(options);
|
|
175
|
+
if (invalid !== null) return Promise.reject(invalid);
|
|
176
|
+
const err = requireHost("dialog.choose()");
|
|
177
|
+
if (err !== null) return Promise.reject(err);
|
|
159
178
|
return new Promise<TResult | null>((resolve) => {
|
|
160
179
|
listener?.({
|
|
161
180
|
...options,
|
|
162
|
-
kind:
|
|
181
|
+
kind: "choose",
|
|
163
182
|
id: nextId++,
|
|
164
183
|
resolve: (result) => resolve(result as TResult | null),
|
|
165
|
-
})
|
|
166
|
-
})
|
|
184
|
+
});
|
|
185
|
+
});
|
|
167
186
|
},
|
|
168
|
-
}
|
|
187
|
+
};
|
|
169
188
|
|
|
170
189
|
/**
|
|
171
190
|
* O host — um por app, no shell. Enfileira: uma por vez (duas caixas empilhadas seriam
|
|
172
191
|
* ambíguas sobre qual clique respondeu o quê).
|
|
173
192
|
*/
|
|
174
193
|
export function DialogHost(): React.ReactElement {
|
|
175
|
-
const [queue, setQueue] = React.useState<Pending[]>([])
|
|
176
|
-
const current = queue[0]
|
|
177
|
-
const [text, setText] = React.useState(
|
|
194
|
+
const [queue, setQueue] = React.useState<Pending[]>([]);
|
|
195
|
+
const current = queue[0];
|
|
196
|
+
const [text, setText] = React.useState("");
|
|
178
197
|
|
|
179
198
|
React.useEffect(() => {
|
|
180
|
-
listener = (p) => setQueue((q) => [...q, p])
|
|
199
|
+
listener = (p) => setQueue((q) => [...q, p]);
|
|
181
200
|
return () => {
|
|
182
|
-
listener = null
|
|
183
|
-
}
|
|
184
|
-
}, [])
|
|
201
|
+
listener = null;
|
|
202
|
+
};
|
|
203
|
+
}, []);
|
|
185
204
|
|
|
186
205
|
// Reseta o input quando a pergunta corrente troca (defaultValue do prompt, ou vazio).
|
|
187
206
|
React.useEffect(() => {
|
|
188
|
-
setText(
|
|
189
|
-
|
|
207
|
+
setText(
|
|
208
|
+
current !== undefined && current.kind === "prompt"
|
|
209
|
+
? (current.defaultValue ?? "")
|
|
210
|
+
: "",
|
|
211
|
+
);
|
|
212
|
+
}, [current?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
190
213
|
|
|
191
214
|
// Resolve a pergunta `id` — e SÓ ela. A 2ª chamada pro mesmo id (clique + o fechamento
|
|
192
215
|
// que o Radix dispara em seguida) é inócua: a promise já resolveu e a fila já andou.
|
|
193
216
|
const drop = (id: number, apply: (p: Pending) => void): void => {
|
|
194
217
|
setQueue((q) => {
|
|
195
|
-
if (q[0]?.id !== id) return q
|
|
196
|
-
apply(q[0])
|
|
197
|
-
return q.slice(1)
|
|
198
|
-
})
|
|
199
|
-
}
|
|
218
|
+
if (q[0]?.id !== id) return q;
|
|
219
|
+
apply(q[0]);
|
|
220
|
+
return q.slice(1);
|
|
221
|
+
});
|
|
222
|
+
};
|
|
200
223
|
// Fechar É responder "não" — o "não" de cada tipo.
|
|
201
224
|
const cancel = (p: Pending): void => {
|
|
202
|
-
if (p.kind ===
|
|
203
|
-
else if (p.kind ===
|
|
204
|
-
else if (p.kind ===
|
|
205
|
-
else p.resolve(null)
|
|
206
|
-
}
|
|
225
|
+
if (p.kind === "alert") p.resolve();
|
|
226
|
+
else if (p.kind === "confirm") p.resolve(false);
|
|
227
|
+
else if (p.kind === "prompt") p.resolve(null);
|
|
228
|
+
else p.resolve(null);
|
|
229
|
+
};
|
|
207
230
|
const accept = (p: Pending, value: string): void => {
|
|
208
|
-
if (p.kind ===
|
|
209
|
-
else if (p.kind ===
|
|
210
|
-
else p.resolve(value)
|
|
211
|
-
}
|
|
231
|
+
if (p.kind === "alert") p.resolve();
|
|
232
|
+
else if (p.kind === "confirm") p.resolve(true);
|
|
233
|
+
else p.resolve(value);
|
|
234
|
+
};
|
|
212
235
|
|
|
213
236
|
const context =
|
|
214
|
-
current !== undefined &&
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
const
|
|
237
|
+
current !== undefined &&
|
|
238
|
+
(current.kind === "confirm" || current.kind === "prompt")
|
|
239
|
+
? (current.context ?? "primary")
|
|
240
|
+
: "primary";
|
|
241
|
+
const defaultAction = current?.kind === "alert" ? "OK" : "Confirmar";
|
|
242
|
+
const bodyId =
|
|
243
|
+
current === undefined ? undefined : `dialog-body-${current.id}`;
|
|
219
244
|
const bodyDescribesDialog =
|
|
220
|
-
current !== undefined &&
|
|
245
|
+
current !== undefined &&
|
|
246
|
+
(typeof current.body === "string" || typeof current.body === "number");
|
|
221
247
|
|
|
222
248
|
return (
|
|
223
249
|
<Dialog
|
|
224
250
|
mode="alert"
|
|
225
251
|
open={current !== undefined}
|
|
226
252
|
onOpenChange={(open) => {
|
|
227
|
-
if (!open && current !== undefined) drop(current.id, cancel)
|
|
253
|
+
if (!open && current !== undefined) drop(current.id, cancel);
|
|
228
254
|
}}
|
|
229
255
|
>
|
|
230
256
|
{current !== undefined && (
|
|
231
257
|
<DialogContent
|
|
232
258
|
data-slot="dialog"
|
|
233
259
|
data-kind={current.kind}
|
|
234
|
-
className={current.kind ===
|
|
260
|
+
className={current.kind === "choose" ? "max-w-lg" : undefined}
|
|
235
261
|
aria-describedby={bodyDescribesDialog ? bodyId : undefined}
|
|
236
262
|
// Prompt: o foco vai pro input, não pro Cancelar (default do Dialog). Via
|
|
237
263
|
// querySelector (não ref) — o Input é function component sem forwardRef, e isto
|
|
238
264
|
// funciona igual em React 18 e 19.
|
|
239
265
|
onOpenAutoFocus={
|
|
240
|
-
current.kind ===
|
|
266
|
+
current.kind === "prompt"
|
|
241
267
|
? (e) => {
|
|
242
|
-
e.preventDefault()
|
|
243
|
-
const root =
|
|
244
|
-
|
|
245
|
-
el
|
|
246
|
-
|
|
268
|
+
e.preventDefault();
|
|
269
|
+
const root =
|
|
270
|
+
(e.currentTarget as HTMLElement | null) ?? document;
|
|
271
|
+
const el = root.querySelector<HTMLInputElement>(
|
|
272
|
+
'[data-slot="input"]',
|
|
273
|
+
);
|
|
274
|
+
el?.focus();
|
|
275
|
+
el?.select();
|
|
247
276
|
}
|
|
248
277
|
: undefined
|
|
249
278
|
}
|
|
250
279
|
>
|
|
251
280
|
<DialogHeader>
|
|
252
|
-
{current.media !== undefined &&
|
|
281
|
+
{current.media !== undefined && (
|
|
282
|
+
<DialogMedia>{current.media}</DialogMedia>
|
|
283
|
+
)}
|
|
253
284
|
<DialogTitle>{current.title}</DialogTitle>
|
|
254
285
|
</DialogHeader>
|
|
255
|
-
{current.body !== undefined &&
|
|
256
|
-
|
|
286
|
+
{current.body !== undefined && (
|
|
287
|
+
<DialogBody id={bodyId}>{current.body}</DialogBody>
|
|
288
|
+
)}
|
|
289
|
+
{current.kind === "prompt" && (
|
|
257
290
|
<Input
|
|
258
291
|
value={text}
|
|
259
292
|
placeholder={current.placeholder}
|
|
260
293
|
onChange={(e) => setText(e.target.value)}
|
|
261
294
|
onKeyDown={(e) => {
|
|
262
|
-
if (e.key ===
|
|
263
|
-
e.preventDefault()
|
|
264
|
-
drop(current.id, (p) => accept(p, text))
|
|
295
|
+
if (e.key === "Enter") {
|
|
296
|
+
e.preventDefault();
|
|
297
|
+
drop(current.id, (p) => accept(p, text));
|
|
265
298
|
}
|
|
266
299
|
}}
|
|
267
300
|
/>
|
|
268
301
|
)}
|
|
269
|
-
<DialogFooter
|
|
270
|
-
|
|
271
|
-
current.kind ===
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
return (
|
|
280
|
-
<DialogClose key={action.result} initialFocus={action.initialFocus} asChild>
|
|
281
|
-
<Button
|
|
282
|
-
context={action.context ?? (primary ? 'primary' : 'neutral')}
|
|
283
|
-
variant={action.variant ?? (primary ? 'solid' : 'ghost')}
|
|
284
|
-
disabled={action.disabled}
|
|
285
|
-
onClick={() =>
|
|
286
|
-
drop(current.id, (p) => {
|
|
287
|
-
if (p.kind === 'choose') p.resolve(action.result)
|
|
288
|
-
})
|
|
289
|
-
}
|
|
302
|
+
<DialogFooter>
|
|
303
|
+
<ButtonGroup mode="spaced" distribution="equal">
|
|
304
|
+
{current.kind === "choose" ? (
|
|
305
|
+
current.actions.map((action, index) => {
|
|
306
|
+
const primary = index === current.actions.length - 1;
|
|
307
|
+
return (
|
|
308
|
+
<DialogClose
|
|
309
|
+
key={action.result}
|
|
310
|
+
initialFocus={action.initialFocus}
|
|
311
|
+
asChild
|
|
290
312
|
>
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
313
|
+
<Button
|
|
314
|
+
context={
|
|
315
|
+
action.context ?? (primary ? "primary" : "neutral")
|
|
316
|
+
}
|
|
317
|
+
variant={
|
|
318
|
+
action.variant ?? (primary ? "solid" : "outline")
|
|
319
|
+
}
|
|
320
|
+
disabled={action.disabled}
|
|
321
|
+
onClick={() =>
|
|
322
|
+
drop(current.id, (p) => {
|
|
323
|
+
if (p.kind === "choose") p.resolve(action.result);
|
|
324
|
+
})
|
|
325
|
+
}
|
|
326
|
+
>
|
|
327
|
+
{action.label}
|
|
328
|
+
</Button>
|
|
329
|
+
</DialogClose>
|
|
330
|
+
);
|
|
331
|
+
})
|
|
332
|
+
) : (
|
|
333
|
+
<>
|
|
334
|
+
{current.kind !== "alert" && (
|
|
335
|
+
<DialogClose initialFocus asChild>
|
|
336
|
+
<Button
|
|
337
|
+
context="neutral"
|
|
338
|
+
variant="outline"
|
|
339
|
+
onClick={() => drop(current.id, cancel)}
|
|
340
|
+
>
|
|
341
|
+
{current.cancel ?? "Cancelar"}
|
|
342
|
+
</Button>
|
|
343
|
+
</DialogClose>
|
|
344
|
+
)}
|
|
345
|
+
<DialogClose initialFocus={current.kind === "alert"} asChild>
|
|
300
346
|
<Button
|
|
301
|
-
context=
|
|
302
|
-
variant="
|
|
303
|
-
onClick={() => drop(current.id,
|
|
347
|
+
context={context}
|
|
348
|
+
variant="solid"
|
|
349
|
+
onClick={() => drop(current.id, (p) => accept(p, text))}
|
|
304
350
|
>
|
|
305
|
-
{current.
|
|
351
|
+
{current.action ?? defaultAction}
|
|
306
352
|
</Button>
|
|
307
353
|
</DialogClose>
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
context={context}
|
|
312
|
-
variant="solid"
|
|
313
|
-
onClick={() => drop(current.id, (p) => accept(p, text))}
|
|
314
|
-
>
|
|
315
|
-
{current.action ?? defaultAction}
|
|
316
|
-
</Button>
|
|
317
|
-
</DialogClose>
|
|
318
|
-
</>
|
|
319
|
-
)}
|
|
354
|
+
</>
|
|
355
|
+
)}
|
|
356
|
+
</ButtonGroup>
|
|
320
357
|
</DialogFooter>
|
|
321
358
|
</DialogContent>
|
|
322
359
|
)}
|
|
323
360
|
</Dialog>
|
|
324
|
-
)
|
|
361
|
+
);
|
|
325
362
|
}
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
type ReactNode,
|
|
10
10
|
} from "react";
|
|
11
11
|
import { cn } from "../../lib/cn.ts";
|
|
12
|
+
import { usePageHeadingPresence } from "./page-heading-context.tsx";
|
|
12
13
|
import {
|
|
13
14
|
SurfaceHeader,
|
|
14
15
|
surfaceHeaderClasses,
|
|
@@ -148,6 +149,7 @@ export type ContentHeaderProps = Omit<HTMLAttributes<HTMLDivElement>, "title">;
|
|
|
148
149
|
* `SurfaceHeader`, a mesma de `PageHeader`: só os nomes dos slots e a hierarquia mudam.
|
|
149
150
|
*/
|
|
150
151
|
export function ContentHeader({
|
|
152
|
+
className,
|
|
151
153
|
children,
|
|
152
154
|
...props
|
|
153
155
|
}: ContentHeaderProps): ReactElement {
|
|
@@ -167,6 +169,11 @@ export function ContentHeader({
|
|
|
167
169
|
description: ContentDescription,
|
|
168
170
|
actions: ContentActions,
|
|
169
171
|
}}
|
|
172
|
+
className={cn(
|
|
173
|
+
content.variant === "page" &&
|
|
174
|
+
"flex-row items-center justify-start gap-3 sm:flex-row sm:items-center sm:justify-start",
|
|
175
|
+
className,
|
|
176
|
+
)}
|
|
170
177
|
{...props}
|
|
171
178
|
>
|
|
172
179
|
{children}
|
|
@@ -195,6 +202,7 @@ export function ContentTitle({
|
|
|
195
202
|
"ContentHeader",
|
|
196
203
|
);
|
|
197
204
|
const Heading = headingTags[context.level];
|
|
205
|
+
usePageHeadingPresence(context.level === 1);
|
|
198
206
|
return (
|
|
199
207
|
<Heading
|
|
200
208
|
id={id ?? context.titleId}
|
|
@@ -217,7 +225,10 @@ export function ContentDescription({
|
|
|
217
225
|
return (
|
|
218
226
|
<p
|
|
219
227
|
data-slot="content-description"
|
|
220
|
-
className={cn(
|
|
228
|
+
className={cn(
|
|
229
|
+
surfaceHeaderClasses[context.variant].description,
|
|
230
|
+
className,
|
|
231
|
+
)}
|
|
221
232
|
{...props}
|
|
222
233
|
/>
|
|
223
234
|
);
|
|
@@ -254,7 +265,11 @@ export function ContentActions({
|
|
|
254
265
|
return (
|
|
255
266
|
<div
|
|
256
267
|
data-slot="content-actions"
|
|
257
|
-
className={cn(
|
|
268
|
+
className={cn(
|
|
269
|
+
surfaceHeaderClasses[context.variant].actions,
|
|
270
|
+
context.variant === "page" && "ml-auto w-auto",
|
|
271
|
+
className,
|
|
272
|
+
)}
|
|
258
273
|
{...props}
|
|
259
274
|
/>
|
|
260
275
|
);
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* pelo próprio form (fonte única do scroll e da faixa, sem string duplicada).
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { useId, type ReactNode } from
|
|
11
|
+
import { useId, type ReactNode } from "react";
|
|
12
12
|
import {
|
|
13
13
|
Dialog,
|
|
14
14
|
DialogBody,
|
|
@@ -16,35 +16,42 @@ import {
|
|
|
16
16
|
DialogFooter,
|
|
17
17
|
DialogHeader,
|
|
18
18
|
DialogTitle,
|
|
19
|
-
} from
|
|
20
|
-
import { ActionForm, type ActionFormProps } from
|
|
19
|
+
} from "../primitives/dialog.tsx";
|
|
20
|
+
import { ActionForm, type ActionFormProps } from "./form.tsx";
|
|
21
|
+
import { ButtonGroup } from "../primitives/button-group.tsx";
|
|
21
22
|
|
|
22
23
|
export interface ActionFormDialogProps<
|
|
23
24
|
TInput extends Record<string, unknown>,
|
|
24
25
|
TData,
|
|
25
26
|
> extends ActionFormProps<TInput, TData> {
|
|
26
|
-
open: boolean
|
|
27
|
-
onOpenChange: (open: boolean) => void
|
|
28
|
-
title: string
|
|
27
|
+
open: boolean;
|
|
28
|
+
onOpenChange: (open: boolean) => void;
|
|
29
|
+
title: string;
|
|
29
30
|
/** Conteúdo relevante apresentado antes dos campos, como texto ou Alert. */
|
|
30
|
-
intro?: ReactNode
|
|
31
|
+
intro?: ReactNode;
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
export function ActionFormDialog<
|
|
34
|
+
export function ActionFormDialog<
|
|
35
|
+
TInput extends Record<string, unknown>,
|
|
36
|
+
TData,
|
|
37
|
+
>({
|
|
34
38
|
open,
|
|
35
39
|
onOpenChange,
|
|
36
40
|
title,
|
|
37
41
|
intro,
|
|
38
42
|
onSuccess,
|
|
39
43
|
onCancel,
|
|
44
|
+
cancelVariant,
|
|
40
45
|
...rest
|
|
41
46
|
}: ActionFormDialogProps<TInput, TData>) {
|
|
42
|
-
const introId = useId()
|
|
47
|
+
const introId = useId();
|
|
43
48
|
return (
|
|
44
49
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
45
50
|
{/* Header e rodapé FIXOS, só o CORPO (campos) scrolla — o "X" não rola junto e
|
|
46
51
|
forms longos não estouram a viewport. O content já é coluna flex com teto. */}
|
|
47
|
-
<DialogContent
|
|
52
|
+
<DialogContent
|
|
53
|
+
aria-describedby={intro === undefined ? undefined : introId}
|
|
54
|
+
>
|
|
48
55
|
<DialogHeader>
|
|
49
56
|
<DialogTitle>{title}</DialogTitle>
|
|
50
57
|
</DialogHeader>
|
|
@@ -63,14 +70,21 @@ export function ActionFormDialog<TInput extends Record<string, unknown>, TData>(
|
|
|
63
70
|
{fields}
|
|
64
71
|
</DialogBody>
|
|
65
72
|
)}
|
|
66
|
-
footer={(actions) =>
|
|
73
|
+
footer={(actions) => (
|
|
74
|
+
<DialogFooter>
|
|
75
|
+
<ButtonGroup mode="spaced" distribution="equal">
|
|
76
|
+
{actions}
|
|
77
|
+
</ButtonGroup>
|
|
78
|
+
</DialogFooter>
|
|
79
|
+
)}
|
|
67
80
|
onSuccess={(d) => {
|
|
68
|
-
onSuccess?.(d)
|
|
69
|
-
onOpenChange(false)
|
|
81
|
+
onSuccess?.(d);
|
|
82
|
+
onOpenChange(false);
|
|
70
83
|
}}
|
|
71
84
|
onCancel={onCancel ?? (() => onOpenChange(false))}
|
|
85
|
+
cancelVariant={cancelVariant ?? "outline"}
|
|
72
86
|
/>
|
|
73
87
|
</DialogContent>
|
|
74
88
|
</Dialog>
|
|
75
|
-
)
|
|
89
|
+
);
|
|
76
90
|
}
|