@softize/opus 22.2.0 → 24.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +120 -1
- package/bin/cli.mjs +3 -9
- package/bin/lib/check.mjs +242 -49
- package/bin/lib/copy.mjs +17 -35
- package/bin/lib/gen-runner.mjs +2 -0
- package/bin/lib/gen-stubs.mjs +3 -1
- package/docs/adr/0005-structural-surfaces-share-an-explicit-anatomy.md +1 -1
- package/docs/adr/0023-surfaces-share-one-anatomy-and-two-layouts.md +6 -0
- package/docs/adr/0032-tab-baseline-respects-the-container-inset.md +16 -12
- package/docs/adr/0034-tabs-can-own-a-frame-without-owning-a-surface.md +8 -6
- package/docs/adr/0036-tabs-declares-trigger-distribution.md +11 -8
- package/docs/adr/0037-design-data-lives-outside-the-contract.md +1 -1
- package/docs/adr/0038-content-level-defines-primary-presentation.md +62 -0
- package/docs/adr/0039-filter-presentation-is-independent-from-filter-type.md +62 -0
- package/docs/adr/0040-tabs-use-a-single-navigation-presentation.md +59 -0
- package/docs/adr/0042-toggle-group-shares-the-grouped-control-grammar.md +79 -0
- package/docs/adr/0043-neutral-surfaces-and-interactions-use-opaque-role-tokens.md +73 -0
- package/docs/adr/0044-neutral-appearance-derives-from-bounded-axes-per-mode.md +133 -0
- package/docs/adr/0045-surface-replaces-content-and-card.md +230 -0
- package/docs/adr/0046-structural-body-owns-content-rhythm.md +103 -0
- package/docs/adr/0047-pane-is-only-a-split-layout-unit.md +71 -0
- package/docs/adr/0048-modal-intro-separates-content-from-chrome.md +34 -0
- package/docs/code-style.md +1 -1
- package/package.json +5 -1
- package/registry/skills/build-opus-ui/SKILL.md +11 -11
- package/registry/skills/build-opus-ui/references/evaluations.md +10 -9
- package/registry/skills/build-opus-ui/references/ui-patterns.md +17 -16
- package/registry/skills/create-opus-action/references/contract-and-binding.md +3 -3
- package/registry/templates/app/src/App.tsx +24 -12
- package/src/core/contracts.ts +1 -1
- package/src/core/runtime.ts +4 -37
- package/src/core/types.ts +5 -23
- package/src/design/index.ts +14 -3
- package/src/testing/index.ts +6 -26
- package/src/ui/appearance.ts +149 -0
- package/src/ui/components/patterns/action-form-card.tsx +46 -33
- package/src/ui/components/patterns/action-list-dialog.tsx +2 -4
- package/src/ui/components/patterns/confirm.tsx +3 -1
- package/src/ui/components/patterns/fill-region.ts +8 -7
- package/src/ui/components/patterns/form-dialog.tsx +1 -3
- package/src/ui/components/patterns/form.tsx +8 -7
- package/src/ui/components/patterns/list.tsx +64 -14
- package/src/ui/components/patterns/page.tsx +15 -11
- package/src/ui/components/patterns/pane.tsx +9 -240
- package/src/ui/components/patterns/presentation.tsx +14 -14
- package/src/ui/components/patterns/sidebar.tsx +7 -17
- package/src/ui/components/patterns/split.tsx +111 -59
- package/src/ui/components/patterns/{surface-header.tsx → structural-header.tsx} +50 -19
- package/src/ui/components/patterns/structural-media.tsx +26 -0
- package/src/ui/components/patterns/surface-anatomy.ts +71 -61
- package/src/ui/components/patterns/surface-assistant.tsx +2 -4
- package/src/ui/components/patterns/surface.tsx +620 -0
- package/src/ui/components/patterns/trigger.tsx +11 -9
- package/src/ui/components/primitives/ask.tsx +155 -83
- package/src/ui/components/primitives/button.tsx +11 -9
- package/src/ui/components/primitives/checkbox.tsx +8 -8
- package/src/ui/components/primitives/command.tsx +1 -1
- package/src/ui/components/primitives/composer.tsx +47 -45
- package/src/ui/components/primitives/detail.tsx +9 -2
- package/src/ui/components/primitives/dialog.tsx +60 -26
- package/src/ui/components/primitives/drawer.tsx +58 -12
- package/src/ui/components/primitives/icon-picker.tsx +93 -70
- package/src/ui/components/primitives/input-group.tsx +35 -28
- package/src/ui/components/primitives/input-otp.tsx +16 -16
- package/src/ui/components/primitives/input.tsx +20 -10
- package/src/ui/components/primitives/item.tsx +9 -12
- package/src/ui/components/primitives/menu.tsx +44 -65
- package/src/ui/components/primitives/metric-card.tsx +75 -59
- package/src/ui/components/primitives/radio-group.tsx +9 -9
- package/src/ui/components/primitives/select.tsx +344 -209
- package/src/ui/components/primitives/switch.tsx +10 -10
- package/src/ui/components/primitives/table.tsx +29 -24
- package/src/ui/components/primitives/tabs.tsx +58 -88
- package/src/ui/components/primitives/textarea.tsx +6 -6
- package/src/ui/components/primitives/toggle-group-context.ts +24 -0
- package/src/ui/components/primitives/toggle-group.tsx +116 -74
- package/src/ui/components/primitives/toggle.tsx +82 -16
- package/src/ui/docs/DocBrowser.tsx +77 -69
- package/src/ui/docs/content/action-form-card.md +9 -9
- package/src/ui/docs/content/action-list.md +85 -70
- package/src/ui/docs/content/action-view.md +33 -18
- package/src/ui/docs/content/actions.md +6 -7
- package/src/ui/docs/content/button.md +1 -1
- package/src/ui/docs/content/communication.md +1 -1
- package/src/ui/docs/content/customization.md +64 -11
- package/src/ui/docs/content/detail.md +8 -6
- package/src/ui/docs/content/dialog.md +100 -58
- package/src/ui/docs/content/drawer.md +63 -19
- package/src/ui/docs/content/field.md +14 -15
- package/src/ui/docs/content/icon-tile.md +14 -13
- package/src/ui/docs/content/page.md +34 -26
- package/src/ui/docs/content/pane.md +41 -290
- package/src/ui/docs/content/presentation.md +3 -3
- package/src/ui/docs/content/semantic-context.md +0 -1
- package/src/ui/docs/content/separator.md +6 -6
- package/src/ui/docs/content/sidebar.md +68 -266
- package/src/ui/docs/content/skeleton.md +20 -18
- package/src/ui/docs/content/split.md +14 -14
- package/src/ui/docs/content/surface.md +513 -0
- package/src/ui/docs/content/tabs.md +82 -83
- package/src/ui/docs/content/testing.md +2 -3
- package/src/ui/docs/content/toggle.md +120 -63
- package/src/ui/docs/content/tokens.md +124 -40
- package/src/ui/docs/registry.tsx +4 -6
- package/src/ui/drivers/react.tsx +4 -3
- package/src/ui/index.ts +30 -27
- package/src/ui/meta.ts +15 -27
- package/src/ui/react.tsx +42 -46
- package/src/ui/theme.css +311 -64
- package/src/vite/design.ts +23 -21
- package/src/ui/components/patterns/content-header.tsx +0 -532
- package/src/ui/components/patterns/surface-media.tsx +0 -69
- package/src/ui/components/primitives/card.tsx +0 -191
- package/src/ui/docs/content/card.md +0 -292
- package/src/ui/docs/content/content.md +0 -276
|
@@ -1,45 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Pane —
|
|
3
|
-
* lista ou conteúdo, com regiões fixas no topo e no rodapé e um corpo que rola.
|
|
2
|
+
* Pane — unidade dimensionável de um Split.
|
|
4
3
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* footer com `p-2`). Agora raiz e slots moram aqui e derivam a anatomia da mesma tabela
|
|
8
|
-
* das demais famílias.
|
|
9
|
-
*
|
|
10
|
-
* <Pane>
|
|
11
|
-
* <PaneHeader><PaneMedia>…</PaneMedia><PaneTitle>Conversas</PaneTitle><PaneActions>…</PaneActions></PaneHeader>
|
|
12
|
-
* <PaneBody>…</PaneBody>
|
|
13
|
-
* <PaneFooter>…</PaneFooter>
|
|
14
|
-
* </Pane>
|
|
15
|
-
*
|
|
16
|
-
* `inset` continua existindo para o pane que NÃO usa slots — a caixa simples que recebe
|
|
17
|
-
* conteúdo direto. Quando há slots, o recuo é de cada região, e o inset do pane vira
|
|
18
|
-
* `none` sozinho: o divisor precisa alcançar a borda.
|
|
4
|
+
* Pane não é uma superfície visual: não aplica inset, borda, fundo, rolagem nem anatomia
|
|
5
|
+
* interna. O filho decide sua própria apresentação, normalmente com Surface ou Sidebar.
|
|
19
6
|
*/
|
|
20
|
-
import {
|
|
21
|
-
Children,
|
|
22
|
-
Fragment,
|
|
23
|
-
isValidElement,
|
|
24
|
-
type CSSProperties,
|
|
25
|
-
type ReactElement,
|
|
26
|
-
type ReactNode,
|
|
27
|
-
} from "react";
|
|
7
|
+
import type { CSSProperties, ReactElement, ReactNode } from "react";
|
|
28
8
|
|
|
29
9
|
import { cn } from "../../lib/cn.ts";
|
|
30
|
-
import { headingTags, type HeadingLevel } from "./heading-level.ts";
|
|
31
|
-
import {
|
|
32
|
-
SurfaceLayoutProvider,
|
|
33
|
-
surfaceActionsClasses,
|
|
34
|
-
surfaceDescriptionClasses,
|
|
35
|
-
surfaceHeaderRowClasses,
|
|
36
|
-
surfaceRegionClasses,
|
|
37
|
-
surfaceTitleClasses,
|
|
38
|
-
useSurfaceLayout,
|
|
39
|
-
type SurfaceLayout,
|
|
40
|
-
} from "./surface-anatomy.ts";
|
|
41
|
-
import { SurfaceHeader } from "./surface-header.tsx";
|
|
42
|
-
import { SurfaceMedia, type SurfaceMediaProps } from "./surface-media.tsx";
|
|
43
10
|
|
|
44
11
|
export type PaneSize =
|
|
45
12
|
| number
|
|
@@ -67,48 +34,15 @@ export interface PaneProps {
|
|
|
67
34
|
maxSize?: PaneSize;
|
|
68
35
|
/** Ocupa o espaço remanescente no layout simples. */
|
|
69
36
|
grow?: boolean;
|
|
70
|
-
/**
|
|
71
|
-
* Respiro interno do pane sem slots. Com header, body ou footer como filhos DIRETOS o
|
|
72
|
-
* default passa a `none`, porque aí o recuo é de cada região. Quando os slots vêm
|
|
73
|
-
* dentro de outro componente — a `Sidebar`, por exemplo —, o pane não os enxerga:
|
|
74
|
-
* declare `inset="none"` você mesmo.
|
|
75
|
-
*/
|
|
76
|
-
inset?: "none" | "sm" | "md" | "lg";
|
|
77
|
-
/** Anatomia das regiões: `sectioned` (default) ou `continuous` — ADR 0023. */
|
|
78
|
-
layout?: SurfaceLayout;
|
|
79
37
|
className?: string;
|
|
80
38
|
children: ReactNode;
|
|
81
39
|
}
|
|
82
40
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
/** Expande fragments de primeiro nível antes de procurar os slots. */
|
|
86
|
-
function flatten(children: ReactNode): ReactNode[] {
|
|
87
|
-
return Children.toArray(children).flatMap((node) =>
|
|
88
|
-
isValidElement(node) && node.type === Fragment
|
|
89
|
-
? Children.toArray((node.props as { children?: ReactNode }).children)
|
|
90
|
-
: [node],
|
|
91
|
-
);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function hasStructuralSlots(children: ReactNode): boolean {
|
|
95
|
-
return flatten(children).some(
|
|
96
|
-
(node) =>
|
|
97
|
-
isValidElement(node) &&
|
|
98
|
-
(node.type === PaneHeader ||
|
|
99
|
-
node.type === PaneBody ||
|
|
100
|
-
node.type === PaneContent ||
|
|
101
|
-
node.type === PaneFooter),
|
|
102
|
-
);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/** Um conteúdo encaixável. Sua posição é dada pela ordem dentro de <Split>. */
|
|
41
|
+
/** Um conteúdo encaixável. Sua posição é dada pela ordem dentro de Split. */
|
|
106
42
|
export function Pane({
|
|
107
43
|
id,
|
|
108
44
|
initialSize,
|
|
109
45
|
grow = false,
|
|
110
|
-
inset,
|
|
111
|
-
layout = "sectioned",
|
|
112
46
|
className,
|
|
113
47
|
children,
|
|
114
48
|
}: PaneProps): ReactElement {
|
|
@@ -116,177 +50,12 @@ export function Pane({
|
|
|
116
50
|
initialSize === undefined
|
|
117
51
|
? undefined
|
|
118
52
|
: { flexBasis: paneSizeToCss(initialSize) };
|
|
119
|
-
const structural = hasStructuralSlots(children);
|
|
120
|
-
const resolvedInset = inset ?? (structural ? "none" : "md");
|
|
121
|
-
return (
|
|
122
|
-
<SurfaceLayoutProvider value={layout}>
|
|
123
|
-
<div
|
|
124
|
-
id={id === undefined ? undefined : String(id)}
|
|
125
|
-
data-slot="pane"
|
|
126
|
-
data-inset={resolvedInset}
|
|
127
|
-
data-layout={layout}
|
|
128
|
-
className={cn(
|
|
129
|
-
"min-h-0 min-w-0 shrink-0",
|
|
130
|
-
grow && "flex-1",
|
|
131
|
-
// Com slots o pane é uma coluna: header fixo, corpo que rola, footer fixo.
|
|
132
|
-
structural && "flex flex-col",
|
|
133
|
-
insets[resolvedInset],
|
|
134
|
-
className,
|
|
135
|
-
)}
|
|
136
|
-
style={style}
|
|
137
|
-
>
|
|
138
|
-
{children}
|
|
139
|
-
</div>
|
|
140
|
-
</SurfaceLayoutProvider>
|
|
141
|
-
);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/** Faixa fixa no topo de um pane — marca, contexto ou ações locais. */
|
|
145
|
-
export function PaneHeader({
|
|
146
|
-
className,
|
|
147
|
-
children,
|
|
148
|
-
...props
|
|
149
|
-
}: React.ComponentProps<"div">): ReactElement {
|
|
150
|
-
const layout = useSurfaceLayout("pane");
|
|
151
|
-
return (
|
|
152
|
-
<SurfaceHeader
|
|
153
|
-
name="PaneHeader"
|
|
154
|
-
slot="pane"
|
|
155
|
-
slots={{
|
|
156
|
-
media: PaneMedia,
|
|
157
|
-
title: PaneTitle,
|
|
158
|
-
description: PaneDescription,
|
|
159
|
-
actions: PaneActions,
|
|
160
|
-
}}
|
|
161
|
-
leadingPlacement="inline"
|
|
162
|
-
titleRequired={false}
|
|
163
|
-
// O pane é a superfície genérica: além dos slots, o header hospeda marca, busca ou
|
|
164
|
-
// chrome que não são título nem ação (ADR 0023).
|
|
165
|
-
allowFreeContent
|
|
166
|
-
rowClassName={surfaceHeaderRowClasses("pane")}
|
|
167
|
-
className={cn(surfaceRegionClasses("pane", layout, "header"), className)}
|
|
168
|
-
{...props}
|
|
169
|
-
>
|
|
170
|
-
{children}
|
|
171
|
-
</SurfaceHeader>
|
|
172
|
-
);
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/** Moldura de ícone ou imagem que abre a linha do header. */
|
|
176
|
-
export function PaneMedia(props: SurfaceMediaProps): ReactElement {
|
|
177
|
-
return <SurfaceMedia slot="pane-media" size="xs" {...props} />;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
export function PaneTitle({
|
|
181
|
-
className,
|
|
182
|
-
level = 2,
|
|
183
|
-
...props
|
|
184
|
-
}: React.ComponentProps<"h2"> & {
|
|
185
|
-
/** Hierarquia do documento (`h1`–`h6`). O tamanho vem do layout, nunca do nível. */
|
|
186
|
-
level?: HeadingLevel;
|
|
187
|
-
}): ReactElement {
|
|
188
|
-
const layout = useSurfaceLayout("pane");
|
|
189
|
-
const Comp = headingTags[level];
|
|
190
|
-
return (
|
|
191
|
-
<Comp
|
|
192
|
-
data-slot="pane-title"
|
|
193
|
-
className={cn(
|
|
194
|
-
"min-w-0 truncate",
|
|
195
|
-
surfaceTitleClasses("pane", layout),
|
|
196
|
-
className,
|
|
197
|
-
)}
|
|
198
|
-
{...props}
|
|
199
|
-
/>
|
|
200
|
-
);
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
export function PaneDescription({
|
|
204
|
-
className,
|
|
205
|
-
...props
|
|
206
|
-
}: React.ComponentProps<"p">): ReactElement {
|
|
207
|
-
return (
|
|
208
|
-
<p
|
|
209
|
-
data-slot="pane-description"
|
|
210
|
-
className={cn("truncate", surfaceDescriptionClasses("pane"), className)}
|
|
211
|
-
{...props}
|
|
212
|
-
/>
|
|
213
|
-
);
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/** Ações do header — a família posiciona no extremo oposto ao título. */
|
|
217
|
-
export function PaneActions({
|
|
218
|
-
className,
|
|
219
|
-
...props
|
|
220
|
-
}: React.ComponentProps<"div">): ReactElement {
|
|
221
|
-
return (
|
|
222
|
-
<div
|
|
223
|
-
data-slot="pane-actions"
|
|
224
|
-
className={cn(surfaceActionsClasses, className)}
|
|
225
|
-
{...props}
|
|
226
|
-
/>
|
|
227
|
-
);
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
/** Região rolável que ocupa o espaço restante de um pane. */
|
|
231
|
-
export function PaneBody({
|
|
232
|
-
className,
|
|
233
|
-
children,
|
|
234
|
-
...props
|
|
235
|
-
}: React.ComponentProps<"div">): ReactElement {
|
|
236
|
-
const layout = useSurfaceLayout("pane");
|
|
237
|
-
return (
|
|
238
|
-
<div
|
|
239
|
-
data-slot="pane-body"
|
|
240
|
-
className={cn(
|
|
241
|
-
"min-h-0 flex-1 overflow-y-auto",
|
|
242
|
-
surfaceRegionClasses("pane", layout, "body"),
|
|
243
|
-
className,
|
|
244
|
-
)}
|
|
245
|
-
{...props}
|
|
246
|
-
>
|
|
247
|
-
{children}
|
|
248
|
-
</div>
|
|
249
|
-
);
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
/** @deprecated Use PaneBody. Alias mantido para migração; sai numa próxima versão major. */
|
|
253
|
-
export function PaneContent({
|
|
254
|
-
className,
|
|
255
|
-
children,
|
|
256
|
-
...props
|
|
257
|
-
}: React.ComponentProps<"div">): ReactElement {
|
|
258
|
-
const layout = useSurfaceLayout("pane");
|
|
259
|
-
return (
|
|
260
|
-
<div
|
|
261
|
-
data-slot="pane-content"
|
|
262
|
-
className={cn(
|
|
263
|
-
"min-h-0 flex-1 overflow-y-auto",
|
|
264
|
-
surfaceRegionClasses("pane", layout, "body"),
|
|
265
|
-
className,
|
|
266
|
-
)}
|
|
267
|
-
{...props}
|
|
268
|
-
>
|
|
269
|
-
{children}
|
|
270
|
-
</div>
|
|
271
|
-
);
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
/** Faixa fixa no rodapé de um pane para ações persistentes. */
|
|
275
|
-
export function PaneFooter({
|
|
276
|
-
className,
|
|
277
|
-
children,
|
|
278
|
-
...props
|
|
279
|
-
}: React.ComponentProps<"div">): ReactElement {
|
|
280
|
-
const layout = useSurfaceLayout("pane");
|
|
281
53
|
return (
|
|
282
54
|
<div
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
className,
|
|
288
|
-
)}
|
|
289
|
-
{...props}
|
|
55
|
+
id={id === undefined ? undefined : String(id)}
|
|
56
|
+
data-slot="pane"
|
|
57
|
+
className={cn("min-h-0 min-w-0 shrink-0", grow && "flex-1", className)}
|
|
58
|
+
style={style}
|
|
290
59
|
>
|
|
291
60
|
{children}
|
|
292
61
|
</div>
|
|
@@ -75,12 +75,12 @@ import { ActionList, type ActionListState } from "./list.tsx";
|
|
|
75
75
|
import { ActionTrigger } from "./trigger.tsx";
|
|
76
76
|
import { ActionView } from "./view.tsx";
|
|
77
77
|
import {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
} from "./
|
|
78
|
+
Surface,
|
|
79
|
+
SurfaceActions,
|
|
80
|
+
SurfaceBody,
|
|
81
|
+
SurfaceHeader,
|
|
82
|
+
SurfaceTitle,
|
|
83
|
+
} from "./surface.tsx";
|
|
84
84
|
import { SurfaceAssistant } from "./surface-assistant.tsx";
|
|
85
85
|
import { fillRegionClassName, useFillRegion } from "./fill-region.ts";
|
|
86
86
|
|
|
@@ -221,17 +221,17 @@ function PresentationFrame({
|
|
|
221
221
|
if (surface === "page") {
|
|
222
222
|
if (listBody) {
|
|
223
223
|
const content = (
|
|
224
|
-
<
|
|
225
|
-
<
|
|
226
|
-
<
|
|
224
|
+
<Surface as="section" level={1}>
|
|
225
|
+
<SurfaceHeader>
|
|
226
|
+
<SurfaceTitle>{title}</SurfaceTitle>
|
|
227
227
|
{headerActions === undefined ? null : (
|
|
228
|
-
<
|
|
228
|
+
<SurfaceActions>
|
|
229
229
|
<PresentationActions>{headerActions}</PresentationActions>
|
|
230
|
-
</
|
|
230
|
+
</SurfaceActions>
|
|
231
231
|
)}
|
|
232
|
-
</
|
|
233
|
-
<
|
|
234
|
-
</
|
|
232
|
+
</SurfaceHeader>
|
|
233
|
+
<SurfaceBody>{body}</SurfaceBody>
|
|
234
|
+
</Surface>
|
|
235
235
|
);
|
|
236
236
|
return (
|
|
237
237
|
<Page
|
|
@@ -8,18 +8,6 @@ import { ChevronRight } from "lucide-react";
|
|
|
8
8
|
import { useOverflowing } from "../../lib/overflow.ts";
|
|
9
9
|
import { cn } from "../../lib/cn.ts";
|
|
10
10
|
|
|
11
|
-
// Os slots de Pane moram em `pane.tsx` desde a ADR 0023. A Sidebar continua sendo uma
|
|
12
|
-
// das superfícies que os compõe, então reexporta o que antes definia.
|
|
13
|
-
export {
|
|
14
|
-
PaneActions,
|
|
15
|
-
PaneBody,
|
|
16
|
-
PaneContent,
|
|
17
|
-
PaneDescription,
|
|
18
|
-
PaneFooter,
|
|
19
|
-
PaneHeader,
|
|
20
|
-
PaneMedia,
|
|
21
|
-
PaneTitle,
|
|
22
|
-
} from "./pane.tsx";
|
|
23
11
|
import { focusRing } from "../primitives/control.ts";
|
|
24
12
|
import {
|
|
25
13
|
Tooltip,
|
|
@@ -127,8 +115,8 @@ export function SidebarItem({
|
|
|
127
115
|
"outline-none",
|
|
128
116
|
focusRing,
|
|
129
117
|
active
|
|
130
|
-
? "bg-
|
|
131
|
-
: "text-foreground/80 hover:bg-
|
|
118
|
+
? "bg-interaction-selected font-medium text-foreground"
|
|
119
|
+
: "text-foreground/80 hover:bg-interaction-hover",
|
|
132
120
|
)}
|
|
133
121
|
>
|
|
134
122
|
<span
|
|
@@ -159,8 +147,10 @@ export function SidebarItem({
|
|
|
159
147
|
data-slot="sidebar-item"
|
|
160
148
|
className={cn(
|
|
161
149
|
"group/sidebar-item relative flex items-center rounded-md transition-colors",
|
|
162
|
-
disabled
|
|
163
|
-
|
|
150
|
+
disabled
|
|
151
|
+
? "text-muted-foreground/50 opacity-40"
|
|
152
|
+
: "hover:bg-interaction-hover",
|
|
153
|
+
active && "bg-interaction-selected",
|
|
164
154
|
dropPosition === "inside" &&
|
|
165
155
|
"bg-primary/5 ring-2 ring-inset ring-primary/60",
|
|
166
156
|
className,
|
|
@@ -333,7 +323,7 @@ export function SidebarTreeGroup({
|
|
|
333
323
|
);
|
|
334
324
|
}
|
|
335
325
|
|
|
336
|
-
/** Navegação controlada para Sidebar.
|
|
326
|
+
/** Navegação controlada para Sidebar. Chrome adicional pertence à composição hospedeira. */
|
|
337
327
|
export function SidebarNav({
|
|
338
328
|
groups,
|
|
339
329
|
activeId,
|
|
@@ -1,33 +1,43 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
import {
|
|
2
|
+
Children,
|
|
3
|
+
cloneElement,
|
|
4
|
+
Fragment,
|
|
5
|
+
isValidElement,
|
|
6
|
+
type ReactElement,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
} from "react";
|
|
9
|
+
import { cn } from "../../lib/cn.ts";
|
|
10
|
+
import {
|
|
11
|
+
ResizableHandle,
|
|
12
|
+
ResizablePanel,
|
|
13
|
+
ResizablePanelGroup,
|
|
14
|
+
} from "../primitives/resizable.tsx";
|
|
15
|
+
import { Pane, paneSizeToCss, type PaneProps, type PaneSize } from "./pane.tsx";
|
|
5
16
|
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
export {
|
|
9
|
-
export type { PaneProps, PaneSize }
|
|
17
|
+
// Pane é a unidade espacial de Split; sua apresentação pertence ao filho (ADR 0047).
|
|
18
|
+
export { Pane } from "./pane.tsx";
|
|
19
|
+
export type { PaneProps, PaneSize };
|
|
10
20
|
|
|
11
|
-
export type SplitLayout = Record<string, number
|
|
21
|
+
export type SplitLayout = Record<string, number>;
|
|
12
22
|
export interface SplitLayoutChange {
|
|
13
|
-
isUserInteraction: boolean
|
|
23
|
+
isUserInteraction: boolean;
|
|
14
24
|
}
|
|
15
25
|
|
|
16
26
|
export interface SplitProps {
|
|
17
27
|
/** Identidade estável do grupo redimensionável. */
|
|
18
|
-
id?: string
|
|
19
|
-
direction?:
|
|
28
|
+
id?: string;
|
|
29
|
+
direction?: "horizontal" | "vertical";
|
|
20
30
|
/** Quando ligado, cada fronteira ganha um separador acessível e arrastável. */
|
|
21
|
-
resizable?: boolean
|
|
22
|
-
handle?: boolean
|
|
31
|
+
resizable?: boolean;
|
|
32
|
+
handle?: boolean;
|
|
23
33
|
/** Layout percentual restaurado, indexado pelos ids dos panes. */
|
|
24
|
-
defaultLayout?: SplitLayout
|
|
34
|
+
defaultLayout?: SplitLayout;
|
|
25
35
|
/** Chamado ao concluir uma mudança de layout; pode persistir o resultado em localStorage. */
|
|
26
|
-
onLayoutChanged?: (layout: SplitLayout, meta: SplitLayoutChange) => void
|
|
27
|
-
className?: string
|
|
36
|
+
onLayoutChanged?: (layout: SplitLayout, meta: SplitLayoutChange) => void;
|
|
37
|
+
className?: string;
|
|
28
38
|
/** Identificador semântico opcional para testes e composição de patterns. */
|
|
29
|
-
|
|
30
|
-
children: ReactNode
|
|
39
|
+
"data-slot"?: string;
|
|
40
|
+
children: ReactNode;
|
|
31
41
|
}
|
|
32
42
|
|
|
33
43
|
/**
|
|
@@ -35,27 +45,36 @@ export interface SplitProps {
|
|
|
35
45
|
* `Children.toArray` numera as chaves por nível, então o filho de um fragment herda a
|
|
36
46
|
* chave do fragment como prefixo — sem isso, `.0` de dentro colidiria com `.0` de fora.
|
|
37
47
|
*/
|
|
38
|
-
function flattenFragments(children: ReactNode, prefix =
|
|
48
|
+
function flattenFragments(children: ReactNode, prefix = ""): ReactNode[] {
|
|
39
49
|
return Children.toArray(children).flatMap((node): ReactNode[] => {
|
|
40
50
|
if (isValidElement(node) && node.type === Fragment)
|
|
41
|
-
return flattenFragments(
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
51
|
+
return flattenFragments(
|
|
52
|
+
(node.props as { children?: ReactNode }).children,
|
|
53
|
+
`${prefix}${String(node.key)}/`,
|
|
54
|
+
);
|
|
55
|
+
if (prefix !== "" && isValidElement(node))
|
|
56
|
+
return [cloneElement(node, { key: `${prefix}${String(node.key)}` })];
|
|
57
|
+
return [node];
|
|
58
|
+
});
|
|
45
59
|
}
|
|
46
60
|
|
|
47
61
|
/** Nome legível do filho recusado, para a mensagem apontar o nó certo. */
|
|
48
62
|
function describeChild(node: ReactNode): string {
|
|
49
|
-
if (
|
|
50
|
-
|
|
51
|
-
|
|
63
|
+
if (
|
|
64
|
+
typeof node === "string" ||
|
|
65
|
+
typeof node === "number" ||
|
|
66
|
+
typeof node === "bigint"
|
|
67
|
+
) {
|
|
68
|
+
const text = String(node).trim();
|
|
69
|
+
return `o texto "${text.length > 40 ? `${text.slice(0, 40)}…` : text}"`;
|
|
52
70
|
}
|
|
53
71
|
if (isValidElement(node)) {
|
|
54
|
-
const type = node.type as string | { displayName?: string; name?: string }
|
|
55
|
-
const name =
|
|
56
|
-
|
|
72
|
+
const type = node.type as string | { displayName?: string; name?: string };
|
|
73
|
+
const name =
|
|
74
|
+
typeof type === "string" ? type : (type.displayName ?? type.name ?? "");
|
|
75
|
+
return name === "" ? "um componente sem nome" : `<${name}>`;
|
|
57
76
|
}
|
|
58
|
-
return
|
|
77
|
+
return "um nó que não é elemento React";
|
|
59
78
|
}
|
|
60
79
|
|
|
61
80
|
/**
|
|
@@ -65,27 +84,27 @@ function describeChild(node: ReactNode): string {
|
|
|
65
84
|
*/
|
|
66
85
|
function panesOf(children: ReactNode): ReactElement<PaneProps>[] {
|
|
67
86
|
return flattenFragments(children).flatMap((child) => {
|
|
68
|
-
if (isValidElement<PaneProps>(child) && child.type === Pane) return [child]
|
|
87
|
+
if (isValidElement<PaneProps>(child) && child.type === Pane) return [child];
|
|
69
88
|
// Espaço em branco (um `{' '}` da formatação) não é conteúdo: nunca ocupou lugar.
|
|
70
|
-
if (typeof child ===
|
|
89
|
+
if (typeof child === "string" && child.trim() === "") return [];
|
|
71
90
|
throw new Error(
|
|
72
91
|
`Split recebeu ${describeChild(child)} como filho, mas só distribui <Pane>: esse conteúdo ficaria fora do layout. ` +
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
)
|
|
76
|
-
})
|
|
92
|
+
"Envolva-o em um <Pane>. Se ele vem de um componente que devolve um <Pane>, escreva o <Pane> dentro do Split e " +
|
|
93
|
+
"passe o componente como filho dele: o Split lê o tamanho de cada pane antes de renderizá-lo.",
|
|
94
|
+
);
|
|
95
|
+
});
|
|
77
96
|
}
|
|
78
97
|
|
|
79
98
|
// Números continuam percentuais por compatibilidade com Split/Pane. Strings preservam
|
|
80
99
|
// a unidade explícita para layouts que precisam de um limite físico, como `28rem`.
|
|
81
100
|
function resizableSize(value: PaneSize | undefined): string | undefined {
|
|
82
|
-
return value === undefined ? undefined : paneSizeToCss(value)
|
|
101
|
+
return value === undefined ? undefined : paneSizeToCss(value);
|
|
83
102
|
}
|
|
84
103
|
|
|
85
104
|
function percentage(value: PaneSize | undefined): number | undefined {
|
|
86
|
-
if (typeof value ===
|
|
87
|
-
if (value?.endsWith(
|
|
88
|
-
return undefined
|
|
105
|
+
if (typeof value === "number") return value;
|
|
106
|
+
if (value?.endsWith("%")) return Number.parseFloat(value);
|
|
107
|
+
return undefined;
|
|
89
108
|
}
|
|
90
109
|
|
|
91
110
|
/**
|
|
@@ -95,31 +114,47 @@ function percentage(value: PaneSize | undefined): number | undefined {
|
|
|
95
114
|
*/
|
|
96
115
|
export function Split({
|
|
97
116
|
id,
|
|
98
|
-
direction =
|
|
117
|
+
direction = "horizontal",
|
|
99
118
|
resizable = false,
|
|
100
119
|
handle = false,
|
|
101
120
|
defaultLayout,
|
|
102
121
|
onLayoutChanged,
|
|
103
122
|
className,
|
|
104
|
-
|
|
123
|
+
"data-slot": dataSlot = "split",
|
|
105
124
|
children,
|
|
106
125
|
}: SplitProps): React.ReactElement {
|
|
107
|
-
const panes = panesOf(children)
|
|
108
|
-
const vertical = direction ===
|
|
126
|
+
const panes = panesOf(children);
|
|
127
|
+
const vertical = direction === "vertical";
|
|
109
128
|
|
|
110
129
|
if (!resizable) {
|
|
111
130
|
return (
|
|
112
|
-
<div
|
|
131
|
+
<div
|
|
132
|
+
id={id === undefined ? undefined : String(id)}
|
|
133
|
+
data-slot={dataSlot}
|
|
134
|
+
data-direction={direction}
|
|
135
|
+
className={cn(
|
|
136
|
+
"flex min-h-0 min-w-0 flex-1",
|
|
137
|
+
vertical && "flex-col",
|
|
138
|
+
className,
|
|
139
|
+
)}
|
|
140
|
+
>
|
|
113
141
|
{panes}
|
|
114
142
|
</div>
|
|
115
|
-
)
|
|
143
|
+
);
|
|
116
144
|
}
|
|
117
145
|
|
|
118
|
-
const fixed = panes.reduce(
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const
|
|
146
|
+
const fixed = panes.reduce(
|
|
147
|
+
(sum, pane) => sum + (percentage(pane.props.initialSize) ?? 0),
|
|
148
|
+
0,
|
|
149
|
+
);
|
|
150
|
+
const hasAbsoluteSize = panes.some(
|
|
151
|
+
(pane) =>
|
|
152
|
+
typeof pane.props.initialSize === "string" &&
|
|
153
|
+
!pane.props.initialSize.endsWith("%"),
|
|
154
|
+
);
|
|
155
|
+
const growing = panes.filter((pane) => pane.props.grow);
|
|
156
|
+
const rest = Math.max(0, 100 - fixed);
|
|
157
|
+
const fallback = panes.length === 0 ? 100 : 100 / panes.length;
|
|
123
158
|
|
|
124
159
|
return (
|
|
125
160
|
<ResizablePanelGroup
|
|
@@ -128,11 +163,16 @@ export function Split({
|
|
|
128
163
|
defaultLayout={defaultLayout}
|
|
129
164
|
onLayoutChanged={onLayoutChanged}
|
|
130
165
|
data-slot={dataSlot}
|
|
131
|
-
className={cn(
|
|
166
|
+
className={cn("min-h-0 min-w-0 flex-1", className)}
|
|
132
167
|
>
|
|
133
168
|
{panes.flatMap((pane, index) => {
|
|
134
|
-
const size =
|
|
135
|
-
|
|
169
|
+
const size =
|
|
170
|
+
pane.props.initialSize ??
|
|
171
|
+
(hasAbsoluteSize
|
|
172
|
+
? undefined
|
|
173
|
+
: pane.props.grow
|
|
174
|
+
? rest / Math.max(1, growing.length)
|
|
175
|
+
: fallback);
|
|
136
176
|
const panel = (
|
|
137
177
|
<ResizablePanel
|
|
138
178
|
key={pane.key ?? index}
|
|
@@ -141,11 +181,23 @@ export function Split({
|
|
|
141
181
|
minSize={resizableSize(pane.props.minSize)}
|
|
142
182
|
maxSize={resizableSize(pane.props.maxSize)}
|
|
143
183
|
>
|
|
144
|
-
<Pane
|
|
184
|
+
<Pane
|
|
185
|
+
{...pane.props}
|
|
186
|
+
id={undefined}
|
|
187
|
+
className={cn("h-full w-full", pane.props.className)}
|
|
188
|
+
/>
|
|
145
189
|
</ResizablePanel>
|
|
146
|
-
)
|
|
147
|
-
return index === 0
|
|
190
|
+
);
|
|
191
|
+
return index === 0
|
|
192
|
+
? [panel]
|
|
193
|
+
: [
|
|
194
|
+
<ResizableHandle
|
|
195
|
+
key={`handle-${pane.key ?? index}`}
|
|
196
|
+
withHandle={handle}
|
|
197
|
+
/>,
|
|
198
|
+
panel,
|
|
199
|
+
];
|
|
148
200
|
})}
|
|
149
201
|
</ResizablePanelGroup>
|
|
150
|
-
)
|
|
202
|
+
);
|
|
151
203
|
}
|