concertina 0.6.1 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
package/dist/index.cjs CHANGED
@@ -33,12 +33,15 @@ __export(index_exports, {
33
33
  ConcertinaContext: () => ConcertinaContext,
34
34
  ConcertinaStore: () => ConcertinaStore,
35
35
  Content: () => Content3,
36
+ Gigbag: () => Gigbag,
37
+ Glide: () => Glide,
36
38
  Header: () => Header,
37
39
  Item: () => Item2,
38
40
  Root: () => Root3,
39
41
  Slot: () => Slot,
40
42
  StableSlot: () => StableSlot,
41
43
  Trigger: () => Trigger2,
44
+ Warmup: () => Warmup,
42
45
  pinToScrollTop: () => pinToScrollTop,
43
46
  useConcertina: () => useConcertina,
44
47
  useExpanded: () => useExpanded,
@@ -1255,13 +1258,99 @@ function useStableSlot(options = {}) {
1255
1258
  return { ref, style };
1256
1259
  }
1257
1260
 
1258
- // src/use-concertina.ts
1261
+ // src/gigbag.tsx
1259
1262
  var import_react13 = require("react");
1263
+ var import_jsx_runtime14 = require("react/jsx-runtime");
1264
+ var Gigbag = (0, import_react13.forwardRef)(
1265
+ function Gigbag2({ axis = "height", as: Tag = "div", className, style, children, ...props }, fwdRef) {
1266
+ const { ref: ratchetRef, style: ratchetStyle } = useStableSlot({ axis });
1267
+ const merged = className ? `concertina-gigbag ${className}` : "concertina-gigbag";
1268
+ return /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(
1269
+ Tag,
1270
+ {
1271
+ ref: (el) => {
1272
+ ratchetRef(el);
1273
+ if (typeof fwdRef === "function") fwdRef(el);
1274
+ else if (fwdRef) fwdRef.current = el;
1275
+ },
1276
+ className: merged,
1277
+ style: { ...ratchetStyle, ...style },
1278
+ ...props,
1279
+ children
1280
+ }
1281
+ );
1282
+ }
1283
+ );
1284
+
1285
+ // src/warmup.tsx
1286
+ var import_react14 = require("react");
1287
+ var import_jsx_runtime15 = require("react/jsx-runtime");
1288
+ var Warmup = (0, import_react14.forwardRef)(
1289
+ function Warmup2({ rows = 3, columns = 1, as: Tag = "div", className, children, ...props }, ref) {
1290
+ const merged = className ? `concertina-warmup ${className}` : "concertina-warmup";
1291
+ const cells = Array.from({ length: rows * columns }, (_, i) => /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)("div", { className: "concertina-warmup-bone", children: [
1292
+ /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("div", { className: "concertina-warmup-line concertina-warmup-line-short" }),
1293
+ /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("div", { className: "concertina-warmup-line concertina-warmup-line-long" })
1294
+ ] }, i));
1295
+ return /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(
1296
+ Tag,
1297
+ {
1298
+ ref,
1299
+ className: merged,
1300
+ style: { gridTemplateColumns: `repeat(${columns}, 1fr)` },
1301
+ ...props,
1302
+ children: cells
1303
+ }
1304
+ );
1305
+ }
1306
+ );
1307
+
1308
+ // src/glide.tsx
1309
+ var import_react15 = require("react");
1310
+ var import_jsx_runtime16 = require("react/jsx-runtime");
1311
+ var Glide = (0, import_react15.forwardRef)(
1312
+ function Glide2({ show, as: Tag = "div", className, children, ...props }, ref) {
1313
+ const [mounted, setMounted] = (0, import_react15.useState)(show);
1314
+ const [phase, setPhase] = (0, import_react15.useState)(show ? "entered" : "exiting");
1315
+ (0, import_react15.useEffect)(() => {
1316
+ if (show) {
1317
+ setMounted(true);
1318
+ setPhase("entering");
1319
+ } else if (mounted) {
1320
+ setPhase("exiting");
1321
+ }
1322
+ }, [show]);
1323
+ const onAnimationEnd = (0, import_react15.useCallback)(
1324
+ (e) => {
1325
+ if (e.target !== e.currentTarget) return;
1326
+ if (phase === "entering") setPhase("entered");
1327
+ if (phase === "exiting") setMounted(false);
1328
+ },
1329
+ [phase]
1330
+ );
1331
+ if (!mounted) return null;
1332
+ const phaseClass = phase === "entering" ? "concertina-glide-entering" : phase === "exiting" ? "concertina-glide-exiting" : "";
1333
+ const merged = ["concertina-glide", phaseClass, className].filter(Boolean).join(" ");
1334
+ return /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
1335
+ Tag,
1336
+ {
1337
+ ref,
1338
+ className: merged,
1339
+ onAnimationEnd,
1340
+ ...props,
1341
+ children
1342
+ }
1343
+ );
1344
+ }
1345
+ );
1346
+
1347
+ // src/use-concertina.ts
1348
+ var import_react16 = require("react");
1260
1349
  function useConcertina() {
1261
- const [value, setValue] = (0, import_react13.useState)("");
1262
- const [switching, setSwitching] = (0, import_react13.useState)(false);
1263
- const itemRefs = (0, import_react13.useRef)({});
1264
- const onValueChange = (0, import_react13.useCallback)(
1350
+ const [value, setValue] = (0, import_react16.useState)("");
1351
+ const [switching, setSwitching] = (0, import_react16.useState)(false);
1352
+ const itemRefs = (0, import_react16.useRef)({});
1353
+ const onValueChange = (0, import_react16.useCallback)(
1265
1354
  (newValue) => {
1266
1355
  if (!newValue) {
1267
1356
  setSwitching(false);
@@ -1273,14 +1362,14 @@ function useConcertina() {
1273
1362
  },
1274
1363
  [value]
1275
1364
  );
1276
- (0, import_react13.useLayoutEffect)(() => {
1365
+ (0, import_react16.useLayoutEffect)(() => {
1277
1366
  if (!value) return;
1278
1367
  pinToScrollTop(itemRefs.current[value]);
1279
1368
  }, [value]);
1280
- (0, import_react13.useEffect)(() => {
1369
+ (0, import_react16.useEffect)(() => {
1281
1370
  if (switching) setSwitching(false);
1282
1371
  }, [switching]);
1283
- const getItemRef = (0, import_react13.useCallback)(
1372
+ const getItemRef = (0, import_react16.useCallback)(
1284
1373
  (id) => (el) => {
1285
1374
  itemRefs.current[id] = el;
1286
1375
  },
@@ -1298,12 +1387,15 @@ function useConcertina() {
1298
1387
  ConcertinaContext,
1299
1388
  ConcertinaStore,
1300
1389
  Content,
1390
+ Gigbag,
1391
+ Glide,
1301
1392
  Header,
1302
1393
  Item,
1303
1394
  Root,
1304
1395
  Slot,
1305
1396
  StableSlot,
1306
1397
  Trigger,
1398
+ Warmup,
1307
1399
  pinToScrollTop,
1308
1400
  useConcertina,
1309
1401
  useExpanded,
package/dist/index.d.cts CHANGED
@@ -115,6 +115,60 @@ declare function useTransitionLock(): {
115
115
  readonly lock: () => void;
116
116
  };
117
117
 
118
+ interface GigbagProps extends HTMLAttributes<HTMLElement> {
119
+ /** Which axis to ratchet. Default: "height". */
120
+ axis?: Axis;
121
+ /** HTML element to render. Default: "div". */
122
+ as?: ElementType;
123
+ }
124
+ /**
125
+ * Size-reserving container.
126
+ *
127
+ * Remembers its largest-ever size (ResizeObserver ratchet) and never
128
+ * shrinks. Swap a spinner for a table inside — no reflow.
129
+ *
130
+ * Uses `contain: layout style` to isolate internal reflow from
131
+ * ancestors.
132
+ */
133
+ declare const Gigbag: react.ForwardRefExoticComponent<GigbagProps & react.RefAttributes<HTMLElement>>;
134
+
135
+ interface WarmupProps extends HTMLAttributes<HTMLElement> {
136
+ /** Number of placeholder rows. Default: 3. */
137
+ rows?: number;
138
+ /** Number of columns per row. Default: 1. */
139
+ columns?: number;
140
+ /** HTML element to render. Default: "div". */
141
+ as?: ElementType;
142
+ }
143
+ /**
144
+ * Structural placeholder — CSS-only shimmer grid.
145
+ *
146
+ * Renders `rows x columns` animated bones that approximate the
147
+ * dimensions of the real content. Pair with <Gigbag> so the
148
+ * container ratchets to the larger of placeholder vs real content.
149
+ *
150
+ * All dimensions are CSS custom properties — consuming apps theme
151
+ * without forking.
152
+ */
153
+ declare const Warmup: react.ForwardRefExoticComponent<WarmupProps & react.RefAttributes<HTMLElement>>;
154
+
155
+ interface GlideProps extends HTMLAttributes<HTMLElement> {
156
+ /** Whether the content is visible. */
157
+ show: boolean;
158
+ /** HTML element to render. Default: "div". */
159
+ as?: ElementType;
160
+ }
161
+ /**
162
+ * Enter/exit animation wrapper.
163
+ *
164
+ * State machine:
165
+ * show=true -> mount + "entering" -> animationEnd -> "entered"
166
+ * show=false -> "exiting" -> animationEnd -> unmount
167
+ *
168
+ * CSS classes: concertina-glide-entering, concertina-glide-exiting
169
+ */
170
+ declare const Glide: react.ForwardRefExoticComponent<GlideProps & react.RefAttributes<HTMLElement>>;
171
+
118
172
  /**
119
173
  * Scroll `el` to the top of its nearest scrollable ancestor,
120
174
  * clearing any sticky headers. Only adjusts one container's
@@ -157,4 +211,4 @@ interface UseConcertinaReturn {
157
211
  */
158
212
  declare function useConcertina(): UseConcertinaReturn;
159
213
 
160
- export { type Axis, ConcertinaContext, type ConcertinaRootProps, ConcertinaStore, Content, Item, Root, Slot, type SlotProps, StableSlot, type StableSlotProps, type UseConcertinaReturn, pinToScrollTop, useConcertina, useExpanded, useStableSlot, useTransitionLock };
214
+ export { type Axis, ConcertinaContext, type ConcertinaRootProps, ConcertinaStore, Content, Gigbag, type GigbagProps, Glide, type GlideProps, Item, Root, Slot, type SlotProps, StableSlot, type StableSlotProps, type UseConcertinaReturn, Warmup, type WarmupProps, pinToScrollTop, useConcertina, useExpanded, useStableSlot, useTransitionLock };
package/dist/index.d.ts CHANGED
@@ -115,6 +115,60 @@ declare function useTransitionLock(): {
115
115
  readonly lock: () => void;
116
116
  };
117
117
 
118
+ interface GigbagProps extends HTMLAttributes<HTMLElement> {
119
+ /** Which axis to ratchet. Default: "height". */
120
+ axis?: Axis;
121
+ /** HTML element to render. Default: "div". */
122
+ as?: ElementType;
123
+ }
124
+ /**
125
+ * Size-reserving container.
126
+ *
127
+ * Remembers its largest-ever size (ResizeObserver ratchet) and never
128
+ * shrinks. Swap a spinner for a table inside — no reflow.
129
+ *
130
+ * Uses `contain: layout style` to isolate internal reflow from
131
+ * ancestors.
132
+ */
133
+ declare const Gigbag: react.ForwardRefExoticComponent<GigbagProps & react.RefAttributes<HTMLElement>>;
134
+
135
+ interface WarmupProps extends HTMLAttributes<HTMLElement> {
136
+ /** Number of placeholder rows. Default: 3. */
137
+ rows?: number;
138
+ /** Number of columns per row. Default: 1. */
139
+ columns?: number;
140
+ /** HTML element to render. Default: "div". */
141
+ as?: ElementType;
142
+ }
143
+ /**
144
+ * Structural placeholder — CSS-only shimmer grid.
145
+ *
146
+ * Renders `rows x columns` animated bones that approximate the
147
+ * dimensions of the real content. Pair with <Gigbag> so the
148
+ * container ratchets to the larger of placeholder vs real content.
149
+ *
150
+ * All dimensions are CSS custom properties — consuming apps theme
151
+ * without forking.
152
+ */
153
+ declare const Warmup: react.ForwardRefExoticComponent<WarmupProps & react.RefAttributes<HTMLElement>>;
154
+
155
+ interface GlideProps extends HTMLAttributes<HTMLElement> {
156
+ /** Whether the content is visible. */
157
+ show: boolean;
158
+ /** HTML element to render. Default: "div". */
159
+ as?: ElementType;
160
+ }
161
+ /**
162
+ * Enter/exit animation wrapper.
163
+ *
164
+ * State machine:
165
+ * show=true -> mount + "entering" -> animationEnd -> "entered"
166
+ * show=false -> "exiting" -> animationEnd -> unmount
167
+ *
168
+ * CSS classes: concertina-glide-entering, concertina-glide-exiting
169
+ */
170
+ declare const Glide: react.ForwardRefExoticComponent<GlideProps & react.RefAttributes<HTMLElement>>;
171
+
118
172
  /**
119
173
  * Scroll `el` to the top of its nearest scrollable ancestor,
120
174
  * clearing any sticky headers. Only adjusts one container's
@@ -157,4 +211,4 @@ interface UseConcertinaReturn {
157
211
  */
158
212
  declare function useConcertina(): UseConcertinaReturn;
159
213
 
160
- export { type Axis, ConcertinaContext, type ConcertinaRootProps, ConcertinaStore, Content, Item, Root, Slot, type SlotProps, StableSlot, type StableSlotProps, type UseConcertinaReturn, pinToScrollTop, useConcertina, useExpanded, useStableSlot, useTransitionLock };
214
+ export { type Axis, ConcertinaContext, type ConcertinaRootProps, ConcertinaStore, Content, Gigbag, type GigbagProps, Glide, type GlideProps, Item, Root, Slot, type SlotProps, StableSlot, type StableSlotProps, type UseConcertinaReturn, Warmup, type WarmupProps, pinToScrollTop, useConcertina, useExpanded, useStableSlot, useTransitionLock };
package/dist/index.js CHANGED
@@ -1222,19 +1222,110 @@ function useStableSlot(options = {}) {
1222
1222
  return { ref, style };
1223
1223
  }
1224
1224
 
1225
- // src/use-concertina.ts
1225
+ // src/gigbag.tsx
1226
+ import { forwardRef as forwardRef9 } from "react";
1227
+ import { jsx as jsx13 } from "react/jsx-runtime";
1228
+ var Gigbag = forwardRef9(
1229
+ function Gigbag2({ axis = "height", as: Tag = "div", className, style, children, ...props }, fwdRef) {
1230
+ const { ref: ratchetRef, style: ratchetStyle } = useStableSlot({ axis });
1231
+ const merged = className ? `concertina-gigbag ${className}` : "concertina-gigbag";
1232
+ return /* @__PURE__ */ jsx13(
1233
+ Tag,
1234
+ {
1235
+ ref: (el) => {
1236
+ ratchetRef(el);
1237
+ if (typeof fwdRef === "function") fwdRef(el);
1238
+ else if (fwdRef) fwdRef.current = el;
1239
+ },
1240
+ className: merged,
1241
+ style: { ...ratchetStyle, ...style },
1242
+ ...props,
1243
+ children
1244
+ }
1245
+ );
1246
+ }
1247
+ );
1248
+
1249
+ // src/warmup.tsx
1250
+ import { forwardRef as forwardRef10 } from "react";
1251
+ import { jsx as jsx14, jsxs } from "react/jsx-runtime";
1252
+ var Warmup = forwardRef10(
1253
+ function Warmup2({ rows = 3, columns = 1, as: Tag = "div", className, children, ...props }, ref) {
1254
+ const merged = className ? `concertina-warmup ${className}` : "concertina-warmup";
1255
+ const cells = Array.from({ length: rows * columns }, (_, i) => /* @__PURE__ */ jsxs("div", { className: "concertina-warmup-bone", children: [
1256
+ /* @__PURE__ */ jsx14("div", { className: "concertina-warmup-line concertina-warmup-line-short" }),
1257
+ /* @__PURE__ */ jsx14("div", { className: "concertina-warmup-line concertina-warmup-line-long" })
1258
+ ] }, i));
1259
+ return /* @__PURE__ */ jsx14(
1260
+ Tag,
1261
+ {
1262
+ ref,
1263
+ className: merged,
1264
+ style: { gridTemplateColumns: `repeat(${columns}, 1fr)` },
1265
+ ...props,
1266
+ children: cells
1267
+ }
1268
+ );
1269
+ }
1270
+ );
1271
+
1272
+ // src/glide.tsx
1226
1273
  import {
1274
+ forwardRef as forwardRef11,
1227
1275
  useState as useState7,
1228
- useCallback as useCallback9,
1276
+ useEffect as useEffect6,
1277
+ useCallback as useCallback9
1278
+ } from "react";
1279
+ import { jsx as jsx15 } from "react/jsx-runtime";
1280
+ var Glide = forwardRef11(
1281
+ function Glide2({ show, as: Tag = "div", className, children, ...props }, ref) {
1282
+ const [mounted, setMounted] = useState7(show);
1283
+ const [phase, setPhase] = useState7(show ? "entered" : "exiting");
1284
+ useEffect6(() => {
1285
+ if (show) {
1286
+ setMounted(true);
1287
+ setPhase("entering");
1288
+ } else if (mounted) {
1289
+ setPhase("exiting");
1290
+ }
1291
+ }, [show]);
1292
+ const onAnimationEnd = useCallback9(
1293
+ (e) => {
1294
+ if (e.target !== e.currentTarget) return;
1295
+ if (phase === "entering") setPhase("entered");
1296
+ if (phase === "exiting") setMounted(false);
1297
+ },
1298
+ [phase]
1299
+ );
1300
+ if (!mounted) return null;
1301
+ const phaseClass = phase === "entering" ? "concertina-glide-entering" : phase === "exiting" ? "concertina-glide-exiting" : "";
1302
+ const merged = ["concertina-glide", phaseClass, className].filter(Boolean).join(" ");
1303
+ return /* @__PURE__ */ jsx15(
1304
+ Tag,
1305
+ {
1306
+ ref,
1307
+ className: merged,
1308
+ onAnimationEnd,
1309
+ ...props,
1310
+ children
1311
+ }
1312
+ );
1313
+ }
1314
+ );
1315
+
1316
+ // src/use-concertina.ts
1317
+ import {
1318
+ useState as useState8,
1319
+ useCallback as useCallback10,
1229
1320
  useRef as useRef7,
1230
1321
  useLayoutEffect as useLayoutEffect4,
1231
- useEffect as useEffect6
1322
+ useEffect as useEffect7
1232
1323
  } from "react";
1233
1324
  function useConcertina() {
1234
- const [value, setValue] = useState7("");
1235
- const [switching, setSwitching] = useState7(false);
1325
+ const [value, setValue] = useState8("");
1326
+ const [switching, setSwitching] = useState8(false);
1236
1327
  const itemRefs = useRef7({});
1237
- const onValueChange = useCallback9(
1328
+ const onValueChange = useCallback10(
1238
1329
  (newValue) => {
1239
1330
  if (!newValue) {
1240
1331
  setSwitching(false);
@@ -1250,10 +1341,10 @@ function useConcertina() {
1250
1341
  if (!value) return;
1251
1342
  pinToScrollTop(itemRefs.current[value]);
1252
1343
  }, [value]);
1253
- useEffect6(() => {
1344
+ useEffect7(() => {
1254
1345
  if (switching) setSwitching(false);
1255
1346
  }, [switching]);
1256
- const getItemRef = useCallback9(
1347
+ const getItemRef = useCallback10(
1257
1348
  (id) => (el) => {
1258
1349
  itemRefs.current[id] = el;
1259
1350
  },
@@ -1270,12 +1361,15 @@ export {
1270
1361
  ConcertinaContext,
1271
1362
  ConcertinaStore,
1272
1363
  Content3 as Content,
1364
+ Gigbag,
1365
+ Glide,
1273
1366
  Header,
1274
1367
  Item2 as Item,
1275
1368
  Root3 as Root,
1276
1369
  Slot,
1277
1370
  StableSlot,
1278
1371
  Trigger2 as Trigger,
1372
+ Warmup,
1279
1373
  pinToScrollTop,
1280
1374
  useConcertina,
1281
1375
  useExpanded,
package/dist/styles.css CHANGED
@@ -65,3 +65,86 @@
65
65
  display: flex;
66
66
  flex-direction: column;
67
67
  }
68
+
69
+ /* Gigbag — size-reserving container.
70
+ contain isolates internal reflow from ancestors. */
71
+ .concertina-gigbag {
72
+ contain: layout style;
73
+ }
74
+
75
+ /* Warmup — structural placeholder shimmer grid. */
76
+ .concertina-warmup {
77
+ display: grid;
78
+ gap: var(--concertina-warmup-gap, 0.75rem);
79
+ contain: layout style;
80
+ }
81
+
82
+ .concertina-warmup-bone {
83
+ display: flex;
84
+ flex-direction: column;
85
+ gap: var(--concertina-warmup-bone-gap, 0.125rem);
86
+ padding: var(--concertina-warmup-bone-padding, 0.375rem 0.5rem);
87
+ }
88
+
89
+ .concertina-warmup-line {
90
+ border-radius: var(--concertina-warmup-line-radius, 0.125rem);
91
+ background: linear-gradient(
92
+ 90deg,
93
+ var(--concertina-warmup-line-color, #e5e7eb) 25%,
94
+ var(--concertina-warmup-line-highlight, #f3f4f6) 50%,
95
+ var(--concertina-warmup-line-color, #e5e7eb) 75%
96
+ );
97
+ background-size: 200% 100%;
98
+ animation: concertina-shimmer 1.5s ease-in-out infinite;
99
+ }
100
+
101
+ .concertina-warmup-line-short {
102
+ height: var(--concertina-warmup-line-short, 0.5rem);
103
+ width: 40%;
104
+ }
105
+
106
+ .concertina-warmup-line-long {
107
+ height: var(--concertina-warmup-line-long, 0.75rem);
108
+ width: 75%;
109
+ }
110
+
111
+ @keyframes concertina-shimmer {
112
+ 0% { background-position: 200% 0; }
113
+ 100% { background-position: -200% 0; }
114
+ }
115
+
116
+ /* Glide — enter/exit animation wrapper. */
117
+ .concertina-glide {
118
+ --concertina-glide-duration: 200ms;
119
+ }
120
+
121
+ .concertina-glide-entering {
122
+ animation: concertina-glide-in var(--concertina-glide-duration) ease-out;
123
+ }
124
+
125
+ .concertina-glide-exiting {
126
+ animation: concertina-glide-out var(--concertina-glide-duration) ease-out forwards;
127
+ }
128
+
129
+ @keyframes concertina-glide-in {
130
+ from { opacity: 0; max-height: 0; overflow: hidden; }
131
+ to { opacity: 1; max-height: var(--concertina-glide-height, 1000px); overflow: hidden; }
132
+ }
133
+
134
+ @keyframes concertina-glide-out {
135
+ from { opacity: 1; max-height: var(--concertina-glide-height, 1000px); overflow: hidden; }
136
+ to { opacity: 0; max-height: 0; overflow: hidden; }
137
+ }
138
+
139
+ /* Respect reduced-motion preferences.
140
+ Disables all animations — accordion open/close, shimmer, and glide enter/exit.
141
+ Layout changes still happen instantly so functionality is preserved. */
142
+ @media (prefers-reduced-motion: reduce) {
143
+ .concertina-content[data-state="open"],
144
+ .concertina-content[data-state="closed"],
145
+ .concertina-glide-entering,
146
+ .concertina-glide-exiting,
147
+ .concertina-warmup-line {
148
+ animation-duration: 0s !important;
149
+ }
150
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "concertina",
3
- "version": "0.6.1",
3
+ "version": "0.7.1",
4
4
  "description": "React toolkit for layout stability.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -28,6 +28,8 @@
28
28
  "scripts": {
29
29
  "build": "tsup && cp src/styles.css dist/styles.css",
30
30
  "prepare": "npm run build",
31
+ "test": "vitest run",
32
+ "test:watch": "vitest",
31
33
  "typecheck": "tsc --noEmit",
32
34
  "prepublishOnly": "npm run build"
33
35
  },
@@ -39,11 +41,15 @@
39
41
  "react-dom": ">=18.0.0"
40
42
  },
41
43
  "devDependencies": {
44
+ "@testing-library/jest-dom": "^6.9.1",
45
+ "@testing-library/react": "^16.3.2",
42
46
  "@types/react": "^19.0.0",
47
+ "jsdom": "^28.1.0",
43
48
  "react": "^19.0.0",
44
49
  "react-dom": "^19.0.0",
45
50
  "tsup": "^8.0.0",
46
- "typescript": "^5.0.0"
51
+ "typescript": "^5.0.0",
52
+ "vitest": "^4.0.18"
47
53
  },
48
54
  "keywords": [
49
55
  "radix",