@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.
Files changed (115) hide show
  1. package/CHANGELOG.md +120 -1
  2. package/bin/cli.mjs +3 -9
  3. package/bin/lib/check.mjs +242 -49
  4. package/bin/lib/copy.mjs +17 -35
  5. package/bin/lib/gen-runner.mjs +2 -0
  6. package/bin/lib/gen-stubs.mjs +3 -1
  7. package/docs/adr/0005-structural-surfaces-share-an-explicit-anatomy.md +1 -1
  8. package/docs/adr/0023-surfaces-share-one-anatomy-and-two-layouts.md +6 -0
  9. package/docs/adr/0032-tab-baseline-respects-the-container-inset.md +16 -12
  10. package/docs/adr/0034-tabs-can-own-a-frame-without-owning-a-surface.md +8 -6
  11. package/docs/adr/0036-tabs-declares-trigger-distribution.md +11 -8
  12. package/docs/adr/0037-design-data-lives-outside-the-contract.md +1 -1
  13. package/docs/adr/0038-content-level-defines-primary-presentation.md +62 -0
  14. package/docs/adr/0039-filter-presentation-is-independent-from-filter-type.md +62 -0
  15. package/docs/adr/0040-tabs-use-a-single-navigation-presentation.md +59 -0
  16. package/docs/adr/0042-toggle-group-shares-the-grouped-control-grammar.md +79 -0
  17. package/docs/adr/0043-neutral-surfaces-and-interactions-use-opaque-role-tokens.md +73 -0
  18. package/docs/adr/0044-neutral-appearance-derives-from-bounded-axes-per-mode.md +133 -0
  19. package/docs/adr/0045-surface-replaces-content-and-card.md +230 -0
  20. package/docs/adr/0046-structural-body-owns-content-rhythm.md +103 -0
  21. package/docs/adr/0047-pane-is-only-a-split-layout-unit.md +71 -0
  22. package/docs/adr/0048-modal-intro-separates-content-from-chrome.md +34 -0
  23. package/docs/code-style.md +1 -1
  24. package/package.json +5 -1
  25. package/registry/skills/build-opus-ui/SKILL.md +11 -11
  26. package/registry/skills/build-opus-ui/references/evaluations.md +10 -9
  27. package/registry/skills/build-opus-ui/references/ui-patterns.md +17 -16
  28. package/registry/skills/create-opus-action/references/contract-and-binding.md +3 -3
  29. package/registry/templates/app/src/App.tsx +24 -12
  30. package/src/core/contracts.ts +1 -1
  31. package/src/core/runtime.ts +4 -37
  32. package/src/core/types.ts +5 -23
  33. package/src/design/index.ts +14 -3
  34. package/src/testing/index.ts +6 -26
  35. package/src/ui/appearance.ts +149 -0
  36. package/src/ui/components/patterns/action-form-card.tsx +46 -33
  37. package/src/ui/components/patterns/action-list-dialog.tsx +2 -4
  38. package/src/ui/components/patterns/confirm.tsx +3 -1
  39. package/src/ui/components/patterns/fill-region.ts +8 -7
  40. package/src/ui/components/patterns/form-dialog.tsx +1 -3
  41. package/src/ui/components/patterns/form.tsx +8 -7
  42. package/src/ui/components/patterns/list.tsx +64 -14
  43. package/src/ui/components/patterns/page.tsx +15 -11
  44. package/src/ui/components/patterns/pane.tsx +9 -240
  45. package/src/ui/components/patterns/presentation.tsx +14 -14
  46. package/src/ui/components/patterns/sidebar.tsx +7 -17
  47. package/src/ui/components/patterns/split.tsx +111 -59
  48. package/src/ui/components/patterns/{surface-header.tsx → structural-header.tsx} +50 -19
  49. package/src/ui/components/patterns/structural-media.tsx +26 -0
  50. package/src/ui/components/patterns/surface-anatomy.ts +71 -61
  51. package/src/ui/components/patterns/surface-assistant.tsx +2 -4
  52. package/src/ui/components/patterns/surface.tsx +620 -0
  53. package/src/ui/components/patterns/trigger.tsx +11 -9
  54. package/src/ui/components/primitives/ask.tsx +155 -83
  55. package/src/ui/components/primitives/button.tsx +11 -9
  56. package/src/ui/components/primitives/checkbox.tsx +8 -8
  57. package/src/ui/components/primitives/command.tsx +1 -1
  58. package/src/ui/components/primitives/composer.tsx +47 -45
  59. package/src/ui/components/primitives/detail.tsx +9 -2
  60. package/src/ui/components/primitives/dialog.tsx +60 -26
  61. package/src/ui/components/primitives/drawer.tsx +58 -12
  62. package/src/ui/components/primitives/icon-picker.tsx +93 -70
  63. package/src/ui/components/primitives/input-group.tsx +35 -28
  64. package/src/ui/components/primitives/input-otp.tsx +16 -16
  65. package/src/ui/components/primitives/input.tsx +20 -10
  66. package/src/ui/components/primitives/item.tsx +9 -12
  67. package/src/ui/components/primitives/menu.tsx +44 -65
  68. package/src/ui/components/primitives/metric-card.tsx +75 -59
  69. package/src/ui/components/primitives/radio-group.tsx +9 -9
  70. package/src/ui/components/primitives/select.tsx +344 -209
  71. package/src/ui/components/primitives/switch.tsx +10 -10
  72. package/src/ui/components/primitives/table.tsx +29 -24
  73. package/src/ui/components/primitives/tabs.tsx +58 -88
  74. package/src/ui/components/primitives/textarea.tsx +6 -6
  75. package/src/ui/components/primitives/toggle-group-context.ts +24 -0
  76. package/src/ui/components/primitives/toggle-group.tsx +116 -74
  77. package/src/ui/components/primitives/toggle.tsx +82 -16
  78. package/src/ui/docs/DocBrowser.tsx +77 -69
  79. package/src/ui/docs/content/action-form-card.md +9 -9
  80. package/src/ui/docs/content/action-list.md +85 -70
  81. package/src/ui/docs/content/action-view.md +33 -18
  82. package/src/ui/docs/content/actions.md +6 -7
  83. package/src/ui/docs/content/button.md +1 -1
  84. package/src/ui/docs/content/communication.md +1 -1
  85. package/src/ui/docs/content/customization.md +64 -11
  86. package/src/ui/docs/content/detail.md +8 -6
  87. package/src/ui/docs/content/dialog.md +100 -58
  88. package/src/ui/docs/content/drawer.md +63 -19
  89. package/src/ui/docs/content/field.md +14 -15
  90. package/src/ui/docs/content/icon-tile.md +14 -13
  91. package/src/ui/docs/content/page.md +34 -26
  92. package/src/ui/docs/content/pane.md +41 -290
  93. package/src/ui/docs/content/presentation.md +3 -3
  94. package/src/ui/docs/content/semantic-context.md +0 -1
  95. package/src/ui/docs/content/separator.md +6 -6
  96. package/src/ui/docs/content/sidebar.md +68 -266
  97. package/src/ui/docs/content/skeleton.md +20 -18
  98. package/src/ui/docs/content/split.md +14 -14
  99. package/src/ui/docs/content/surface.md +513 -0
  100. package/src/ui/docs/content/tabs.md +82 -83
  101. package/src/ui/docs/content/testing.md +2 -3
  102. package/src/ui/docs/content/toggle.md +120 -63
  103. package/src/ui/docs/content/tokens.md +124 -40
  104. package/src/ui/docs/registry.tsx +4 -6
  105. package/src/ui/drivers/react.tsx +4 -3
  106. package/src/ui/index.ts +30 -27
  107. package/src/ui/meta.ts +15 -27
  108. package/src/ui/react.tsx +42 -46
  109. package/src/ui/theme.css +311 -64
  110. package/src/vite/design.ts +23 -21
  111. package/src/ui/components/patterns/content-header.tsx +0 -532
  112. package/src/ui/components/patterns/surface-media.tsx +0 -69
  113. package/src/ui/components/primitives/card.tsx +0 -191
  114. package/src/ui/docs/content/card.md +0 -292
  115. package/src/ui/docs/content/content.md +0 -276
@@ -1,45 +1,12 @@
1
1
  /**
2
- * Pane — a superfície encaixável de um Split: a coluna ou faixa que hospeda navegação,
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
- * Até a ADR 0023 esta família vivia partida: a raiz em `split.tsx`, os slots em
6
- * `sidebar.tsx`, sem validação entre eles e com padding assimétrico (header sem recuo,
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
- const insets = { none: "", sm: "p-2", md: "p-4", lg: "p-6" } as const;
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
- data-slot="pane-footer"
284
- className={cn(
285
- "shrink-0",
286
- surfaceRegionClasses("pane", layout, "footer"),
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
- Content,
79
- ContentActions,
80
- ContentBody,
81
- ContentHeader,
82
- ContentTitle,
83
- } from "./content-header.tsx";
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
- <Content level={1} variant="page">
225
- <ContentHeader>
226
- <ContentTitle>{title}</ContentTitle>
224
+ <Surface as="section" level={1}>
225
+ <SurfaceHeader>
226
+ <SurfaceTitle>{title}</SurfaceTitle>
227
227
  {headerActions === undefined ? null : (
228
- <ContentActions>
228
+ <SurfaceActions>
229
229
  <PresentationActions>{headerActions}</PresentationActions>
230
- </ContentActions>
230
+ </SurfaceActions>
231
231
  )}
232
- </ContentHeader>
233
- <ContentBody>{body}</ContentBody>
234
- </Content>
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-muted font-medium text-foreground"
131
- : "text-foreground/80 hover:bg-muted/60",
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 ? "text-muted-foreground/50 opacity-40" : "hover:bg-muted/60",
163
- active && "bg-muted",
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. Header/footer continuam slots explícitos do pai. */
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 { Children, cloneElement, Fragment, isValidElement, type ReactElement, type ReactNode } from 'react'
2
- import { cn } from '../../lib/cn.ts'
3
- import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '../primitives/resizable.tsx'
4
- import { Pane, paneSizeToCss, type PaneProps, type PaneSize } from './pane.tsx'
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
- // A família Pane mora em `pane.tsx` desde a ADR 0023; o Split continua sendo sua casa
7
- // pública, então reexporta o que já era importado daqui.
8
- export { Pane, PaneActions, PaneBody, PaneContent, PaneDescription, PaneFooter, PaneHeader, PaneMedia, PaneTitle } from './pane.tsx'
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?: 'horizontal' | 'vertical'
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
- 'data-slot'?: string
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 = ''): ReactNode[] {
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((node.props as { children?: ReactNode }).children, `${prefix}${String(node.key)}/`)
42
- if (prefix !== '' && isValidElement(node)) return [cloneElement(node, { key: `${prefix}${String(node.key)}` })]
43
- return [node]
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 (typeof node === 'string' || typeof node === 'number' || typeof node === 'bigint') {
50
- const text = String(node).trim()
51
- return `o texto "${text.length > 40 ? `${text.slice(0, 40)}…` : text}"`
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 = typeof type === 'string' ? type : (type.displayName ?? type.name ?? '')
56
- return name === '' ? 'um componente sem nome' : `<${name}>`
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 'um nó que não é elemento React'
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 === 'string' && child.trim() === '') return []
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
- 'Envolva-o em um <Pane>. Se ele vem de um componente que devolve um <Pane>, escreva o <Pane> dentro do Split e ' +
74
- 'passe o componente como filho dele: o Split lê o tamanho de cada pane antes de renderizá-lo.',
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 === 'number') return value
87
- if (value?.endsWith('%')) return Number.parseFloat(value)
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 = 'horizontal',
117
+ direction = "horizontal",
99
118
  resizable = false,
100
119
  handle = false,
101
120
  defaultLayout,
102
121
  onLayoutChanged,
103
122
  className,
104
- 'data-slot': dataSlot = 'split',
123
+ "data-slot": dataSlot = "split",
105
124
  children,
106
125
  }: SplitProps): React.ReactElement {
107
- const panes = panesOf(children)
108
- const vertical = direction === 'vertical'
126
+ const panes = panesOf(children);
127
+ const vertical = direction === "vertical";
109
128
 
110
129
  if (!resizable) {
111
130
  return (
112
- <div id={id === undefined ? undefined : String(id)} data-slot={dataSlot} data-direction={direction} className={cn('flex min-h-0 min-w-0 flex-1', vertical && 'flex-col', className)}>
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((sum, pane) => sum + (percentage(pane.props.initialSize) ?? 0), 0)
119
- const hasAbsoluteSize = panes.some((pane) => typeof pane.props.initialSize === 'string' && !pane.props.initialSize.endsWith('%'))
120
- const growing = panes.filter((pane) => pane.props.grow)
121
- const rest = Math.max(0, 100 - fixed)
122
- const fallback = panes.length === 0 ? 100 : 100 / panes.length
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('min-h-0 min-w-0 flex-1', className)}
166
+ className={cn("min-h-0 min-w-0 flex-1", className)}
132
167
  >
133
168
  {panes.flatMap((pane, index) => {
134
- const size = pane.props.initialSize
135
- ?? (hasAbsoluteSize ? undefined : pane.props.grow ? rest / Math.max(1, growing.length) : fallback)
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 {...pane.props} id={undefined} className={cn('h-full w-full', pane.props.className)} />
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 ? [panel] : [<ResizableHandle key={`handle-${pane.key ?? index}`} withHandle={handle} />, panel]
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
  }