@pasquelin/panels 0.1.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/LICENSE +21 -0
- package/README.md +165 -0
- package/dist/components/Band.d.ts +13 -0
- package/dist/components/Center.d.ts +18 -0
- package/dist/components/IconButton.d.ts +20 -0
- package/dist/components/Panel.d.ts +31 -0
- package/dist/components/PanelFrame.d.ts +23 -0
- package/dist/components/PanelHeader.d.ts +15 -0
- package/dist/components/Panels.d.ts +30 -0
- package/dist/components/Rail.d.ts +23 -0
- package/dist/components/ResizeHandle.d.ts +37 -0
- package/dist/components/Separator.d.ts +6 -0
- package/dist/components/Surface.d.ts +9 -0
- package/dist/components/ZoneEdge.d.ts +17 -0
- package/dist/components/content.d.ts +9 -0
- package/dist/components/labels.d.ts +12 -0
- package/dist/core/clamps.d.ts +55 -0
- package/dist/core/context.d.ts +39 -0
- package/dist/core/cx.d.ts +5 -0
- package/dist/core/hooks/useArrangement.d.ts +36 -0
- package/dist/core/hooks/useContainerFit.d.ts +9 -0
- package/dist/core/hooks/usePanels.d.ts +26 -0
- package/dist/core/hooks/usePointerDrag.d.ts +21 -0
- package/dist/core/hooks/useZone.d.ts +28 -0
- package/dist/core/layoutEffect.d.ts +10 -0
- package/dist/core/persistence.d.ts +30 -0
- package/dist/core/store.d.ts +86 -0
- package/dist/core/types.d.ts +88 -0
- package/dist/cx-CcykAxZN.js +6 -0
- package/dist/cx-YyuC5RtB.cjs +1 -0
- package/dist/dockview/DockviewCenter.d.ts +37 -0
- package/dist/dockview/index.d.ts +3 -0
- package/dist/dockview.cjs +1 -0
- package/dist/dockview.js +39 -0
- package/dist/index.cjs +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +935 -0
- package/dist/styles.css +1 -0
- package/llms-full.txt +1242 -0
- package/llms.txt +150 -0
- package/package.json +99 -0
package/llms-full.txt
ADDED
|
@@ -0,0 +1,1242 @@
|
|
|
1
|
+
# @pasquelin/panels
|
|
2
|
+
|
|
3
|
+
> **React panel chassis** (React 19, no runtime dependencies), published to npm (ESM + CJS +
|
|
4
|
+
> types). Icon rails on the edges, five resizable zones around a free centre, a layout that
|
|
5
|
+
> survives a reload. **Headless underneath, repaintable on top** — the components are built on
|
|
6
|
+
> hooks that render nothing, and every visual value is a CSS custom property.
|
|
7
|
+
> MIT. Live: https://pasquelin.github.io/panel/
|
|
8
|
+
|
|
9
|
+
## Mental model (read this first)
|
|
10
|
+
|
|
11
|
+
- **It is a LAYOUT, not a framework.** It draws the frame and remembers the sizes. What goes in
|
|
12
|
+
the panels is the consuming project's business. No state management, no data layer, no icon
|
|
13
|
+
set, no i18n, no opinion about the centre.
|
|
14
|
+
- **`<Panel>` and `<Center>` are DESCRIPTORS**, in the shape of `<Route>`: they return `null` and
|
|
15
|
+
are never rendered where they are written. `<Panels>` reads them out of its children with
|
|
16
|
+
`Children.toArray` and hands their content to the zone they named. They must therefore be
|
|
17
|
+
children of `<Panels>`, not of a component in between.
|
|
18
|
+
- **Not portals, and deliberately so**: a portal puts panel content in a different React tree, so
|
|
19
|
+
context, error boundaries and suspense would stop at the boundary and a panel could not read a
|
|
20
|
+
provider its own file sits under.
|
|
21
|
+
- **One store per chassis**, created not imported (`createPanelsStore`). No module singleton —
|
|
22
|
+
two chassis can live in one application, and a test cannot leak into the next.
|
|
23
|
+
- **What is stored is not what is shown.** Stored: which panel each half holds, and the sizes.
|
|
24
|
+
Resolved at render: what a zone draws (a `solo` panel silences the other half), the size it
|
|
25
|
+
takes (bounded against the opposite zone), whether it takes room at all (the band's two halves
|
|
26
|
+
share one height).
|
|
27
|
+
- **There are no "surfaces" or sections.** An earlier design carried them; they were removed. In
|
|
28
|
+
React the router changes the children — there is nothing to solve.
|
|
29
|
+
- **A panel that should not be offered is a panel you do not declare.** That replaces what a
|
|
30
|
+
capability system would be for.
|
|
31
|
+
|
|
32
|
+
## Install and shape
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pnpm add @pasquelin/panels
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
```tsx
|
|
39
|
+
import { Panels, Panel } from '@pasquelin/panels'
|
|
40
|
+
import '@pasquelin/panels/styles.css'
|
|
41
|
+
|
|
42
|
+
type PanelId = 'files' | 'notes'
|
|
43
|
+
|
|
44
|
+
<Panels<PanelId>>
|
|
45
|
+
<Panel<PanelId> id="files" zone="left" title="Files" icon={<Icon />}>
|
|
46
|
+
<FileTree />
|
|
47
|
+
</Panel>
|
|
48
|
+
<Panel<PanelId> id="notes" zone="right" title="Notes" opens={380}>
|
|
49
|
+
<Notes />
|
|
50
|
+
</Panel>
|
|
51
|
+
<Panels.Center>
|
|
52
|
+
<YourApp />
|
|
53
|
+
</Panels.Center>
|
|
54
|
+
</Panels>
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`<Panels>` fills its parent: that parent needs a height. On a flex child, `minHeight: 0` is the
|
|
58
|
+
usual missing piece.
|
|
59
|
+
|
|
60
|
+
## Geometry
|
|
61
|
+
|
|
62
|
+
- Zones: `left` `right` `top` `bottomLeft` `bottomRight`. **An empty zone takes no room at all.**
|
|
63
|
+
- Each zone has two halves: `primary` (nearest the window edge) and `secondary`.
|
|
64
|
+
- Panels sharing a zone AND a slot **take turns** — the rail switches between them. Different
|
|
65
|
+
slots show together, stacked.
|
|
66
|
+
- `bottomLeft` + `bottomRight` share **one height**. Whichever is alone runs under the opposite
|
|
67
|
+
column; a side column runs to the foot unless the band's half on its side is drawing.
|
|
68
|
+
- The centre stays at one place in the React tree through every arrangement — moving it would
|
|
69
|
+
unmount whatever engine or canvas it holds.
|
|
70
|
+
|
|
71
|
+
## API
|
|
72
|
+
|
|
73
|
+
### Components
|
|
74
|
+
`Panels` `Panel` `Center` · `Rail` `RailZone` `ZoneEdge` `Band` `PanelFrame` · `Surface`
|
|
75
|
+
`PanelHeader` `IconButton` `Separator` `ResizeHandle`
|
|
76
|
+
|
|
77
|
+
### `<Panels>` props
|
|
78
|
+
`store` `storageKey` `storage` `defaultOpen` `header` `footer` `railHeader` `labels` `theme`
|
|
79
|
+
`className`
|
|
80
|
+
|
|
81
|
+
### `<Panel>` props
|
|
82
|
+
`id` `zone` `slot?` `title` `icon?` `actions?` `opens?` `solo?`
|
|
83
|
+
|
|
84
|
+
### Hooks
|
|
85
|
+
- `usePanels<Id>()` → `{ panels, reveal, close, toggle, isShown, focusedZone, reset }`
|
|
86
|
+
- `useZone<Id>(zone)` → `{ primary, secondary, draws, size, split, focused }`
|
|
87
|
+
- `useArrangement` `useShownIn` `useZoneDraws` `useZoneTakesRoom` `useBandHalves`
|
|
88
|
+
- `useContainerFit(ref)` `usePointerDrag()` `usePanelsState(selector)` `usePanelsActions()`
|
|
89
|
+
|
|
90
|
+
### Core
|
|
91
|
+
`createPanelsStore` `shownIn` `specOf` `zoneDraws` `zoneTakesRoom` `undraggedSizeOf`
|
|
92
|
+
`fitZoneSize` `fitSplit` `sharedSizes` `sizeKeyOf` `MIN_SIZE` `MIN_CENTER` `MIN_SPLIT`
|
|
93
|
+
`DEFAULT_SIZES` · `browserStorage` `memoryStorage` `readLayout` `writeLayout` `LAYOUT_VERSION`
|
|
94
|
+
· `ZONES` `SLOTS` `BOTTOM_ZONES` `ZONES_BY_SIDE` `isBottom` `isHorizontal` `isLeading` `cx`
|
|
95
|
+
|
|
96
|
+
### Optional entry point
|
|
97
|
+
`@pasquelin/panels/dockview` → `DockviewCenter`, for document tabs. Separate so its weight only
|
|
98
|
+
lands on projects that want it. Requires `dockview-react` as a peer.
|
|
99
|
+
|
|
100
|
+
## Driving it from outside React
|
|
101
|
+
|
|
102
|
+
```tsx
|
|
103
|
+
const store = createPanelsStore<PanelId>()
|
|
104
|
+
socket.on('alert', () => store.getState().show('alerts'))
|
|
105
|
+
window.electron?.onMenu(id => store.getState().show(id))
|
|
106
|
+
|
|
107
|
+
<Panels<PanelId> store={store}>…</Panels>
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Theming
|
|
111
|
+
|
|
112
|
+
Every value is a custom property on `.pnl-root`. Set them anywhere that reaches the chassis.
|
|
113
|
+
|
|
114
|
+
Surfaces: `--pnl-chassis` `--pnl-panel` `--pnl-elevated` `--pnl-border` `--pnl-text` `--pnl-muted`
|
|
115
|
+
Accent: `--pnl-accent` `--pnl-accent-text`
|
|
116
|
+
Gauges: `--pnl-rail` `--pnl-rail-button` `--pnl-rail-inset` `--pnl-gutter` `--pnl-header`
|
|
117
|
+
`--pnl-radius` `--pnl-radius-sm` `--pnl-font-size`
|
|
118
|
+
|
|
119
|
+
- **Panels are DARKER than the chassis.** That inversion is the one visual idea the library is
|
|
120
|
+
built on. Invert it back by repainting two tokens.
|
|
121
|
+
- Defaults sit inside `:where()`, at zero specificity, so a consumer's palette always wins
|
|
122
|
+
whatever order the stylesheets load in. A guard reads the sheet to keep it that way.
|
|
123
|
+
- `<Panels theme="dark" | "light">` forces the palette; left out it follows `prefers-color-scheme`.
|
|
124
|
+
|
|
125
|
+
## Traps worth knowing
|
|
126
|
+
|
|
127
|
+
- `usePanels` **must be called inside `<Panels>`**. A component that renders the chassis cannot
|
|
128
|
+
also read it — put the consumer in a child, or in a panel's `actions`.
|
|
129
|
+
- Subscribe with **scalar** selectors. `lengths` and `available` are replaced wholesale on every
|
|
130
|
+
write, so subscribing to either as an object re-renders on every frame of a drag.
|
|
131
|
+
- `close(id)` closes THAT panel or nothing. `close(zone, slot)` on the store empties the half
|
|
132
|
+
whatever stands in it — two panels share a half.
|
|
133
|
+
- `useZoneTakesRoom` ≠ `useZoneDraws`: the band's halves share a height, so either drawing means
|
|
134
|
+
the strip takes it.
|
|
135
|
+
- Two chassis in one app need two `storageKey`s, or they overwrite each other.
|
|
136
|
+
- The chassis measures **its container**, never the window — that is what lets it sit inside an
|
|
137
|
+
existing page.
|
|
138
|
+
- Client-side only: it reads `localStorage` and measures the DOM. Under Next.js, `'use client'`.
|
|
139
|
+
|
|
140
|
+
## Not in v1
|
|
141
|
+
|
|
142
|
+
Floating panels, and dragging a panel from one zone to another. The state model already allows
|
|
143
|
+
an override of the declared zone; the gesture is not built.
|
|
144
|
+
|
|
145
|
+
## Documentation
|
|
146
|
+
|
|
147
|
+
- English: docs/en/README.md · PANELS · LAYOUT · HOOKS · THEMING · COMPONENTS · DOCKVIEW · RECIPES
|
|
148
|
+
- Français: docs/fr/README.md (same eight chapters)
|
|
149
|
+
- Architecture: docs/ARCHITECTURE.md
|
|
150
|
+
- Full text of all of it: llms-full.txt
|
|
151
|
+
|
|
152
|
+
==============================================================================
|
|
153
|
+
README
|
|
154
|
+
==============================================================================
|
|
155
|
+
|
|
156
|
+
# @pasquelin/panels
|
|
157
|
+
|
|
158
|
+
**[Live demo ↗](https://pasquelin.github.io/panel/)** · **[Documentation 🇫🇷](docs/fr/README.md)** · **[Documentation 🇬🇧](docs/en/README.md)** · MIT
|
|
159
|
+
|
|
160
|
+
A React panel chassis: icon rails on the edges, resizable zones around a free centre, and a
|
|
161
|
+
layout that survives a reload.
|
|
162
|
+
|
|
163
|
+
It is a **layout, not a framework**. What goes in the panels is your project's business — the
|
|
164
|
+
library draws the frame, remembers the sizes, and gets out of the way.
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
pnpm add @pasquelin/panels
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
```tsx
|
|
171
|
+
import { Panels, Panel } from '@pasquelin/panels'
|
|
172
|
+
import '@pasquelin/panels/styles.css'
|
|
173
|
+
|
|
174
|
+
type PanelId = 'files' | 'chat'
|
|
175
|
+
|
|
176
|
+
export function App() {
|
|
177
|
+
return (
|
|
178
|
+
<Panels<PanelId>>
|
|
179
|
+
<Panel<PanelId> id="files" zone="left" title="Files" icon={<FilesIcon />}>
|
|
180
|
+
<FileTree />
|
|
181
|
+
</Panel>
|
|
182
|
+
|
|
183
|
+
<Panel<PanelId> id="chat" zone="right" title="Assistant" icon={<ChatIcon />}>
|
|
184
|
+
<Conversation />
|
|
185
|
+
</Panel>
|
|
186
|
+
|
|
187
|
+
<Panels.Center>
|
|
188
|
+
<Outlet />
|
|
189
|
+
</Panels.Center>
|
|
190
|
+
</Panels>
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
That is the whole of it. The rails, the resize handles, the persistence and the keyboard are
|
|
196
|
+
already there.
|
|
197
|
+
|
|
198
|
+
## What you get
|
|
199
|
+
|
|
200
|
+
- **Five zones** — `left`, `right`, `top`, `bottomLeft`, `bottomRight`. A zone nobody fills takes
|
|
201
|
+
no room at all.
|
|
202
|
+
- **Two halves per zone** — `primary` and `secondary`, parted by a handle. Panels sharing a half
|
|
203
|
+
take turns; the rail switches between them.
|
|
204
|
+
- **Icon rails** that stay put when a zone closes, so a closed panel is always one click away.
|
|
205
|
+
- **Resize** by pointer or keyboard, clamped so the centre never disappears.
|
|
206
|
+
- **Persistence** to `localStorage` out of the box, or wherever you say.
|
|
207
|
+
- **Typed ids** — `reveal('chatt')` does not compile.
|
|
208
|
+
- **No dependencies** beyond React. No icon set, no CSS framework, no i18n.
|
|
209
|
+
|
|
210
|
+
## Panel props
|
|
211
|
+
|
|
212
|
+
| Prop | Meaning |
|
|
213
|
+
| --- | --- |
|
|
214
|
+
| `id` | Unique, and the type parameter of `<Panels>` |
|
|
215
|
+
| `zone` | Which edge it hangs from |
|
|
216
|
+
| `slot` | `primary` (nearest the edge) or `secondary`. Defaults to `primary` |
|
|
217
|
+
| `title` | Accessible name and header title, **already translated** |
|
|
218
|
+
| `icon` | Anything React renders — the library imposes no icon set |
|
|
219
|
+
| `actions` | Rendered on the panel's own title row |
|
|
220
|
+
| `opens` | The width this panel wants when it leads its zone |
|
|
221
|
+
| `solo` | Takes the zone whole; the other half is put away, not closed, and comes back |
|
|
222
|
+
|
|
223
|
+
## Driving the panels from anywhere
|
|
224
|
+
|
|
225
|
+
`usePanels()` is all a header, a shortcut or a menu needs:
|
|
226
|
+
|
|
227
|
+
```tsx
|
|
228
|
+
const { panels, reveal, close, toggle, isShown } = usePanels<PanelId>()
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
From **outside React** — a socket message, an Electron menu — build the store yourself and keep
|
|
232
|
+
the reference:
|
|
233
|
+
|
|
234
|
+
```tsx
|
|
235
|
+
const store = createPanelsStore<PanelId>()
|
|
236
|
+
|
|
237
|
+
socket.on('alert', () => store.getState().show('alerts'))
|
|
238
|
+
|
|
239
|
+
<Panels<PanelId> store={store}>…</Panels>
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
## Making it yours
|
|
243
|
+
|
|
244
|
+
Three levels, and you can stop at any of them.
|
|
245
|
+
|
|
246
|
+
**1. Repaint it.** Every value is a custom property on `.pnl-root`:
|
|
247
|
+
|
|
248
|
+
```css
|
|
249
|
+
.pnl-root {
|
|
250
|
+
--pnl-panel: #101418;
|
|
251
|
+
--pnl-chassis: #1b1f24;
|
|
252
|
+
--pnl-accent: #47965c; /* the rails follow your brand */
|
|
253
|
+
--pnl-radius: 10px;
|
|
254
|
+
--pnl-rail: 56px;
|
|
255
|
+
}
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
**2. Replace a piece.** `Rail`, `PanelHeader`, `ResizeHandle`, `Surface`, `IconButton` and the
|
|
259
|
+
rest are exported. Build your own frame out of the ones you keep.
|
|
260
|
+
|
|
261
|
+
**3. Take the logic only.** Every component is built on hooks that render nothing:
|
|
262
|
+
|
|
263
|
+
```tsx
|
|
264
|
+
const zone = useZone('left') // what it draws, its size, its split
|
|
265
|
+
const { reveal, isShown } = usePanels()
|
|
266
|
+
useContainerFit(ref) // re-clamps when the container resizes
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Draw whatever you like on top. The chassis has no opinion about it.
|
|
270
|
+
|
|
271
|
+
## It fits inside your page
|
|
272
|
+
|
|
273
|
+
The chassis measures **its own container**, never the window. Put it in a route, under your
|
|
274
|
+
navigation, beside your sidebar — the clamps follow the box it is actually in.
|
|
275
|
+
|
|
276
|
+
## Header and footer are yours
|
|
277
|
+
|
|
278
|
+
`<Panels>` draws no chrome. `header` and `footer` are slots; pass your own, or nothing. Where a
|
|
279
|
+
project's panels come from — a router, a state machine, a config — is that project's business.
|
|
280
|
+
|
|
281
|
+
## Examples
|
|
282
|
+
|
|
283
|
+
All four run at **[pasquelin.github.io/panel](https://pasquelin.github.io/panel/)**, or locally:
|
|
284
|
+
|
|
285
|
+
```bash
|
|
286
|
+
pnpm dev
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
- `examples/minimal` — the smallest working chassis
|
|
290
|
+
- `examples/router` — the centre as a React Router outlet
|
|
291
|
+
- `examples/dockview` — document tabs, on the optional Dockview entry point
|
|
292
|
+
- `examples/theme` — the same chassis under four palettes
|
|
293
|
+
|
|
294
|
+
## Documentation
|
|
295
|
+
|
|
296
|
+
| | 🇫🇷 Français | 🇬🇧 English |
|
|
297
|
+
| --- | --- | --- |
|
|
298
|
+
| **Guide + index** | [docs/fr/](docs/fr/README.md) | [docs/en/](docs/en/README.md) |
|
|
299
|
+
| Panels | [PANELS.md](docs/fr/PANELS.md) | [PANELS.md](docs/en/PANELS.md) |
|
|
300
|
+
| Layout | [LAYOUT.md](docs/fr/LAYOUT.md) | [LAYOUT.md](docs/en/LAYOUT.md) |
|
|
301
|
+
| Hooks | [HOOKS.md](docs/fr/HOOKS.md) | [HOOKS.md](docs/en/HOOKS.md) |
|
|
302
|
+
| Theming | [THEMING.md](docs/fr/THEMING.md) | [THEMING.md](docs/en/THEMING.md) |
|
|
303
|
+
| Components | [COMPONENTS.md](docs/fr/COMPONENTS.md) | [COMPONENTS.md](docs/en/COMPONENTS.md) |
|
|
304
|
+
| Document tabs | [DOCKVIEW.md](docs/fr/DOCKVIEW.md) | [DOCKVIEW.md](docs/en/DOCKVIEW.md) |
|
|
305
|
+
| Recipes | [RECIPES.md](docs/fr/RECIPES.md) | [RECIPES.md](docs/en/RECIPES.md) |
|
|
306
|
+
| Architecture | [ARCHITECTURE.md](docs/ARCHITECTURE.md) | — |
|
|
307
|
+
|
|
308
|
+
For coding agents: [`llms.txt`](llms.txt) is the mental model, the API and the traps in one
|
|
309
|
+
page; [`llms-full.txt`](llms-full.txt) is every English chapter concatenated. Both ship with the
|
|
310
|
+
package, and `pnpm llms` regenerates the second from the first plus `docs/`.
|
|
311
|
+
|
|
312
|
+
## Licence
|
|
313
|
+
|
|
314
|
+
**MIT** — see [LICENSE](LICENSE).
|
|
315
|
+
|
|
316
|
+
Every dependency is MIT too, so there is no copyleft obligation anywhere in the tree.
|
|
317
|
+
`zustand` is bundled into the published package and its notice travels with it; everything else
|
|
318
|
+
is a peer dependency the consuming project installs itself. The full accounting is in
|
|
319
|
+
[THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md), and `pnpm licences:check` fails the build if it
|
|
320
|
+
stops being true.
|
|
321
|
+
|
|
322
|
+
==============================================================================
|
|
323
|
+
Panels
|
|
324
|
+
==============================================================================
|
|
325
|
+
|
|
326
|
+
# Panels
|
|
327
|
+
|
|
328
|
+
A panel is declared with `<Panel>`, which is a **descriptor**: it is never rendered where you
|
|
329
|
+
write it. `<Panels>` reads its props to build the rail and hands its children to the zone it
|
|
330
|
+
named.
|
|
331
|
+
|
|
332
|
+
```tsx
|
|
333
|
+
<Panel<PanelId>
|
|
334
|
+
id="files"
|
|
335
|
+
zone="left"
|
|
336
|
+
slot="primary"
|
|
337
|
+
title="Files"
|
|
338
|
+
icon={<FilesIcon />}
|
|
339
|
+
actions={<RefreshButton />}
|
|
340
|
+
opens={320}
|
|
341
|
+
>
|
|
342
|
+
<FileTree />
|
|
343
|
+
</Panel>
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
## Props
|
|
347
|
+
|
|
348
|
+
| Prop | Required | Meaning |
|
|
349
|
+
| --- | --- | --- |
|
|
350
|
+
| `id` | yes | Unique, and the type parameter of `<Panels>` |
|
|
351
|
+
| `zone` | yes | `left`, `right`, `top`, `bottomLeft`, `bottomRight` |
|
|
352
|
+
| `slot` | no | `primary` (nearest the edge) or `secondary`. Defaults to `primary` |
|
|
353
|
+
| `title` | yes | Accessible name and header title, **already translated** |
|
|
354
|
+
| `icon` | no | Anything React renders. No icon set is imposed |
|
|
355
|
+
| `actions` | no | Rendered on the panel's own title row, beside its name |
|
|
356
|
+
| `opens` | no | The size this panel wants when it leads its zone |
|
|
357
|
+
| `solo` | no | Takes the zone whole. `primary` only |
|
|
358
|
+
|
|
359
|
+
## Sharing a half
|
|
360
|
+
|
|
361
|
+
Two panels with the same `zone` and the same `slot` **take turns**: the rail switches between
|
|
362
|
+
them, and only one is on screen at a time.
|
|
363
|
+
|
|
364
|
+
```tsx
|
|
365
|
+
<Panel id="files" zone="left" title="Files">…</Panel>
|
|
366
|
+
<Panel id="search" zone="left" title="Search">…</Panel>
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
To show them **together**, stacked, put the second in the other half:
|
|
370
|
+
|
|
371
|
+
```tsx
|
|
372
|
+
<Panel id="files" zone="left" title="Files">…</Panel>
|
|
373
|
+
<Panel id="outline" zone="left" slot="secondary" title="Outline">…</Panel>
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
The rail draws the same cut as a separator: icons above it open in the first half, icons below in
|
|
377
|
+
the second. The rail is the legend of the column.
|
|
378
|
+
|
|
379
|
+
## Which panel opens first
|
|
380
|
+
|
|
381
|
+
A half with nothing chosen opens on the **first panel declared for it**. Declaration order is
|
|
382
|
+
therefore meaningful: put the panel a reader should land on first.
|
|
383
|
+
|
|
384
|
+
To open somewhere else, name the halves yourself:
|
|
385
|
+
|
|
386
|
+
```tsx
|
|
387
|
+
<Panels<PanelId> defaultOpen={{ left: { primary: 'search' } }}>
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
`defaultOpen` is read once, and never against a layout that was restored — an arrangement the
|
|
391
|
+
reader made outranks a default.
|
|
392
|
+
|
|
393
|
+
## `opens`
|
|
394
|
+
|
|
395
|
+
A zone has a width of its own (320 for `left`, 260 for `right`, 180 for `top`, 240 for the band).
|
|
396
|
+
A panel that needs more says so:
|
|
397
|
+
|
|
398
|
+
```tsx
|
|
399
|
+
<Panel id="chat" zone="right" title="Assistant" opens={460}>
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
A size the reader dragged always wins over it: a length somebody chose is an answer about the
|
|
403
|
+
column, not about the panel that happened to be in it.
|
|
404
|
+
|
|
405
|
+
## `solo`
|
|
406
|
+
|
|
407
|
+
A `solo` panel takes its zone **whole**; the other half draws nothing while it is up.
|
|
408
|
+
|
|
409
|
+
```tsx
|
|
410
|
+
<Panel id="chat" zone="right" title="Assistant" solo opens={460}>
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
The half it silences is **put away, not closed**. Close the solo panel and what was there comes
|
|
414
|
+
back untouched. That stash is never persisted: a column reopening by itself days later, on an
|
|
415
|
+
arrangement nobody remembers making, is not a restoration.
|
|
416
|
+
|
|
417
|
+
`solo` only applies to `primary`.
|
|
418
|
+
|
|
419
|
+
## Adding and removing panels
|
|
420
|
+
|
|
421
|
+
The registry follows your JSX. A panel that stops being declared is unregistered, and the half it
|
|
422
|
+
occupied is emptied — a stored id naming a panel that no longer exists would otherwise leave a
|
|
423
|
+
frame with nothing to draw in it.
|
|
424
|
+
|
|
425
|
+
Conditional panels work as you would expect:
|
|
426
|
+
|
|
427
|
+
```tsx
|
|
428
|
+
{project && (
|
|
429
|
+
<Panel id="git" zone="left" slot="secondary" title="History">…</Panel>
|
|
430
|
+
)}
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
This is how you express what a capability system would otherwise be for: a panel that should not
|
|
434
|
+
be offered is a panel you do not declare.
|
|
435
|
+
|
|
436
|
+
==============================================================================
|
|
437
|
+
Layout
|
|
438
|
+
==============================================================================
|
|
439
|
+
|
|
440
|
+
# Layout
|
|
441
|
+
|
|
442
|
+
```
|
|
443
|
+
┌──────────────────────────────────────────────────────────┐
|
|
444
|
+
│ header (yours, or nothing) │
|
|
445
|
+
├──┬────────────────────────────────────────────────────┬──┤
|
|
446
|
+
│ │ top │ │
|
|
447
|
+
│R │────────────────────────────────────────────────────│R │
|
|
448
|
+
│A │ left │ │ right │A │
|
|
449
|
+
│I │ primary │ │ primary │I │
|
|
450
|
+
│L │ ┄┄┄┄┄┄┄ │ CENTRE │ ┄┄┄┄┄┄┄ │L │
|
|
451
|
+
│ │ secondary │ │ secondary │ │
|
|
452
|
+
│ │────────────┴────────────────────────┴──────────────│ │
|
|
453
|
+
│ │ bottomLeft ┊ bottomRight │ │
|
|
454
|
+
├──┴────────────────────────────────────────────────────┴──┤
|
|
455
|
+
│ footer (yours, or nothing) │
|
|
456
|
+
└──────────────────────────────────────────────────────────┘
|
|
457
|
+
┄┄┄ handle between the two halves of a zone
|
|
458
|
+
│ handle between a zone and the centre
|
|
459
|
+
┊ handle between the band's two halves
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
## Five zones, two halves each
|
|
463
|
+
|
|
464
|
+
`left`, `right`, `top`, `bottomLeft`, `bottomRight`. **A zone nobody fills takes no room at all** —
|
|
465
|
+
no width, no handle, nothing.
|
|
466
|
+
|
|
467
|
+
Each zone is cut in two: `primary` is the half nearest the window edge the zone hangs from — the
|
|
468
|
+
top of a side column, the left of the bottom strip.
|
|
469
|
+
|
|
470
|
+
## The band is one strip
|
|
471
|
+
|
|
472
|
+
`bottomLeft` and `bottomRight` share **one height**. Whichever of them is alone runs under the
|
|
473
|
+
opposite column; together they split the width, parted by a handle that starts at the middle.
|
|
474
|
+
|
|
475
|
+
That is also why a side column runs to the foot of the frame **unless** the band's half on its
|
|
476
|
+
side is drawing.
|
|
477
|
+
|
|
478
|
+
## The centre
|
|
479
|
+
|
|
480
|
+
Whatever you put in `<Panels.Center>`. It stays at the same place in the React tree through every
|
|
481
|
+
arrangement of the zones around it — moved, React would unmount it and take down whatever engine,
|
|
482
|
+
canvas or editor it holds.
|
|
483
|
+
|
|
484
|
+
## Resizing
|
|
485
|
+
|
|
486
|
+
Three handles, three different questions:
|
|
487
|
+
|
|
488
|
+
| Handle | Moves |
|
|
489
|
+
| --- | --- |
|
|
490
|
+
| Between a zone and the centre | the zone's own length |
|
|
491
|
+
| Between two halves of a zone | the divider inside it |
|
|
492
|
+
| Between the band's two zones | how they share the width |
|
|
493
|
+
|
|
494
|
+
Every one is a `role="separator"`, reachable with <kbd>Tab</kbd> and driven by the arrow keys — a
|
|
495
|
+
separator that only answers a pointer is a control a keyboard user cannot operate at all.
|
|
496
|
+
|
|
497
|
+
## The floors
|
|
498
|
+
|
|
499
|
+
| | |
|
|
500
|
+
| --- | --- |
|
|
501
|
+
| `MIN_SIZE` | 140 — smallest a zone may be dragged to |
|
|
502
|
+
| `MIN_CENTER` | 240 — room the centre always keeps |
|
|
503
|
+
| `MIN_SPLIT` | 100 — room a half keeps inside its zone |
|
|
504
|
+
|
|
505
|
+
A zone is bounded against **what the opposite zone already takes**. Capping each side at half the
|
|
506
|
+
container independently would let left and right add up to the full width, leaving the centre at
|
|
507
|
+
zero — and overflowing once the container shrinks.
|
|
508
|
+
|
|
509
|
+
That bound applies whether or not anything was ever dragged. Two untouched columns asking for 320
|
|
510
|
+
and 380 on a 900 px container do not leave the centre 104 px: they give ground in proportion to
|
|
511
|
+
what they asked for, so neither collapses while the other keeps its full width.
|
|
512
|
+
|
|
513
|
+
## It measures its container, not the window
|
|
514
|
+
|
|
515
|
+
The chassis observes **its own box**. Put it in a route, under your navigation, beside your
|
|
516
|
+
sidebar — the floors follow the box it is actually in.
|
|
517
|
+
|
|
518
|
+
This is what lets it be adopted a piece at a time in an application that already exists.
|
|
519
|
+
|
|
520
|
+
## Persistence
|
|
521
|
+
|
|
522
|
+
What is stored: which half holds which panel, and the sizes. That is all.
|
|
523
|
+
|
|
524
|
+
```tsx
|
|
525
|
+
<Panels
|
|
526
|
+
storageKey="my-app:layout" // two chassis in one app need two keys
|
|
527
|
+
storage={myStorage} // or null to store nothing
|
|
528
|
+
>
|
|
529
|
+
```
|
|
530
|
+
|
|
531
|
+
Focus and the solo stash are session state and are deliberately not written down.
|
|
532
|
+
|
|
533
|
+
A custom store is two functions:
|
|
534
|
+
|
|
535
|
+
```ts
|
|
536
|
+
const storage = {
|
|
537
|
+
read: (key: string) => string | null,
|
|
538
|
+
write: (key: string, value: string) => void,
|
|
539
|
+
}
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
A layout written by another version, or corrupted, is **dropped rather than half-read**: your
|
|
543
|
+
defaults are a deliberate arrangement and a broken one is not. Unknown zones, unknown slots and
|
|
544
|
+
values of the wrong type are discarded on the way in.
|
|
545
|
+
|
|
546
|
+
==============================================================================
|
|
547
|
+
Hooks
|
|
548
|
+
==============================================================================
|
|
549
|
+
|
|
550
|
+
# Hooks
|
|
551
|
+
|
|
552
|
+
The components are built on these. Take them and draw your own frame, or use them from a header
|
|
553
|
+
that knows nothing about zones.
|
|
554
|
+
|
|
555
|
+
## `usePanels`
|
|
556
|
+
|
|
557
|
+
Everything a header, a shortcut or a menu needs.
|
|
558
|
+
|
|
559
|
+
```tsx
|
|
560
|
+
const { panels, reveal, close, toggle, isShown, focusedZone, reset } = usePanels<PanelId>()
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
| | |
|
|
564
|
+
| --- | --- |
|
|
565
|
+
| `panels` | Every declared panel, in declaration order |
|
|
566
|
+
| `reveal(id)` | Brings it up in the half it declared, and focuses its zone |
|
|
567
|
+
| `close(id)` | Closes **that** panel, or nothing |
|
|
568
|
+
| `toggle(id)` | What a rail icon does |
|
|
569
|
+
| `isShown(id)` | Whether it is on screen right now |
|
|
570
|
+
| `focusedZone` | The last zone clicked, or `null` |
|
|
571
|
+
| `reset()` | Back to the opening arrangement |
|
|
572
|
+
|
|
573
|
+
`close(id)` is asked about the panel, not the half: two panels share a half, and closing "the
|
|
574
|
+
half" would close whichever happened to be standing there and report success.
|
|
575
|
+
|
|
576
|
+
A header built on this follows the chassis whatever moves it — a rail click, a keyboard
|
|
577
|
+
shortcut, a socket message:
|
|
578
|
+
|
|
579
|
+
```tsx
|
|
580
|
+
function Header() {
|
|
581
|
+
const { panels, isShown, toggle } = usePanels<PanelId>()
|
|
582
|
+
|
|
583
|
+
return (
|
|
584
|
+
<nav>
|
|
585
|
+
{panels.map(panel => (
|
|
586
|
+
<button key={panel.id} aria-pressed={isShown(panel.id)} onClick={() => toggle(panel.id)}>
|
|
587
|
+
{panel.title}
|
|
588
|
+
</button>
|
|
589
|
+
))}
|
|
590
|
+
</nav>
|
|
591
|
+
)
|
|
592
|
+
}
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
⚠️ It must be called **inside** `<Panels>`. A component that renders the chassis cannot also read
|
|
596
|
+
it — put the button in a child, or in a panel's `actions`.
|
|
597
|
+
|
|
598
|
+
## `useZone`
|
|
599
|
+
|
|
600
|
+
What one zone needs to draw itself.
|
|
601
|
+
|
|
602
|
+
```tsx
|
|
603
|
+
const { primary, secondary, draws, size, split, focused } = useZone<PanelId>('left')
|
|
604
|
+
```
|
|
605
|
+
|
|
606
|
+
`primary` and `secondary` are the panels the zone actually **draws**, which is not always what it
|
|
607
|
+
holds — a `solo` panel silences the other half. `size` is already bounded against the opposite
|
|
608
|
+
zone and the measured room.
|
|
609
|
+
|
|
610
|
+
## `useShownIn`, `useZoneDraws`, `useZoneTakesRoom`, `useBandHalves`
|
|
611
|
+
|
|
612
|
+
Smaller questions, for a frame of your own.
|
|
613
|
+
|
|
614
|
+
`useZoneTakesRoom` is not `useZoneDraws`: the band's two halves share one height, so either of
|
|
615
|
+
them drawing means the strip is taking that height. Ask the wrong one and a zone can be dragged
|
|
616
|
+
over room the band is already using.
|
|
617
|
+
|
|
618
|
+
## `useContainerFit`
|
|
619
|
+
|
|
620
|
+
Re-clamps the zones when your container changes size.
|
|
621
|
+
|
|
622
|
+
```tsx
|
|
623
|
+
const box = useRef<HTMLDivElement>(null)
|
|
624
|
+
useContainerFit(box)
|
|
625
|
+
```
|
|
626
|
+
|
|
627
|
+
Only needed if you build your own frame — `<Panels>` already does it, on the box the zones and
|
|
628
|
+
the centre share rather than on the whole chassis.
|
|
629
|
+
|
|
630
|
+
## Outside React
|
|
631
|
+
|
|
632
|
+
Build the store yourself and keep the reference:
|
|
633
|
+
|
|
634
|
+
```tsx
|
|
635
|
+
const store = createPanelsStore<PanelId>()
|
|
636
|
+
|
|
637
|
+
socket.on('alert', () => store.getState().show('alerts'))
|
|
638
|
+
window.electron?.onMenu(id => store.getState().show(id))
|
|
639
|
+
|
|
640
|
+
<Panels<PanelId> store={store}>…</Panels>
|
|
641
|
+
```
|
|
642
|
+
|
|
643
|
+
`store.getState()` gives the same actions the hooks call: `show`, `close`, `toggle`, `focus`,
|
|
644
|
+
`resize`, `resplit`, `resplitBand`, `fit`, `reset`.
|
|
645
|
+
|
|
646
|
+
## `usePanelsState`
|
|
647
|
+
|
|
648
|
+
Any slice, subscribed:
|
|
649
|
+
|
|
650
|
+
```tsx
|
|
651
|
+
const open = usePanelsState<PanelId, OpenByZone<PanelId>>(state => state.open)
|
|
652
|
+
```
|
|
653
|
+
|
|
654
|
+
Prefer scalar selectors. `lengths` and `available` are replaced wholesale on every write, so
|
|
655
|
+
subscribing to either as an object wakes your component on every frame of a drag.
|
|
656
|
+
|
|
657
|
+
==============================================================================
|
|
658
|
+
Theming
|
|
659
|
+
==============================================================================
|
|
660
|
+
|
|
661
|
+
# Theming
|
|
662
|
+
|
|
663
|
+
Every value is a custom property on the chassis root. There is no theming API beyond this,
|
|
664
|
+
because it does not need one.
|
|
665
|
+
|
|
666
|
+
```css
|
|
667
|
+
.pnl-root {
|
|
668
|
+
--pnl-panel: #101418;
|
|
669
|
+
--pnl-accent: #47965c;
|
|
670
|
+
--pnl-radius: 10px;
|
|
671
|
+
}
|
|
672
|
+
```
|
|
673
|
+
|
|
674
|
+
Scope it however you like — a class on `<Panels>`, an ancestor, `:root`. The chassis reads the
|
|
675
|
+
computed value, so anything that reaches it works.
|
|
676
|
+
|
|
677
|
+
```tsx
|
|
678
|
+
<Panels className="my-theme">
|
|
679
|
+
```
|
|
680
|
+
|
|
681
|
+
## The tokens
|
|
682
|
+
|
|
683
|
+
### Surfaces
|
|
684
|
+
|
|
685
|
+
| Token | Default (dark) | |
|
|
686
|
+
| --- | --- | --- |
|
|
687
|
+
| `--pnl-chassis` | `#2b2d30` | The frame |
|
|
688
|
+
| `--pnl-panel` | `#191a1c` | The surfaces laid on it |
|
|
689
|
+
| `--pnl-elevated` | `#3c3f44` | Hover, and an active rail icon |
|
|
690
|
+
| `--pnl-border` | `#34363a` | Separators |
|
|
691
|
+
| `--pnl-text` | `#dfe1e5` | |
|
|
692
|
+
| `--pnl-muted` | `#91959b` | Secondary text, idle icons |
|
|
693
|
+
|
|
694
|
+
**Panels are darker than the chassis.** That inversion is the one visual idea the library is
|
|
695
|
+
built on — it is what reads as "panels on a frame" rather than as a web page. Invert it back by
|
|
696
|
+
repainting two tokens; nothing else assumes a direction.
|
|
697
|
+
|
|
698
|
+
### Accent
|
|
699
|
+
|
|
700
|
+
| Token | Default | |
|
|
701
|
+
| --- | --- | --- |
|
|
702
|
+
| `--pnl-accent` | `#346ef2` | What is **actioned**: an active icon in the focused zone |
|
|
703
|
+
| `--pnl-accent-text` | `#ffffff` | What is written on it |
|
|
704
|
+
|
|
705
|
+
Set `--pnl-accent` on any ancestor and the chassis takes your brand instead of imposing one.
|
|
706
|
+
|
|
707
|
+
The default is a placeholder, and the contrast of **your** accent is yours to check: white on it
|
|
708
|
+
needs 4.5:1 for a word, 3:1 for a glyph that informs.
|
|
709
|
+
|
|
710
|
+
### Gauges
|
|
711
|
+
|
|
712
|
+
| Token | Default | |
|
|
713
|
+
| --- | --- | --- |
|
|
714
|
+
| `--pnl-rail` | `48px` | Rail width |
|
|
715
|
+
| `--pnl-rail-button` | `36px` | Rail icon button |
|
|
716
|
+
| `--pnl-rail-inset` | `14px` | |
|
|
717
|
+
| `--pnl-gutter` | `6px` | The space between surfaces, **which is the resize area** |
|
|
718
|
+
| `--pnl-header` | `40px` | Panel title row |
|
|
719
|
+
| `--pnl-radius` | `6px` | |
|
|
720
|
+
| `--pnl-radius-sm` | `4px` | |
|
|
721
|
+
| `--pnl-font-size` | `13px` | |
|
|
722
|
+
|
|
723
|
+
The gutter is not decoration: it **is** where the pointer resizes. Widening it widens the target.
|
|
724
|
+
|
|
725
|
+
## Light, dark, and following the reader
|
|
726
|
+
|
|
727
|
+
Left alone, the chassis follows `prefers-color-scheme`. Force it:
|
|
728
|
+
|
|
729
|
+
```tsx
|
|
730
|
+
<Panels theme="dark"> // or "light"
|
|
731
|
+
```
|
|
732
|
+
|
|
733
|
+
The light palette is the **mirror** of the dark one, not its inversion: surfaces stand apart from
|
|
734
|
+
the chassis by being lighter, with the chassis as a grey gutter around white panels.
|
|
735
|
+
|
|
736
|
+
## Why your palette always wins
|
|
737
|
+
|
|
738
|
+
Every default token is declared inside `:where()`, which carries **no specificity**. Anything you
|
|
739
|
+
write — a class, an id, an element selector — outranks it, whatever order the stylesheets load in.
|
|
740
|
+
|
|
741
|
+
This was a bug once: the defaults sat on `.pnl-root:not([data-pnl-theme='light'])`, which
|
|
742
|
+
outscores any single class, so a project's palette applied in the dark and vanished the moment
|
|
743
|
+
the reader's system asked for the light one. A guard now reads the stylesheet to keep it at zero.
|
|
744
|
+
|
|
745
|
+
## Beyond colour
|
|
746
|
+
|
|
747
|
+
If tokens are not enough, every piece is exported and replaceable on its own — see
|
|
748
|
+
[Components](COMPONENTS.md). And if you want none of the rendering, the
|
|
749
|
+
[hooks](HOOKS.md) draw nothing at all.
|
|
750
|
+
|
|
751
|
+
==============================================================================
|
|
752
|
+
Components
|
|
753
|
+
==============================================================================
|
|
754
|
+
|
|
755
|
+
# Components
|
|
756
|
+
|
|
757
|
+
Three levels of adoption, and you can stop at any of them: take the chassis whole, replace a
|
|
758
|
+
piece, or take only the [hooks](HOOKS.md) and draw everything yourself.
|
|
759
|
+
|
|
760
|
+
## What is exported
|
|
761
|
+
|
|
762
|
+
| | |
|
|
763
|
+
| --- | --- |
|
|
764
|
+
| `Panels` | The chassis. Collects the descriptors and assembles the frame |
|
|
765
|
+
| `Panel` · `Center` | Descriptors. Never rendered where they are written |
|
|
766
|
+
| `Rail` · `RailZone` | The icon rail, and one zone's group of icons |
|
|
767
|
+
| `ZoneEdge` | A zone: its two halves and its handle |
|
|
768
|
+
| `Band` | The bottom strip, and its two zones |
|
|
769
|
+
| `PanelFrame` | One panel on screen: surface, title row, body |
|
|
770
|
+
| `Surface` | The rounded box |
|
|
771
|
+
| `PanelHeader` | The title row |
|
|
772
|
+
| `IconButton` | The rail's button, and the header's |
|
|
773
|
+
| `Separator` · `ResizeHandle` | |
|
|
774
|
+
| `DEFAULT_LABELS` | Every word the chassis says |
|
|
775
|
+
|
|
776
|
+
## Replacing a piece
|
|
777
|
+
|
|
778
|
+
Build your own frame out of the ones you keep:
|
|
779
|
+
|
|
780
|
+
```tsx
|
|
781
|
+
import { PanelsProvider, Rail, ZoneEdge, useBandHalves } from '@pasquelin/panels'
|
|
782
|
+
|
|
783
|
+
function MyChassis({ children }) {
|
|
784
|
+
const band = useBandHalves()
|
|
785
|
+
|
|
786
|
+
return (
|
|
787
|
+
<PanelsProvider>
|
|
788
|
+
<div className="my-frame">
|
|
789
|
+
<MyOwnRail side="left" />
|
|
790
|
+
<ZoneEdge zone="left" labels={MY_LABELS} />
|
|
791
|
+
<main>{children}</main>
|
|
792
|
+
<Rail side="right" />
|
|
793
|
+
</div>
|
|
794
|
+
</PanelsProvider>
|
|
795
|
+
)
|
|
796
|
+
}
|
|
797
|
+
```
|
|
798
|
+
|
|
799
|
+
`<Panels>` is itself only a provider plus an arrangement of these pieces — there is nothing in it
|
|
800
|
+
you cannot write yourself.
|
|
801
|
+
|
|
802
|
+
## Words
|
|
803
|
+
|
|
804
|
+
The chassis says four things. They are English by default; pass your own, already translated:
|
|
805
|
+
|
|
806
|
+
```tsx
|
|
807
|
+
<Panels
|
|
808
|
+
labels={{
|
|
809
|
+
closePanel: 'Fermer le panneau',
|
|
810
|
+
resizeZone: 'Redimensionner la zone',
|
|
811
|
+
resizeSplit: 'Redimensionner les deux panneaux',
|
|
812
|
+
resizeBand: 'Redimensionner la bande',
|
|
813
|
+
}}
|
|
814
|
+
>
|
|
815
|
+
```
|
|
816
|
+
|
|
817
|
+
The library carries **no i18n**. A key would impose a namespace on every project that installs
|
|
818
|
+
it, and translation is something your application already knows how to do.
|
|
819
|
+
|
|
820
|
+
## Class names
|
|
821
|
+
|
|
822
|
+
Every class is prefixed `pnl-`. They are part of the public surface — style them, override them,
|
|
823
|
+
target them in tests:
|
|
824
|
+
|
|
825
|
+
```
|
|
826
|
+
pnl-root · pnl-middle · pnl-columns · pnl-row · pnl-stack · pnl-centre
|
|
827
|
+
pnl-rail · pnl-rail__group · pnl-rail__button
|
|
828
|
+
pnl-zone · pnl-band · pnl-surface · pnl-body
|
|
829
|
+
pnl-header · pnl-header__title · pnl-header__actions · pnl-header__trailing
|
|
830
|
+
pnl-icon-button · pnl-separator · pnl-handle
|
|
831
|
+
```
|
|
832
|
+
|
|
833
|
+
## Accessibility
|
|
834
|
+
|
|
835
|
+
What the chassis does for you:
|
|
836
|
+
|
|
837
|
+
- the rail is a `role="toolbar"`, its buttons carry `aria-pressed`
|
|
838
|
+
- every panel is a `<section>` named by its title
|
|
839
|
+
- every handle is a `role="separator"` with `aria-orientation`, `aria-valuenow` and its bounds,
|
|
840
|
+
focusable and driven by the arrow keys
|
|
841
|
+
- the focus ring is visible and `prefers-reduced-motion` is respected
|
|
842
|
+
|
|
843
|
+
What is yours: the contrast of your palette, and the accessible names of whatever you put inside
|
|
844
|
+
the panels.
|
|
845
|
+
|
|
846
|
+
==============================================================================
|
|
847
|
+
Document tabs
|
|
848
|
+
==============================================================================
|
|
849
|
+
|
|
850
|
+
# Document tabs
|
|
851
|
+
|
|
852
|
+
The centre is a free slot: a router outlet, a canvas, a map. If you want **document tabs** —
|
|
853
|
+
draggable, splittable, stackable — the library ships an adapter over
|
|
854
|
+
[Dockview](https://dockview.dev).
|
|
855
|
+
|
|
856
|
+
It is a **separate entry point**, so its weight only lands on the projects that want it.
|
|
857
|
+
|
|
858
|
+
```bash
|
|
859
|
+
pnpm add dockview-react
|
|
860
|
+
```
|
|
861
|
+
|
|
862
|
+
```tsx
|
|
863
|
+
import { DockviewCenter } from '@pasquelin/panels/dockview'
|
|
864
|
+
|
|
865
|
+
function Editor({ params }) {
|
|
866
|
+
return <FileView name={params.name as string} />
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
const DOCUMENTS = { editor: Editor }
|
|
870
|
+
|
|
871
|
+
<Panels.Center>
|
|
872
|
+
<DockviewCenter
|
|
873
|
+
documents={DOCUMENTS}
|
|
874
|
+
empty={NothingOpen}
|
|
875
|
+
layout={stored}
|
|
876
|
+
onLayout={setStored}
|
|
877
|
+
onReady={setApi}
|
|
878
|
+
/>
|
|
879
|
+
</Panels.Center>
|
|
880
|
+
```
|
|
881
|
+
|
|
882
|
+
| Prop | |
|
|
883
|
+
| --- | --- |
|
|
884
|
+
| `documents` | What each kind renders, keyed by the component name a panel asks for |
|
|
885
|
+
| `empty` | Drawn while nothing is open. Without one, Dockview shows a bare watermark |
|
|
886
|
+
| `layout` | A layout to restore, as `api.toJSON()` gave it |
|
|
887
|
+
| `onLayout` | Called whenever the arrangement changes |
|
|
888
|
+
| `onReady` | The api, for opening documents from outside |
|
|
889
|
+
|
|
890
|
+
## Opening a document
|
|
891
|
+
|
|
892
|
+
```tsx
|
|
893
|
+
const open = (name: string) => {
|
|
894
|
+
const held = api.getPanel(name)
|
|
895
|
+
if (held) return held.api.setActive() // already open: bring it forward
|
|
896
|
+
|
|
897
|
+
api.addPanel({ id: name, component: 'editor', title: name, params: { name } })
|
|
898
|
+
}
|
|
899
|
+
```
|
|
900
|
+
|
|
901
|
+
## Panels never enter the centre
|
|
902
|
+
|
|
903
|
+
The rule the whole chassis is built on. **A document has a name; a panel has an icon.**
|
|
904
|
+
|
|
905
|
+
Documents are things a person opens and closes, and they wear a tab that carries their name.
|
|
906
|
+
Panels are switched from the rail and wear an icon. Mixing them gives you a window where nothing
|
|
907
|
+
tells you what is a file and what is a tool.
|
|
908
|
+
|
|
909
|
+
## Theming
|
|
910
|
+
|
|
911
|
+
The adapter dresses Dockview in the chassis' tokens: repaint `--pnl-panel` and the tab strip
|
|
912
|
+
follows. Without it you would get Dockview's default navy in the middle of your palette.
|
|
913
|
+
|
|
914
|
+
## Restoring a layout
|
|
915
|
+
|
|
916
|
+
Dockview throws on a layout naming a component it cannot find. A refused one is **dropped**, and
|
|
917
|
+
`onLayout(undefined)` is called so you can forget it — kept, it would fail again at every launch.
|
|
918
|
+
|
|
919
|
+
==============================================================================
|
|
920
|
+
Recipes
|
|
921
|
+
==============================================================================
|
|
922
|
+
|
|
923
|
+
# Recipes
|
|
924
|
+
|
|
925
|
+
## Inside an application that already exists
|
|
926
|
+
|
|
927
|
+
The chassis measures **its own container**, so it does not have to own the window.
|
|
928
|
+
|
|
929
|
+
```tsx
|
|
930
|
+
<div className="my-app">
|
|
931
|
+
<MyNavigation />
|
|
932
|
+
<section style={{ flex: 1, minHeight: 0 }}>
|
|
933
|
+
<Panels<PanelId>>…</Panels>
|
|
934
|
+
</section>
|
|
935
|
+
</div>
|
|
936
|
+
```
|
|
937
|
+
|
|
938
|
+
The only requirement is that the box has a height. `minHeight: 0` on a flex child is the usual
|
|
939
|
+
missing piece — without it the child refuses to shrink and the chassis overflows.
|
|
940
|
+
|
|
941
|
+
## A header that is entirely yours
|
|
942
|
+
|
|
943
|
+
`<Panels>` draws no chrome. Pass your own, or nothing:
|
|
944
|
+
|
|
945
|
+
```tsx
|
|
946
|
+
<Panels header={<MyHeader />} footer={<MyStatusBar />}>
|
|
947
|
+
```
|
|
948
|
+
|
|
949
|
+
Anything inside them can drive the chassis with [`usePanels`](HOOKS.md) — it does not need to
|
|
950
|
+
know what a zone is.
|
|
951
|
+
|
|
952
|
+
## Electron
|
|
953
|
+
|
|
954
|
+
Nothing is Electron-specific, but two things are worth wiring.
|
|
955
|
+
|
|
956
|
+
**The native menu**, which lives in the main process and cannot call a hook:
|
|
957
|
+
|
|
958
|
+
```tsx
|
|
959
|
+
const store = createPanelsStore<PanelId>()
|
|
960
|
+
|
|
961
|
+
window.electron.onMenuItem(id => store.getState().show(id))
|
|
962
|
+
|
|
963
|
+
<Panels<PanelId> store={store}>…</Panels>
|
|
964
|
+
```
|
|
965
|
+
|
|
966
|
+
**Persistence to disk** rather than `localStorage`:
|
|
967
|
+
|
|
968
|
+
```tsx
|
|
969
|
+
<Panels
|
|
970
|
+
storage={{
|
|
971
|
+
read: key => window.electron.readLayout(key),
|
|
972
|
+
write: (key, value) => window.electron.writeLayout(key, value),
|
|
973
|
+
}}
|
|
974
|
+
>
|
|
975
|
+
```
|
|
976
|
+
|
|
977
|
+
A frameless window's drag region is your header's business: put `WebkitAppRegion: 'drag'` on it,
|
|
978
|
+
and `'no-drag'` on the controls inside.
|
|
979
|
+
|
|
980
|
+
## Keyboard shortcuts
|
|
981
|
+
|
|
982
|
+
```tsx
|
|
983
|
+
function Shortcuts() {
|
|
984
|
+
const { toggle } = usePanels<PanelId>()
|
|
985
|
+
|
|
986
|
+
useEffect(() => {
|
|
987
|
+
const onKey = (event: KeyboardEvent) => {
|
|
988
|
+
if (event.metaKey && event.key === 'b') toggle('files')
|
|
989
|
+
}
|
|
990
|
+
window.addEventListener('keydown', onKey)
|
|
991
|
+
return () => window.removeEventListener('keydown', onKey)
|
|
992
|
+
}, [toggle])
|
|
993
|
+
|
|
994
|
+
return null
|
|
995
|
+
}
|
|
996
|
+
```
|
|
997
|
+
|
|
998
|
+
Render it inside `<Panels>`, like any other consumer of the hook.
|
|
999
|
+
|
|
1000
|
+
## Two chassis in one application
|
|
1001
|
+
|
|
1002
|
+
Give them different storage keys, or they overwrite each other's layout:
|
|
1003
|
+
|
|
1004
|
+
```tsx
|
|
1005
|
+
<Panels storageKey="app:main">…</Panels>
|
|
1006
|
+
<Panels storageKey="app:report">…</Panels>
|
|
1007
|
+
```
|
|
1008
|
+
|
|
1009
|
+
Each `<Panels>` makes its own store; nothing is shared between them.
|
|
1010
|
+
|
|
1011
|
+
## Testing
|
|
1012
|
+
|
|
1013
|
+
The chassis is ordinary React. Give it a storage that forgets, so one test cannot leak into the
|
|
1014
|
+
next:
|
|
1015
|
+
|
|
1016
|
+
```tsx
|
|
1017
|
+
import { memoryStorage } from '@pasquelin/panels'
|
|
1018
|
+
|
|
1019
|
+
render(
|
|
1020
|
+
<Panels storage={memoryStorage()}>
|
|
1021
|
+
<Panel id="files" zone="left" title="Files">
|
|
1022
|
+
<p>file list</p>
|
|
1023
|
+
</Panel>
|
|
1024
|
+
</Panels>,
|
|
1025
|
+
)
|
|
1026
|
+
|
|
1027
|
+
await user.click(screen.getByRole('button', { name: 'Files' }))
|
|
1028
|
+
```
|
|
1029
|
+
|
|
1030
|
+
Everything is reachable by role: the rail is a `toolbar`, panels are `region`s named by their
|
|
1031
|
+
title, handles are `separator`s.
|
|
1032
|
+
|
|
1033
|
+
Note that jsdom lays nothing out, so `useContainerFit` measures zero and the zones keep the sizes
|
|
1034
|
+
they ask for. That is enough to test what is open and what is not; it is not enough to test the
|
|
1035
|
+
floors — those are unit-tested against `sharedSizes` instead.
|
|
1036
|
+
|
|
1037
|
+
## Server rendering
|
|
1038
|
+
|
|
1039
|
+
The chassis reads `localStorage` and measures the DOM, so it belongs on the client. Under
|
|
1040
|
+
Next.js, mark the file `'use client'`; there is nothing useful to render on the server anyway,
|
|
1041
|
+
since the layout is the reader's own.
|
|
1042
|
+
|
|
1043
|
+
==============================================================================
|
|
1044
|
+
Architecture
|
|
1045
|
+
==============================================================================
|
|
1046
|
+
|
|
1047
|
+
# Architecture
|
|
1048
|
+
|
|
1049
|
+
How the chassis works inside. For using it, start at
|
|
1050
|
+
[docs/en](en/README.md) · [docs/fr](fr/README.md).
|
|
1051
|
+
|
|
1052
|
+
## Two layers, and the boundary between them
|
|
1053
|
+
|
|
1054
|
+
```
|
|
1055
|
+
core/ the logic. Renders nothing, touches no DOM but to measure
|
|
1056
|
+
types zones, slots, what a panel spec is
|
|
1057
|
+
clamps the floors and the sharing — pure functions, tested alone
|
|
1058
|
+
store one zustand store per chassis, created not imported
|
|
1059
|
+
persistence localStorage by default, any adapter
|
|
1060
|
+
context the provider, and the subscribed readers
|
|
1061
|
+
hooks/ usePanels · useZone · useArrangement · useContainerFit · usePointerDrag
|
|
1062
|
+
|
|
1063
|
+
components/ built on those hooks, replaceable one at a time
|
|
1064
|
+
Panels collects the descriptors, assembles the frame
|
|
1065
|
+
Panel·Center descriptors — never rendered where they are written
|
|
1066
|
+
Rail · ZoneEdge · Band · PanelFrame
|
|
1067
|
+
Surface · PanelHeader · IconButton · Separator · ResizeHandle
|
|
1068
|
+
|
|
1069
|
+
dockview/ a separate entry point. Nothing else imports it
|
|
1070
|
+
```
|
|
1071
|
+
|
|
1072
|
+
The rule: **`core/` never renders, `components/` never decides.** A hook that returns JSX belongs
|
|
1073
|
+
in `components/`; a component that computes a size belongs in `core/`.
|
|
1074
|
+
|
|
1075
|
+
## The store is created, not imported
|
|
1076
|
+
|
|
1077
|
+
`createPanelsStore()` makes one per chassis. There is no module-level singleton, which is what
|
|
1078
|
+
lets two chassis live in one application, and what lets a test render without leaking into the
|
|
1079
|
+
next.
|
|
1080
|
+
|
|
1081
|
+
The provider makes one if you do not pass one. Passing your own is how you drive the chassis from
|
|
1082
|
+
outside React — see [Recipes](en/RECIPES.md).
|
|
1083
|
+
|
|
1084
|
+
## Descriptors, not portals
|
|
1085
|
+
|
|
1086
|
+
`<Panel>` and `<Center>` return `null`. `<Panels>` reads them out of its children with
|
|
1087
|
+
`Children.toArray`, which flattens fragments and drops the falsy, so `{ready && <Panel/>}` and a
|
|
1088
|
+
mapped list both work.
|
|
1089
|
+
|
|
1090
|
+
The alternative was a portal per panel. It was rejected because a portal puts the panel's content
|
|
1091
|
+
in a **different React tree** from the one it was declared in: context, error boundaries and
|
|
1092
|
+
suspense would all stop at the boundary, and a panel could not read a provider its own file sits
|
|
1093
|
+
under.
|
|
1094
|
+
|
|
1095
|
+
The cost is that panels must be children of `<Panels>`, not of some component in between. That is
|
|
1096
|
+
the same constraint `<Route>` has, and it is understood.
|
|
1097
|
+
|
|
1098
|
+
## What is stored, and what is resolved
|
|
1099
|
+
|
|
1100
|
+
Stored: which panel each half holds, and the sizes. Nothing else.
|
|
1101
|
+
|
|
1102
|
+
Everything else is **resolved at render**:
|
|
1103
|
+
|
|
1104
|
+
- what a zone *draws* is not what it *holds* — a `solo` panel silences the other half
|
|
1105
|
+
- the size a zone *takes* is bounded against the opposite zone and the measured room
|
|
1106
|
+
- whether a zone *takes room* is not whether it *draws* — the band's two halves share one height
|
|
1107
|
+
|
|
1108
|
+
Each of those was a bug before it was a function. They are `shownIn`, `sharedSizes` and
|
|
1109
|
+
`zoneTakesRoom`, and each has a test that starts from the arrangement that broke it.
|
|
1110
|
+
|
|
1111
|
+
## Why the selectors are scalar
|
|
1112
|
+
|
|
1113
|
+
`resize`, `resplit` and `fit` replace `lengths` and `available` wholesale. A component subscribed
|
|
1114
|
+
to either as an object therefore re-renders on **every** `pointermove` of a drag — with five zones
|
|
1115
|
+
mounted, five re-renders a frame where two are owed.
|
|
1116
|
+
|
|
1117
|
+
`useZone` subscribes to individual numbers instead, and `PanelFrame` and `ZoneEdge` are memoised
|
|
1118
|
+
to stop the cascade below them.
|
|
1119
|
+
|
|
1120
|
+
## Measuring the columns, not the chassis
|
|
1121
|
+
|
|
1122
|
+
`useContainerFit` observes the box the zones and the centre share — not the root, which also holds
|
|
1123
|
+
the rails, the header and the footer. Counting those would let a column overrun the centre by
|
|
1124
|
+
exactly their width.
|
|
1125
|
+
|
|
1126
|
+
Observing the *container* rather than the window is what lets the chassis live inside a page that
|
|
1127
|
+
already exists.
|
|
1128
|
+
|
|
1129
|
+
## The stylesheet owes its specificity to its consumers
|
|
1130
|
+
|
|
1131
|
+
Every default token sits inside `:where()`, at zero specificity, so anything a project writes
|
|
1132
|
+
wins whatever order the stylesheets load in. The layout rules keep normal specificity: they are
|
|
1133
|
+
not something a repaint should reach.
|
|
1134
|
+
|
|
1135
|
+
`src/styles/tokens.test.ts` reads the sheet and fails if a token block escapes `:where()`.
|
|
1136
|
+
|
|
1137
|
+
## What was deliberately left out
|
|
1138
|
+
|
|
1139
|
+
- **surfaces / sections** — the chassis has no notion of "which screen am I on". In React the
|
|
1140
|
+
router changes the children; there is no problem to solve.
|
|
1141
|
+
- **capabilities** — a panel that should not be offered is a panel you do not declare.
|
|
1142
|
+
- **i18n** — four strings, passed in already translated.
|
|
1143
|
+
- **an icon set** — `icon` takes any node.
|
|
1144
|
+
- **floating and dragging panels between zones** — planned, and the state model already allows an
|
|
1145
|
+
override of the declared zone. Not in v1.
|
|
1146
|
+
|
|
1147
|
+
==============================================================================
|
|
1148
|
+
Licences
|
|
1149
|
+
==============================================================================
|
|
1150
|
+
|
|
1151
|
+
# Third-party notices
|
|
1152
|
+
|
|
1153
|
+
`@pasquelin/panels` is distributed under the MIT licence — see [LICENSE](LICENSE).
|
|
1154
|
+
|
|
1155
|
+
This file lists the third-party code it carries or requires, and reproduces the notices those
|
|
1156
|
+
licences ask to be preserved. **Every dependency below is MIT**, so there is no copyleft
|
|
1157
|
+
obligation and no conflict with this project's own licence.
|
|
1158
|
+
|
|
1159
|
+
---
|
|
1160
|
+
|
|
1161
|
+
## Bundled into the published package
|
|
1162
|
+
|
|
1163
|
+
Code that ships **inside** `dist/`, and whose notice therefore travels with this library.
|
|
1164
|
+
|
|
1165
|
+
### zustand
|
|
1166
|
+
|
|
1167
|
+
- Version: 5.0.15
|
|
1168
|
+
- Licence: MIT
|
|
1169
|
+
- Homepage: https://github.com/pmndrs/zustand
|
|
1170
|
+
|
|
1171
|
+
The chassis keeps its state in a zustand store, bundled so that consumers never have to install
|
|
1172
|
+
or know about it. Its notice, reproduced in full as MIT requires:
|
|
1173
|
+
|
|
1174
|
+
```
|
|
1175
|
+
MIT License
|
|
1176
|
+
|
|
1177
|
+
Copyright (c) 2019 Paul Henschel
|
|
1178
|
+
|
|
1179
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
1180
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
1181
|
+
in the Software without restriction, including without limitation the rights
|
|
1182
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
1183
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
1184
|
+
furnished to do so, subject to the following conditions:
|
|
1185
|
+
|
|
1186
|
+
The above copyright notice and this permission notice shall be included in all
|
|
1187
|
+
copies or substantial portions of the Software.
|
|
1188
|
+
|
|
1189
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
1190
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
1191
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
1192
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
1193
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
1194
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
1195
|
+
SOFTWARE.
|
|
1196
|
+
```
|
|
1197
|
+
|
|
1198
|
+
---
|
|
1199
|
+
|
|
1200
|
+
## Required, not bundled
|
|
1201
|
+
|
|
1202
|
+
Peer dependencies. They are installed by the consuming project, under their own licences, and no
|
|
1203
|
+
part of them is copied here.
|
|
1204
|
+
|
|
1205
|
+
| Package | Licence | Required |
|
|
1206
|
+
| --- | --- | --- |
|
|
1207
|
+
| [react](https://github.com/facebook/react) | MIT | yes |
|
|
1208
|
+
| [react-dom](https://github.com/facebook/react) | MIT | yes |
|
|
1209
|
+
| [dockview-react](https://github.com/mathuo/dockview) | MIT | only for `@pasquelin/panels/dockview` |
|
|
1210
|
+
|
|
1211
|
+
---
|
|
1212
|
+
|
|
1213
|
+
## Used by the examples and the showcase only
|
|
1214
|
+
|
|
1215
|
+
None of this is published to npm — it builds the site under `examples/`.
|
|
1216
|
+
|
|
1217
|
+
| Package | Licence | Used for |
|
|
1218
|
+
| --- | --- | --- |
|
|
1219
|
+
| [react-router-dom](https://github.com/remix-run/react-router) | MIT | the `router` example |
|
|
1220
|
+
| [dockview-react](https://github.com/mathuo/dockview) | MIT | the `dockview` example |
|
|
1221
|
+
| [vite](https://github.com/vitejs/vite) · [vitest](https://github.com/vitest-dev/vitest) | MIT | build and tests |
|
|
1222
|
+
| [typescript](https://github.com/microsoft/TypeScript) | Apache-2.0 | build only, nothing shipped |
|
|
1223
|
+
| [eslint](https://github.com/eslint/eslint) · [prettier](https://github.com/prettier/prettier) | MIT | development only |
|
|
1224
|
+
|
|
1225
|
+
### Fonts
|
|
1226
|
+
|
|
1227
|
+
The showcase loads three families from Google Fonts, each under the
|
|
1228
|
+
[SIL Open Font License 1.1](https://openfontlicense.org/):
|
|
1229
|
+
|
|
1230
|
+
- **Space Grotesk** — © Florian Karsten
|
|
1231
|
+
- **IBM Plex Sans** — © IBM Corp.
|
|
1232
|
+
- **JetBrains Mono** — © JetBrains s.r.o.
|
|
1233
|
+
|
|
1234
|
+
They are linked, not redistributed. The library itself embeds no font and sets no
|
|
1235
|
+
`font-family`: it inherits whatever the host application uses.
|
|
1236
|
+
|
|
1237
|
+
---
|
|
1238
|
+
|
|
1239
|
+
## Keeping this file honest
|
|
1240
|
+
|
|
1241
|
+
Run `pnpm licences:check` after adding or upgrading a dependency. It reads the installed tree and
|
|
1242
|
+
fails if anything is not MIT, or if a package is bundled without a notice here.
|