concertina 0.8.1 → 0.9.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 +222 -24
- package/dist/index.cjs +63 -30
- package/dist/index.d.cts +35 -4
- package/dist/index.d.ts +35 -4
- package/dist/index.js +50 -19
- package/dist/styles.css +14 -10
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -14,21 +14,29 @@
|
|
|
14
14
|
|
|
15
15
|
<p align="center"><b>47 tests</b> · 716 lines of source · 1 dependency</p>
|
|
16
16
|
|
|
17
|
-
##
|
|
17
|
+
## The problem
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
Layout shift happens when the browser changes the size of a box and moves everything else to compensate. A button swaps for a stepper — the text next to it reflows. A spinner becomes a table — the page jumps 400 pixels. An accordion opens — the thing you clicked scrolls off the screen.
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
The React ecosystem treats this as a **state problem**. Suspense, skeleton libraries, loading spinners — they model the transition between pending and loaded. They give you a nice-looking placeholder that's a completely different DOM structure from the real content, then act surprised when the swap causes a jump.
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
It's not a state problem. It's a **structure problem.** The box changed size because you swapped the structure inside it.
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
## The fix
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
Don't swap structures. Swap what's inside them.
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
Every tool in concertina is a different expression of this one idea:
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
| What changes | Tool | How it works |
|
|
32
|
+
|---|---|---|
|
|
33
|
+
| Which variant is visible | StableSlot + Slot | Render all variants in one grid cell, toggle visibility |
|
|
34
|
+
| Content loading | Gigbag | Container remembers its biggest size, refuses to shrink |
|
|
35
|
+
| Data arriving in a table | Stub data pattern | Same render path for loading and loaded — shimmer or content inside the same wrapper |
|
|
36
|
+
| A panel mounting/unmounting | Glide | Animated enter/exit instead of instant DOM swap |
|
|
37
|
+
| Which accordion item is open | Root + Item + Content | Scroll pinning keeps the opened item visible |
|
|
38
|
+
|
|
39
|
+
Structure is the contract. Content is what varies. If you internalize that, the API is obvious. If you don't, no amount of tooling will save you.
|
|
32
40
|
|
|
33
41
|
## Install
|
|
34
42
|
|
|
@@ -41,9 +49,13 @@ import * as Concertina from "concertina";
|
|
|
41
49
|
import "concertina/styles.css";
|
|
42
50
|
```
|
|
43
51
|
|
|
44
|
-
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## StableSlot + Slot
|
|
45
55
|
|
|
46
|
-
|
|
56
|
+
Two components swap in one slot. An "Add" button becomes a quantity stepper. The stepper is wider. The text next to it jumps left.
|
|
57
|
+
|
|
58
|
+
The fix: don't swap them. Render both at the same time, in the same grid cell, stacked. The cell sizes to the bigger one. Toggle which one is visible. The box never changes size because both variants are always in there.
|
|
47
59
|
|
|
48
60
|
```tsx
|
|
49
61
|
<Concertina.StableSlot axis="width" className="action-slot">
|
|
@@ -108,11 +120,11 @@ Every independently appearing element needs its own StableSlot. An Undo link tha
|
|
|
108
120
|
</div>
|
|
109
121
|
```
|
|
110
122
|
|
|
111
|
-
A single Slot inside a StableSlot is valid. It reserves the element's space, showing or hiding it without shift.
|
|
123
|
+
A single Slot inside a StableSlot is valid. It reserves the element's space, showing or hiding it without shift.
|
|
112
124
|
|
|
113
|
-
|
|
125
|
+
---
|
|
114
126
|
|
|
115
|
-
|
|
127
|
+
## Gigbag + Warmup
|
|
116
128
|
|
|
117
129
|
```jsx
|
|
118
130
|
if (loading) return <Spinner />; // 48px
|
|
@@ -120,11 +132,11 @@ if (empty) return <EmptyMsg />; // 64px
|
|
|
120
132
|
return <BigTable data={data} />; // 500px+
|
|
121
133
|
```
|
|
122
134
|
|
|
123
|
-
|
|
135
|
+
Three different structures, three different heights. Every transition jumps.
|
|
124
136
|
|
|
125
|
-
Gigbag is a container that remembers its largest-ever size via ResizeObserver and will not shrink.
|
|
137
|
+
Gigbag is a container that remembers its largest-ever size via ResizeObserver and will not shrink. 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. It also uses `contain: layout style` so internal reflows don't bother the ancestors.
|
|
126
138
|
|
|
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
|
|
139
|
+
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 approximates the content's shape. The browser knows how tall things will be because you told it.
|
|
128
140
|
|
|
129
141
|
```tsx
|
|
130
142
|
<Concertina.Gigbag axis="height">
|
|
@@ -136,7 +148,7 @@ Warmup is a CSS-only shimmer grid that goes inside the Gigbag while you're loadi
|
|
|
136
148
|
</Concertina.Gigbag>
|
|
137
149
|
```
|
|
138
150
|
|
|
139
|
-
The Gigbag ratchets to whichever is taller. On subsequent re-fetches it holds at the table's height instead of collapsing back.
|
|
151
|
+
The Gigbag ratchets to whichever child is taller. On subsequent re-fetches it holds at the table's height instead of collapsing back.
|
|
140
152
|
|
|
141
153
|
### Gigbag props
|
|
142
154
|
|
|
@@ -155,7 +167,7 @@ The Gigbag ratchets to whichever is taller. On subsequent re-fetches it holds at
|
|
|
155
167
|
|
|
156
168
|
### Theming Warmup
|
|
157
169
|
|
|
158
|
-
All dimensions are CSS custom properties
|
|
170
|
+
All dimensions are CSS custom properties:
|
|
159
171
|
|
|
160
172
|
```css
|
|
161
173
|
.concertina-warmup {
|
|
@@ -166,9 +178,186 @@ All dimensions are CSS custom properties. Override them to match your app:
|
|
|
166
178
|
}
|
|
167
179
|
```
|
|
168
180
|
|
|
169
|
-
|
|
181
|
+
### Shimmer dimensions come from the text styles, not from the shimmer
|
|
182
|
+
|
|
183
|
+
This is the single most important thing in the library and the easiest to get wrong.
|
|
184
|
+
|
|
185
|
+
A shimmer line replaces text. It needs to be exactly as tall as the text it replaces. For 17 years, CSS had no unit for "one line of text." `em` is font-size, not line-height. `rem` is the root font-size. `px` is absolute. Every shimmer library picked a number — `height: 0.75em`, `height: 12px`, `height: 1rem` — and it was wrong, because text height is determined by line-height, which is determined by the font styles on the element. A shimmer that invents its own height is a shimmer that shifts layout when the real text arrives.
|
|
186
|
+
|
|
187
|
+
CSS now has the `lh` unit. `1lh` resolves to the element's computed line-height. The shimmer uses `height: 1lh`. That's not a magic number — it's a relative unit that derives its value from the element's styles, the same way `100%` derives its value from the container's size.
|
|
188
|
+
|
|
189
|
+
But `1lh` only works if the shimmer has the right styles. A bare `<div className="concertina-warmup-line" />` inherits line-height from its parent. If it's inside a `<span className="text-sm">`, it inherits `text-sm`'s line-height. Correct. But if it's a direct child of a toolbar with no font context, `1lh` resolves against the default line-height. Wrong.
|
|
190
|
+
|
|
191
|
+
**The `WarmupLine` component exists so you can pass the text styles explicitly:**
|
|
192
|
+
|
|
193
|
+
```tsx
|
|
194
|
+
// Toolbar — no parent provides text styles, so pass them directly
|
|
195
|
+
{loading
|
|
196
|
+
? <Concertina.WarmupLine className="text-sm text-stone flex-1" />
|
|
197
|
+
: <span className="text-sm text-stone">{count} customers</span>
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Grid cell — parent wrapper provides text styles via inheritance
|
|
201
|
+
<span className="table-val-primary">
|
|
202
|
+
{row._warmup
|
|
203
|
+
? <div className="concertina-warmup-line" />
|
|
204
|
+
: row.name
|
|
205
|
+
}
|
|
206
|
+
</span>
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
In grid cells, the shimmer inherits from its wrapper (wrapper-once pattern). In standalone contexts like toolbars, pass the same `className` you'd put on the text element. The shimmer's `1lh` resolves against those styles and matches the text exactly.
|
|
210
|
+
|
|
211
|
+
**Width** comes from the container, not the shimmer. In a grid cell, the column definition provides width (`minmax(4rem, auto)`). In a flex toolbar, pass `flex-1` so the shimmer fills the available space. The shimmer never invents a width — it fills whatever its context provides.
|
|
212
|
+
|
|
213
|
+
```css
|
|
214
|
+
/* The shimmer stretches to fill whatever its container provides */
|
|
215
|
+
grid-template-columns: minmax(10rem, 2fr) minmax(3rem, auto) minmax(4.5rem, auto) auto;
|
|
216
|
+
/* name column qty column total column action (StableSlot) */
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
One thing knows the expected content width: the grid column definition. Zero things should guess it.
|
|
220
|
+
|
|
221
|
+
---
|
|
222
|
+
|
|
223
|
+
## The stub-data pattern
|
|
224
|
+
|
|
225
|
+
Gigbag + Warmup works for flat containers. But when your content renders through structured components — an accordion with `Root > Item > Trigger > Content`, or a data table with cell wrappers — a separate loading skeleton is a different DOM structure. Different wrappers, different padding, different height. The swap from skeleton to real content shifts layout. It has to. The structures are different.
|
|
226
|
+
|
|
227
|
+
This is where the core principle applies directly. Don't build a separate loading path. **Pass placeholder data through the same render path as real data.**
|
|
228
|
+
|
|
229
|
+
### How it works
|
|
230
|
+
|
|
231
|
+
Create stub objects with the same shape as your real data, marked with a `_warmup` flag. Pass them to the same component that renders real data. Each cell renders shimmer or content inside the same wrapper — one wrapper definition, ternary on the guts:
|
|
232
|
+
|
|
233
|
+
```tsx
|
|
234
|
+
// Stub data — same shape as real rows
|
|
235
|
+
const STUB_ROWS = Array.from({ length: 8 }, (_, i) => ({
|
|
236
|
+
_warmup: true as const,
|
|
237
|
+
id: `warmup-${i}`,
|
|
238
|
+
name: null,
|
|
239
|
+
items: [],
|
|
240
|
+
}));
|
|
241
|
+
|
|
242
|
+
// Cell renderer — wrapper defined once, content varies inside it
|
|
243
|
+
cell: ({ row }) => (
|
|
244
|
+
<span className="table-val-primary">
|
|
245
|
+
{row.original._warmup
|
|
246
|
+
? <div className="concertina-warmup-line" />
|
|
247
|
+
: row.original.name
|
|
248
|
+
}
|
|
249
|
+
</span>
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
// Table component — no separate loading branch
|
|
253
|
+
function MyTable({ data, loading }) {
|
|
254
|
+
return (
|
|
255
|
+
<Concertina.Root>
|
|
256
|
+
{(loading ? STUB_ROWS : data).map((row) => (
|
|
257
|
+
<Concertina.Item key={row.id} value={row.id}>
|
|
258
|
+
<Concertina.Trigger>
|
|
259
|
+
{/* cells render shimmer or content in the same wrappers */}
|
|
260
|
+
</Concertina.Trigger>
|
|
261
|
+
<Concertina.Content>
|
|
262
|
+
{row._warmup ? null : <DetailPanel row={row} />}
|
|
263
|
+
</Concertina.Content>
|
|
264
|
+
</Concertina.Item>
|
|
265
|
+
))}
|
|
266
|
+
</Concertina.Root>
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
The stub rows go through `Root > Item > Trigger > Content` — the exact same components as real rows. The `Content` element exists in the DOM (collapsed, zero height) for both stubs and real data. Every wrapper, every padding, every border is identical. The only difference is what's inside the cells.
|
|
272
|
+
|
|
273
|
+
### The wrapper-once rule
|
|
274
|
+
|
|
275
|
+
This is the part that matters. The wrapper is the structural contract — it determines padding, font-size, line-height, and therefore the cell's height. Define it once. Put the ternary inside it. Never write the wrapper in two branches.
|
|
276
|
+
|
|
277
|
+
```tsx
|
|
278
|
+
// WRONG — wrapper duplicated, will drift apart silently
|
|
279
|
+
if (row.original._warmup) {
|
|
280
|
+
return <span className="table-val-money"><div className="concertina-warmup-line" /></span>;
|
|
281
|
+
}
|
|
282
|
+
return <span className="table-val-money">${total}</span>;
|
|
283
|
+
|
|
284
|
+
// RIGHT — wrapper defined once, content switches inside it
|
|
285
|
+
<span className="table-val-money">
|
|
286
|
+
{row.original._warmup
|
|
287
|
+
? <div className="concertina-warmup-line" />
|
|
288
|
+
: `$${total}`
|
|
289
|
+
}
|
|
290
|
+
</span>
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
When the wrapper is duplicated across branches, it will drift. Someone adds a class to the live branch, forgets the warmup branch. The heights diverge. Layout shifts. And nobody notices until a user watches their screen jump on every page load.
|
|
170
294
|
|
|
171
|
-
|
|
295
|
+
One wrapper. One definition. Ternary on the guts. That's the whole rule.
|
|
296
|
+
|
|
297
|
+
### Action columns with StableSlot
|
|
298
|
+
|
|
299
|
+
For columns with interactive controls, pass `null` as the entity during warmup. The StableSlot still renders all variants in `visibility: hidden`, reserving the exact same space:
|
|
300
|
+
|
|
301
|
+
```tsx
|
|
302
|
+
<ActionCell entity={row._warmup ? null : row} />
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
### What TypeScript enforces (and what it doesn't)
|
|
306
|
+
|
|
307
|
+
A discriminated union guarantees you check `_warmup` before accessing real data:
|
|
308
|
+
|
|
309
|
+
```ts
|
|
310
|
+
type WarmupRow = {
|
|
311
|
+
_warmup: true;
|
|
312
|
+
id: string;
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
type RealRow = {
|
|
316
|
+
_warmup?: never;
|
|
317
|
+
id: string;
|
|
318
|
+
name: string;
|
|
319
|
+
items: Item[];
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
type Row = WarmupRow | RealRow;
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
The compiler forces the branch:
|
|
326
|
+
|
|
327
|
+
```ts
|
|
328
|
+
function renderCell(row: Row) {
|
|
329
|
+
// TS error: 'name' doesn't exist on WarmupRow
|
|
330
|
+
return <span className="table-val-primary">{row.name}</span>;
|
|
331
|
+
|
|
332
|
+
// compiles — wrapper once, ternary inside
|
|
333
|
+
return (
|
|
334
|
+
<span className="table-val-primary">
|
|
335
|
+
{row._warmup
|
|
336
|
+
? <div className="concertina-warmup-line" />
|
|
337
|
+
: row.name // TS narrows to RealRow here
|
|
338
|
+
}
|
|
339
|
+
</span>
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
You can't access `row.name` without checking `_warmup` first. A cell renderer that forgets the check fails at build time.
|
|
345
|
+
|
|
346
|
+
**What TypeScript does NOT enforce:** that you use the wrapper-once pattern. The compiler can't see inside JSX structure. This compiles without error and shifts layout:
|
|
347
|
+
|
|
348
|
+
```ts
|
|
349
|
+
// TS is happy. Layout shifts anyway. Don't do this.
|
|
350
|
+
if (row._warmup) return <div className="concertina-warmup-line" />;
|
|
351
|
+
return <span className="table-val-primary">{row.name}</span>;
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
TypeScript prevents you from forgetting the branch. The wrapper-once pattern prevents you from forgetting the wrapper. Use both.
|
|
355
|
+
|
|
356
|
+
---
|
|
357
|
+
|
|
358
|
+
## Glide
|
|
359
|
+
|
|
360
|
+
`{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. A light switch that also moves your furniture.
|
|
172
361
|
|
|
173
362
|
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
363
|
|
|
@@ -196,7 +385,9 @@ When `show` goes true, children mount with a `concertina-glide-entering` class.
|
|
|
196
385
|
}
|
|
197
386
|
```
|
|
198
387
|
|
|
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.
|
|
388
|
+
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.
|
|
389
|
+
|
|
390
|
+
---
|
|
200
391
|
|
|
201
392
|
## Composing them
|
|
202
393
|
|
|
@@ -211,7 +402,9 @@ Gigbag and Glide solve different problems and they compose:
|
|
|
211
402
|
</Concertina.Glide>
|
|
212
403
|
```
|
|
213
404
|
|
|
214
|
-
|
|
405
|
+
---
|
|
406
|
+
|
|
407
|
+
## useStableSlot
|
|
215
408
|
|
|
216
409
|
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.
|
|
217
410
|
|
|
@@ -229,7 +422,7 @@ const slot = Concertina.useStableSlot({ axis: "width" });
|
|
|
229
422
|
|
|
230
423
|
Returns `{ ref, style }`. Attach both to the container element.
|
|
231
424
|
|
|
232
|
-
##
|
|
425
|
+
## useTransitionLock
|
|
233
426
|
|
|
234
427
|
Suppresses CSS transitions during batched state changes. Sets a flag synchronously (batched with state updates in React 18), auto-clears after paint.
|
|
235
428
|
|
|
@@ -246,7 +439,7 @@ const handleChange = (newValue) => {
|
|
|
246
439
|
</div>
|
|
247
440
|
```
|
|
248
441
|
|
|
249
|
-
##
|
|
442
|
+
## pinToScrollTop
|
|
250
443
|
|
|
251
444
|
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.
|
|
252
445
|
|
|
@@ -254,6 +447,8 @@ Scrolls an element to the top of its nearest scrollable ancestor. Only touches `
|
|
|
254
447
|
Concertina.pinToScrollTop(element);
|
|
255
448
|
```
|
|
256
449
|
|
|
450
|
+
---
|
|
451
|
+
|
|
257
452
|
## Accordion
|
|
258
453
|
|
|
259
454
|
Wraps Radix Accordion with scroll pinning, animation suppression during switches, and per-item memoization via `useSyncExternalStore`.
|
|
@@ -314,6 +509,8 @@ const { rootProps, getItemRef } = Concertina.useConcertina();
|
|
|
314
509
|
|
|
315
510
|
If items near the bottom can't scroll to the top, add `padding-bottom: 50vh` to the scroll container.
|
|
316
511
|
|
|
512
|
+
---
|
|
513
|
+
|
|
317
514
|
## Picking the right tool
|
|
318
515
|
|
|
319
516
|
| Problem | Tool |
|
|
@@ -321,6 +518,7 @@ If items near the bottom can't scroll to the top, add `padding-bottom: 50vh` to
|
|
|
321
518
|
| Two variants swap in one slot | StableSlot + Slot |
|
|
322
519
|
| Text changes width unpredictably | useStableSlot (or CSS `tabular-nums` for numbers) |
|
|
323
520
|
| Spinner replaced by loaded content | Gigbag + Warmup |
|
|
521
|
+
| Accordion/table loading skeleton | Stub data through same render path |
|
|
324
522
|
| Panel mounts/unmounts conditionally | Glide |
|
|
325
523
|
| Accordion with scroll pinning | Root + Item + Content |
|
|
326
524
|
|
package/dist/index.cjs
CHANGED
|
@@ -42,6 +42,7 @@ __export(index_exports, {
|
|
|
42
42
|
StableSlot: () => StableSlot,
|
|
43
43
|
Trigger: () => Trigger2,
|
|
44
44
|
Warmup: () => Warmup,
|
|
45
|
+
WarmupLine: () => WarmupLine,
|
|
45
46
|
pinToScrollTop: () => pinToScrollTop,
|
|
46
47
|
useConcertina: () => useConcertina,
|
|
47
48
|
useExpanded: () => useExpanded,
|
|
@@ -49,7 +50,8 @@ __export(index_exports, {
|
|
|
49
50
|
useScrollPin: () => useScrollPin,
|
|
50
51
|
useSize: () => useSize,
|
|
51
52
|
useStableSlot: () => useStableSlot,
|
|
52
|
-
useTransitionLock: () => useTransitionLock
|
|
53
|
+
useTransitionLock: () => useTransitionLock,
|
|
54
|
+
useWarmupExit: () => useWarmupExit
|
|
53
55
|
});
|
|
54
56
|
module.exports = __toCommonJS(index_exports);
|
|
55
57
|
|
|
@@ -1306,34 +1308,41 @@ var Warmup = (0, import_react15.forwardRef)(
|
|
|
1306
1308
|
function Warmup2({ rows = 3, columns = 1, as: Tag = "div", className, children, ...props }, ref) {
|
|
1307
1309
|
const merged = className ? `concertina-warmup ${className}` : "concertina-warmup";
|
|
1308
1310
|
const cells = Array.from({ length: rows * columns }, (_, i) => /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)("div", { className: "concertina-warmup-bone", children: [
|
|
1309
|
-
/* @__PURE__ */ (0, import_jsx_runtime15.jsx)("div", { className: "concertina-warmup-line
|
|
1310
|
-
/* @__PURE__ */ (0, import_jsx_runtime15.jsx)("div", { className: "concertina-warmup-line
|
|
1311
|
+
/* @__PURE__ */ (0, import_jsx_runtime15.jsx)("div", { className: "concertina-warmup-line" }),
|
|
1312
|
+
/* @__PURE__ */ (0, import_jsx_runtime15.jsx)("div", { className: "concertina-warmup-line" })
|
|
1311
1313
|
] }, i));
|
|
1312
|
-
return /* @__PURE__ */ (0, import_jsx_runtime15.
|
|
1314
|
+
return /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(
|
|
1313
1315
|
Tag,
|
|
1314
1316
|
{
|
|
1315
1317
|
ref,
|
|
1316
1318
|
className: merged,
|
|
1317
1319
|
style: { gridTemplateColumns: `repeat(${columns}, 1fr)` },
|
|
1318
1320
|
...props,
|
|
1319
|
-
children:
|
|
1320
|
-
children,
|
|
1321
|
-
cells
|
|
1322
|
-
]
|
|
1321
|
+
children: cells
|
|
1323
1322
|
}
|
|
1324
1323
|
);
|
|
1325
1324
|
}
|
|
1326
1325
|
);
|
|
1327
1326
|
|
|
1327
|
+
// src/components/warmup-line.tsx
|
|
1328
|
+
var import_react16 = require("react");
|
|
1329
|
+
var import_jsx_runtime16 = require("react/jsx-runtime");
|
|
1330
|
+
var WarmupLine = (0, import_react16.forwardRef)(
|
|
1331
|
+
function WarmupLine2({ as: Tag = "div", className, ...props }, ref) {
|
|
1332
|
+
const merged = className ? `concertina-warmup-line ${className}` : "concertina-warmup-line";
|
|
1333
|
+
return /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(Tag, { ref, className: merged, ...props });
|
|
1334
|
+
}
|
|
1335
|
+
);
|
|
1336
|
+
|
|
1328
1337
|
// src/components/glide.tsx
|
|
1329
|
-
var
|
|
1338
|
+
var import_react18 = require("react");
|
|
1330
1339
|
|
|
1331
1340
|
// src/primitives/use-presence.ts
|
|
1332
|
-
var
|
|
1341
|
+
var import_react17 = require("react");
|
|
1333
1342
|
function usePresence2(show) {
|
|
1334
|
-
const [mounted, setMounted] = (0,
|
|
1335
|
-
const [phase, setPhase] = (0,
|
|
1336
|
-
(0,
|
|
1343
|
+
const [mounted, setMounted] = (0, import_react17.useState)(show);
|
|
1344
|
+
const [phase, setPhase] = (0, import_react17.useState)(show ? "entered" : "exiting");
|
|
1345
|
+
(0, import_react17.useEffect)(() => {
|
|
1337
1346
|
if (show) {
|
|
1338
1347
|
setMounted(true);
|
|
1339
1348
|
setPhase("entering");
|
|
@@ -1341,7 +1350,7 @@ function usePresence2(show) {
|
|
|
1341
1350
|
setPhase("exiting");
|
|
1342
1351
|
}
|
|
1343
1352
|
}, [show]);
|
|
1344
|
-
const onAnimationEnd = (0,
|
|
1353
|
+
const onAnimationEnd = (0, import_react17.useCallback)(
|
|
1345
1354
|
(e) => {
|
|
1346
1355
|
if (e.target !== e.currentTarget) return;
|
|
1347
1356
|
if (phase === "entering") setPhase("entered");
|
|
@@ -1353,14 +1362,14 @@ function usePresence2(show) {
|
|
|
1353
1362
|
}
|
|
1354
1363
|
|
|
1355
1364
|
// src/components/glide.tsx
|
|
1356
|
-
var
|
|
1357
|
-
var Glide = (0,
|
|
1365
|
+
var import_jsx_runtime17 = require("react/jsx-runtime");
|
|
1366
|
+
var Glide = (0, import_react18.forwardRef)(
|
|
1358
1367
|
function Glide2({ show, as: Tag = "div", className, children, ...props }, ref) {
|
|
1359
1368
|
const { mounted, phase, onAnimationEnd } = usePresence2(show);
|
|
1360
1369
|
if (!mounted) return null;
|
|
1361
1370
|
const phaseClass = phase === "entering" ? "concertina-glide-entering" : phase === "exiting" ? "concertina-glide-exiting" : "";
|
|
1362
1371
|
const merged = ["concertina-glide", phaseClass, className].filter(Boolean).join(" ");
|
|
1363
|
-
return /* @__PURE__ */ (0,
|
|
1372
|
+
return /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(
|
|
1364
1373
|
Tag,
|
|
1365
1374
|
{
|
|
1366
1375
|
ref,
|
|
@@ -1374,11 +1383,11 @@ var Glide = (0, import_react17.forwardRef)(
|
|
|
1374
1383
|
);
|
|
1375
1384
|
|
|
1376
1385
|
// src/primitives/use-size.ts
|
|
1377
|
-
var
|
|
1386
|
+
var import_react19 = require("react");
|
|
1378
1387
|
function useSize() {
|
|
1379
|
-
const [size, setSize] = (0,
|
|
1380
|
-
const observerRef = (0,
|
|
1381
|
-
const ref = (0,
|
|
1388
|
+
const [size, setSize] = (0, import_react19.useState)({ width: 0, height: 0 });
|
|
1389
|
+
const observerRef = (0, import_react19.useRef)(null);
|
|
1390
|
+
const ref = (0, import_react19.useCallback)((el) => {
|
|
1382
1391
|
if (observerRef.current) {
|
|
1383
1392
|
observerRef.current.disconnect();
|
|
1384
1393
|
observerRef.current = null;
|
|
@@ -1406,13 +1415,35 @@ function useSize() {
|
|
|
1406
1415
|
return { ref, size };
|
|
1407
1416
|
}
|
|
1408
1417
|
|
|
1418
|
+
// src/primitives/use-warmup-exit.ts
|
|
1419
|
+
var import_react20 = require("react");
|
|
1420
|
+
function useWarmupExit(loading, duration = 150) {
|
|
1421
|
+
const [exiting, setExiting] = (0, import_react20.useState)(false);
|
|
1422
|
+
const prevLoading = (0, import_react20.useRef)(loading);
|
|
1423
|
+
(0, import_react20.useEffect)(() => {
|
|
1424
|
+
if (prevLoading.current && !loading) {
|
|
1425
|
+
setExiting(true);
|
|
1426
|
+
const id = setTimeout(() => setExiting(false), duration);
|
|
1427
|
+
prevLoading.current = loading;
|
|
1428
|
+
return () => clearTimeout(id);
|
|
1429
|
+
}
|
|
1430
|
+
prevLoading.current = loading;
|
|
1431
|
+
}, [loading, duration]);
|
|
1432
|
+
return {
|
|
1433
|
+
/** True during loading AND during exit animation — use for data selection */
|
|
1434
|
+
showWarmup: loading || exiting,
|
|
1435
|
+
/** True only during the exit animation — use for CSS class */
|
|
1436
|
+
exiting
|
|
1437
|
+
};
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1409
1440
|
// src/accordion/use-concertina.ts
|
|
1410
|
-
var
|
|
1441
|
+
var import_react21 = require("react");
|
|
1411
1442
|
function useConcertina() {
|
|
1412
|
-
const [value, setValue] = (0,
|
|
1413
|
-
const [switching, setSwitching] = (0,
|
|
1414
|
-
const itemRefs = (0,
|
|
1415
|
-
const onValueChange = (0,
|
|
1443
|
+
const [value, setValue] = (0, import_react21.useState)("");
|
|
1444
|
+
const [switching, setSwitching] = (0, import_react21.useState)(false);
|
|
1445
|
+
const itemRefs = (0, import_react21.useRef)({});
|
|
1446
|
+
const onValueChange = (0, import_react21.useCallback)(
|
|
1416
1447
|
(newValue) => {
|
|
1417
1448
|
if (!newValue) {
|
|
1418
1449
|
setSwitching(false);
|
|
@@ -1424,14 +1455,14 @@ function useConcertina() {
|
|
|
1424
1455
|
},
|
|
1425
1456
|
[value]
|
|
1426
1457
|
);
|
|
1427
|
-
(0,
|
|
1458
|
+
(0, import_react21.useLayoutEffect)(() => {
|
|
1428
1459
|
if (!value) return;
|
|
1429
1460
|
pinToScrollTop(itemRefs.current[value]);
|
|
1430
1461
|
}, [value]);
|
|
1431
|
-
(0,
|
|
1462
|
+
(0, import_react21.useEffect)(() => {
|
|
1432
1463
|
if (switching) setSwitching(false);
|
|
1433
1464
|
}, [switching]);
|
|
1434
|
-
const getItemRef = (0,
|
|
1465
|
+
const getItemRef = (0, import_react21.useCallback)(
|
|
1435
1466
|
(id) => (el) => {
|
|
1436
1467
|
itemRefs.current[id] = el;
|
|
1437
1468
|
},
|
|
@@ -1458,6 +1489,7 @@ function useConcertina() {
|
|
|
1458
1489
|
StableSlot,
|
|
1459
1490
|
Trigger,
|
|
1460
1491
|
Warmup,
|
|
1492
|
+
WarmupLine,
|
|
1461
1493
|
pinToScrollTop,
|
|
1462
1494
|
useConcertina,
|
|
1463
1495
|
useExpanded,
|
|
@@ -1465,5 +1497,6 @@ function useConcertina() {
|
|
|
1465
1497
|
useScrollPin,
|
|
1466
1498
|
useSize,
|
|
1467
1499
|
useStableSlot,
|
|
1468
|
-
useTransitionLock
|
|
1500
|
+
useTransitionLock,
|
|
1501
|
+
useWarmupExit
|
|
1469
1502
|
});
|
package/dist/index.d.cts
CHANGED
|
@@ -105,14 +105,28 @@ interface WarmupProps extends HTMLAttributes<HTMLElement> {
|
|
|
105
105
|
* dimensions of the real content. Pair with <Gigbag> so the
|
|
106
106
|
* container ratchets to the larger of placeholder vs real content.
|
|
107
107
|
*
|
|
108
|
-
* Pass children to inject structure before the generated bones
|
|
109
|
-
* (e.g. a toolbar placeholder that spans all grid columns).
|
|
110
|
-
*
|
|
111
108
|
* All dimensions are CSS custom properties — consuming apps theme
|
|
112
109
|
* without forking.
|
|
113
110
|
*/
|
|
114
111
|
declare const Warmup: react.ForwardRefExoticComponent<WarmupProps & react.RefAttributes<HTMLElement>>;
|
|
115
112
|
|
|
113
|
+
interface WarmupLineProps extends HTMLAttributes<HTMLElement> {
|
|
114
|
+
/** HTML element to render. Default: "div". */
|
|
115
|
+
as?: ElementType;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Single shimmer line — CSS-aware placeholder for text.
|
|
119
|
+
*
|
|
120
|
+
* Sizes itself from inherited text styles (font-size, line-height)
|
|
121
|
+
* via an invisible `::before` character. No explicit height — the
|
|
122
|
+
* shimmer IS one line of text in whatever font context it lives in.
|
|
123
|
+
*
|
|
124
|
+
* Pass `className` to apply the same text styles as the content
|
|
125
|
+
* this shimmer stands in for. Width fills the container by default
|
|
126
|
+
* (block element).
|
|
127
|
+
*/
|
|
128
|
+
declare const WarmupLine: react.ForwardRefExoticComponent<WarmupLineProps & react.RefAttributes<HTMLElement>>;
|
|
129
|
+
|
|
116
130
|
interface GlideProps extends HTMLAttributes<HTMLElement> {
|
|
117
131
|
/** Whether the content is visible. */
|
|
118
132
|
show: boolean;
|
|
@@ -234,6 +248,23 @@ declare function useTransitionLock(): {
|
|
|
234
248
|
*/
|
|
235
249
|
declare function pinToScrollTop(el: HTMLElement | null): void;
|
|
236
250
|
|
|
251
|
+
/**
|
|
252
|
+
* Manages the warmup → content transition for stub-data tables.
|
|
253
|
+
*
|
|
254
|
+
* When `loading` transitions from true to false, holds stub data
|
|
255
|
+
* for one animation cycle so warmup lines can fade out before
|
|
256
|
+
* real content mounts.
|
|
257
|
+
*
|
|
258
|
+
* @param loading - Whether data is still loading
|
|
259
|
+
* @param duration - Exit animation duration in ms (default 150, matches CSS)
|
|
260
|
+
*/
|
|
261
|
+
declare function useWarmupExit(loading: boolean, duration?: number): {
|
|
262
|
+
/** True during loading AND during exit animation — use for data selection */
|
|
263
|
+
showWarmup: boolean;
|
|
264
|
+
/** True only during the exit animation — use for CSS class */
|
|
265
|
+
exiting: boolean;
|
|
266
|
+
};
|
|
267
|
+
|
|
237
268
|
interface ConcertinaRootProps {
|
|
238
269
|
value: string;
|
|
239
270
|
onValueChange: (value: string) => void;
|
|
@@ -263,4 +294,4 @@ interface UseConcertinaReturn {
|
|
|
263
294
|
*/
|
|
264
295
|
declare function useConcertina(): UseConcertinaReturn;
|
|
265
296
|
|
|
266
|
-
export { type Axis, ConcertinaContext, type ConcertinaRootProps, ConcertinaStore, Content, Gigbag, type GigbagProps, Glide, type GlideProps, Item, type Phase, Root, type Size, Slot, type SlotProps, StableSlot, type StableSlotProps, type UseConcertinaReturn, type UsePresenceReturn, Warmup, type WarmupProps, pinToScrollTop, useConcertina, useExpanded, usePresence, useScrollPin, useSize, useStableSlot, useTransitionLock };
|
|
297
|
+
export { type Axis, ConcertinaContext, type ConcertinaRootProps, ConcertinaStore, Content, Gigbag, type GigbagProps, Glide, type GlideProps, Item, type Phase, Root, type Size, Slot, type SlotProps, StableSlot, type StableSlotProps, type UseConcertinaReturn, type UsePresenceReturn, Warmup, WarmupLine, type WarmupLineProps, type WarmupProps, pinToScrollTop, useConcertina, useExpanded, usePresence, useScrollPin, useSize, useStableSlot, useTransitionLock, useWarmupExit };
|
package/dist/index.d.ts
CHANGED
|
@@ -105,14 +105,28 @@ interface WarmupProps extends HTMLAttributes<HTMLElement> {
|
|
|
105
105
|
* dimensions of the real content. Pair with <Gigbag> so the
|
|
106
106
|
* container ratchets to the larger of placeholder vs real content.
|
|
107
107
|
*
|
|
108
|
-
* Pass children to inject structure before the generated bones
|
|
109
|
-
* (e.g. a toolbar placeholder that spans all grid columns).
|
|
110
|
-
*
|
|
111
108
|
* All dimensions are CSS custom properties — consuming apps theme
|
|
112
109
|
* without forking.
|
|
113
110
|
*/
|
|
114
111
|
declare const Warmup: react.ForwardRefExoticComponent<WarmupProps & react.RefAttributes<HTMLElement>>;
|
|
115
112
|
|
|
113
|
+
interface WarmupLineProps extends HTMLAttributes<HTMLElement> {
|
|
114
|
+
/** HTML element to render. Default: "div". */
|
|
115
|
+
as?: ElementType;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Single shimmer line — CSS-aware placeholder for text.
|
|
119
|
+
*
|
|
120
|
+
* Sizes itself from inherited text styles (font-size, line-height)
|
|
121
|
+
* via an invisible `::before` character. No explicit height — the
|
|
122
|
+
* shimmer IS one line of text in whatever font context it lives in.
|
|
123
|
+
*
|
|
124
|
+
* Pass `className` to apply the same text styles as the content
|
|
125
|
+
* this shimmer stands in for. Width fills the container by default
|
|
126
|
+
* (block element).
|
|
127
|
+
*/
|
|
128
|
+
declare const WarmupLine: react.ForwardRefExoticComponent<WarmupLineProps & react.RefAttributes<HTMLElement>>;
|
|
129
|
+
|
|
116
130
|
interface GlideProps extends HTMLAttributes<HTMLElement> {
|
|
117
131
|
/** Whether the content is visible. */
|
|
118
132
|
show: boolean;
|
|
@@ -234,6 +248,23 @@ declare function useTransitionLock(): {
|
|
|
234
248
|
*/
|
|
235
249
|
declare function pinToScrollTop(el: HTMLElement | null): void;
|
|
236
250
|
|
|
251
|
+
/**
|
|
252
|
+
* Manages the warmup → content transition for stub-data tables.
|
|
253
|
+
*
|
|
254
|
+
* When `loading` transitions from true to false, holds stub data
|
|
255
|
+
* for one animation cycle so warmup lines can fade out before
|
|
256
|
+
* real content mounts.
|
|
257
|
+
*
|
|
258
|
+
* @param loading - Whether data is still loading
|
|
259
|
+
* @param duration - Exit animation duration in ms (default 150, matches CSS)
|
|
260
|
+
*/
|
|
261
|
+
declare function useWarmupExit(loading: boolean, duration?: number): {
|
|
262
|
+
/** True during loading AND during exit animation — use for data selection */
|
|
263
|
+
showWarmup: boolean;
|
|
264
|
+
/** True only during the exit animation — use for CSS class */
|
|
265
|
+
exiting: boolean;
|
|
266
|
+
};
|
|
267
|
+
|
|
237
268
|
interface ConcertinaRootProps {
|
|
238
269
|
value: string;
|
|
239
270
|
onValueChange: (value: string) => void;
|
|
@@ -263,4 +294,4 @@ interface UseConcertinaReturn {
|
|
|
263
294
|
*/
|
|
264
295
|
declare function useConcertina(): UseConcertinaReturn;
|
|
265
296
|
|
|
266
|
-
export { type Axis, ConcertinaContext, type ConcertinaRootProps, ConcertinaStore, Content, Gigbag, type GigbagProps, Glide, type GlideProps, Item, type Phase, Root, type Size, Slot, type SlotProps, StableSlot, type StableSlotProps, type UseConcertinaReturn, type UsePresenceReturn, Warmup, type WarmupProps, pinToScrollTop, useConcertina, useExpanded, usePresence, useScrollPin, useSize, useStableSlot, useTransitionLock };
|
|
297
|
+
export { type Axis, ConcertinaContext, type ConcertinaRootProps, ConcertinaStore, Content, Gigbag, type GigbagProps, Glide, type GlideProps, Item, type Phase, Root, type Size, Slot, type SlotProps, StableSlot, type StableSlotProps, type UseConcertinaReturn, type UsePresenceReturn, Warmup, WarmupLine, type WarmupLineProps, type WarmupProps, pinToScrollTop, useConcertina, useExpanded, usePresence, useScrollPin, useSize, useStableSlot, useTransitionLock, useWarmupExit };
|
package/dist/index.js
CHANGED
|
@@ -1266,28 +1266,35 @@ var Warmup = forwardRef10(
|
|
|
1266
1266
|
function Warmup2({ rows = 3, columns = 1, as: Tag = "div", className, children, ...props }, ref) {
|
|
1267
1267
|
const merged = className ? `concertina-warmup ${className}` : "concertina-warmup";
|
|
1268
1268
|
const cells = Array.from({ length: rows * columns }, (_, i) => /* @__PURE__ */ jsxs("div", { className: "concertina-warmup-bone", children: [
|
|
1269
|
-
/* @__PURE__ */ jsx14("div", { className: "concertina-warmup-line
|
|
1270
|
-
/* @__PURE__ */ jsx14("div", { className: "concertina-warmup-line
|
|
1269
|
+
/* @__PURE__ */ jsx14("div", { className: "concertina-warmup-line" }),
|
|
1270
|
+
/* @__PURE__ */ jsx14("div", { className: "concertina-warmup-line" })
|
|
1271
1271
|
] }, i));
|
|
1272
|
-
return /* @__PURE__ */
|
|
1272
|
+
return /* @__PURE__ */ jsx14(
|
|
1273
1273
|
Tag,
|
|
1274
1274
|
{
|
|
1275
1275
|
ref,
|
|
1276
1276
|
className: merged,
|
|
1277
1277
|
style: { gridTemplateColumns: `repeat(${columns}, 1fr)` },
|
|
1278
1278
|
...props,
|
|
1279
|
-
children:
|
|
1280
|
-
children,
|
|
1281
|
-
cells
|
|
1282
|
-
]
|
|
1279
|
+
children: cells
|
|
1283
1280
|
}
|
|
1284
1281
|
);
|
|
1285
1282
|
}
|
|
1286
1283
|
);
|
|
1287
1284
|
|
|
1285
|
+
// src/components/warmup-line.tsx
|
|
1286
|
+
import { forwardRef as forwardRef11 } from "react";
|
|
1287
|
+
import { jsx as jsx15 } from "react/jsx-runtime";
|
|
1288
|
+
var WarmupLine = forwardRef11(
|
|
1289
|
+
function WarmupLine2({ as: Tag = "div", className, ...props }, ref) {
|
|
1290
|
+
const merged = className ? `concertina-warmup-line ${className}` : "concertina-warmup-line";
|
|
1291
|
+
return /* @__PURE__ */ jsx15(Tag, { ref, className: merged, ...props });
|
|
1292
|
+
}
|
|
1293
|
+
);
|
|
1294
|
+
|
|
1288
1295
|
// src/components/glide.tsx
|
|
1289
1296
|
import {
|
|
1290
|
-
forwardRef as
|
|
1297
|
+
forwardRef as forwardRef12
|
|
1291
1298
|
} from "react";
|
|
1292
1299
|
|
|
1293
1300
|
// src/primitives/use-presence.ts
|
|
@@ -1319,14 +1326,14 @@ function usePresence2(show) {
|
|
|
1319
1326
|
}
|
|
1320
1327
|
|
|
1321
1328
|
// src/components/glide.tsx
|
|
1322
|
-
import { jsx as
|
|
1323
|
-
var Glide =
|
|
1329
|
+
import { jsx as jsx16 } from "react/jsx-runtime";
|
|
1330
|
+
var Glide = forwardRef12(
|
|
1324
1331
|
function Glide2({ show, as: Tag = "div", className, children, ...props }, ref) {
|
|
1325
1332
|
const { mounted, phase, onAnimationEnd } = usePresence2(show);
|
|
1326
1333
|
if (!mounted) return null;
|
|
1327
1334
|
const phaseClass = phase === "entering" ? "concertina-glide-entering" : phase === "exiting" ? "concertina-glide-exiting" : "";
|
|
1328
1335
|
const merged = ["concertina-glide", phaseClass, className].filter(Boolean).join(" ");
|
|
1329
|
-
return /* @__PURE__ */
|
|
1336
|
+
return /* @__PURE__ */ jsx16(
|
|
1330
1337
|
Tag,
|
|
1331
1338
|
{
|
|
1332
1339
|
ref,
|
|
@@ -1372,18 +1379,40 @@ function useSize() {
|
|
|
1372
1379
|
return { ref, size };
|
|
1373
1380
|
}
|
|
1374
1381
|
|
|
1382
|
+
// src/primitives/use-warmup-exit.ts
|
|
1383
|
+
import { useState as useState9, useEffect as useEffect7, useRef as useRef8 } from "react";
|
|
1384
|
+
function useWarmupExit(loading, duration = 150) {
|
|
1385
|
+
const [exiting, setExiting] = useState9(false);
|
|
1386
|
+
const prevLoading = useRef8(loading);
|
|
1387
|
+
useEffect7(() => {
|
|
1388
|
+
if (prevLoading.current && !loading) {
|
|
1389
|
+
setExiting(true);
|
|
1390
|
+
const id = setTimeout(() => setExiting(false), duration);
|
|
1391
|
+
prevLoading.current = loading;
|
|
1392
|
+
return () => clearTimeout(id);
|
|
1393
|
+
}
|
|
1394
|
+
prevLoading.current = loading;
|
|
1395
|
+
}, [loading, duration]);
|
|
1396
|
+
return {
|
|
1397
|
+
/** True during loading AND during exit animation — use for data selection */
|
|
1398
|
+
showWarmup: loading || exiting,
|
|
1399
|
+
/** True only during the exit animation — use for CSS class */
|
|
1400
|
+
exiting
|
|
1401
|
+
};
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1375
1404
|
// src/accordion/use-concertina.ts
|
|
1376
1405
|
import {
|
|
1377
|
-
useState as
|
|
1406
|
+
useState as useState10,
|
|
1378
1407
|
useCallback as useCallback11,
|
|
1379
|
-
useRef as
|
|
1408
|
+
useRef as useRef9,
|
|
1380
1409
|
useLayoutEffect as useLayoutEffect4,
|
|
1381
|
-
useEffect as
|
|
1410
|
+
useEffect as useEffect8
|
|
1382
1411
|
} from "react";
|
|
1383
1412
|
function useConcertina() {
|
|
1384
|
-
const [value, setValue] =
|
|
1385
|
-
const [switching, setSwitching] =
|
|
1386
|
-
const itemRefs =
|
|
1413
|
+
const [value, setValue] = useState10("");
|
|
1414
|
+
const [switching, setSwitching] = useState10(false);
|
|
1415
|
+
const itemRefs = useRef9({});
|
|
1387
1416
|
const onValueChange = useCallback11(
|
|
1388
1417
|
(newValue) => {
|
|
1389
1418
|
if (!newValue) {
|
|
@@ -1400,7 +1429,7 @@ function useConcertina() {
|
|
|
1400
1429
|
if (!value) return;
|
|
1401
1430
|
pinToScrollTop(itemRefs.current[value]);
|
|
1402
1431
|
}, [value]);
|
|
1403
|
-
|
|
1432
|
+
useEffect8(() => {
|
|
1404
1433
|
if (switching) setSwitching(false);
|
|
1405
1434
|
}, [switching]);
|
|
1406
1435
|
const getItemRef = useCallback11(
|
|
@@ -1429,6 +1458,7 @@ export {
|
|
|
1429
1458
|
StableSlot,
|
|
1430
1459
|
Trigger2 as Trigger,
|
|
1431
1460
|
Warmup,
|
|
1461
|
+
WarmupLine,
|
|
1432
1462
|
pinToScrollTop,
|
|
1433
1463
|
useConcertina,
|
|
1434
1464
|
useExpanded,
|
|
@@ -1436,5 +1466,6 @@ export {
|
|
|
1436
1466
|
useScrollPin,
|
|
1437
1467
|
useSize,
|
|
1438
1468
|
useStableSlot,
|
|
1439
|
-
useTransitionLock
|
|
1469
|
+
useTransitionLock,
|
|
1470
|
+
useWarmupExit
|
|
1440
1471
|
};
|
package/dist/styles.css
CHANGED
|
@@ -87,6 +87,7 @@
|
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
.concertina-warmup-line {
|
|
90
|
+
height: 1lh;
|
|
90
91
|
border-radius: var(--concertina-warmup-line-radius, 0.125rem);
|
|
91
92
|
background: linear-gradient(
|
|
92
93
|
90deg,
|
|
@@ -98,21 +99,13 @@
|
|
|
98
99
|
animation: concertina-shimmer 1.5s ease-in-out infinite;
|
|
99
100
|
}
|
|
100
101
|
|
|
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
102
|
|
|
111
103
|
@keyframes concertina-shimmer {
|
|
112
104
|
0% { background-position: 200% 0; }
|
|
113
105
|
100% { background-position: -200% 0; }
|
|
114
106
|
}
|
|
115
107
|
|
|
108
|
+
|
|
116
109
|
/* Glide — enter/exit animation wrapper. */
|
|
117
110
|
.concertina-glide {
|
|
118
111
|
--concertina-glide-duration: 200ms;
|
|
@@ -136,6 +129,16 @@
|
|
|
136
129
|
to { opacity: 0; max-height: 0; overflow: hidden; }
|
|
137
130
|
}
|
|
138
131
|
|
|
132
|
+
/* Warmup exit — fade out shimmer lines before content mounts.
|
|
133
|
+
Applied by useWarmupExit() via className on the grid container. */
|
|
134
|
+
.concertina-warmup-exiting .concertina-warmup-line {
|
|
135
|
+
animation: concertina-warmup-exit var(--concertina-close-duration, 150ms) ease-out forwards;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
@keyframes concertina-warmup-exit {
|
|
139
|
+
to { opacity: 0; }
|
|
140
|
+
}
|
|
141
|
+
|
|
139
142
|
/* Respect reduced-motion preferences.
|
|
140
143
|
Disables all animations — accordion open/close, shimmer, and glide enter/exit.
|
|
141
144
|
Layout changes still happen instantly so functionality is preserved. */
|
|
@@ -144,7 +147,8 @@
|
|
|
144
147
|
.concertina-content[data-state="closed"],
|
|
145
148
|
.concertina-glide-entering,
|
|
146
149
|
.concertina-glide-exiting,
|
|
147
|
-
.concertina-warmup-line
|
|
150
|
+
.concertina-warmup-line,
|
|
151
|
+
.concertina-warmup-exiting .concertina-warmup-line {
|
|
148
152
|
animation-duration: 0s !important;
|
|
149
153
|
}
|
|
150
154
|
}
|