@softize/opus 12.0.0 → 12.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 +12 -0
- package/package.json +1 -1
- package/src/ui/components/patterns/split.tsx +66 -24
- package/src/ui/docs/content/split.md +43 -3
- package/src/ui/react.tsx +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,18 @@ Depois de qualquer bump, rode os gates (`typecheck` · `test` · `opus check` ·
|
|
|
7
7
|
`opus copy --check` · `base copy check` · `manifest:check`) — eles apontam o que a
|
|
8
8
|
mudança cobra do seu código.
|
|
9
9
|
|
|
10
|
+
## 12.0.1 — 2026-08-22
|
|
11
|
+
|
|
12
|
+
`Split` volta a atender layouts que precisam preservar uma medida física e a preferência da
|
|
13
|
+
pessoa entre visitas. `Pane.initialSize`, `minSize` e `maxSize` agora aceitam `%`, `rem`, `em`,
|
|
14
|
+
`vh`, `vw` e `px`, enquanto valores numéricos continuam representando porcentagens por
|
|
15
|
+
compatibilidade. `Split` também expõe `id`, `defaultLayout` e `onLayoutChanged`, e cada `Pane`
|
|
16
|
+
pode declarar um `id` estável. Assim, a aplicação pode restaurar e persistir o layout em
|
|
17
|
+
`localStorage`, `sessionStorage` ou outra camada sem voltar aos primitives removidos na 11.0.0.
|
|
18
|
+
|
|
19
|
+
A mudança é aditiva. Separadores continuam acessíveis por teclado, e consumidores que usam
|
|
20
|
+
apenas tamanhos percentuais não precisam alterar o código.
|
|
21
|
+
|
|
10
22
|
## 12.0.0 — 2026-08-21
|
|
11
23
|
|
|
12
24
|
**Copy declarativa passa a ter inventário semântico e gate reproduzível.** `opus copy`
|
package/package.json
CHANGED
|
@@ -2,13 +2,21 @@ import { Children, isValidElement, type CSSProperties, type ReactElement, type R
|
|
|
2
2
|
import { cn } from '../../lib/cn.ts'
|
|
3
3
|
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '../primitives/resizable.tsx'
|
|
4
4
|
|
|
5
|
+
export type PaneSize = number | `${number}%` | `${number}rem` | `${number}em` | `${number}vh` | `${number}vw` | `${number}px`
|
|
6
|
+
export type SplitLayout = Record<string, number>
|
|
7
|
+
export interface SplitLayoutChange {
|
|
8
|
+
isUserInteraction: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
5
11
|
export interface PaneProps {
|
|
6
|
-
/**
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
|
|
12
|
+
/** Identidade estável usada pelo layout redimensionável e por layouts persistidos. */
|
|
13
|
+
id?: string
|
|
14
|
+
/** Tamanho inicial. Números representam porcentagens; strings aceitam %, rem, em, vh, vw e px. */
|
|
15
|
+
initialSize?: PaneSize
|
|
16
|
+
/** Tamanho mínimo quando o split é redimensionável. */
|
|
17
|
+
minSize?: PaneSize
|
|
18
|
+
/** Tamanho máximo quando o split é redimensionável. */
|
|
19
|
+
maxSize?: PaneSize
|
|
12
20
|
/** Ocupa o espaço remanescente no layout simples. */
|
|
13
21
|
grow?: boolean
|
|
14
22
|
/** Respiro interno padrão. Use `none` para chrome, navegação ou conteúdo com inset próprio. */
|
|
@@ -18,21 +26,27 @@ export interface PaneProps {
|
|
|
18
26
|
}
|
|
19
27
|
|
|
20
28
|
/** Um conteúdo encaixável. Sua posição é dada pela ordem dentro de <Split>. */
|
|
21
|
-
export function Pane({ initialSize, grow = false, inset = 'md', className, children }: PaneProps): React.ReactElement {
|
|
22
|
-
const style: CSSProperties | undefined = initialSize === undefined ? undefined : { flexBasis:
|
|
29
|
+
export function Pane({ id, initialSize, grow = false, inset = 'md', className, children }: PaneProps): React.ReactElement {
|
|
30
|
+
const style: CSSProperties | undefined = initialSize === undefined ? undefined : { flexBasis: sizeToCss(initialSize) }
|
|
23
31
|
const insets = { none: '', sm: 'p-2', md: 'p-4', lg: 'p-6' } as const
|
|
24
32
|
return (
|
|
25
|
-
<div data-slot="pane" data-inset={inset} className={cn('min-h-0 min-w-0 shrink-0', grow && 'flex-1', insets[inset], className)} style={style}>
|
|
33
|
+
<div id={id === undefined ? undefined : String(id)} data-slot="pane" data-inset={inset} className={cn('min-h-0 min-w-0 shrink-0', grow && 'flex-1', insets[inset], className)} style={style}>
|
|
26
34
|
{children}
|
|
27
35
|
</div>
|
|
28
36
|
)
|
|
29
37
|
}
|
|
30
38
|
|
|
31
39
|
export interface SplitProps {
|
|
40
|
+
/** Identidade estável do grupo redimensionável. */
|
|
41
|
+
id?: string
|
|
32
42
|
direction?: 'horizontal' | 'vertical'
|
|
33
|
-
/** Quando ligado, cada fronteira ganha um
|
|
43
|
+
/** Quando ligado, cada fronteira ganha um separador acessível e arrastável. */
|
|
34
44
|
resizable?: boolean
|
|
35
45
|
handle?: boolean
|
|
46
|
+
/** Layout percentual restaurado, indexado pelos ids dos panes. */
|
|
47
|
+
defaultLayout?: SplitLayout
|
|
48
|
+
/** Chamado ao concluir uma mudança de layout; pode persistir o resultado em localStorage. */
|
|
49
|
+
onLayoutChanged?: (layout: SplitLayout, meta: SplitLayoutChange) => void
|
|
36
50
|
className?: string
|
|
37
51
|
children: ReactNode
|
|
38
52
|
}
|
|
@@ -43,11 +57,20 @@ function panesOf(children: ReactNode): ReactElement<PaneProps>[] {
|
|
|
43
57
|
)
|
|
44
58
|
}
|
|
45
59
|
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
60
|
+
// Números continuam percentuais por compatibilidade com Split/Pane. Strings preservam
|
|
61
|
+
// a unidade explícita para layouts que precisam de um limite físico, como `28rem`.
|
|
62
|
+
function sizeToCss(value: PaneSize): string {
|
|
63
|
+
return typeof value === 'number' ? `${value}%` : value
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function resizableSize(value: PaneSize | undefined): string | undefined {
|
|
67
|
+
return value === undefined ? undefined : sizeToCss(value)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function percentage(value: PaneSize | undefined): number | undefined {
|
|
71
|
+
if (typeof value === 'number') return value
|
|
72
|
+
if (value?.endsWith('%')) return Number.parseFloat(value)
|
|
73
|
+
return undefined
|
|
51
74
|
}
|
|
52
75
|
|
|
53
76
|
/**
|
|
@@ -55,35 +78,54 @@ function percent(value: number | undefined): string | undefined {
|
|
|
55
78
|
* quando fixa e o primitive Resizable quando `resizable`; quem consome não troca de
|
|
56
79
|
* modelo de layout para ganhar arraste.
|
|
57
80
|
*/
|
|
58
|
-
export function Split({
|
|
81
|
+
export function Split({
|
|
82
|
+
id,
|
|
83
|
+
direction = 'horizontal',
|
|
84
|
+
resizable = false,
|
|
85
|
+
handle = false,
|
|
86
|
+
defaultLayout,
|
|
87
|
+
onLayoutChanged,
|
|
88
|
+
className,
|
|
89
|
+
children,
|
|
90
|
+
}: SplitProps): React.ReactElement {
|
|
59
91
|
const panes = panesOf(children)
|
|
60
92
|
const vertical = direction === 'vertical'
|
|
61
93
|
|
|
62
94
|
if (!resizable) {
|
|
63
95
|
return (
|
|
64
|
-
<div data-slot="split" data-direction={direction} className={cn('flex min-h-0 min-w-0 flex-1', vertical && 'flex-col', className)}>
|
|
96
|
+
<div id={id === undefined ? undefined : String(id)} data-slot="split" data-direction={direction} className={cn('flex min-h-0 min-w-0 flex-1', vertical && 'flex-col', className)}>
|
|
65
97
|
{panes}
|
|
66
98
|
</div>
|
|
67
99
|
)
|
|
68
100
|
}
|
|
69
101
|
|
|
70
|
-
const fixed = panes.reduce((sum, pane) => sum + (pane.props.initialSize ?? 0), 0)
|
|
102
|
+
const fixed = panes.reduce((sum, pane) => sum + (percentage(pane.props.initialSize) ?? 0), 0)
|
|
103
|
+
const hasAbsoluteSize = panes.some((pane) => typeof pane.props.initialSize === 'string' && !pane.props.initialSize.endsWith('%'))
|
|
71
104
|
const growing = panes.filter((pane) => pane.props.grow)
|
|
72
105
|
const rest = Math.max(0, 100 - fixed)
|
|
73
106
|
const fallback = panes.length === 0 ? 100 : 100 / panes.length
|
|
74
107
|
|
|
75
108
|
return (
|
|
76
|
-
<ResizablePanelGroup
|
|
109
|
+
<ResizablePanelGroup
|
|
110
|
+
id={id}
|
|
111
|
+
orientation={direction}
|
|
112
|
+
defaultLayout={defaultLayout}
|
|
113
|
+
onLayoutChanged={onLayoutChanged}
|
|
114
|
+
data-slot="split"
|
|
115
|
+
className={cn('min-h-0 min-w-0 flex-1', className)}
|
|
116
|
+
>
|
|
77
117
|
{panes.flatMap((pane, index) => {
|
|
78
|
-
const size = pane.props.initialSize
|
|
118
|
+
const size = pane.props.initialSize
|
|
119
|
+
?? (hasAbsoluteSize ? undefined : pane.props.grow ? rest / Math.max(1, growing.length) : fallback)
|
|
79
120
|
const panel = (
|
|
80
121
|
<ResizablePanel
|
|
81
122
|
key={pane.key ?? index}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
123
|
+
id={pane.props.id}
|
|
124
|
+
defaultSize={resizableSize(size)}
|
|
125
|
+
minSize={resizableSize(pane.props.minSize)}
|
|
126
|
+
maxSize={resizableSize(pane.props.maxSize)}
|
|
85
127
|
>
|
|
86
|
-
<Pane {...pane.props} className={cn('h-full w-full', pane.props.className)} />
|
|
128
|
+
<Pane {...pane.props} id={undefined} className={cn('h-full w-full', pane.props.className)} />
|
|
87
129
|
</ResizablePanel>
|
|
88
130
|
)
|
|
89
131
|
return index === 0 ? [panel] : [<ResizableHandle key={`handle-${pane.key ?? index}`} withHandle={handle} />, panel]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
## Split & Pane
|
|
2
2
|
|
|
3
|
-
`Split`
|
|
3
|
+
`Split` organiza áreas lado a lado ou uma sobre a outra. A ordem dos `Pane` define a posição de cada área. Sem `resizable`, o layout usa flex; com essa opção, a pessoa também pode ajustar as divisórias com ponteiro ou teclado.
|
|
4
4
|
|
|
5
5
|
```tsx preview
|
|
6
6
|
render(
|
|
@@ -23,11 +23,51 @@ O `Pane` traz `inset="md"` por default. Use `none` para chrome, navegação e co
|
|
|
23
23
|
|
|
24
24
|
## Rail
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
Quando uma área precisa continuar legível em telas largas, use uma unidade absoluta para o tamanho inicial ou mínimo. Números continuam representando porcentagens; strings aceitam `%`, `rem`, `em`, `vh`, `vw` e `px`. Prefira `rem` para acompanhar a escala tipográfica configurada pela aplicação.
|
|
27
27
|
|
|
28
28
|
```tsx
|
|
29
29
|
<Split resizable>
|
|
30
30
|
<Pane grow inset="none"><Main /></Pane>
|
|
31
|
-
<Pane initialSize=
|
|
31
|
+
<Pane initialSize="28rem" minSize="20rem" inset="none"><Inspector /></Pane>
|
|
32
32
|
</Split>
|
|
33
33
|
```
|
|
34
|
+
|
|
35
|
+
## Persistência
|
|
36
|
+
|
|
37
|
+
Um layout pode ser restaurado sem acoplar `Split` a um mecanismo específico de armazenamento. Dê um `id` estável a cada `Pane`, leia o valor salvo para formar `defaultLayout` e persista apenas mudanças concluídas pela pessoa.
|
|
38
|
+
|
|
39
|
+
```tsx
|
|
40
|
+
function readLayout(key: string): SplitLayout | undefined {
|
|
41
|
+
if (typeof window === 'undefined') return undefined
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const parsed: unknown = JSON.parse(localStorage.getItem(key) ?? 'null')
|
|
45
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined
|
|
46
|
+
const entries = Object.entries(parsed)
|
|
47
|
+
const valid = entries.every(([id, size]) =>
|
|
48
|
+
id !== '' && typeof size === 'number' && Number.isFinite(size) && size >= 0 && size <= 100,
|
|
49
|
+
)
|
|
50
|
+
return valid ? Object.fromEntries(entries) : undefined
|
|
51
|
+
} catch {
|
|
52
|
+
return undefined
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const defaultLayout = readLayout('workspace-layout')
|
|
57
|
+
|
|
58
|
+
<Split
|
|
59
|
+
id="workspace"
|
|
60
|
+
resizable
|
|
61
|
+
defaultLayout={defaultLayout}
|
|
62
|
+
onLayoutChanged={(layout, meta) => {
|
|
63
|
+
if (meta.isUserInteraction) {
|
|
64
|
+
localStorage.setItem('workspace-layout', JSON.stringify(layout))
|
|
65
|
+
}
|
|
66
|
+
}}
|
|
67
|
+
>
|
|
68
|
+
<Pane id="navigation" initialSize="28rem" minSize="28rem"><Navigation /></Pane>
|
|
69
|
+
<Pane id="content" grow><Content /></Pane>
|
|
70
|
+
</Split>
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Em aplicações renderizadas no servidor, leia o armazenamento somente no cliente. `onLayoutChanged` também permite usar `sessionStorage` ou uma camada própria quando o layout precisa acompanhar outro escopo.
|
package/src/ui/react.tsx
CHANGED
|
@@ -207,7 +207,7 @@ export type { PageProps } from './components/patterns/page.tsx'
|
|
|
207
207
|
|
|
208
208
|
// Layout composicional: Split decide a relação espacial; Pane carrega conteúdo com inset.
|
|
209
209
|
export { Split, Pane } from './components/patterns/split.tsx'
|
|
210
|
-
export type { SplitProps, PaneProps } from './components/patterns/split.tsx'
|
|
210
|
+
export type { SplitProps, PaneProps, PaneSize, SplitLayout, SplitLayoutChange } from './components/patterns/split.tsx'
|
|
211
211
|
|
|
212
212
|
// Barra lateral composicional: header/conteúdo/footer e navegação, sem possuir o layout.
|
|
213
213
|
export { PaneHeader, PaneContent, PaneFooter, Sidebar, SidebarItem, SidebarNav } from './components/patterns/sidebar.tsx'
|