concertina 0.7.0 → 0.8.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/README.md +170 -52
- package/dist/index.cjs +146 -81
- package/dist/index.d.cts +98 -49
- package/dist/index.d.ts +98 -49
- package/dist/index.js +112 -50
- package/dist/styles.css +40 -5
- package/package.json +8 -2
package/README.md
CHANGED
|
@@ -8,6 +8,28 @@
|
|
|
8
8
|
React toolkit for layout stability.
|
|
9
9
|
</p>
|
|
10
10
|
|
|
11
|
+
<p align="center">
|
|
12
|
+
<a href="https://github.com/ryandward/concertina/actions/workflows/ci.yml"><img src="https://github.com/ryandward/concertina/actions/workflows/ci.yml/badge.svg" alt="CI" /></a>
|
|
13
|
+
</p>
|
|
14
|
+
|
|
15
|
+
<p align="center"><b>47 tests</b> · 716 lines of source · 1 dependency</p>
|
|
16
|
+
|
|
17
|
+
## Why this exists
|
|
18
|
+
|
|
19
|
+
Concertina started because accordions in React are broken. You click an item, it expands, and the thing you just clicked scrolls off the screen. The browser shoved everything down to make room and now you're staring at content you didn't ask for while the thing you wanted is somewhere above you. On mobile it's worse — `scrollIntoView` grabs the entire viewport and drags it around like a dog with a sock.
|
|
20
|
+
|
|
21
|
+
So concertina started as an accordion wrapper with scroll pinning. But the deeper we got, the more we realized accordions are just one instance of a bigger problem: things change size and the browser moves everything else to compensate. Swap a button for a stepper. Replace a spinner with a table. Mount a panel. Unmount it. Same disease, every time.
|
|
22
|
+
|
|
23
|
+
The core idea is almost embarrassingly simple: don't swap things. Render all the variants at the same time, in the same grid cell, stacked on top of each other. The cell sizes itself to the biggest one. You toggle which one is visible. The box never changes size because all the variants are always in there. No measurement, no ResizeObserver, no layout effect. CSS grid figured it out on the first frame because that's what it already does.
|
|
24
|
+
|
|
25
|
+
That covers the most common source of layout shift. Two cases it doesn't cover:
|
|
26
|
+
|
|
27
|
+
1. **Data loads.** A spinner sits at 48 pixels. The real table shows up at 500. The scroll region has an episode. You can't enumerate all variants upfront because the content is dynamic, so you need a container that remembers its biggest size and refuses to shrink.
|
|
28
|
+
|
|
29
|
+
2. **Conditional content.** A panel mounts or unmounts. Everything below it teleports in a single frame. No transition. No grace. On, off, furniture moved.
|
|
30
|
+
|
|
31
|
+
Concertina has a small primitive for each.
|
|
32
|
+
|
|
11
33
|
## Install
|
|
12
34
|
|
|
13
35
|
```bash
|
|
@@ -19,13 +41,9 @@ import * as Concertina from "concertina";
|
|
|
19
41
|
import "concertina/styles.css";
|
|
20
42
|
```
|
|
21
43
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
## Layer 1: Primitives
|
|
25
|
-
|
|
26
|
-
### StableSlot + Slot — Zero-shift variant switching
|
|
44
|
+
## Variant switching: StableSlot + Slot
|
|
27
45
|
|
|
28
|
-
|
|
46
|
+
Your layout shifts when you swap components because the new one is a different size and the browser just rolls with it. The fix: don't swap them. Render all of them at the same time, same grid cell, stacked. The cell sizes to the biggest one. Toggle visibility. The box can't change size. All the variants are always in there.
|
|
29
47
|
|
|
30
48
|
```tsx
|
|
31
49
|
<Concertina.StableSlot axis="width" className="action-slot">
|
|
@@ -38,55 +56,50 @@ A UI slot toggles between variants of different sizes (Add button ↔ quantity s
|
|
|
38
56
|
</Concertina.StableSlot>
|
|
39
57
|
```
|
|
40
58
|
|
|
41
|
-
|
|
42
|
-
1. `display: grid` on container, `grid-area: 1/1` on all Slots — everything overlaps
|
|
43
|
-
2. `visibility: hidden` on inactive Slots — invisible but still in layout flow
|
|
44
|
-
3. `inert` attribute on inactive Slots — no focus, no clicks, no screen reader
|
|
45
|
-
4. `display: flex; flex-direction: column` on Slots — content stretches to fill the reserved width
|
|
46
|
-
5. Zero JS measurement — pure CSS, works on first frame
|
|
59
|
+
How it works:
|
|
47
60
|
|
|
48
|
-
|
|
61
|
+
- `display: grid` on the container, `grid-area: 1/1` on all Slots. Everything overlaps in one cell.
|
|
62
|
+
- Inactive Slots get `visibility: hidden` (invisible, still in layout flow) and `inert` (no focus, no clicks, no screen reader).
|
|
63
|
+
- Each Slot uses `display: flex; flex-direction: column` so content stretches to fill the reserved width.
|
|
64
|
+
- Zero JS measurement. Pure CSS. Works on the first frame.
|
|
65
|
+
|
|
66
|
+
### StableSlot props
|
|
49
67
|
|
|
50
68
|
| Prop | Type | Default | Description |
|
|
51
69
|
|------|------|---------|-------------|
|
|
52
70
|
| `axis` | `"width"` \| `"height"` \| `"both"` | `"both"` | Which axis to stabilize |
|
|
53
|
-
| `as` | `ElementType` | `"div"` | HTML element to render
|
|
54
|
-
| `className` | `string` |
|
|
55
|
-
|
|
56
|
-
All other HTML attributes are forwarded.
|
|
71
|
+
| `as` | `ElementType` | `"div"` | HTML element to render |
|
|
72
|
+
| `className` | `string` | | Passed to wrapper |
|
|
57
73
|
|
|
58
|
-
|
|
74
|
+
### Slot props
|
|
59
75
|
|
|
60
76
|
| Prop | Type | Description |
|
|
61
77
|
|------|------|-------------|
|
|
62
78
|
| `active` | `boolean` | Controls visibility |
|
|
63
79
|
| `as` | `ElementType` | HTML element to render. Default `"div"`. |
|
|
64
80
|
|
|
65
|
-
|
|
81
|
+
All other HTML attributes are forwarded on both components.
|
|
66
82
|
|
|
67
|
-
|
|
68
|
-
|
|
83
|
+
### Rules for correct behavior
|
|
84
|
+
|
|
85
|
+
Parent containers must allow content-based sizing. A StableSlot inside `grid-template-columns: 1fr 10rem` is trapped — the fixed column clips it and the whole thing is pointless. Use `auto`:
|
|
69
86
|
|
|
70
87
|
```css
|
|
71
|
-
/*
|
|
88
|
+
/* StableSlot can't do its job in here */
|
|
72
89
|
grid-template-columns: 1fr 10rem;
|
|
73
90
|
|
|
74
|
-
/*
|
|
91
|
+
/* now it can size itself */
|
|
75
92
|
grid-template-columns: 1fr auto;
|
|
76
93
|
```
|
|
77
94
|
|
|
78
|
-
|
|
79
|
-
If an element appears in one state but not another (e.g., an Undo link below a Charge button), it must be in a separate StableSlot — not nested inside one Slot of the main StableSlot. Stack StableSlots vertically:
|
|
95
|
+
Every independently appearing element needs its own StableSlot. An Undo link that only shows up in one state gets its own wrapper — don't nest it inside a Slot of the main StableSlot:
|
|
80
96
|
|
|
81
97
|
```tsx
|
|
82
98
|
<div className="action-column">
|
|
83
|
-
{/* Main action — morphs between Deliver/Charge/Retry/paid */}
|
|
84
99
|
<Concertina.StableSlot axis="width">
|
|
85
100
|
<Concertina.Slot active={showDeliver}><Button>Deliver</Button></Concertina.Slot>
|
|
86
101
|
<Concertina.Slot active={showCharge}><Button>Charge</Button></Concertina.Slot>
|
|
87
|
-
<Concertina.Slot active={showRetry}><Button>Retry</Button></Concertina.Slot>
|
|
88
102
|
</Concertina.StableSlot>
|
|
89
|
-
{/* Undo — appears only in Charge state, but space is always reserved */}
|
|
90
103
|
<Concertina.StableSlot>
|
|
91
104
|
<Concertina.Slot active={showCharge}>
|
|
92
105
|
<button className="undo-link">Undo</button>
|
|
@@ -95,11 +108,112 @@ If an element appears in one state but not another (e.g., an Undo link below a C
|
|
|
95
108
|
</div>
|
|
96
109
|
```
|
|
97
110
|
|
|
98
|
-
A single Slot inside a StableSlot is valid
|
|
111
|
+
A single Slot inside a StableSlot is valid. It reserves the element's space, showing or hiding it without shift. This is fine. This is good actually.
|
|
112
|
+
|
|
113
|
+
## Progressive loading: Gigbag + Warmup
|
|
114
|
+
|
|
115
|
+
You've seen this a thousand times:
|
|
116
|
+
|
|
117
|
+
```jsx
|
|
118
|
+
if (loading) return <Spinner />; // 48px
|
|
119
|
+
if (empty) return <EmptyMsg />; // 64px
|
|
120
|
+
return <BigTable data={data} />; // 500px+
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
The spinner is 48 pixels. The table is 500. When the data arrives, the container quintuples in height and everything the user was looking at gets launched off screen.
|
|
124
|
+
|
|
125
|
+
Gigbag is a container that remembers its largest-ever size via ResizeObserver and will not shrink. Will not. Put a spinner in there, then a table, then a spinner again — it stays at the table's height the whole time. Like a guitar case. You don't reshape the case every time you take the guitar out. The case is the size of the guitar. Always. It also uses `contain: layout style` so internal reflows don't bother the ancestors.
|
|
126
|
+
|
|
127
|
+
Warmup is a CSS-only shimmer grid that goes inside the Gigbag while you're loading. Instead of a spinner that tells the browser nothing about what's coming, the Warmup looks like the content. Rows. Columns. Pulsing. The browser knows how tall things will be because you told it. With shapes.
|
|
128
|
+
|
|
129
|
+
```tsx
|
|
130
|
+
<Concertina.Gigbag axis="height">
|
|
131
|
+
{loading ? (
|
|
132
|
+
<Concertina.Warmup rows={8} columns={3} />
|
|
133
|
+
) : (
|
|
134
|
+
<DataTable data={data} />
|
|
135
|
+
)}
|
|
136
|
+
</Concertina.Gigbag>
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
The Gigbag ratchets to whichever is taller. On subsequent re-fetches it holds at the table's height instead of collapsing back. The data can come and go. The container does not care.
|
|
140
|
+
|
|
141
|
+
### Gigbag props
|
|
142
|
+
|
|
143
|
+
| Prop | Type | Default | Description |
|
|
144
|
+
|------|------|---------|-------------|
|
|
145
|
+
| `axis` | `"width"` \| `"height"` \| `"both"` | `"height"` | Which axis to ratchet |
|
|
146
|
+
| `as` | `ElementType` | `"div"` | HTML element to render |
|
|
147
|
+
|
|
148
|
+
### Warmup props
|
|
149
|
+
|
|
150
|
+
| Prop | Type | Default | Description |
|
|
151
|
+
|------|------|---------|-------------|
|
|
152
|
+
| `rows` | `number` | `3` | Number of placeholder rows |
|
|
153
|
+
| `columns` | `number` | `1` | Columns per row |
|
|
154
|
+
| `as` | `ElementType` | `"div"` | HTML element to render |
|
|
155
|
+
|
|
156
|
+
### Theming Warmup
|
|
157
|
+
|
|
158
|
+
All dimensions are CSS custom properties. Override them to match your app:
|
|
159
|
+
|
|
160
|
+
```css
|
|
161
|
+
.concertina-warmup {
|
|
162
|
+
--concertina-warmup-gap: 0.5rem;
|
|
163
|
+
--concertina-warmup-bone-height: 2.5rem;
|
|
164
|
+
--concertina-warmup-bone-radius: 0.25rem;
|
|
165
|
+
--concertina-warmup-bone-color: #e5e7eb;
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Conditional content: Glide
|
|
170
|
+
|
|
171
|
+
`{show && <Panel />}`. The panel is either in the DOM or it's not. When it enters, everything below it gets shoved down in a single frame. When it leaves, everything snaps back up. It's a light switch that also moves your furniture.
|
|
172
|
+
|
|
173
|
+
Glide wraps conditional content with enter/exit CSS animations and delays unmount until the exit animation finishes. The panel slides in, the panel slides out, content around it moves smoothly.
|
|
174
|
+
|
|
175
|
+
```tsx
|
|
176
|
+
<Concertina.Glide show={showPanel}>
|
|
177
|
+
<Panel />
|
|
178
|
+
</Concertina.Glide>
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
When `show` goes true, children mount with a `concertina-glide-entering` class. When `show` goes false, they get `concertina-glide-exiting` and stay in the DOM until the animation finishes. Then they unmount for real.
|
|
182
|
+
|
|
183
|
+
### Glide props
|
|
184
|
+
|
|
185
|
+
| Prop | Type | Default | Description |
|
|
186
|
+
|------|------|---------|-------------|
|
|
187
|
+
| `show` | `boolean` | | Whether the content is visible |
|
|
188
|
+
| `as` | `ElementType` | `"div"` | HTML element to render |
|
|
189
|
+
|
|
190
|
+
### Customizing Glide timing
|
|
191
|
+
|
|
192
|
+
```css
|
|
193
|
+
.concertina-glide {
|
|
194
|
+
--concertina-glide-duration: 300ms;
|
|
195
|
+
--concertina-glide-height: 2000px; /* max-height ceiling for the animation */
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
The height variable is a ceiling, not an exact value. `max-height` is how you animate height on auto-height elements. `overflow: hidden` clips any overshoot. CSS doesn't give us anything better.
|
|
200
|
+
|
|
201
|
+
## Composing them
|
|
202
|
+
|
|
203
|
+
Gigbag and Glide solve different problems and they compose:
|
|
204
|
+
|
|
205
|
+
```tsx
|
|
206
|
+
{/* a form that animates in/out and doesn't collapse during re-renders */}
|
|
207
|
+
<Concertina.Glide show={isEditing}>
|
|
208
|
+
<Concertina.Gigbag axis="height">
|
|
209
|
+
<EditForm />
|
|
210
|
+
</Concertina.Gigbag>
|
|
211
|
+
</Concertina.Glide>
|
|
212
|
+
```
|
|
99
213
|
|
|
100
|
-
|
|
214
|
+
## Dynamic text: useStableSlot
|
|
101
215
|
|
|
102
|
-
For content that changes size unpredictably (prices, names, status messages) where you can't enumerate all variants upfront.
|
|
216
|
+
For content that changes size unpredictably (prices, names, status messages) where you can't enumerate all variants upfront. This is what Gigbag uses internally. Use it directly when you want a ref-based API instead of a wrapper component.
|
|
103
217
|
|
|
104
218
|
```tsx
|
|
105
219
|
const slot = Concertina.useStableSlot({ axis: "width" });
|
|
@@ -113,11 +227,11 @@ const slot = Concertina.useStableSlot({ axis: "width" });
|
|
|
113
227
|
|--------|------|---------|-------------|
|
|
114
228
|
| `axis` | `"width"` \| `"height"` \| `"both"` | `"both"` | Which axis to ratchet |
|
|
115
229
|
|
|
116
|
-
Returns `{ ref, style }
|
|
230
|
+
Returns `{ ref, style }`. Attach both to the container element.
|
|
117
231
|
|
|
118
|
-
|
|
232
|
+
## Animation suppression: useTransitionLock
|
|
119
233
|
|
|
120
|
-
|
|
234
|
+
Suppresses CSS transitions during batched state changes. Sets a flag synchronously (batched with state updates in React 18), auto-clears after paint.
|
|
121
235
|
|
|
122
236
|
```tsx
|
|
123
237
|
const { locked, lock } = Concertina.useTransitionLock();
|
|
@@ -132,26 +246,18 @@ const handleChange = (newValue) => {
|
|
|
132
246
|
</div>
|
|
133
247
|
```
|
|
134
248
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
Scrolls `el` to the top of its nearest scrollable ancestor. Adjusts `scrollTop` only — never cascades to the viewport (critical on mobile where `scrollIntoView` yanks the whole page). Accounts for sticky headers automatically.
|
|
138
|
-
|
|
139
|
-
### When to use which
|
|
249
|
+
## Scroll pinning: pinToScrollTop
|
|
140
250
|
|
|
141
|
-
|
|
142
|
-
|-------------|------|----------------|
|
|
143
|
-
| Discrete variants (button A ↔ button B) | `StableSlot` + `Slot` | Zero — ever |
|
|
144
|
-
| Dynamic text (prices, names, messages) | `useStableSlot` | Once per new max, then stable |
|
|
145
|
-
| Numeric text specifically | CSS `tabular-nums` | Zero (font-level) |
|
|
251
|
+
Scrolls an element to the top of its nearest scrollable ancestor. Only touches `scrollTop` on that one container. Never cascades to the viewport — no full-page drag on mobile. Accounts for sticky headers automatically.
|
|
146
252
|
|
|
147
|
-
|
|
253
|
+
```tsx
|
|
254
|
+
Concertina.pinToScrollTop(element);
|
|
255
|
+
```
|
|
148
256
|
|
|
149
|
-
##
|
|
257
|
+
## Accordion
|
|
150
258
|
|
|
151
259
|
Wraps Radix Accordion with scroll pinning, animation suppression during switches, and per-item memoization via `useSyncExternalStore`.
|
|
152
260
|
|
|
153
|
-
### Component API
|
|
154
|
-
|
|
155
261
|
```tsx
|
|
156
262
|
<Concertina.Root className="my-accordion">
|
|
157
263
|
{items.map((item) => (
|
|
@@ -167,7 +273,7 @@ Wraps Radix Accordion with scroll pinning, animation suppression during switches
|
|
|
167
273
|
|
|
168
274
|
When you switch between items, the new one pins to the top of the scroll container. Animations are suppressed during the switch and restored after paint.
|
|
169
275
|
|
|
170
|
-
|
|
276
|
+
`useExpanded(id)` is a per-item expansion hook. Only re-renders when this specific item's boolean flips:
|
|
171
277
|
|
|
172
278
|
```tsx
|
|
173
279
|
function MyItem({ item }) {
|
|
@@ -176,7 +282,9 @@ function MyItem({ item }) {
|
|
|
176
282
|
}
|
|
177
283
|
```
|
|
178
284
|
|
|
179
|
-
###
|
|
285
|
+
### Legacy hook API
|
|
286
|
+
|
|
287
|
+
For cases where you need to manage Radix Accordion directly:
|
|
180
288
|
|
|
181
289
|
```tsx
|
|
182
290
|
const { rootProps, getItemRef } = Concertina.useConcertina();
|
|
@@ -190,12 +298,12 @@ const { rootProps, getItemRef } = Concertina.useConcertina();
|
|
|
190
298
|
|
|
191
299
|
| Property | Type | Description |
|
|
192
300
|
|---|---|---|
|
|
193
|
-
| `rootProps` | `object` | Spread onto `Accordion.Root
|
|
301
|
+
| `rootProps` | `object` | Spread onto `Accordion.Root`. Contains `value`, `onValueChange`, `data-switching`. |
|
|
194
302
|
| `getItemRef` | `(id: string) => RefCallback` | Attach to each `Accordion.Item` |
|
|
195
303
|
| `value` | `string` | Currently expanded item (empty string when collapsed) |
|
|
196
304
|
| `switching` | `boolean` | True during a switch between items |
|
|
197
305
|
|
|
198
|
-
|
|
306
|
+
### Customizing accordion animation
|
|
199
307
|
|
|
200
308
|
```css
|
|
201
309
|
.concertina-content {
|
|
@@ -206,6 +314,16 @@ const { rootProps, getItemRef } = Concertina.useConcertina();
|
|
|
206
314
|
|
|
207
315
|
If items near the bottom can't scroll to the top, add `padding-bottom: 50vh` to the scroll container.
|
|
208
316
|
|
|
317
|
+
## Picking the right tool
|
|
318
|
+
|
|
319
|
+
| Problem | Tool |
|
|
320
|
+
|---------|------|
|
|
321
|
+
| Two variants swap in one slot | StableSlot + Slot |
|
|
322
|
+
| Text changes width unpredictably | useStableSlot (or CSS `tabular-nums` for numbers) |
|
|
323
|
+
| Spinner replaced by loaded content | Gigbag + Warmup |
|
|
324
|
+
| Panel mounts/unmounts conditionally | Glide |
|
|
325
|
+
| Accordion with scroll pinning | Root + Item + Content |
|
|
326
|
+
|
|
209
327
|
## License
|
|
210
328
|
|
|
211
329
|
MIT
|