concertina 0.8.0 → 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 +61 -25
- package/dist/index.d.cts +35 -1
- package/dist/index.d.ts +35 -1
- package/dist/index.js +48 -14
- 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,8 +1308,8 @@ 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
1314
|
return /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(
|
|
1313
1315
|
Tag,
|
|
@@ -1322,15 +1324,25 @@ var Warmup = (0, import_react15.forwardRef)(
|
|
|
1322
1324
|
}
|
|
1323
1325
|
);
|
|
1324
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
|
+
|
|
1325
1337
|
// src/components/glide.tsx
|
|
1326
|
-
var
|
|
1338
|
+
var import_react18 = require("react");
|
|
1327
1339
|
|
|
1328
1340
|
// src/primitives/use-presence.ts
|
|
1329
|
-
var
|
|
1341
|
+
var import_react17 = require("react");
|
|
1330
1342
|
function usePresence2(show) {
|
|
1331
|
-
const [mounted, setMounted] = (0,
|
|
1332
|
-
const [phase, setPhase] = (0,
|
|
1333
|
-
(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)(() => {
|
|
1334
1346
|
if (show) {
|
|
1335
1347
|
setMounted(true);
|
|
1336
1348
|
setPhase("entering");
|
|
@@ -1338,7 +1350,7 @@ function usePresence2(show) {
|
|
|
1338
1350
|
setPhase("exiting");
|
|
1339
1351
|
}
|
|
1340
1352
|
}, [show]);
|
|
1341
|
-
const onAnimationEnd = (0,
|
|
1353
|
+
const onAnimationEnd = (0, import_react17.useCallback)(
|
|
1342
1354
|
(e) => {
|
|
1343
1355
|
if (e.target !== e.currentTarget) return;
|
|
1344
1356
|
if (phase === "entering") setPhase("entered");
|
|
@@ -1350,14 +1362,14 @@ function usePresence2(show) {
|
|
|
1350
1362
|
}
|
|
1351
1363
|
|
|
1352
1364
|
// src/components/glide.tsx
|
|
1353
|
-
var
|
|
1354
|
-
var Glide = (0,
|
|
1365
|
+
var import_jsx_runtime17 = require("react/jsx-runtime");
|
|
1366
|
+
var Glide = (0, import_react18.forwardRef)(
|
|
1355
1367
|
function Glide2({ show, as: Tag = "div", className, children, ...props }, ref) {
|
|
1356
1368
|
const { mounted, phase, onAnimationEnd } = usePresence2(show);
|
|
1357
1369
|
if (!mounted) return null;
|
|
1358
1370
|
const phaseClass = phase === "entering" ? "concertina-glide-entering" : phase === "exiting" ? "concertina-glide-exiting" : "";
|
|
1359
1371
|
const merged = ["concertina-glide", phaseClass, className].filter(Boolean).join(" ");
|
|
1360
|
-
return /* @__PURE__ */ (0,
|
|
1372
|
+
return /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(
|
|
1361
1373
|
Tag,
|
|
1362
1374
|
{
|
|
1363
1375
|
ref,
|
|
@@ -1371,11 +1383,11 @@ var Glide = (0, import_react17.forwardRef)(
|
|
|
1371
1383
|
);
|
|
1372
1384
|
|
|
1373
1385
|
// src/primitives/use-size.ts
|
|
1374
|
-
var
|
|
1386
|
+
var import_react19 = require("react");
|
|
1375
1387
|
function useSize() {
|
|
1376
|
-
const [size, setSize] = (0,
|
|
1377
|
-
const observerRef = (0,
|
|
1378
|
-
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) => {
|
|
1379
1391
|
if (observerRef.current) {
|
|
1380
1392
|
observerRef.current.disconnect();
|
|
1381
1393
|
observerRef.current = null;
|
|
@@ -1403,13 +1415,35 @@ function useSize() {
|
|
|
1403
1415
|
return { ref, size };
|
|
1404
1416
|
}
|
|
1405
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
|
+
|
|
1406
1440
|
// src/accordion/use-concertina.ts
|
|
1407
|
-
var
|
|
1441
|
+
var import_react21 = require("react");
|
|
1408
1442
|
function useConcertina() {
|
|
1409
|
-
const [value, setValue] = (0,
|
|
1410
|
-
const [switching, setSwitching] = (0,
|
|
1411
|
-
const itemRefs = (0,
|
|
1412
|
-
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)(
|
|
1413
1447
|
(newValue) => {
|
|
1414
1448
|
if (!newValue) {
|
|
1415
1449
|
setSwitching(false);
|
|
@@ -1421,14 +1455,14 @@ function useConcertina() {
|
|
|
1421
1455
|
},
|
|
1422
1456
|
[value]
|
|
1423
1457
|
);
|
|
1424
|
-
(0,
|
|
1458
|
+
(0, import_react21.useLayoutEffect)(() => {
|
|
1425
1459
|
if (!value) return;
|
|
1426
1460
|
pinToScrollTop(itemRefs.current[value]);
|
|
1427
1461
|
}, [value]);
|
|
1428
|
-
(0,
|
|
1462
|
+
(0, import_react21.useEffect)(() => {
|
|
1429
1463
|
if (switching) setSwitching(false);
|
|
1430
1464
|
}, [switching]);
|
|
1431
|
-
const getItemRef = (0,
|
|
1465
|
+
const getItemRef = (0, import_react21.useCallback)(
|
|
1432
1466
|
(id) => (el) => {
|
|
1433
1467
|
itemRefs.current[id] = el;
|
|
1434
1468
|
},
|
|
@@ -1455,6 +1489,7 @@ function useConcertina() {
|
|
|
1455
1489
|
StableSlot,
|
|
1456
1490
|
Trigger,
|
|
1457
1491
|
Warmup,
|
|
1492
|
+
WarmupLine,
|
|
1458
1493
|
pinToScrollTop,
|
|
1459
1494
|
useConcertina,
|
|
1460
1495
|
useExpanded,
|
|
@@ -1462,5 +1497,6 @@ function useConcertina() {
|
|
|
1462
1497
|
useScrollPin,
|
|
1463
1498
|
useSize,
|
|
1464
1499
|
useStableSlot,
|
|
1465
|
-
useTransitionLock
|
|
1500
|
+
useTransitionLock,
|
|
1501
|
+
useWarmupExit
|
|
1466
1502
|
});
|
package/dist/index.d.cts
CHANGED
|
@@ -110,6 +110,23 @@ interface WarmupProps extends HTMLAttributes<HTMLElement> {
|
|
|
110
110
|
*/
|
|
111
111
|
declare const Warmup: react.ForwardRefExoticComponent<WarmupProps & react.RefAttributes<HTMLElement>>;
|
|
112
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
|
+
|
|
113
130
|
interface GlideProps extends HTMLAttributes<HTMLElement> {
|
|
114
131
|
/** Whether the content is visible. */
|
|
115
132
|
show: boolean;
|
|
@@ -231,6 +248,23 @@ declare function useTransitionLock(): {
|
|
|
231
248
|
*/
|
|
232
249
|
declare function pinToScrollTop(el: HTMLElement | null): void;
|
|
233
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
|
+
|
|
234
268
|
interface ConcertinaRootProps {
|
|
235
269
|
value: string;
|
|
236
270
|
onValueChange: (value: string) => void;
|
|
@@ -260,4 +294,4 @@ interface UseConcertinaReturn {
|
|
|
260
294
|
*/
|
|
261
295
|
declare function useConcertina(): UseConcertinaReturn;
|
|
262
296
|
|
|
263
|
-
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
|
@@ -110,6 +110,23 @@ interface WarmupProps extends HTMLAttributes<HTMLElement> {
|
|
|
110
110
|
*/
|
|
111
111
|
declare const Warmup: react.ForwardRefExoticComponent<WarmupProps & react.RefAttributes<HTMLElement>>;
|
|
112
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
|
+
|
|
113
130
|
interface GlideProps extends HTMLAttributes<HTMLElement> {
|
|
114
131
|
/** Whether the content is visible. */
|
|
115
132
|
show: boolean;
|
|
@@ -231,6 +248,23 @@ declare function useTransitionLock(): {
|
|
|
231
248
|
*/
|
|
232
249
|
declare function pinToScrollTop(el: HTMLElement | null): void;
|
|
233
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
|
+
|
|
234
268
|
interface ConcertinaRootProps {
|
|
235
269
|
value: string;
|
|
236
270
|
onValueChange: (value: string) => void;
|
|
@@ -260,4 +294,4 @@ interface UseConcertinaReturn {
|
|
|
260
294
|
*/
|
|
261
295
|
declare function useConcertina(): UseConcertinaReturn;
|
|
262
296
|
|
|
263
|
-
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,8 +1266,8 @@ 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
1272
|
return /* @__PURE__ */ jsx14(
|
|
1273
1273
|
Tag,
|
|
@@ -1282,9 +1282,19 @@ var Warmup = forwardRef10(
|
|
|
1282
1282
|
}
|
|
1283
1283
|
);
|
|
1284
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
|
+
|
|
1285
1295
|
// src/components/glide.tsx
|
|
1286
1296
|
import {
|
|
1287
|
-
forwardRef as
|
|
1297
|
+
forwardRef as forwardRef12
|
|
1288
1298
|
} from "react";
|
|
1289
1299
|
|
|
1290
1300
|
// src/primitives/use-presence.ts
|
|
@@ -1316,14 +1326,14 @@ function usePresence2(show) {
|
|
|
1316
1326
|
}
|
|
1317
1327
|
|
|
1318
1328
|
// src/components/glide.tsx
|
|
1319
|
-
import { jsx as
|
|
1320
|
-
var Glide =
|
|
1329
|
+
import { jsx as jsx16 } from "react/jsx-runtime";
|
|
1330
|
+
var Glide = forwardRef12(
|
|
1321
1331
|
function Glide2({ show, as: Tag = "div", className, children, ...props }, ref) {
|
|
1322
1332
|
const { mounted, phase, onAnimationEnd } = usePresence2(show);
|
|
1323
1333
|
if (!mounted) return null;
|
|
1324
1334
|
const phaseClass = phase === "entering" ? "concertina-glide-entering" : phase === "exiting" ? "concertina-glide-exiting" : "";
|
|
1325
1335
|
const merged = ["concertina-glide", phaseClass, className].filter(Boolean).join(" ");
|
|
1326
|
-
return /* @__PURE__ */
|
|
1336
|
+
return /* @__PURE__ */ jsx16(
|
|
1327
1337
|
Tag,
|
|
1328
1338
|
{
|
|
1329
1339
|
ref,
|
|
@@ -1369,18 +1379,40 @@ function useSize() {
|
|
|
1369
1379
|
return { ref, size };
|
|
1370
1380
|
}
|
|
1371
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
|
+
|
|
1372
1404
|
// src/accordion/use-concertina.ts
|
|
1373
1405
|
import {
|
|
1374
|
-
useState as
|
|
1406
|
+
useState as useState10,
|
|
1375
1407
|
useCallback as useCallback11,
|
|
1376
|
-
useRef as
|
|
1408
|
+
useRef as useRef9,
|
|
1377
1409
|
useLayoutEffect as useLayoutEffect4,
|
|
1378
|
-
useEffect as
|
|
1410
|
+
useEffect as useEffect8
|
|
1379
1411
|
} from "react";
|
|
1380
1412
|
function useConcertina() {
|
|
1381
|
-
const [value, setValue] =
|
|
1382
|
-
const [switching, setSwitching] =
|
|
1383
|
-
const itemRefs =
|
|
1413
|
+
const [value, setValue] = useState10("");
|
|
1414
|
+
const [switching, setSwitching] = useState10(false);
|
|
1415
|
+
const itemRefs = useRef9({});
|
|
1384
1416
|
const onValueChange = useCallback11(
|
|
1385
1417
|
(newValue) => {
|
|
1386
1418
|
if (!newValue) {
|
|
@@ -1397,7 +1429,7 @@ function useConcertina() {
|
|
|
1397
1429
|
if (!value) return;
|
|
1398
1430
|
pinToScrollTop(itemRefs.current[value]);
|
|
1399
1431
|
}, [value]);
|
|
1400
|
-
|
|
1432
|
+
useEffect8(() => {
|
|
1401
1433
|
if (switching) setSwitching(false);
|
|
1402
1434
|
}, [switching]);
|
|
1403
1435
|
const getItemRef = useCallback11(
|
|
@@ -1426,6 +1458,7 @@ export {
|
|
|
1426
1458
|
StableSlot,
|
|
1427
1459
|
Trigger2 as Trigger,
|
|
1428
1460
|
Warmup,
|
|
1461
|
+
WarmupLine,
|
|
1429
1462
|
pinToScrollTop,
|
|
1430
1463
|
useConcertina,
|
|
1431
1464
|
useExpanded,
|
|
@@ -1433,5 +1466,6 @@ export {
|
|
|
1433
1466
|
useScrollPin,
|
|
1434
1467
|
useSize,
|
|
1435
1468
|
useStableSlot,
|
|
1436
|
-
useTransitionLock
|
|
1469
|
+
useTransitionLock,
|
|
1470
|
+
useWarmupExit
|
|
1437
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
|
}
|