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 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> &middot; 716 lines of source &middot; 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
- A UI slot toggles between variants of different sizes (Add button quantity stepper). Surrounding content reflows. The fix: render **all variants simultaneously** in the same CSS grid cell. The cell auto-sizes to the largest child. Only the active variant is visible.
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
- **How it works:**
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
- **StableSlot props:**
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. Use `"span"` inside buttons. |
54
- | `className` | `string` | | Passed to wrapper element |
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
- **Slot props:**
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
- #### Rules for correct behavior
81
+ All other HTML attributes are forwarded on both components.
66
82
 
67
- **1. Parent containers must allow content-based sizing.**
68
- A fixed-width parent (e.g., `grid-template-columns: 10rem`) clips the StableSlot and defeats the mechanism. If a grid column contains a StableSlot, use `auto`:
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
- /* Bad fixed column ignores StableSlot's intrinsic width */
88
+ /* StableSlot can't do its job in here */
72
89
  grid-template-columns: 1fr 10rem;
73
90
 
74
- /* Good column auto-sizes to the StableSlot's widest child */
91
+ /* now it can size itself */
75
92
  grid-template-columns: 1fr auto;
76
93
  ```
77
94
 
78
- **2. Every independently appearing element needs its own StableSlot.**
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 it simply reserves the element's space, showing or hiding it without layout shift.
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
- ### useStableSlot ResizeObserver ratchet for dynamic content
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. Watches the container, tracks the maximum size ever observed, applies min-width/min-height that only ratchets up.
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 }` attach both to the container element.
230
+ Returns `{ ref, style }`. Attach both to the container element.
117
231
 
118
- ### useTransitionLock — Animation suppression
232
+ ## Animation suppression: useTransitionLock
119
233
 
120
- Suppress CSS transitions during batched state changes. Sets a flag synchronously (batched with state updates in React 18), auto-clears after paint.
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
- ### pinToScrollTop(el)
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
- | Content type | Tool | Shift behavior |
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
- ## Layer 2: Accordion
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
- **`useExpanded(id)`** per-item expansion hook. Only re-renders when this item's boolean flips:
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
- ### Hook API (legacy)
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` contains `value`, `onValueChange`, `data-switching` |
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
- ## Customize animation timing
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