@softize/opus 15.2.2 → 16.1.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 +60 -18
- package/bin/lib/check.mjs +98 -15
- package/bin/lib/copy.mjs +811 -314
- package/bin/lib/gen-manifest.mjs +24 -23
- package/bin/lib/gen-runner.mjs +188 -148
- package/docs/adr/0010-page-header-owns-page-chrome.md +3 -4
- package/docs/adr/0011-page-shell-coordinates-persistent-page-chrome.md +5 -3
- package/docs/adr/0012-modal-header-only-names-the-surface.md +45 -0
- package/docs/adr/0013-presentation-is-a-portable-action-oriented-artifact.md +92 -0
- package/docs/code-style.md +24 -19
- package/package.json +5 -1
- package/registry/skills/build-opus-ui/references/ui-patterns.md +43 -26
- package/src/core/presentation.ts +512 -0
- package/src/presentation/index.ts +1 -0
- package/src/ui/components/patterns/action-list-dialog.tsx +26 -9
- package/src/ui/components/patterns/confirm.tsx +34 -29
- package/src/ui/components/patterns/form-dialog.tsx +20 -8
- package/src/ui/components/patterns/list.tsx +26 -9
- package/src/ui/components/patterns/page.tsx +99 -139
- package/src/ui/components/patterns/presentation.tsx +539 -0
- package/src/ui/components/patterns/sidebar.tsx +3 -3
- package/src/ui/components/patterns/trigger.tsx +38 -17
- package/src/ui/components/primitives/button-group.tsx +53 -43
- package/src/ui/components/primitives/command.tsx +30 -72
- package/src/ui/components/primitives/dialog.tsx +23 -89
- package/src/ui/components/primitives/drawer.tsx +8 -34
- package/src/ui/docs/content/action-form-dialog.md +12 -10
- package/src/ui/docs/content/action-list-dialog.md +22 -17
- package/src/ui/docs/content/button.md +52 -35
- package/src/ui/docs/content/communication.md +26 -26
- package/src/ui/docs/content/dialog.md +173 -154
- package/src/ui/docs/content/drawer.md +12 -11
- package/src/ui/docs/content/page.md +72 -91
- package/src/ui/docs/content/presentation.md +188 -0
- package/src/ui/docs/registry.tsx +6 -0
- package/src/ui/meta.ts +8 -2
- package/src/ui/react.tsx +16 -3
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `dialog` — respostas imperativas (alert · confirm · prompt · choose) em UMA linha.
|
|
3
3
|
*
|
|
4
|
-
* await dialog.alert({ title: 'Sessão expirada',
|
|
4
|
+
* await dialog.alert({ title: 'Sessão expirada', body: 'Você será desconectado.' })
|
|
5
5
|
* if (await dialog.confirm({ title: 'Excluir a sessão?', context: 'danger' })) …
|
|
6
6
|
* const nome = await dialog.prompt({ title: 'Nome do snapshot' })
|
|
7
7
|
*
|
|
@@ -26,23 +26,17 @@ import {
|
|
|
26
26
|
DialogBody,
|
|
27
27
|
DialogClose,
|
|
28
28
|
DialogContent,
|
|
29
|
-
DialogDescription,
|
|
30
29
|
DialogFooter,
|
|
31
30
|
DialogHeader,
|
|
32
31
|
DialogMedia,
|
|
33
32
|
DialogTitle,
|
|
34
33
|
} from '../primitives/dialog.tsx'
|
|
35
|
-
import {
|
|
36
|
-
Button,
|
|
37
|
-
type ButtonContext,
|
|
38
|
-
type ButtonVariant,
|
|
39
|
-
} from '../primitives/button.tsx'
|
|
34
|
+
import { Button, type ButtonContext, type ButtonVariant } from '../primitives/button.tsx'
|
|
40
35
|
import { Input } from '../primitives/input.tsx'
|
|
41
36
|
|
|
42
|
-
/** Campos comuns aos
|
|
37
|
+
/** Campos comuns aos quatro modos imperativos. */
|
|
43
38
|
interface DialogBase {
|
|
44
39
|
title: React.ReactNode
|
|
45
|
-
description?: React.ReactNode
|
|
46
40
|
/** Ícone grande acima do título (o quadro do DialogMedia). */
|
|
47
41
|
media?: React.ReactNode
|
|
48
42
|
/** Conteúdo próprio entre a descrição e os botões (lista, detalhe destacado). */
|
|
@@ -80,16 +74,27 @@ export interface DialogChoice<TResult extends string = string> {
|
|
|
80
74
|
}
|
|
81
75
|
|
|
82
76
|
/** `choose` — uma escolha simples entre resultados explícitos. */
|
|
83
|
-
export interface ChooseOptions<TResult extends string = string>
|
|
84
|
-
extends Omit<DialogBase, 'action'> {
|
|
77
|
+
export interface ChooseOptions<TResult extends string = string> extends Omit<DialogBase, 'action'> {
|
|
85
78
|
actions: readonly DialogChoice<TResult>[]
|
|
86
79
|
}
|
|
87
80
|
|
|
88
81
|
type Pending =
|
|
89
82
|
| ({ kind: 'alert'; id: number; resolve: () => void } & AlertOptions)
|
|
90
|
-
| ({
|
|
91
|
-
|
|
92
|
-
|
|
83
|
+
| ({
|
|
84
|
+
kind: 'confirm'
|
|
85
|
+
id: number
|
|
86
|
+
resolve: (ok: boolean) => void
|
|
87
|
+
} & ConfirmOptions)
|
|
88
|
+
| ({
|
|
89
|
+
kind: 'prompt'
|
|
90
|
+
id: number
|
|
91
|
+
resolve: (value: string | null) => void
|
|
92
|
+
} & PromptOptions)
|
|
93
|
+
| ({
|
|
94
|
+
kind: 'choose'
|
|
95
|
+
id: number
|
|
96
|
+
resolve: (result: string | null) => void
|
|
97
|
+
} & ChooseOptions)
|
|
93
98
|
|
|
94
99
|
let nextId = 0
|
|
95
100
|
let listener: ((p: Pending) => void) | null = null
|
|
@@ -205,10 +210,14 @@ export function DialogHost(): React.ReactElement {
|
|
|
205
210
|
else p.resolve(value)
|
|
206
211
|
}
|
|
207
212
|
|
|
208
|
-
const context =
|
|
209
|
-
|
|
210
|
-
|
|
213
|
+
const context =
|
|
214
|
+
current !== undefined && (current.kind === 'confirm' || current.kind === 'prompt')
|
|
215
|
+
? (current.context ?? 'primary')
|
|
216
|
+
: 'primary'
|
|
211
217
|
const defaultAction = current?.kind === 'alert' ? 'OK' : 'Confirmar'
|
|
218
|
+
const bodyId = current === undefined ? undefined : `dialog-body-${current.id}`
|
|
219
|
+
const bodyDescribesDialog =
|
|
220
|
+
current !== undefined && (typeof current.body === 'string' || typeof current.body === 'number')
|
|
212
221
|
|
|
213
222
|
return (
|
|
214
223
|
<Dialog
|
|
@@ -223,6 +232,7 @@ export function DialogHost(): React.ReactElement {
|
|
|
223
232
|
data-slot="dialog"
|
|
224
233
|
data-kind={current.kind}
|
|
225
234
|
className={current.kind === 'choose' ? 'max-w-lg' : undefined}
|
|
235
|
+
aria-describedby={bodyDescribesDialog ? bodyId : undefined}
|
|
226
236
|
// Prompt: o foco vai pro input, não pro Cancelar (default do Dialog). Via
|
|
227
237
|
// querySelector (não ref) — o Input é function component sem forwardRef, e isto
|
|
228
238
|
// funciona igual em React 18 e 19.
|
|
@@ -241,11 +251,8 @@ export function DialogHost(): React.ReactElement {
|
|
|
241
251
|
<DialogHeader>
|
|
242
252
|
{current.media !== undefined && <DialogMedia>{current.media}</DialogMedia>}
|
|
243
253
|
<DialogTitle>{current.title}</DialogTitle>
|
|
244
|
-
{current.description !== undefined && (
|
|
245
|
-
<DialogDescription>{current.description}</DialogDescription>
|
|
246
|
-
)}
|
|
247
254
|
</DialogHeader>
|
|
248
|
-
{current.body !== undefined && <DialogBody>{current.body}</DialogBody>}
|
|
255
|
+
{current.body !== undefined && <DialogBody id={bodyId}>{current.body}</DialogBody>}
|
|
249
256
|
{current.kind === 'prompt' && (
|
|
250
257
|
<Input
|
|
251
258
|
value={text}
|
|
@@ -270,18 +277,16 @@ export function DialogHost(): React.ReactElement {
|
|
|
270
277
|
current.actions.map((action, index) => {
|
|
271
278
|
const primary = index === current.actions.length - 1
|
|
272
279
|
return (
|
|
273
|
-
<DialogClose
|
|
274
|
-
key={action.result}
|
|
275
|
-
initialFocus={action.initialFocus}
|
|
276
|
-
asChild
|
|
277
|
-
>
|
|
280
|
+
<DialogClose key={action.result} initialFocus={action.initialFocus} asChild>
|
|
278
281
|
<Button
|
|
279
282
|
context={action.context ?? (primary ? 'primary' : 'neutral')}
|
|
280
283
|
variant={action.variant ?? (primary ? 'solid' : 'ghost')}
|
|
281
284
|
disabled={action.disabled}
|
|
282
|
-
onClick={() =>
|
|
283
|
-
|
|
284
|
-
|
|
285
|
+
onClick={() =>
|
|
286
|
+
drop(current.id, (p) => {
|
|
287
|
+
if (p.kind === 'choose') p.resolve(action.result)
|
|
288
|
+
})
|
|
289
|
+
}
|
|
285
290
|
>
|
|
286
291
|
{action.label}
|
|
287
292
|
</Button>
|
|
@@ -8,6 +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 'react'
|
|
11
12
|
import {
|
|
12
13
|
Dialog,
|
|
13
14
|
DialogBody,
|
|
@@ -15,42 +16,53 @@ import {
|
|
|
15
16
|
DialogFooter,
|
|
16
17
|
DialogHeader,
|
|
17
18
|
DialogTitle,
|
|
18
|
-
DialogDescription,
|
|
19
19
|
} from '../primitives/dialog.tsx'
|
|
20
20
|
import { ActionForm, type ActionFormProps } from './form.tsx'
|
|
21
21
|
|
|
22
|
-
export interface ActionFormDialogProps<
|
|
23
|
-
extends
|
|
22
|
+
export interface ActionFormDialogProps<
|
|
23
|
+
TInput extends Record<string, unknown>,
|
|
24
|
+
TData,
|
|
25
|
+
> extends ActionFormProps<TInput, TData> {
|
|
24
26
|
open: boolean
|
|
25
27
|
onOpenChange: (open: boolean) => void
|
|
26
28
|
title: string
|
|
27
|
-
|
|
29
|
+
/** Conteúdo relevante apresentado antes dos campos, como texto ou Alert. */
|
|
30
|
+
intro?: ReactNode
|
|
28
31
|
}
|
|
29
32
|
|
|
30
33
|
export function ActionFormDialog<TInput extends Record<string, unknown>, TData>({
|
|
31
34
|
open,
|
|
32
35
|
onOpenChange,
|
|
33
36
|
title,
|
|
34
|
-
|
|
37
|
+
intro,
|
|
35
38
|
onSuccess,
|
|
36
39
|
onCancel,
|
|
37
40
|
...rest
|
|
38
41
|
}: ActionFormDialogProps<TInput, TData>) {
|
|
42
|
+
const introId = useId()
|
|
39
43
|
return (
|
|
40
44
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
41
45
|
{/* Header e rodapé FIXOS, só o CORPO (campos) scrolla — o "X" não rola junto e
|
|
42
46
|
forms longos não estouram a viewport. O content já é coluna flex com teto. */}
|
|
43
|
-
<DialogContent>
|
|
47
|
+
<DialogContent aria-describedby={intro === undefined ? undefined : introId}>
|
|
44
48
|
<DialogHeader>
|
|
45
49
|
<DialogTitle>{title}</DialogTitle>
|
|
46
|
-
{description !== undefined && <DialogDescription>{description}</DialogDescription>}
|
|
47
50
|
</DialogHeader>
|
|
48
51
|
<ActionForm
|
|
49
52
|
{...rest}
|
|
50
53
|
className="min-h-0 flex-1"
|
|
51
54
|
// Slots injetados: o corpo vira DialogBody (rola), o rodapé vira DialogFooter (a
|
|
52
55
|
// faixa da casa — fonte única). Os botões seguem dentro do <form> do ActionForm.
|
|
53
|
-
body={(fields) =>
|
|
56
|
+
body={(fields) => (
|
|
57
|
+
<DialogBody>
|
|
58
|
+
{intro !== undefined && (
|
|
59
|
+
<div id={introId} className="mb-4">
|
|
60
|
+
{intro}
|
|
61
|
+
</div>
|
|
62
|
+
)}
|
|
63
|
+
{fields}
|
|
64
|
+
</DialogBody>
|
|
65
|
+
)}
|
|
54
66
|
footer={(actions) => <DialogFooter>{actions}</DialogFooter>}
|
|
55
67
|
onSuccess={(d) => {
|
|
56
68
|
onSuccess?.(d)
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
isValidElement,
|
|
26
26
|
useEffect,
|
|
27
27
|
useLayoutEffect,
|
|
28
|
+
useId,
|
|
28
29
|
useMemo,
|
|
29
30
|
useRef,
|
|
30
31
|
useState,
|
|
@@ -90,7 +91,6 @@ import {
|
|
|
90
91
|
DialogBody,
|
|
91
92
|
DialogClose,
|
|
92
93
|
DialogContent,
|
|
93
|
-
DialogDescription,
|
|
94
94
|
DialogFooter,
|
|
95
95
|
DialogHeader,
|
|
96
96
|
DialogTitle,
|
|
@@ -1106,7 +1106,12 @@ export function ActionFilterBar({
|
|
|
1106
1106
|
className="flex flex-wrap items-center gap-1.5"
|
|
1107
1107
|
>
|
|
1108
1108
|
{chips.map((chip) => (
|
|
1109
|
-
<Badge
|
|
1109
|
+
<Badge
|
|
1110
|
+
key={chip.name}
|
|
1111
|
+
context="neutral"
|
|
1112
|
+
variant="solid"
|
|
1113
|
+
className="gap-1 pr-1"
|
|
1114
|
+
>
|
|
1110
1115
|
<span className="text-muted-foreground">{chip.label}:</span>{" "}
|
|
1111
1116
|
{chip.value}
|
|
1112
1117
|
<button
|
|
@@ -1193,6 +1198,7 @@ export function ActionList<TInput, TItem>({
|
|
|
1193
1198
|
state: controlledState,
|
|
1194
1199
|
onStateChange,
|
|
1195
1200
|
}: ActionListProps<TInput, TItem>) {
|
|
1201
|
+
const confirmBodyId = useId();
|
|
1196
1202
|
// Query-backed (useListAction): busca declarativa por [name, input] E refetch
|
|
1197
1203
|
// automático quando um form/trigger invalida a action (`invalidates: ['x.list']`).
|
|
1198
1204
|
// O input efetivo é computado abaixo; o hook re-busca quando ele muda.
|
|
@@ -1818,17 +1824,26 @@ export function ActionList<TInput, TItem>({
|
|
|
1818
1824
|
if (!o) setConfirming(null);
|
|
1819
1825
|
}}
|
|
1820
1826
|
>
|
|
1821
|
-
<DialogContent
|
|
1827
|
+
<DialogContent
|
|
1828
|
+
aria-describedby={
|
|
1829
|
+
confirming.confirm?.message === undefined
|
|
1830
|
+
? undefined
|
|
1831
|
+
: confirmBodyId
|
|
1832
|
+
}
|
|
1833
|
+
>
|
|
1822
1834
|
<DialogHeader>
|
|
1823
1835
|
<DialogTitle>
|
|
1824
1836
|
{confirming.confirm?.title ?? confirming.label}
|
|
1825
1837
|
</DialogTitle>
|
|
1826
|
-
{confirming.confirm?.message !== undefined && (
|
|
1827
|
-
<DialogDescription>
|
|
1828
|
-
{confirming.confirm.message}
|
|
1829
|
-
</DialogDescription>
|
|
1830
|
-
)}
|
|
1831
1838
|
</DialogHeader>
|
|
1839
|
+
{confirming.confirm?.message !== undefined && (
|
|
1840
|
+
<DialogBody
|
|
1841
|
+
id={confirmBodyId}
|
|
1842
|
+
className="text-sm text-muted-foreground"
|
|
1843
|
+
>
|
|
1844
|
+
{confirming.confirm.message}
|
|
1845
|
+
</DialogBody>
|
|
1846
|
+
)}
|
|
1832
1847
|
<DialogFooter>
|
|
1833
1848
|
<DialogClose initialFocus asChild>
|
|
1834
1849
|
<Button variant="ghost" size="sm">
|
|
@@ -1838,7 +1853,9 @@ export function ActionList<TInput, TItem>({
|
|
|
1838
1853
|
<DialogClose asChild>
|
|
1839
1854
|
<Button
|
|
1840
1855
|
size="sm"
|
|
1841
|
-
context={
|
|
1856
|
+
context={
|
|
1857
|
+
confirming.destructive === true ? "danger" : "primary"
|
|
1858
|
+
}
|
|
1842
1859
|
variant="solid"
|
|
1843
1860
|
onClick={() => {
|
|
1844
1861
|
const a = confirming;
|