@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
|
@@ -7,20 +7,24 @@
|
|
|
7
7
|
* - Confirmação opcional via <Dialog> (compacto, não fecha no clique fora) antes de disparar.
|
|
8
8
|
* - `icon` faz o botão virar icon-only com tooltip, no quadrado `icon-xs` (1.5rem) da escala:
|
|
9
9
|
* é a ação que mora NO item (linha, card). Uma composição que peça mais presença — a barra
|
|
10
|
-
* do `PageHeader`, por exemplo — declara `size="icon
|
|
10
|
+
* do `PageHeader`, por exemplo — declara `size="icon"`. Absorveu o antigo DeleteButton,
|
|
11
11
|
* que era este componente com uma lixeira.
|
|
12
12
|
* Emite `data-action="<action.name>"` na raiz (selector E2E).
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import type { ReactNode } from
|
|
16
|
-
import { useEffect, useId, useState } from
|
|
17
|
-
import type { SimpleContract } from
|
|
18
|
-
import { useTriggerAction } from
|
|
19
|
-
import { toast } from
|
|
20
|
-
import { cn } from
|
|
21
|
-
import { humanizeActionError } from
|
|
22
|
-
import { Button, type ButtonProps } from
|
|
23
|
-
import {
|
|
15
|
+
import type { ReactNode } from "react";
|
|
16
|
+
import { useEffect, useId, useState } from "react";
|
|
17
|
+
import type { SimpleContract } from "../../../core/index.ts";
|
|
18
|
+
import { useTriggerAction } from "../../drivers/react.tsx";
|
|
19
|
+
import { toast } from "../primitives/sonner.tsx";
|
|
20
|
+
import { cn } from "../../lib/cn.ts";
|
|
21
|
+
import { humanizeActionError } from "../../lib/action-errors.ts";
|
|
22
|
+
import { Button, type ButtonProps } from "../primitives/button.tsx";
|
|
23
|
+
import {
|
|
24
|
+
Tooltip,
|
|
25
|
+
TooltipContent,
|
|
26
|
+
TooltipTrigger,
|
|
27
|
+
} from "../primitives/tooltip.tsx";
|
|
24
28
|
import {
|
|
25
29
|
Dialog,
|
|
26
30
|
DialogBody,
|
|
@@ -29,52 +33,53 @@ import {
|
|
|
29
33
|
DialogFooter,
|
|
30
34
|
DialogHeader,
|
|
31
35
|
DialogTitle,
|
|
32
|
-
} from
|
|
36
|
+
} from "../primitives/dialog.tsx";
|
|
37
|
+
import { ButtonGroup } from "../primitives/button-group.tsx";
|
|
33
38
|
|
|
34
39
|
/** action.messages.* pode ser string ou I18nRef ({ key, default }). Resolve pra texto. */
|
|
35
40
|
function msgText(m: unknown, fallback: string): string {
|
|
36
|
-
if (typeof m ===
|
|
37
|
-
if (m !== null && typeof m ===
|
|
38
|
-
const d = (m as { default: unknown }).default
|
|
39
|
-
if (typeof d ===
|
|
41
|
+
if (typeof m === "string") return m;
|
|
42
|
+
if (m !== null && typeof m === "object" && "default" in m) {
|
|
43
|
+
const d = (m as { default: unknown }).default;
|
|
44
|
+
if (typeof d === "string") return d;
|
|
40
45
|
}
|
|
41
|
-
return fallback
|
|
46
|
+
return fallback;
|
|
42
47
|
}
|
|
43
48
|
|
|
44
49
|
export interface ActionTriggerProps<TInput, TData> {
|
|
45
|
-
action: SimpleContract<TInput, TData
|
|
50
|
+
action: SimpleContract<TInput, TData>;
|
|
46
51
|
/** Input enviado pra action. Geralmente { id } ou similar. */
|
|
47
|
-
input: TInput
|
|
52
|
+
input: TInput;
|
|
48
53
|
/** Texto do botão. Default usa action.label. */
|
|
49
|
-
label?: string
|
|
54
|
+
label?: string;
|
|
50
55
|
/** Contexto visual. Por padrão, ações destrutivas usam `danger`. */
|
|
51
|
-
context?: ButtonProps[
|
|
56
|
+
context?: ButtonProps["context"];
|
|
52
57
|
/** Tratamento visual do Button. */
|
|
53
|
-
variant?: ButtonProps[
|
|
58
|
+
variant?: ButtonProps["variant"];
|
|
54
59
|
/** Tamanho do botão (escala de control.ts). Com `icon`, o default é `icon-xs`. */
|
|
55
|
-
size?: ButtonProps[
|
|
60
|
+
size?: ButtonProps["size"];
|
|
56
61
|
/** Desabilita o botão independente de loading. */
|
|
57
|
-
disabled?: boolean
|
|
62
|
+
disabled?: boolean;
|
|
58
63
|
/** Texto do confirm dialog. Default vem do `action.confirm` (ConfirmSpec do
|
|
59
64
|
* contrato — declarativo). Sem nenhum dos dois, dispara direto. */
|
|
60
65
|
confirm?: {
|
|
61
|
-
title: string
|
|
66
|
+
title: string;
|
|
62
67
|
/** Conteúdo relevante apresentado antes das ações. */
|
|
63
|
-
body?: ReactNode
|
|
64
|
-
actionLabel?: string
|
|
65
|
-
cancelLabel?: string
|
|
66
|
-
}
|
|
68
|
+
body?: ReactNode;
|
|
69
|
+
actionLabel?: string;
|
|
70
|
+
cancelLabel?: string;
|
|
71
|
+
};
|
|
67
72
|
/** Callback adicional pós-sucesso (cache já foi invalidado). */
|
|
68
|
-
onSuccess?: (data: TData) => void
|
|
73
|
+
onSuccess?: (data: TData) => void;
|
|
69
74
|
/** Notifica o renderer que coordena bloqueio entre várias actions. */
|
|
70
|
-
onLoadingChange?: (loading: boolean) => void
|
|
75
|
+
onLoadingChange?: (loading: boolean) => void;
|
|
71
76
|
/** Ícone: o botão vira icon-only, com o `label` no tooltip e no aria-label. É a forma
|
|
72
77
|
* da ação que mora DENTRO de um item (linha, card) — o clique não vaza pro item. */
|
|
73
|
-
icon?: ReactNode
|
|
78
|
+
icon?: ReactNode;
|
|
74
79
|
/** Nome do item na pergunta (ex.: o slug, o nome da unidade) — sai entre aspas, em
|
|
75
80
|
* destaque, antes da mensagem do contrato. */
|
|
76
|
-
itemLabel?: string
|
|
77
|
-
className?: string
|
|
81
|
+
itemLabel?: string;
|
|
82
|
+
className?: string;
|
|
78
83
|
}
|
|
79
84
|
|
|
80
85
|
export function ActionTrigger<TInput, TData>({
|
|
@@ -92,46 +97,52 @@ export function ActionTrigger<TInput, TData>({
|
|
|
92
97
|
itemLabel,
|
|
93
98
|
className,
|
|
94
99
|
}: ActionTriggerProps<TInput, TData>) {
|
|
95
|
-
const confirmBodyId = useId()
|
|
96
|
-
const [open, setOpen] = useState(false)
|
|
100
|
+
const confirmBodyId = useId();
|
|
101
|
+
const [open, setOpen] = useState(false);
|
|
97
102
|
const { trigger, isLoading } = useTriggerAction(action, {
|
|
98
103
|
onSuccess: (data) => {
|
|
99
|
-
toast.success(msgText(action.messages?.success,
|
|
100
|
-
onSuccess?.(data)
|
|
101
|
-
setOpen(false)
|
|
104
|
+
toast.success(msgText(action.messages?.success, "Concluído"));
|
|
105
|
+
onSuccess?.(data);
|
|
106
|
+
setOpen(false);
|
|
102
107
|
},
|
|
103
108
|
onError: (err) => {
|
|
104
109
|
// A frase de negócio do servidor vence o rótulo do contrato (ver lib/action-errors.ts);
|
|
105
110
|
// fora da allowlist, vale o rótulo — nunca o texto técnico cru.
|
|
106
111
|
toast.error(
|
|
107
|
-
humanizeActionError(
|
|
108
|
-
|
|
112
|
+
humanizeActionError(
|
|
113
|
+
err,
|
|
114
|
+
msgText(action.messages?.error, "Não foi possível concluir."),
|
|
115
|
+
),
|
|
116
|
+
);
|
|
109
117
|
},
|
|
110
|
-
})
|
|
118
|
+
});
|
|
111
119
|
useEffect(() => {
|
|
112
|
-
onLoadingChange?.(isLoading)
|
|
113
|
-
return () => onLoadingChange?.(false)
|
|
114
|
-
}, [isLoading, onLoadingChange])
|
|
120
|
+
onLoadingChange?.(isLoading);
|
|
121
|
+
return () => onLoadingChange?.(false);
|
|
122
|
+
}, [isLoading, onLoadingChange]);
|
|
115
123
|
|
|
116
|
-
const buttonLabel =
|
|
124
|
+
const buttonLabel =
|
|
125
|
+
label ?? (typeof action.label === "string" ? action.label : action.name);
|
|
117
126
|
|
|
118
127
|
// Prop sobrepõe; sem prop, o ConfirmSpec DECLARADO no contrato (action.confirm) vale.
|
|
119
|
-
const spec = action.confirm
|
|
128
|
+
const spec = action.confirm;
|
|
120
129
|
const confirm =
|
|
121
130
|
confirmProp ??
|
|
122
131
|
(spec !== undefined
|
|
123
132
|
? {
|
|
124
|
-
title: msgText(spec.title,
|
|
125
|
-
...(spec.message !== undefined
|
|
133
|
+
title: msgText(spec.title, "Confirmar?"),
|
|
134
|
+
...(spec.message !== undefined
|
|
135
|
+
? { body: msgText(spec.message, "") }
|
|
136
|
+
: {}),
|
|
126
137
|
...(spec.confirmLabel !== undefined
|
|
127
138
|
? { actionLabel: msgText(spec.confirmLabel, buttonLabel) }
|
|
128
139
|
: {}),
|
|
129
140
|
...(spec.cancelLabel !== undefined
|
|
130
|
-
? { cancelLabel: msgText(spec.cancelLabel,
|
|
141
|
+
? { cancelLabel: msgText(spec.cancelLabel, "Cancelar") }
|
|
131
142
|
: {}),
|
|
132
143
|
}
|
|
133
|
-
: undefined)
|
|
134
|
-
const destructive = spec?.destructive === true
|
|
144
|
+
: undefined);
|
|
145
|
+
const destructive = spec?.destructive === true;
|
|
135
146
|
/**
|
|
136
147
|
* Precedência de aparência:
|
|
137
148
|
* - Gatilho: `context` explícito vence `destructive`; sem ele, destrutivo é `danger`. Com
|
|
@@ -142,14 +153,15 @@ export function ActionTrigger<TInput, TData>({
|
|
|
142
153
|
* decisão.
|
|
143
154
|
*/
|
|
144
155
|
const triggerContext =
|
|
145
|
-
context ??
|
|
146
|
-
|
|
147
|
-
const
|
|
148
|
-
const
|
|
156
|
+
context ??
|
|
157
|
+
(destructive ? "danger" : icon !== undefined ? "neutral" : "primary");
|
|
158
|
+
const triggerVariant = variant ?? (icon !== undefined ? "ghost" : "solid");
|
|
159
|
+
const confirmContext = destructive ? "danger" : (context ?? "primary");
|
|
160
|
+
const confirmVariant = "solid";
|
|
149
161
|
|
|
150
162
|
const fire = () => {
|
|
151
|
-
void trigger(input)
|
|
152
|
-
}
|
|
163
|
+
void trigger(input);
|
|
164
|
+
};
|
|
153
165
|
|
|
154
166
|
/** O gatilho. Com `icon`: icon-only, rótulo no tooltip e no aria-label. O
|
|
155
167
|
* stopPropagation é o que permite viver DENTRO de uma linha clicável — sem ele,
|
|
@@ -159,30 +171,33 @@ export function ActionTrigger<TInput, TData>({
|
|
|
159
171
|
<Button
|
|
160
172
|
context={triggerContext}
|
|
161
173
|
variant={triggerVariant}
|
|
162
|
-
size={size ?? (icon !== undefined ?
|
|
174
|
+
size={size ?? (icon !== undefined ? "icon-xs" : "default")}
|
|
163
175
|
busy={isLoading}
|
|
164
176
|
disabled={disabled}
|
|
165
177
|
aria-label={icon !== undefined ? buttonLabel : undefined}
|
|
166
178
|
onClick={(e) => {
|
|
167
|
-
e.stopPropagation()
|
|
168
|
-
onClick()
|
|
179
|
+
e.stopPropagation();
|
|
180
|
+
onClick();
|
|
169
181
|
}}
|
|
170
|
-
className={cn(
|
|
182
|
+
className={cn(
|
|
183
|
+
icon !== undefined && "shrink-0 text-muted-foreground/60",
|
|
184
|
+
className,
|
|
185
|
+
)}
|
|
171
186
|
data-action={action.name}
|
|
172
187
|
>
|
|
173
188
|
{icon ?? buttonLabel}
|
|
174
189
|
</Button>
|
|
175
|
-
)
|
|
176
|
-
if (icon === undefined) return button
|
|
190
|
+
);
|
|
191
|
+
if (icon === undefined) return button;
|
|
177
192
|
return (
|
|
178
193
|
<Tooltip>
|
|
179
194
|
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
|
180
195
|
<TooltipContent>{buttonLabel}</TooltipContent>
|
|
181
196
|
</Tooltip>
|
|
182
|
-
)
|
|
183
|
-
}
|
|
197
|
+
);
|
|
198
|
+
};
|
|
184
199
|
|
|
185
|
-
if (confirm === undefined) return renderButton(fire)
|
|
200
|
+
if (confirm === undefined) return renderButton(fire);
|
|
186
201
|
|
|
187
202
|
return (
|
|
188
203
|
<>
|
|
@@ -192,38 +207,53 @@ export function ActionTrigger<TInput, TData>({
|
|
|
192
207
|
<DialogContent
|
|
193
208
|
data-action={action.name}
|
|
194
209
|
aria-describedby={
|
|
195
|
-
itemLabel !== undefined || confirm.body !== undefined
|
|
210
|
+
itemLabel !== undefined || confirm.body !== undefined
|
|
211
|
+
? confirmBodyId
|
|
212
|
+
: undefined
|
|
196
213
|
}
|
|
197
214
|
>
|
|
198
215
|
<DialogHeader>
|
|
199
216
|
<DialogTitle>{confirm.title}</DialogTitle>
|
|
200
217
|
</DialogHeader>
|
|
201
218
|
{(itemLabel !== undefined || confirm.body !== undefined) && (
|
|
202
|
-
<DialogBody
|
|
219
|
+
<DialogBody
|
|
220
|
+
id={confirmBodyId}
|
|
221
|
+
className="text-sm text-muted-foreground"
|
|
222
|
+
>
|
|
203
223
|
{itemLabel !== undefined && (
|
|
204
|
-
<span className="font-medium text-foreground"
|
|
224
|
+
<span className="font-medium text-foreground">
|
|
225
|
+
“{itemLabel}”
|
|
226
|
+
</span>
|
|
205
227
|
)}
|
|
206
|
-
{itemLabel !== undefined && confirm.body !== undefined
|
|
228
|
+
{itemLabel !== undefined && confirm.body !== undefined
|
|
229
|
+
? " — "
|
|
230
|
+
: ""}
|
|
207
231
|
{confirm.body}
|
|
208
232
|
</DialogBody>
|
|
209
233
|
)}
|
|
210
234
|
<DialogFooter>
|
|
211
|
-
<
|
|
212
|
-
<
|
|
213
|
-
|
|
235
|
+
<ButtonGroup mode="spaced" distribution="equal">
|
|
236
|
+
<DialogClose initialFocus asChild>
|
|
237
|
+
<Button
|
|
238
|
+
context="neutral"
|
|
239
|
+
variant="outline"
|
|
240
|
+
disabled={isLoading}
|
|
241
|
+
>
|
|
242
|
+
{confirm.cancelLabel ?? "Cancelar"}
|
|
243
|
+
</Button>
|
|
244
|
+
</DialogClose>
|
|
245
|
+
<Button
|
|
246
|
+
context={confirmContext}
|
|
247
|
+
variant={confirmVariant}
|
|
248
|
+
busy={isLoading}
|
|
249
|
+
onClick={fire}
|
|
250
|
+
>
|
|
251
|
+
{confirm.actionLabel ?? buttonLabel}
|
|
214
252
|
</Button>
|
|
215
|
-
</
|
|
216
|
-
<Button
|
|
217
|
-
context={confirmContext}
|
|
218
|
-
variant={confirmVariant}
|
|
219
|
-
busy={isLoading}
|
|
220
|
-
onClick={fire}
|
|
221
|
-
>
|
|
222
|
-
{confirm.actionLabel ?? buttonLabel}
|
|
223
|
-
</Button>
|
|
253
|
+
</ButtonGroup>
|
|
224
254
|
</DialogFooter>
|
|
225
255
|
</DialogContent>
|
|
226
256
|
</Dialog>
|
|
227
257
|
</>
|
|
228
|
-
)
|
|
258
|
+
);
|
|
229
259
|
}
|
|
@@ -4,8 +4,8 @@ import { Slot } from '@radix-ui/react-slot'
|
|
|
4
4
|
import type { UiContext } from '../../../core/ui-context.ts'
|
|
5
5
|
import { cn } from '../../lib/cn.ts'
|
|
6
6
|
import {
|
|
7
|
-
controlBase,
|
|
8
7
|
controlGlyphClass,
|
|
8
|
+
controlGlyphSize,
|
|
9
9
|
controlHeight,
|
|
10
10
|
controlSquare,
|
|
11
11
|
focusRing,
|
|
@@ -149,7 +149,7 @@ export function Button({
|
|
|
149
149
|
children
|
|
150
150
|
) : (
|
|
151
151
|
<>
|
|
152
|
-
{busy ? <Spinner size={
|
|
152
|
+
{busy ? <Spinner size={controlGlyphSize(size)} /> : icon}
|
|
153
153
|
{children}
|
|
154
154
|
</>
|
|
155
155
|
)}
|
|
@@ -151,6 +151,7 @@ const ChatTranscript = React.memo(function ChatTranscript({
|
|
|
151
151
|
const scrollRef = React.useRef<HTMLDivElement>(null)
|
|
152
152
|
const turns = React.useMemo(() => groupTurns(items), [items])
|
|
153
153
|
const indicatorVisible = indicator !== undefined
|
|
154
|
+
const compactIndicator = indicator === ''
|
|
154
155
|
// `null`/`false` em `empty` significam "sem nó" (o dado ainda não chegou): o greeting
|
|
155
156
|
// continua como fallback em vez de um wrapper composto vazio.
|
|
156
157
|
const composedEmpty = empty !== undefined && empty !== null && empty !== false
|
|
@@ -162,7 +163,7 @@ const ChatTranscript = React.memo(function ChatTranscript({
|
|
|
162
163
|
<div
|
|
163
164
|
ref={scrollRef}
|
|
164
165
|
data-slot="chat-scroll"
|
|
165
|
-
className="flex min-h-0 flex-1 flex-col gap-
|
|
166
|
+
className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto p-4"
|
|
166
167
|
>
|
|
167
168
|
{items.length === 0 && !indicatorVisible && composedEmpty ? (
|
|
168
169
|
<div data-slot="chat-empty" data-variant="composed" className="m-auto flex justify-center text-center">
|
|
@@ -189,9 +190,17 @@ const ChatTranscript = React.memo(function ChatTranscript({
|
|
|
189
190
|
data-slot="chat-turn-user"
|
|
190
191
|
data-message-id={turn.user.id}
|
|
191
192
|
data-message-created-at={turn.user.createdAt}
|
|
192
|
-
className="flex justify-end py-1
|
|
193
|
+
className="group/chat-message mb-1 flex flex-col items-end justify-end py-1"
|
|
193
194
|
>
|
|
194
195
|
<UserTurn content={turn.user.content} />
|
|
196
|
+
{renderMessageActions !== undefined && (
|
|
197
|
+
<div
|
|
198
|
+
data-slot="chat-message-actions"
|
|
199
|
+
className="mt-0.5 flex min-h-5 items-center opacity-0 transition-opacity group-hover/chat-message:opacity-100 focus-within:opacity-100"
|
|
200
|
+
>
|
|
201
|
+
{renderMessageActions(turn.user)}
|
|
202
|
+
</div>
|
|
203
|
+
)}
|
|
195
204
|
</div>
|
|
196
205
|
)}
|
|
197
206
|
{turn.rest.map((item, i) => {
|
|
@@ -238,7 +247,7 @@ const ChatTranscript = React.memo(function ChatTranscript({
|
|
|
238
247
|
{renderMessageActions !== undefined && (
|
|
239
248
|
<div
|
|
240
249
|
data-slot="chat-message-actions"
|
|
241
|
-
className="mt-
|
|
250
|
+
className="mt-0.5 flex min-h-5 items-center opacity-0 transition-opacity group-hover/chat-message:opacity-100 focus-within:opacity-100"
|
|
242
251
|
>
|
|
243
252
|
{renderMessageActions(item)}
|
|
244
253
|
</div>
|
|
@@ -251,7 +260,12 @@ const ChatTranscript = React.memo(function ChatTranscript({
|
|
|
251
260
|
{indicatorVisible && (
|
|
252
261
|
<div
|
|
253
262
|
data-slot="chat-activity"
|
|
254
|
-
|
|
263
|
+
role="status"
|
|
264
|
+
aria-label={compactIndicator ? 'Pensando…' : undefined}
|
|
265
|
+
className={cn(
|
|
266
|
+
'flex items-center gap-2 py-1 text-xs text-muted-foreground',
|
|
267
|
+
compactIndicator && 'justify-center',
|
|
268
|
+
)}
|
|
255
269
|
>
|
|
256
270
|
<span className="flex gap-1">
|
|
257
271
|
{[0, 1, 2].map((i) => (
|
|
@@ -262,7 +276,7 @@ const ChatTranscript = React.memo(function ChatTranscript({
|
|
|
262
276
|
/>
|
|
263
277
|
))}
|
|
264
278
|
</span>
|
|
265
|
-
<span>{indicator ?? 'Pensando…'}</span>
|
|
279
|
+
{!compactIndicator && <span>{indicator ?? 'Pensando…'}</span>}
|
|
266
280
|
</div>
|
|
267
281
|
)}
|
|
268
282
|
</div>
|
|
@@ -10,8 +10,9 @@ export type ControlShape = 'default' | 'pill'
|
|
|
10
10
|
* - Quadrado só-ícone (`icon-xs` · `icon-sm` · `icon` · `icon-lg`): 1.5 · 1.75 · 2.25 · 2.5rem.
|
|
11
11
|
* `icon-xs` é o degrau da ação que mora dentro de uma linha densa. `icon-sm` atende
|
|
12
12
|
* composições compactas que ainda precisam de mais presença.
|
|
13
|
-
* - Glifo (o svg dentro do controle daquele tamanho): 0.875 · 0.875 · 1 · 1.25rem. O
|
|
14
|
-
* usa
|
|
13
|
+
* - Glifo (o svg dentro do controle daquele tamanho): 0.875 · 0.875 · 1 · 1.25rem. O quadrado
|
|
14
|
+
* `icon` usa o glifo mais discreto de 0.875rem sem reduzir sua área clicável de 2.25rem. O
|
|
15
|
+
* Spinner usa a mesma medida, por isso `busy` substitui o ícone sem mexer na largura do botão.
|
|
15
16
|
*/
|
|
16
17
|
export type ControlSize = 'xs' | 'sm' | 'default' | 'lg'
|
|
17
18
|
export type ControlIconSize = 'icon-xs' | 'icon-sm' | 'icon' | 'icon-lg'
|
|
@@ -65,9 +66,14 @@ export function controlBase(size: ControlScale | null | undefined): ControlSize
|
|
|
65
66
|
}
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
/** O degrau visual do glifo; `icon` preserva a caixa default com um desenho mais discreto. */
|
|
70
|
+
export function controlGlyphSize(size: ControlScale | null | undefined): ControlSize {
|
|
71
|
+
return size === 'icon' ? 'sm' : controlBase(size)
|
|
72
|
+
}
|
|
73
|
+
|
|
68
74
|
/** Fallback de tamanho do svg descendente, respeitando um `size-*` explícito no próprio ícone. */
|
|
69
75
|
export function controlGlyphClass(size: ControlScale | null | undefined): string {
|
|
70
|
-
return controlGlyphSelector[
|
|
76
|
+
return controlGlyphSelector[controlGlyphSize(size)]
|
|
71
77
|
}
|
|
72
78
|
|
|
73
79
|
/**
|
|
@@ -156,7 +156,7 @@ function DialogCloseButton({ disabled = false }: { disabled?: boolean }) {
|
|
|
156
156
|
<DialogPrimitive.Close data-slot="dialog-close" asChild>
|
|
157
157
|
<Button
|
|
158
158
|
type="button"
|
|
159
|
-
size="icon
|
|
159
|
+
size="icon"
|
|
160
160
|
variant="ghost"
|
|
161
161
|
disabled={disabled}
|
|
162
162
|
aria-label="Fechar"
|
|
@@ -196,7 +196,9 @@ export function DialogContent({
|
|
|
196
196
|
aria-describedby={ariaDescribedBy}
|
|
197
197
|
{...props}
|
|
198
198
|
>
|
|
199
|
-
<DialogContentContext.Provider
|
|
199
|
+
<DialogContentContext.Provider
|
|
200
|
+
value={{ showCloseButton: false, closeDisabled: true }}
|
|
201
|
+
>
|
|
200
202
|
{children}
|
|
201
203
|
</DialogContentContext.Provider>
|
|
202
204
|
</AlertDialogPrimitive.Content>
|
|
@@ -218,7 +220,9 @@ export function DialogContent({
|
|
|
218
220
|
aria-describedby={ariaDescribedBy}
|
|
219
221
|
{...props}
|
|
220
222
|
>
|
|
221
|
-
<DialogContentContext.Provider
|
|
223
|
+
<DialogContentContext.Provider
|
|
224
|
+
value={{ showCloseButton: showClose, closeDisabled }}
|
|
225
|
+
>
|
|
222
226
|
{children}
|
|
223
227
|
</DialogContentContext.Provider>
|
|
224
228
|
</DialogPrimitive.Content>
|
|
@@ -232,20 +236,23 @@ export function DialogHeader({
|
|
|
232
236
|
...props
|
|
233
237
|
}: React.ComponentProps<"div">) {
|
|
234
238
|
const { mode } = useDialogContext();
|
|
235
|
-
const { showCloseButton, closeDisabled } =
|
|
239
|
+
const { showCloseButton, closeDisabled } =
|
|
240
|
+
React.useContext(DialogContentContext);
|
|
236
241
|
return (
|
|
237
242
|
<div
|
|
238
243
|
data-slot="dialog-header"
|
|
239
244
|
className={cn(
|
|
240
245
|
mode === "alert"
|
|
241
246
|
? "grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=dialog-media]:grid-rows-[auto_auto_1fr]"
|
|
242
|
-
: "flex shrink-0 items-center gap-4 border-b p-
|
|
247
|
+
: "flex shrink-0 items-center gap-4 border-b p-4 text-left [&:has(+[data-slot=dialog-footer])]:border-b-0",
|
|
243
248
|
className,
|
|
244
249
|
)}
|
|
245
250
|
{...props}
|
|
246
251
|
>
|
|
247
252
|
{children}
|
|
248
|
-
{mode !== "alert" && showCloseButton &&
|
|
253
|
+
{mode !== "alert" && showCloseButton && (
|
|
254
|
+
<DialogCloseButton disabled={closeDisabled} />
|
|
255
|
+
)}
|
|
249
256
|
</div>
|
|
250
257
|
);
|
|
251
258
|
}
|
|
@@ -275,7 +282,7 @@ export function DialogBody({
|
|
|
275
282
|
<div
|
|
276
283
|
data-slot="dialog-body"
|
|
277
284
|
className={cn(
|
|
278
|
-
mode === "alert" ? "min-w-0" : "min-h-0 flex-1 overflow-y-auto p-
|
|
285
|
+
mode === "alert" ? "min-w-0" : "min-h-0 flex-1 overflow-y-auto p-4",
|
|
279
286
|
className,
|
|
280
287
|
)}
|
|
281
288
|
{...props}
|
|
@@ -293,8 +300,8 @@ export function DialogFooter({
|
|
|
293
300
|
data-slot="dialog-footer"
|
|
294
301
|
className={cn(
|
|
295
302
|
mode === "alert"
|
|
296
|
-
? "
|
|
297
|
-
: "flex shrink-0 flex-col-reverse gap-2 border-t
|
|
303
|
+
? "w-full"
|
|
304
|
+
: "flex shrink-0 flex-col-reverse gap-2 border-t p-4 sm:flex-row sm:justify-end",
|
|
298
305
|
className,
|
|
299
306
|
)}
|
|
300
307
|
{...props}
|
|
@@ -58,7 +58,7 @@ function DrawerCloseButton({ disabled = false }: { disabled?: boolean }) {
|
|
|
58
58
|
<DrawerPrimitive.Close data-slot="drawer-close" asChild>
|
|
59
59
|
<Button
|
|
60
60
|
type="button"
|
|
61
|
-
size="icon
|
|
61
|
+
size="icon"
|
|
62
62
|
variant="ghost"
|
|
63
63
|
disabled={disabled}
|
|
64
64
|
aria-label="Fechar"
|
|
@@ -102,7 +102,9 @@ function DrawerContent({
|
|
|
102
102
|
aria-describedby={ariaDescribedBy}
|
|
103
103
|
{...props}
|
|
104
104
|
>
|
|
105
|
-
<DrawerContentContext.Provider
|
|
105
|
+
<DrawerContentContext.Provider
|
|
106
|
+
value={{ showCloseButton, closeDisabled }}
|
|
107
|
+
>
|
|
106
108
|
{children}
|
|
107
109
|
</DrawerContentContext.Provider>
|
|
108
110
|
</DrawerPrimitive.Content>
|
|
@@ -115,11 +117,12 @@ function DrawerHeader({
|
|
|
115
117
|
children,
|
|
116
118
|
...props
|
|
117
119
|
}: React.ComponentProps<"div">) {
|
|
118
|
-
const { showCloseButton, closeDisabled } =
|
|
120
|
+
const { showCloseButton, closeDisabled } =
|
|
121
|
+
React.useContext(DrawerContentContext);
|
|
119
122
|
return (
|
|
120
123
|
<div
|
|
121
124
|
data-slot="drawer-header"
|
|
122
|
-
className={cn("flex shrink-0 items-center gap-4 border-b p-
|
|
125
|
+
className={cn("flex shrink-0 items-center gap-4 border-b p-4", className)}
|
|
123
126
|
{...props}
|
|
124
127
|
>
|
|
125
128
|
{children}
|
|
@@ -132,7 +135,7 @@ function DrawerBody({ className, ...props }: React.ComponentProps<"div">) {
|
|
|
132
135
|
return (
|
|
133
136
|
<div
|
|
134
137
|
data-slot="drawer-body"
|
|
135
|
-
className={cn("min-h-0 flex-1 overflow-y-auto p-
|
|
138
|
+
className={cn("min-h-0 flex-1 overflow-y-auto p-4", className)}
|
|
136
139
|
{...props}
|
|
137
140
|
/>
|
|
138
141
|
);
|
|
@@ -143,7 +146,7 @@ function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|
|
143
146
|
<div
|
|
144
147
|
data-slot="drawer-footer"
|
|
145
148
|
className={cn(
|
|
146
|
-
"mt-auto flex shrink-0 flex-col-reverse gap-2 border-t
|
|
149
|
+
"mt-auto flex shrink-0 flex-col-reverse gap-2 border-t p-4 sm:flex-row sm:justify-end",
|
|
147
150
|
className,
|
|
148
151
|
)}
|
|
149
152
|
{...props}
|
|
@@ -2,23 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
Use `ActionFormCard` para apresentar um `ActionForm` como seção delimitada da página. O componente
|
|
4
4
|
aplica a estrutura e o espaçamento de `Card`, sem a faixa de ações de um modal. O título é opcional.
|
|
5
|
+
Quando `onCancel` acrescenta uma segunda ação, o componente organiza os botões em um
|
|
6
|
+
`ButtonGroup` espaçado sem forçar a divisão 50/50 do modal.
|
|
5
7
|
|
|
6
8
|
```tsx preview col md
|
|
7
9
|
<DocBrowserActionProvider>
|
|
8
10
|
<ActionFormCard
|
|
9
11
|
title="Editar workspace"
|
|
10
12
|
action={docWorkspaceCreate}
|
|
11
|
-
defaultValues={{ name:
|
|
12
|
-
submitLabel="Salvar alterações"
|
|
13
|
+
defaultValues={{ name: "Empresa X", status: "active" }}
|
|
13
14
|
/>
|
|
14
15
|
</DocBrowserActionProvider>
|
|
15
16
|
```
|
|
16
17
|
|
|
17
18
|
## Propriedades de ActionFormCard
|
|
18
19
|
|
|
19
|
-
| Propriedade
|
|
20
|
-
|
|
21
|
-
| `title`
|
|
22
|
-
| `description`
|
|
23
|
-
| `action / defaultValues / fieldOptions / submitLabel / onSuccess / onCancel` | Propriedades de `ActionForm` |
|
|
24
|
-
| `cardClassName`
|
|
20
|
+
| Propriedade | Tipo | Padrão | Descrição |
|
|
21
|
+
| ---------------------------------------------------------------------------- | ---------------------------- | ------ | ---------------------------------------------------------------------------------------- |
|
|
22
|
+
| `title` | `string` | | Título do cabeçalho. Sem ele, o card começa diretamente pelo corpo. |
|
|
23
|
+
| `description` | `string` | | Subtítulo opcional, abaixo do título. |
|
|
24
|
+
| `action / defaultValues / fieldOptions / submitLabel / onSuccess / onCancel` | Propriedades de `ActionForm` | | Mantêm o mesmo comportamento do formulário interno. |
|
|
25
|
+
| `cardClassName` | `string` | | Classes aplicadas à superfície do card. `className` continua sendo aplicado ao `<form>`. |
|
|
@@ -3,21 +3,20 @@
|
|
|
3
3
|
Use `ActionFormDialog` quando o formulário precisar interromper o fluxo atual sem levar a pessoa
|
|
4
4
|
para outra página. O consumidor controla `open`; depois de uma execução bem-sucedida, o componente
|
|
5
5
|
fecha o modal. O cabeçalho e o rodapé permanecem visíveis enquanto os campos podem rolar.
|
|
6
|
+
Quando há cancelamento, o rodapé usa um `ButtonGroup` dividido: `Cancelar` aparece em `outline` e
|
|
7
|
+
a ação principal permanece `solid`, ambas no tamanho normal de uma decisão modal.
|
|
6
8
|
|
|
7
9
|
```tsx preview
|
|
8
10
|
const [open, setOpen] = useState(false);
|
|
9
11
|
|
|
10
12
|
render(
|
|
11
13
|
<DocBrowserActionProvider>
|
|
12
|
-
<Button onClick={() => setOpen(true)}>
|
|
13
|
-
<Plus /> Novo workspace
|
|
14
|
-
</Button>
|
|
14
|
+
<Button onClick={() => setOpen(true)}>Criar workspace</Button>
|
|
15
15
|
<ActionFormDialog
|
|
16
16
|
open={open}
|
|
17
17
|
onOpenChange={setOpen}
|
|
18
|
-
title="
|
|
18
|
+
title="Criar workspace"
|
|
19
19
|
action={docWorkspaceCreate}
|
|
20
|
-
submitLabel="Criar workspace"
|
|
21
20
|
/>
|
|
22
21
|
</DocBrowserActionProvider>,
|
|
23
22
|
);
|
|
@@ -25,10 +24,10 @@ render(
|
|
|
25
24
|
|
|
26
25
|
## Propriedades de ActionFormDialog
|
|
27
26
|
|
|
28
|
-
| Propriedade | Tipo | Padrão | Descrição
|
|
29
|
-
| --------------------- | ------------------------------------------------- | ---------- |
|
|
30
|
-
| `open / onOpenChange` | `boolean / (open: boolean) => void` | | Estado controlado do modal. `onOpenChange(false)` é chamado no sucesso e ao cancelar.
|
|
31
|
-
| `title` | `string` | | Nome da tarefa exibido no cabeçalho.
|
|
32
|
-
| `intro` | `ReactNode` | | Contexto relevante no início do corpo, como texto ou `Alert`.
|
|
33
|
-
| `submitLabel` | `string` | `'Salvar'` | Resultado da ação principal.
|
|
34
|
-
| `…ActionFormProps` | `action, defaultValues, onSuccess, fieldOptions…` | | Demais propriedades repassadas ao `ActionForm` interno.
|
|
27
|
+
| Propriedade | Tipo | Padrão | Descrição |
|
|
28
|
+
| --------------------- | ------------------------------------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
29
|
+
| `open / onOpenChange` | `boolean / (open: boolean) => void` | | Estado controlado do modal. `onOpenChange(false)` é chamado no sucesso e ao cancelar. |
|
|
30
|
+
| `title` | `string` | | Nome da tarefa exibido no cabeçalho. |
|
|
31
|
+
| `intro` | `ReactNode` | | Contexto relevante no início do corpo, como texto ou `Alert`. |
|
|
32
|
+
| `submitLabel` | `string` | `'Salvar'` | Resultado da ação principal. Preserve o padrão em criação e edição comuns; sobrescreva apenas quando a operação tiver outro efeito, como `Renomear` ou `Criar nova versão`. |
|
|
33
|
+
| `…ActionFormProps` | `action, defaultValues, onSuccess, fieldOptions…` | | Demais propriedades repassadas ao `ActionForm` interno. |
|