@softize/opus 12.9.0 → 12.11.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 +44 -0
- package/bin/lib/check.mjs +1103 -310
- package/bin/lib/copy.mjs +74 -6
- package/docs/adr/0003-dictionary-presentation-is-declared.md +3 -0
- package/docs/adr/0004-page-content-state-is-composed.md +65 -0
- package/docs/adr/0005-structural-surfaces-share-an-explicit-anatomy.md +97 -0
- package/docs/adr/0006-semantic-context-precedes-visual-variant.md +182 -0
- package/docs/code-style.md +6 -2
- package/package.json +1 -1
- package/registry/instructions/opus.md +5 -0
- package/registry/skills/build-opus-ui/SKILL.md +27 -16
- package/registry/skills/build-opus-ui/references/evaluations.md +16 -5
- package/registry/skills/build-opus-ui/references/ui-patterns.md +38 -15
- package/registry/skills/model-opus-dictionary/SKILL.md +4 -2
- package/registry/skills/model-opus-dictionary/references/evaluations.md +4 -3
- package/registry/templates/app/src/App.tsx +1 -1
- package/src/core/dictionary.ts +52 -14
- package/src/core/index.ts +10 -0
- package/src/core/ui-context.ts +29 -0
- package/src/schema/drivers/zod.ts +34 -19
- package/src/ui/components/patterns/action-form-card.tsx +18 -12
- package/src/ui/components/patterns/confirm.tsx +26 -3
- package/src/ui/components/patterns/content-header.tsx +335 -61
- package/src/ui/components/patterns/data-state.tsx +23 -10
- package/src/ui/components/patterns/form.tsx +1 -1
- package/src/ui/components/patterns/list.tsx +1096 -777
- package/src/ui/components/patterns/page-state.tsx +115 -0
- package/src/ui/components/patterns/page.tsx +231 -41
- package/src/ui/components/patterns/sidebar.tsx +354 -80
- package/src/ui/components/patterns/trigger.tsx +13 -9
- package/src/ui/components/patterns/view.tsx +7 -11
- package/src/ui/components/primitives/alert-dialog.tsx +7 -5
- package/src/ui/components/primitives/alert.tsx +298 -80
- package/src/ui/components/primitives/ask.tsx +2 -1
- package/src/ui/components/primitives/badge.tsx +91 -30
- package/src/ui/components/primitives/button.tsx +99 -60
- package/src/ui/components/primitives/calendar.tsx +39 -39
- package/src/ui/components/primitives/card.tsx +96 -23
- package/src/ui/components/primitives/detail.tsx +2 -2
- package/src/ui/components/primitives/dictionary-value.tsx +9 -14
- package/src/ui/components/primitives/dot.tsx +74 -21
- package/src/ui/components/primitives/drawer.tsx +33 -20
- package/src/ui/components/primitives/field.tsx +4 -4
- package/src/ui/components/primitives/item.tsx +137 -81
- package/src/ui/components/primitives/menu.tsx +11 -3
- package/src/ui/components/primitives/metric-card.tsx +133 -0
- package/src/ui/components/primitives/table.tsx +2 -2
- package/src/ui/components/primitives/tooltip.tsx +1 -1
- package/src/ui/docs/DocBrowser.tsx +3 -3
- package/src/ui/docs/changelog.tsx +1 -1
- package/src/ui/docs/content/action-form-card.md +1 -1
- package/src/ui/docs/content/alert-dialog.md +8 -8
- package/src/ui/docs/content/alert.md +54 -23
- package/src/ui/docs/content/badge.md +18 -19
- package/src/ui/docs/content/button.md +12 -9
- package/src/ui/docs/content/card.md +5 -5
- package/src/ui/docs/content/content.md +44 -0
- package/src/ui/docs/content/customization.md +2 -2
- package/src/ui/docs/content/detail.md +5 -2
- package/src/ui/docs/content/dialog.md +2 -2
- package/src/ui/docs/content/dictionary-value.md +11 -10
- package/src/ui/docs/content/dot.md +7 -7
- package/src/ui/docs/content/drawer.md +6 -3
- package/src/ui/docs/content/field.md +1 -1
- package/src/ui/docs/content/input-group.md +3 -2
- package/src/ui/docs/content/item.md +47 -21
- package/src/ui/docs/content/menu.md +5 -4
- package/src/ui/docs/content/metric-card.md +41 -0
- package/src/ui/docs/content/page-state.md +45 -0
- package/src/ui/docs/content/page.md +48 -10
- package/src/ui/docs/content/semantic-context.md +63 -0
- package/src/ui/docs/content/sidebar.md +4 -4
- package/src/ui/docs/content/skeleton.md +2 -2
- package/src/ui/docs/content/table.md +3 -3
- package/src/ui/docs/content/tokens.md +28 -0
- package/src/ui/docs/content/tooltip.md +15 -1
- package/src/ui/docs/doc-client.tsx +2 -2
- package/src/ui/docs/registry.tsx +596 -228
- package/src/ui/lib/semantic-context.ts +30 -0
- package/src/ui/meta.ts +292 -270
- package/src/ui/react.tsx +378 -111
- package/src/ui/theme.css +66 -0
|
@@ -1,160 +1,434 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useContext,
|
|
4
|
+
type HTMLAttributes,
|
|
5
|
+
type ReactNode,
|
|
6
|
+
} from "react";
|
|
7
|
+
import { ChevronRight } from "lucide-react";
|
|
8
|
+
import { useOverflowing } from "../../lib/overflow.ts";
|
|
9
|
+
import { cn } from "../../lib/cn.ts";
|
|
10
|
+
import {
|
|
11
|
+
Tooltip,
|
|
12
|
+
TooltipContent,
|
|
13
|
+
TooltipProvider,
|
|
14
|
+
TooltipTrigger,
|
|
15
|
+
} from "../primitives/tooltip.tsx";
|
|
6
16
|
|
|
7
|
-
interface SidebarContextValue {
|
|
8
|
-
|
|
17
|
+
interface SidebarContextValue {
|
|
18
|
+
collapsed: boolean;
|
|
19
|
+
}
|
|
20
|
+
const SidebarContext = createContext<SidebarContextValue | null>(null);
|
|
9
21
|
|
|
10
22
|
/** Estado da Sidebar mais próxima. Uso interno por navegações que se adaptam ao colapso. */
|
|
11
23
|
export function useSidebarCollapsed(): boolean {
|
|
12
|
-
return useContext(SidebarContext)?.collapsed ?? false
|
|
24
|
+
return useContext(SidebarContext)?.collapsed ?? false;
|
|
13
25
|
}
|
|
14
26
|
|
|
15
27
|
export interface SidebarProps {
|
|
16
|
-
collapsed?: boolean
|
|
28
|
+
collapsed?: boolean;
|
|
17
29
|
/** Desliga a borda quando o Split já desenha a divisória. */
|
|
18
|
-
divider?: boolean
|
|
19
|
-
className?: string
|
|
20
|
-
children: ReactNode
|
|
30
|
+
divider?: boolean;
|
|
31
|
+
className?: string;
|
|
32
|
+
children: ReactNode;
|
|
21
33
|
}
|
|
22
34
|
|
|
23
35
|
/** Moldura composicional de uma barra lateral; o Split decide a posição, Sidebar o chrome. */
|
|
24
|
-
export function Sidebar({
|
|
36
|
+
export function Sidebar({
|
|
37
|
+
collapsed = false,
|
|
38
|
+
divider = true,
|
|
39
|
+
className,
|
|
40
|
+
children,
|
|
41
|
+
}: SidebarProps): React.ReactElement {
|
|
25
42
|
return (
|
|
26
43
|
<SidebarContext.Provider value={{ collapsed }}>
|
|
27
|
-
<aside
|
|
44
|
+
<aside
|
|
45
|
+
data-slot="sidebar"
|
|
46
|
+
data-collapsed={String(collapsed)}
|
|
47
|
+
className={cn(
|
|
48
|
+
"group/sidebar flex h-full min-h-0 shrink-0 flex-col transition-[width] duration-200 ease-out",
|
|
49
|
+
divider && "border-r border-border",
|
|
50
|
+
collapsed ? "w-14" : "w-64",
|
|
51
|
+
className,
|
|
52
|
+
)}
|
|
53
|
+
>
|
|
28
54
|
{children}
|
|
29
55
|
</aside>
|
|
30
56
|
</SidebarContext.Provider>
|
|
31
|
-
)
|
|
57
|
+
);
|
|
32
58
|
}
|
|
33
59
|
|
|
34
60
|
/** Faixa fixa no topo de um pane — marca, contexto ou ações locais. */
|
|
35
|
-
export function PaneHeader({
|
|
36
|
-
|
|
61
|
+
export function PaneHeader({
|
|
62
|
+
className,
|
|
63
|
+
children,
|
|
64
|
+
}: {
|
|
65
|
+
className?: string;
|
|
66
|
+
children: ReactNode;
|
|
67
|
+
}): React.ReactElement {
|
|
68
|
+
return (
|
|
69
|
+
<div
|
|
70
|
+
data-slot="pane-header"
|
|
71
|
+
className={cn("shrink-0 border-b border-border", className)}
|
|
72
|
+
>
|
|
73
|
+
{children}
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
37
76
|
}
|
|
38
77
|
|
|
39
78
|
/** Região rolável que ocupa o espaço restante de um pane. */
|
|
40
|
-
export function
|
|
41
|
-
|
|
79
|
+
export function PaneBody({
|
|
80
|
+
className,
|
|
81
|
+
children,
|
|
82
|
+
}: {
|
|
83
|
+
className?: string;
|
|
84
|
+
children: ReactNode;
|
|
85
|
+
}): React.ReactElement {
|
|
86
|
+
return (
|
|
87
|
+
<div
|
|
88
|
+
data-slot="pane-body"
|
|
89
|
+
className={cn("min-h-0 flex-1 overflow-y-auto", className)}
|
|
90
|
+
>
|
|
91
|
+
{children}
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** @deprecated Use PaneBody. Mantido durante a versão 12 para migração gradual. */
|
|
97
|
+
export function PaneContent({
|
|
98
|
+
className,
|
|
99
|
+
children,
|
|
100
|
+
}: {
|
|
101
|
+
className?: string;
|
|
102
|
+
children: ReactNode;
|
|
103
|
+
}): React.ReactElement {
|
|
104
|
+
return (
|
|
105
|
+
<div
|
|
106
|
+
data-slot="pane-content"
|
|
107
|
+
className={cn("min-h-0 flex-1 overflow-y-auto", className)}
|
|
108
|
+
>
|
|
109
|
+
{children}
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
42
112
|
}
|
|
43
113
|
|
|
44
114
|
/** Faixa fixa no rodapé de um pane para ações persistentes. */
|
|
45
|
-
export function PaneFooter({
|
|
46
|
-
|
|
115
|
+
export function PaneFooter({
|
|
116
|
+
className,
|
|
117
|
+
children,
|
|
118
|
+
}: {
|
|
119
|
+
className?: string;
|
|
120
|
+
children: ReactNode;
|
|
121
|
+
}): React.ReactElement {
|
|
122
|
+
return (
|
|
123
|
+
<div
|
|
124
|
+
data-slot="pane-footer"
|
|
125
|
+
className={cn("shrink-0 border-t border-border p-2", className)}
|
|
126
|
+
>
|
|
127
|
+
{children}
|
|
128
|
+
</div>
|
|
129
|
+
);
|
|
47
130
|
}
|
|
48
131
|
|
|
49
132
|
export interface SidebarItemProps {
|
|
50
|
-
label: string
|
|
51
|
-
icon?: ReactNode
|
|
52
|
-
badge?: ReactNode
|
|
53
|
-
active?: boolean
|
|
54
|
-
disabled?: boolean
|
|
133
|
+
label: string;
|
|
134
|
+
icon?: ReactNode;
|
|
135
|
+
badge?: ReactNode;
|
|
136
|
+
active?: boolean;
|
|
137
|
+
disabled?: boolean;
|
|
55
138
|
/** Controles irmãos do alvo de navegação (ex.: menu de ações). */
|
|
56
|
-
actions?: ReactNode
|
|
139
|
+
actions?: ReactNode;
|
|
57
140
|
/** Presente quando a linha desdobra filhos; o controle fica fora do botão principal. */
|
|
58
|
-
onToggle?: () => void
|
|
59
|
-
expanded?: boolean
|
|
141
|
+
onToggle?: () => void;
|
|
142
|
+
expanded?: boolean;
|
|
60
143
|
/** Posição indicada durante drag-and-drop; bordas inserem, centro recebe dentro. */
|
|
61
|
-
dropPosition?:
|
|
144
|
+
dropPosition?: "before" | "inside" | "after";
|
|
62
145
|
/** Props de drag-and-drop aplicadas à linha, sem acoplar a um driver. */
|
|
63
|
-
dragProps?: HTMLAttributes<HTMLDivElement
|
|
146
|
+
dragProps?: HTMLAttributes<HTMLDivElement>;
|
|
64
147
|
/** Complemento do tooltip no rail recolhido. */
|
|
65
|
-
tooltipHint?: ReactNode
|
|
66
|
-
className?: string
|
|
67
|
-
onClick: () => void
|
|
148
|
+
tooltipHint?: ReactNode;
|
|
149
|
+
className?: string;
|
|
150
|
+
onClick: () => void;
|
|
68
151
|
}
|
|
69
152
|
|
|
70
153
|
/** Linha única de navegação: o alvo principal e seus controles são irmãos, nunca botões aninhados. */
|
|
71
|
-
export function SidebarItem({
|
|
72
|
-
|
|
73
|
-
|
|
154
|
+
export function SidebarItem({
|
|
155
|
+
label,
|
|
156
|
+
icon,
|
|
157
|
+
badge,
|
|
158
|
+
active = false,
|
|
159
|
+
disabled = false,
|
|
160
|
+
actions,
|
|
161
|
+
onToggle,
|
|
162
|
+
expanded = false,
|
|
163
|
+
dropPosition,
|
|
164
|
+
dragProps,
|
|
165
|
+
tooltipHint,
|
|
166
|
+
className,
|
|
167
|
+
onClick,
|
|
168
|
+
}: SidebarItemProps): React.ReactElement {
|
|
169
|
+
const collapsed = useSidebarCollapsed();
|
|
170
|
+
const hasActions = actions !== undefined && actions !== null;
|
|
74
171
|
// O rótulo cortado esmaece em vez de terminar em reticências; a medição do transbordo
|
|
75
172
|
// mantém a máscara fora do rótulo que cabe inteiro na linha.
|
|
76
|
-
const { ref: labelRef, overflowing: labelCut } =
|
|
173
|
+
const { ref: labelRef, overflowing: labelCut } =
|
|
174
|
+
useOverflowing<HTMLSpanElement>(label);
|
|
77
175
|
if (collapsed) {
|
|
78
176
|
const button = (
|
|
79
|
-
<button
|
|
177
|
+
<button
|
|
178
|
+
type="button"
|
|
179
|
+
disabled={disabled}
|
|
180
|
+
aria-current={active ? "page" : undefined}
|
|
181
|
+
onClick={onClick}
|
|
182
|
+
className={cn(
|
|
183
|
+
"mx-auto grid size-9 place-items-center rounded-md transition-colors disabled:pointer-events-none disabled:opacity-40",
|
|
184
|
+
"outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
|
|
185
|
+
active
|
|
186
|
+
? "bg-muted font-medium text-foreground"
|
|
187
|
+
: "text-foreground/80 hover:bg-muted/60",
|
|
188
|
+
)}
|
|
189
|
+
>
|
|
80
190
|
{icon}
|
|
81
191
|
</button>
|
|
82
|
-
)
|
|
192
|
+
);
|
|
83
193
|
// `className` vai no MESMO elemento nos dois modos — a linha —, senão a prop mudaria de alvo
|
|
84
194
|
// conforme o rail estivesse recolhido, e o consumidor não teria como saber o que estilizou.
|
|
85
|
-
return
|
|
195
|
+
return (
|
|
196
|
+
<div data-slot="sidebar-item" className={className} {...dragProps}>
|
|
197
|
+
<Tooltip>
|
|
198
|
+
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
|
199
|
+
<TooltipContent side="right">
|
|
200
|
+
{label}
|
|
201
|
+
{tooltipHint}
|
|
202
|
+
</TooltipContent>
|
|
203
|
+
</Tooltip>
|
|
204
|
+
</div>
|
|
205
|
+
);
|
|
86
206
|
}
|
|
87
207
|
|
|
88
208
|
return (
|
|
89
|
-
<div
|
|
90
|
-
|
|
209
|
+
<div
|
|
210
|
+
data-slot="sidebar-item"
|
|
211
|
+
className={cn(
|
|
212
|
+
"group/sidebar-item relative flex items-center rounded-md transition-colors",
|
|
213
|
+
disabled ? "text-muted-foreground/50 opacity-40" : "hover:bg-muted/60",
|
|
214
|
+
active && "bg-muted",
|
|
215
|
+
dropPosition === "inside" &&
|
|
216
|
+
"bg-primary/5 ring-2 ring-inset ring-primary/60",
|
|
217
|
+
className,
|
|
218
|
+
)}
|
|
219
|
+
{...dragProps}
|
|
220
|
+
>
|
|
221
|
+
{(dropPosition === "before" || dropPosition === "after") && (
|
|
222
|
+
<span
|
|
223
|
+
data-slot="sidebar-item-drop-indicator"
|
|
224
|
+
className={cn(
|
|
225
|
+
"pointer-events-none absolute inset-x-1 z-20 h-0.5 rounded-full bg-primary before:absolute before:-left-1 before:top-1/2 before:size-2 before:-translate-y-1/2 before:rounded-full before:border-2 before:border-primary before:bg-background",
|
|
226
|
+
dropPosition === "before" ? "-top-px" : "-bottom-px",
|
|
227
|
+
)}
|
|
228
|
+
/>
|
|
229
|
+
)}
|
|
91
230
|
{(icon !== undefined || onToggle !== undefined) && (
|
|
92
|
-
<span
|
|
231
|
+
<span
|
|
232
|
+
data-slot="sidebar-item-leading"
|
|
233
|
+
className="pointer-events-none absolute left-2.5 top-1/2 z-10 grid size-4 -translate-y-1/2 place-items-center"
|
|
234
|
+
>
|
|
93
235
|
{/* Sem ícone, o chevron não se esconde: ele é o único ocupante do lugar. */}
|
|
94
|
-
{icon !== undefined &&
|
|
236
|
+
{icon !== undefined && (
|
|
237
|
+
<span
|
|
238
|
+
className={cn(
|
|
239
|
+
"absolute inset-0 flex items-center justify-center transition-opacity",
|
|
240
|
+
onToggle !== undefined &&
|
|
241
|
+
"group-hover/sidebar-item:opacity-0 group-has-[[data-slot=sidebar-item-toggle]:focus-visible]/sidebar-item:opacity-0",
|
|
242
|
+
)}
|
|
243
|
+
>
|
|
244
|
+
{icon}
|
|
245
|
+
</span>
|
|
246
|
+
)}
|
|
95
247
|
{onToggle !== undefined && (
|
|
96
|
-
<button
|
|
97
|
-
|
|
248
|
+
<button
|
|
249
|
+
type="button"
|
|
250
|
+
data-slot="sidebar-item-toggle"
|
|
251
|
+
onClick={onToggle}
|
|
252
|
+
aria-label={expanded ? `Fechar ${label}` : `Abrir ${label}`}
|
|
253
|
+
aria-expanded={expanded}
|
|
254
|
+
className={cn(
|
|
255
|
+
"absolute inset-0 grid place-items-center rounded-sm text-muted-foreground transition-[color,opacity] outline-none hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring/50",
|
|
256
|
+
icon === undefined
|
|
257
|
+
? "pointer-events-auto opacity-100"
|
|
258
|
+
: "pointer-events-none opacity-0 group-hover/sidebar-item:pointer-events-auto group-hover/sidebar-item:opacity-100",
|
|
259
|
+
)}
|
|
260
|
+
>
|
|
261
|
+
<ChevronRight
|
|
262
|
+
className={cn(
|
|
263
|
+
"size-3 transition-transform",
|
|
264
|
+
expanded && "rotate-90",
|
|
265
|
+
)}
|
|
266
|
+
/>
|
|
98
267
|
</button>
|
|
99
268
|
)}
|
|
100
269
|
</span>
|
|
101
270
|
)}
|
|
102
|
-
<button
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
{
|
|
271
|
+
<button
|
|
272
|
+
type="button"
|
|
273
|
+
disabled={disabled}
|
|
274
|
+
aria-current={active ? "page" : undefined}
|
|
275
|
+
onClick={onClick}
|
|
276
|
+
className={cn(
|
|
277
|
+
"flex min-w-0 flex-1 items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm disabled:pointer-events-none",
|
|
278
|
+
"outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
|
|
279
|
+
disabled
|
|
280
|
+
? "text-muted-foreground/50"
|
|
281
|
+
: active
|
|
282
|
+
? "font-medium text-foreground"
|
|
283
|
+
: "text-foreground/80",
|
|
284
|
+
)}
|
|
285
|
+
>
|
|
286
|
+
{(icon !== undefined || onToggle !== undefined) && (
|
|
287
|
+
<span
|
|
288
|
+
data-slot="sidebar-item-leading-space"
|
|
289
|
+
aria-hidden
|
|
290
|
+
className="size-4 shrink-0"
|
|
291
|
+
/>
|
|
292
|
+
)}
|
|
293
|
+
<span
|
|
294
|
+
ref={labelRef}
|
|
295
|
+
data-slot="sidebar-item-label"
|
|
296
|
+
className={cn(
|
|
297
|
+
"min-w-0 flex-1 overflow-hidden whitespace-nowrap group-has-[[data-slot=dot]]/sidebar-item:mr-[1.1875rem]",
|
|
298
|
+
labelCut && "truncate-fade",
|
|
299
|
+
)}
|
|
300
|
+
>
|
|
301
|
+
{label}
|
|
302
|
+
</span>
|
|
303
|
+
{badge !== undefined && (
|
|
304
|
+
<span
|
|
305
|
+
data-slot="sidebar-item-badge"
|
|
306
|
+
className={cn(
|
|
307
|
+
"shrink-0 transition-opacity has-[[data-slot=dot]]:absolute has-[[data-slot=dot]]:right-1 has-[[data-slot=dot]]:top-1/2 has-[[data-slot=dot]]:z-10 has-[[data-slot=dot]]:grid has-[[data-slot=dot]]:size-6 has-[[data-slot=dot]]:-translate-y-1/2 has-[[data-slot=dot]]:place-items-center",
|
|
308
|
+
hasActions &&
|
|
309
|
+
"group-hover/sidebar-item:has-[[data-slot=dot]]:opacity-0",
|
|
310
|
+
)}
|
|
311
|
+
>
|
|
312
|
+
{badge}
|
|
313
|
+
</span>
|
|
314
|
+
)}
|
|
106
315
|
</button>
|
|
107
|
-
{hasActions &&
|
|
316
|
+
{hasActions && (
|
|
317
|
+
<div
|
|
318
|
+
data-slot="sidebar-item-actions"
|
|
319
|
+
className="pointer-events-none absolute right-1 top-1/2 z-10 flex -translate-y-1/2 items-center opacity-0 transition-opacity group-hover/sidebar-item:pointer-events-auto group-hover/sidebar-item:opacity-100 has-[:focus-visible]:pointer-events-auto has-[:focus-visible]:opacity-100 has-[[data-state=open]]:pointer-events-auto has-[[data-state=open]]:opacity-100 [&_button]:grid [&_button]:size-6 [&_button]:place-items-center [&_button]:p-0 [&_svg]:size-3.5"
|
|
320
|
+
>
|
|
321
|
+
{actions}
|
|
322
|
+
</div>
|
|
323
|
+
)}
|
|
108
324
|
</div>
|
|
109
|
-
)
|
|
325
|
+
);
|
|
110
326
|
}
|
|
111
327
|
|
|
112
328
|
export interface SidebarNavGroup {
|
|
113
|
-
label?: string
|
|
114
|
-
items?: SidebarNavItem[]
|
|
115
|
-
subgroups?: SidebarNavSubgroup[]
|
|
329
|
+
label?: string;
|
|
330
|
+
items?: SidebarNavItem[];
|
|
331
|
+
subgroups?: SidebarNavSubgroup[];
|
|
116
332
|
}
|
|
117
333
|
|
|
118
|
-
export type SidebarNavItem = Omit<SidebarItemProps,
|
|
334
|
+
export type SidebarNavItem = Omit<SidebarItemProps, "active" | "onClick"> & {
|
|
335
|
+
id: string;
|
|
336
|
+
};
|
|
119
337
|
|
|
120
338
|
export interface SidebarNavSubgroup {
|
|
121
|
-
label?: string
|
|
122
|
-
items: SidebarNavItem[]
|
|
339
|
+
label?: string;
|
|
340
|
+
items: SidebarNavItem[];
|
|
123
341
|
}
|
|
124
342
|
|
|
125
|
-
const titled = (label?: string): boolean => (label ??
|
|
126
|
-
const subFilled = (subgroup: SidebarNavSubgroup): boolean =>
|
|
343
|
+
const titled = (label?: string): boolean => (label ?? "").trim() !== "";
|
|
344
|
+
const subFilled = (subgroup: SidebarNavSubgroup): boolean =>
|
|
345
|
+
subgroup.items.length > 0;
|
|
127
346
|
const groupFilled = (group: SidebarNavGroup): boolean =>
|
|
128
|
-
(group.items?.length ?? 0) > 0 || (group.subgroups?.some(subFilled) ?? false)
|
|
347
|
+
(group.items?.length ?? 0) > 0 || (group.subgroups?.some(subFilled) ?? false);
|
|
129
348
|
|
|
130
349
|
/** Rótulo discreto de um grupo de navegação, compartilhado por listas e árvores. */
|
|
131
|
-
export function SidebarGroupLabel({
|
|
132
|
-
|
|
350
|
+
export function SidebarGroupLabel({
|
|
351
|
+
className,
|
|
352
|
+
children,
|
|
353
|
+
}: {
|
|
354
|
+
className?: string;
|
|
355
|
+
children: ReactNode;
|
|
356
|
+
}): React.ReactElement {
|
|
357
|
+
return (
|
|
358
|
+
<div
|
|
359
|
+
data-slot="sidebar-group-label"
|
|
360
|
+
className={cn(
|
|
361
|
+
"px-2.5 pb-1 text-sm font-medium text-muted-foreground/70",
|
|
362
|
+
className,
|
|
363
|
+
)}
|
|
364
|
+
>
|
|
365
|
+
{children}
|
|
366
|
+
</div>
|
|
367
|
+
);
|
|
133
368
|
}
|
|
134
369
|
|
|
135
370
|
/** Navegação controlada para Sidebar. Header/footer continuam slots explícitos do pai. */
|
|
136
|
-
export function SidebarNav({
|
|
137
|
-
|
|
138
|
-
|
|
371
|
+
export function SidebarNav({
|
|
372
|
+
groups,
|
|
373
|
+
activeId,
|
|
374
|
+
onSelect,
|
|
375
|
+
navLabel = "Navegação",
|
|
376
|
+
className,
|
|
377
|
+
}: {
|
|
378
|
+
groups: SidebarNavGroup[];
|
|
379
|
+
activeId?: string;
|
|
380
|
+
onSelect: (id: string) => void;
|
|
381
|
+
navLabel?: string;
|
|
382
|
+
className?: string;
|
|
383
|
+
}): React.ReactElement {
|
|
384
|
+
const collapsed = useSidebarCollapsed();
|
|
385
|
+
const renderItem = (
|
|
386
|
+
item: SidebarNavItem,
|
|
387
|
+
nested = false,
|
|
388
|
+
): React.ReactElement => (
|
|
139
389
|
<SidebarItem
|
|
140
390
|
key={item.id}
|
|
141
391
|
{...item}
|
|
142
|
-
className={cn(nested && !collapsed &&
|
|
392
|
+
className={cn(nested && !collapsed && "pl-6", item.className)}
|
|
143
393
|
active={item.id === activeId}
|
|
144
394
|
onClick={() => onSelect(item.id)}
|
|
145
395
|
/>
|
|
146
|
-
)
|
|
396
|
+
);
|
|
147
397
|
const body = (
|
|
148
|
-
<nav
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
398
|
+
<nav
|
|
399
|
+
aria-label={navLabel}
|
|
400
|
+
data-slot="sidebar-nav"
|
|
401
|
+
className={cn("space-y-4 p-2", className)}
|
|
402
|
+
>
|
|
403
|
+
{groups.map((group, gi) =>
|
|
404
|
+
!groupFilled(group) ? null : (
|
|
405
|
+
<div key={gi} className="space-y-0.5">
|
|
406
|
+
{!collapsed && titled(group.label) && (
|
|
407
|
+
<SidebarGroupLabel>{group.label}</SidebarGroupLabel>
|
|
408
|
+
)}
|
|
409
|
+
{group.items?.map((item) => renderItem(item))}
|
|
410
|
+
{group.subgroups?.map((subgroup, si) =>
|
|
411
|
+
!subFilled(subgroup) ? null : (
|
|
412
|
+
<div key={si} className="space-y-0.5">
|
|
413
|
+
{!collapsed && titled(subgroup.label) && (
|
|
414
|
+
<div className="pb-0.5 pl-4 pr-2.5 pt-1.5 text-[0.6875rem] font-medium text-muted-foreground/50">
|
|
415
|
+
{subgroup.label}
|
|
416
|
+
</div>
|
|
417
|
+
)}
|
|
418
|
+
{subgroup.items.map((item) =>
|
|
419
|
+
renderItem(item, titled(subgroup.label)),
|
|
420
|
+
)}
|
|
421
|
+
</div>
|
|
422
|
+
),
|
|
423
|
+
)}
|
|
424
|
+
</div>
|
|
425
|
+
),
|
|
426
|
+
)}
|
|
157
427
|
</nav>
|
|
158
|
-
)
|
|
159
|
-
return collapsed ?
|
|
428
|
+
);
|
|
429
|
+
return collapsed ? (
|
|
430
|
+
<TooltipProvider delayDuration={200}>{body}</TooltipProvider>
|
|
431
|
+
) : (
|
|
432
|
+
body
|
|
433
|
+
);
|
|
160
434
|
}
|
|
@@ -16,7 +16,7 @@ import type { SimpleContract } from '../../../core/index.ts'
|
|
|
16
16
|
import { useTriggerAction } from '../../drivers/react.tsx'
|
|
17
17
|
import { toast } from '../primitives/sonner.tsx'
|
|
18
18
|
import { cn } from '../../lib/cn.ts'
|
|
19
|
-
import { Button } from '../primitives/button.tsx'
|
|
19
|
+
import { Button, type ButtonProps } from '../primitives/button.tsx'
|
|
20
20
|
import { Tooltip, TooltipContent, TooltipTrigger } from '../primitives/tooltip.tsx'
|
|
21
21
|
import {
|
|
22
22
|
AlertDialog,
|
|
@@ -43,9 +43,10 @@ export interface ActionTriggerProps<TInput, TData> {
|
|
|
43
43
|
input: TInput
|
|
44
44
|
/** Texto do botão. Default usa action.label. */
|
|
45
45
|
label?: string
|
|
46
|
-
/**
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
/** Contexto visual. Por padrão, ações destrutivas usam `danger`. */
|
|
47
|
+
context?: ButtonProps['context']
|
|
48
|
+
/** Tratamento visual do Button. */
|
|
49
|
+
variant?: ButtonProps['variant']
|
|
49
50
|
/** Tamanho do botão. */
|
|
50
51
|
size?: 'default' | 'sm' | 'lg' | 'icon'
|
|
51
52
|
/** Desabilita o botão independente de loading. */
|
|
@@ -83,6 +84,7 @@ export function ActionTrigger<TInput, TData>({
|
|
|
83
84
|
action,
|
|
84
85
|
input,
|
|
85
86
|
label,
|
|
87
|
+
context,
|
|
86
88
|
variant,
|
|
87
89
|
size = 'default',
|
|
88
90
|
disabled = false,
|
|
@@ -123,8 +125,10 @@ export function ActionTrigger<TInput, TData>({
|
|
|
123
125
|
const destructive = spec?.destructive === true
|
|
124
126
|
// Com ícone o botão é chrome do item: fica ghost, e o vermelho aparece no hover — uma
|
|
125
127
|
// lixeira sólida vermelha em cada linha seria um campo minado visual.
|
|
126
|
-
const
|
|
127
|
-
const
|
|
128
|
+
const triggerContext = context ?? (destructive ? 'danger' : icon !== undefined ? 'neutral' : 'primary')
|
|
129
|
+
const triggerVariant = variant ?? (icon !== undefined ? 'ghost' : 'solid')
|
|
130
|
+
const confirmContext = destructive ? 'danger' : (context ?? 'primary')
|
|
131
|
+
const confirmVariant = variant ?? 'solid'
|
|
128
132
|
|
|
129
133
|
const fire = () => {
|
|
130
134
|
void trigger(input)
|
|
@@ -136,6 +140,7 @@ export function ActionTrigger<TInput, TData>({
|
|
|
136
140
|
const renderButton = (onClick: () => void): React.ReactElement => {
|
|
137
141
|
const button = (
|
|
138
142
|
<Button
|
|
143
|
+
context={triggerContext}
|
|
139
144
|
variant={triggerVariant}
|
|
140
145
|
size={icon !== undefined ? 'icon' : size}
|
|
141
146
|
busy={isLoading}
|
|
@@ -147,7 +152,6 @@ export function ActionTrigger<TInput, TData>({
|
|
|
147
152
|
}}
|
|
148
153
|
className={cn(
|
|
149
154
|
icon !== undefined && 'size-7 shrink-0 text-muted-foreground/60',
|
|
150
|
-
icon !== undefined && destructive && 'hover:bg-destructive/10 hover:text-destructive',
|
|
151
155
|
className,
|
|
152
156
|
)}
|
|
153
157
|
data-action={action.name}
|
|
@@ -183,10 +187,10 @@ export function ActionTrigger<TInput, TData>({
|
|
|
183
187
|
)}
|
|
184
188
|
</AlertDialogHeader>
|
|
185
189
|
<AlertDialogFooter>
|
|
186
|
-
<Button variant="ghost" onClick={() => setOpen(false)} disabled={isLoading}>
|
|
190
|
+
<Button context="neutral" variant="ghost" onClick={() => setOpen(false)} disabled={isLoading}>
|
|
187
191
|
{confirm.cancelLabel ?? 'Cancelar'}
|
|
188
192
|
</Button>
|
|
189
|
-
<Button variant={confirmVariant} busy={isLoading} onClick={fire}>
|
|
193
|
+
<Button context={confirmContext} variant={confirmVariant} busy={isLoading} onClick={fire}>
|
|
190
194
|
{confirm.actionLabel ?? buttonLabel}
|
|
191
195
|
</Button>
|
|
192
196
|
</AlertDialogFooter>
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import type { ReactNode } from 'react'
|
|
10
10
|
import type { ViewAction } from '../../../core/index.ts'
|
|
11
11
|
import { useViewAction } from '../../drivers/react.tsx'
|
|
12
|
+
import { Alert } from '../primitives/alert.tsx'
|
|
12
13
|
import { Skeleton } from '../primitives/skeleton.tsx'
|
|
13
14
|
import { Button } from '../primitives/button.tsx'
|
|
14
15
|
|
|
@@ -61,17 +62,12 @@ export function ActionView<TInput, TData>({
|
|
|
61
62
|
return <div data-action={action.name}>{error(err, refetch)}</div>
|
|
62
63
|
}
|
|
63
64
|
return (
|
|
64
|
-
<div
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
<p className="text-destructive">{err.message}</p>
|
|
71
|
-
</div>
|
|
72
|
-
<Button variant="outline" size="sm" onClick={() => void refetch()}>
|
|
73
|
-
Tentar de novo
|
|
74
|
-
</Button>
|
|
65
|
+
<div data-action={action.name}>
|
|
66
|
+
<Alert context="danger" title={err.code} description={err.message}>
|
|
67
|
+
<Button variant="outline" size="sm" onClick={() => void refetch()}>
|
|
68
|
+
Tentar de novo
|
|
69
|
+
</Button>
|
|
70
|
+
</Alert>
|
|
75
71
|
</div>
|
|
76
72
|
)
|
|
77
73
|
}
|
|
@@ -140,13 +140,14 @@ function AlertDialogMedia({
|
|
|
140
140
|
|
|
141
141
|
function AlertDialogAction({
|
|
142
142
|
className,
|
|
143
|
-
|
|
143
|
+
context = "primary",
|
|
144
|
+
variant = "solid",
|
|
144
145
|
size = "default",
|
|
145
146
|
...props
|
|
146
147
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
|
|
147
|
-
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
|
148
|
+
Pick<React.ComponentProps<typeof Button>, "context" | "variant" | "size">) {
|
|
148
149
|
return (
|
|
149
|
-
<Button variant={variant} size={size} asChild>
|
|
150
|
+
<Button context={context} variant={variant} size={size} asChild>
|
|
150
151
|
<AlertDialogPrimitive.Action
|
|
151
152
|
data-slot="alert-dialog-action"
|
|
152
153
|
className={cn(className)}
|
|
@@ -158,13 +159,14 @@ function AlertDialogAction({
|
|
|
158
159
|
|
|
159
160
|
function AlertDialogCancel({
|
|
160
161
|
className,
|
|
162
|
+
context = "neutral",
|
|
161
163
|
variant = "ghost",
|
|
162
164
|
size = "default",
|
|
163
165
|
...props
|
|
164
166
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
|
|
165
|
-
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
|
167
|
+
Pick<React.ComponentProps<typeof Button>, "context" | "variant" | "size">) {
|
|
166
168
|
return (
|
|
167
|
-
<Button variant={variant} size={size} asChild>
|
|
169
|
+
<Button context={context} variant={variant} size={size} asChild>
|
|
168
170
|
<AlertDialogPrimitive.Cancel
|
|
169
171
|
data-slot="alert-dialog-cancel"
|
|
170
172
|
className={cn(className)}
|