concertina 0.5.2 → 0.7.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 +43 -6
- package/dist/index.cjs +103 -20
- package/dist/index.d.cts +65 -9
- package/dist/index.d.ts +65 -9
- package/dist/index.js +105 -20
- package/dist/styles.css +53 -1
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -39,10 +39,10 @@ A UI slot toggles between variants of different sizes (Add button ↔ quantity s
|
|
|
39
39
|
```
|
|
40
40
|
|
|
41
41
|
**How it works:**
|
|
42
|
-
1. `display: grid` on container, `grid-area: 1/1` on all
|
|
43
|
-
2. `visibility: hidden` on inactive
|
|
44
|
-
3. `inert` attribute on inactive
|
|
45
|
-
4.
|
|
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
46
|
5. Zero JS measurement — pure CSS, works on first frame
|
|
47
47
|
|
|
48
48
|
**StableSlot props:**
|
|
@@ -50,15 +50,52 @@ A UI slot toggles between variants of different sizes (Add button ↔ quantity s
|
|
|
50
50
|
| Prop | Type | Default | Description |
|
|
51
51
|
|------|------|---------|-------------|
|
|
52
52
|
| `axis` | `"width"` \| `"height"` \| `"both"` | `"both"` | Which axis to stabilize |
|
|
53
|
-
| `
|
|
53
|
+
| `as` | `ElementType` | `"div"` | HTML element to render. Use `"span"` inside buttons. |
|
|
54
|
+
| `className` | `string` | — | Passed to wrapper element |
|
|
54
55
|
|
|
55
|
-
All other
|
|
56
|
+
All other HTML attributes are forwarded.
|
|
56
57
|
|
|
57
58
|
**Slot props:**
|
|
58
59
|
|
|
59
60
|
| Prop | Type | Description |
|
|
60
61
|
|------|------|-------------|
|
|
61
62
|
| `active` | `boolean` | Controls visibility |
|
|
63
|
+
| `as` | `ElementType` | HTML element to render. Default `"div"`. |
|
|
64
|
+
|
|
65
|
+
#### Rules for correct behavior
|
|
66
|
+
|
|
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`:
|
|
69
|
+
|
|
70
|
+
```css
|
|
71
|
+
/* Bad — fixed column ignores StableSlot's intrinsic width */
|
|
72
|
+
grid-template-columns: 1fr 10rem;
|
|
73
|
+
|
|
74
|
+
/* Good — column auto-sizes to the StableSlot's widest child */
|
|
75
|
+
grid-template-columns: 1fr auto;
|
|
76
|
+
```
|
|
77
|
+
|
|
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:
|
|
80
|
+
|
|
81
|
+
```tsx
|
|
82
|
+
<div className="action-column">
|
|
83
|
+
{/* Main action — morphs between Deliver/Charge/Retry/paid */}
|
|
84
|
+
<Concertina.StableSlot axis="width">
|
|
85
|
+
<Concertina.Slot active={showDeliver}><Button>Deliver</Button></Concertina.Slot>
|
|
86
|
+
<Concertina.Slot active={showCharge}><Button>Charge</Button></Concertina.Slot>
|
|
87
|
+
<Concertina.Slot active={showRetry}><Button>Retry</Button></Concertina.Slot>
|
|
88
|
+
</Concertina.StableSlot>
|
|
89
|
+
{/* Undo — appears only in Charge state, but space is always reserved */}
|
|
90
|
+
<Concertina.StableSlot>
|
|
91
|
+
<Concertina.Slot active={showCharge}>
|
|
92
|
+
<button className="undo-link">Undo</button>
|
|
93
|
+
</Concertina.Slot>
|
|
94
|
+
</Concertina.StableSlot>
|
|
95
|
+
</div>
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
A single Slot inside a StableSlot is valid — it simply reserves the element's space, showing or hiding it without layout shift.
|
|
62
99
|
|
|
63
100
|
### useStableSlot — ResizeObserver ratchet for dynamic content
|
|
64
101
|
|
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,
|
|
@@ -1173,30 +1176,24 @@ var import_react10 = require("react");
|
|
|
1173
1176
|
var import_jsx_runtime12 = require("react/jsx-runtime");
|
|
1174
1177
|
var AxisContext = (0, import_react10.createContext)("both");
|
|
1175
1178
|
var StableSlot = (0, import_react10.forwardRef)(
|
|
1176
|
-
function StableSlot2({ axis = "both", className, children, ...props }, ref) {
|
|
1179
|
+
function StableSlot2({ axis = "both", as: Tag = "div", className, style, children, ...props }, ref) {
|
|
1177
1180
|
const merged = className ? `concertina-stable-slot ${className}` : "concertina-stable-slot";
|
|
1178
|
-
return /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(AxisContext.Provider, { value: axis, children: /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(
|
|
1181
|
+
return /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(AxisContext.Provider, { value: axis, children: /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(Tag, { ref, className: merged, style, ...props, children }) });
|
|
1179
1182
|
}
|
|
1180
1183
|
);
|
|
1181
1184
|
|
|
1182
1185
|
// src/slot.tsx
|
|
1183
1186
|
var import_react11 = require("react");
|
|
1184
1187
|
var import_jsx_runtime13 = require("react/jsx-runtime");
|
|
1185
|
-
function inactiveStyle(
|
|
1186
|
-
|
|
1187
|
-
if (axis === "width") {
|
|
1188
|
-
base.maxHeight = 0;
|
|
1189
|
-
} else if (axis === "height") {
|
|
1190
|
-
base.maxWidth = 0;
|
|
1191
|
-
}
|
|
1192
|
-
return base;
|
|
1188
|
+
function inactiveStyle(_axis) {
|
|
1189
|
+
return { visibility: "hidden" };
|
|
1193
1190
|
}
|
|
1194
1191
|
var Slot = (0, import_react11.forwardRef)(
|
|
1195
|
-
function Slot2({ active, style, children, ...props }, ref) {
|
|
1192
|
+
function Slot2({ active, as: Tag = "div", style, children, ...props }, ref) {
|
|
1196
1193
|
const axis = (0, import_react11.useContext)(AxisContext);
|
|
1197
1194
|
const merged = active ? { ...style } : { ...inactiveStyle(axis), ...style };
|
|
1198
1195
|
return /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(
|
|
1199
|
-
|
|
1196
|
+
Tag,
|
|
1200
1197
|
{
|
|
1201
1198
|
ref,
|
|
1202
1199
|
inert: !active || void 0,
|
|
@@ -1261,13 +1258,96 @@ function useStableSlot(options = {}) {
|
|
|
1261
1258
|
return { ref, style };
|
|
1262
1259
|
}
|
|
1263
1260
|
|
|
1264
|
-
// src/
|
|
1261
|
+
// src/gigbag.tsx
|
|
1265
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.jsx)("div", { className: "concertina-warmup-bone" }, i));
|
|
1292
|
+
return /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(
|
|
1293
|
+
Tag,
|
|
1294
|
+
{
|
|
1295
|
+
ref,
|
|
1296
|
+
className: merged,
|
|
1297
|
+
style: { gridTemplateColumns: `repeat(${columns}, 1fr)` },
|
|
1298
|
+
...props,
|
|
1299
|
+
children: cells
|
|
1300
|
+
}
|
|
1301
|
+
);
|
|
1302
|
+
}
|
|
1303
|
+
);
|
|
1304
|
+
|
|
1305
|
+
// src/glide.tsx
|
|
1306
|
+
var import_react15 = require("react");
|
|
1307
|
+
var import_jsx_runtime16 = require("react/jsx-runtime");
|
|
1308
|
+
var Glide = (0, import_react15.forwardRef)(
|
|
1309
|
+
function Glide2({ show, as: Tag = "div", className, children, ...props }, ref) {
|
|
1310
|
+
const [mounted, setMounted] = (0, import_react15.useState)(show);
|
|
1311
|
+
const [phase, setPhase] = (0, import_react15.useState)(show ? "entered" : "exiting");
|
|
1312
|
+
(0, import_react15.useEffect)(() => {
|
|
1313
|
+
if (show) {
|
|
1314
|
+
setMounted(true);
|
|
1315
|
+
setPhase("entering");
|
|
1316
|
+
} else if (mounted) {
|
|
1317
|
+
setPhase("exiting");
|
|
1318
|
+
}
|
|
1319
|
+
}, [show]);
|
|
1320
|
+
const onAnimationEnd = (0, import_react15.useCallback)(
|
|
1321
|
+
(e) => {
|
|
1322
|
+
if (e.target !== e.currentTarget) return;
|
|
1323
|
+
if (phase === "entering") setPhase("entered");
|
|
1324
|
+
if (phase === "exiting") setMounted(false);
|
|
1325
|
+
},
|
|
1326
|
+
[phase]
|
|
1327
|
+
);
|
|
1328
|
+
if (!mounted) return null;
|
|
1329
|
+
const phaseClass = phase === "entering" ? "concertina-glide-entering" : phase === "exiting" ? "concertina-glide-exiting" : "";
|
|
1330
|
+
const merged = ["concertina-glide", phaseClass, className].filter(Boolean).join(" ");
|
|
1331
|
+
return /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
|
|
1332
|
+
Tag,
|
|
1333
|
+
{
|
|
1334
|
+
ref,
|
|
1335
|
+
className: merged,
|
|
1336
|
+
onAnimationEnd,
|
|
1337
|
+
...props,
|
|
1338
|
+
children
|
|
1339
|
+
}
|
|
1340
|
+
);
|
|
1341
|
+
}
|
|
1342
|
+
);
|
|
1343
|
+
|
|
1344
|
+
// src/use-concertina.ts
|
|
1345
|
+
var import_react16 = require("react");
|
|
1266
1346
|
function useConcertina() {
|
|
1267
|
-
const [value, setValue] = (0,
|
|
1268
|
-
const [switching, setSwitching] = (0,
|
|
1269
|
-
const itemRefs = (0,
|
|
1270
|
-
const onValueChange = (0,
|
|
1347
|
+
const [value, setValue] = (0, import_react16.useState)("");
|
|
1348
|
+
const [switching, setSwitching] = (0, import_react16.useState)(false);
|
|
1349
|
+
const itemRefs = (0, import_react16.useRef)({});
|
|
1350
|
+
const onValueChange = (0, import_react16.useCallback)(
|
|
1271
1351
|
(newValue) => {
|
|
1272
1352
|
if (!newValue) {
|
|
1273
1353
|
setSwitching(false);
|
|
@@ -1279,14 +1359,14 @@ function useConcertina() {
|
|
|
1279
1359
|
},
|
|
1280
1360
|
[value]
|
|
1281
1361
|
);
|
|
1282
|
-
(0,
|
|
1362
|
+
(0, import_react16.useLayoutEffect)(() => {
|
|
1283
1363
|
if (!value) return;
|
|
1284
1364
|
pinToScrollTop(itemRefs.current[value]);
|
|
1285
1365
|
}, [value]);
|
|
1286
|
-
(0,
|
|
1366
|
+
(0, import_react16.useEffect)(() => {
|
|
1287
1367
|
if (switching) setSwitching(false);
|
|
1288
1368
|
}, [switching]);
|
|
1289
|
-
const getItemRef = (0,
|
|
1369
|
+
const getItemRef = (0, import_react16.useCallback)(
|
|
1290
1370
|
(id) => (el) => {
|
|
1291
1371
|
itemRefs.current[id] = el;
|
|
1292
1372
|
},
|
|
@@ -1304,12 +1384,15 @@ function useConcertina() {
|
|
|
1304
1384
|
ConcertinaContext,
|
|
1305
1385
|
ConcertinaStore,
|
|
1306
1386
|
Content,
|
|
1387
|
+
Gigbag,
|
|
1388
|
+
Glide,
|
|
1307
1389
|
Header,
|
|
1308
1390
|
Item,
|
|
1309
1391
|
Root,
|
|
1310
1392
|
Slot,
|
|
1311
1393
|
StableSlot,
|
|
1312
1394
|
Trigger,
|
|
1395
|
+
Warmup,
|
|
1313
1396
|
pinToScrollTop,
|
|
1314
1397
|
useConcertina,
|
|
1315
1398
|
useExpanded,
|
package/dist/index.d.cts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as react from 'react';
|
|
2
|
-
import { HTMLAttributes, CSSProperties } from 'react';
|
|
2
|
+
import { HTMLAttributes, ElementType, CSSProperties } from 'react';
|
|
3
3
|
import * as Accordion from '@radix-ui/react-accordion';
|
|
4
4
|
export { Header, Trigger } from '@radix-ui/react-accordion';
|
|
5
5
|
|
|
@@ -40,9 +40,11 @@ declare class ConcertinaStore {
|
|
|
40
40
|
declare const ConcertinaContext: react.Context<ConcertinaStore | null>;
|
|
41
41
|
|
|
42
42
|
type Axis = "width" | "height" | "both";
|
|
43
|
-
interface StableSlotProps extends HTMLAttributes<
|
|
43
|
+
interface StableSlotProps extends HTMLAttributes<HTMLElement> {
|
|
44
44
|
/** Which axis to stabilize. Default: "both". */
|
|
45
45
|
axis?: Axis;
|
|
46
|
+
/** HTML element to render. Use "span" inside buttons. Default: "div". */
|
|
47
|
+
as?: ElementType;
|
|
46
48
|
}
|
|
47
49
|
/**
|
|
48
50
|
* Grid container that auto-sizes to the largest child.
|
|
@@ -51,25 +53,25 @@ interface StableSlotProps extends HTMLAttributes<HTMLDivElement> {
|
|
|
51
53
|
*
|
|
52
54
|
* Zero JS measurement — pure CSS grid sizing.
|
|
53
55
|
*/
|
|
54
|
-
declare const StableSlot: react.ForwardRefExoticComponent<StableSlotProps & react.RefAttributes<
|
|
56
|
+
declare const StableSlot: react.ForwardRefExoticComponent<StableSlotProps & react.RefAttributes<HTMLElement>>;
|
|
55
57
|
|
|
56
|
-
interface SlotProps extends HTMLAttributes<
|
|
58
|
+
interface SlotProps extends HTMLAttributes<HTMLElement> {
|
|
57
59
|
/** Whether this slot is the active (visible) variant. */
|
|
58
60
|
active: boolean;
|
|
61
|
+
/** HTML element to render. Use "span" inside buttons. Default: "div". */
|
|
62
|
+
as?: ElementType;
|
|
59
63
|
}
|
|
60
64
|
/**
|
|
61
65
|
* A single variant inside a <StableSlot>.
|
|
62
66
|
* All slots overlap via CSS grid. Inactive slots are hidden
|
|
63
67
|
* but still contribute to grid cell sizing.
|
|
64
68
|
*
|
|
65
|
-
*
|
|
69
|
+
* Three things work together:
|
|
66
70
|
* 1. grid-area: 1/1 — all slots overlap in the same cell
|
|
67
71
|
* 2. visibility: hidden — invisible but in layout flow
|
|
68
72
|
* 3. inert — no focus, no clicks, no screen reader
|
|
69
|
-
* 4. max-height/max-width: 0 — axis-aware collapse
|
|
70
|
-
* 5. overflow: hidden — prevents content bleed from collapsed axis
|
|
71
73
|
*/
|
|
72
|
-
declare const Slot: react.ForwardRefExoticComponent<SlotProps & react.RefAttributes<
|
|
74
|
+
declare const Slot: react.ForwardRefExoticComponent<SlotProps & react.RefAttributes<HTMLElement>>;
|
|
73
75
|
|
|
74
76
|
interface UseStableSlotOptions {
|
|
75
77
|
/** Which axis to ratchet. Default: "both". */
|
|
@@ -113,6 +115,60 @@ declare function useTransitionLock(): {
|
|
|
113
115
|
readonly lock: () => void;
|
|
114
116
|
};
|
|
115
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
|
+
|
|
116
172
|
/**
|
|
117
173
|
* Scroll `el` to the top of its nearest scrollable ancestor,
|
|
118
174
|
* clearing any sticky headers. Only adjusts one container's
|
|
@@ -155,4 +211,4 @@ interface UseConcertinaReturn {
|
|
|
155
211
|
*/
|
|
156
212
|
declare function useConcertina(): UseConcertinaReturn;
|
|
157
213
|
|
|
158
|
-
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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as react from 'react';
|
|
2
|
-
import { HTMLAttributes, CSSProperties } from 'react';
|
|
2
|
+
import { HTMLAttributes, ElementType, CSSProperties } from 'react';
|
|
3
3
|
import * as Accordion from '@radix-ui/react-accordion';
|
|
4
4
|
export { Header, Trigger } from '@radix-ui/react-accordion';
|
|
5
5
|
|
|
@@ -40,9 +40,11 @@ declare class ConcertinaStore {
|
|
|
40
40
|
declare const ConcertinaContext: react.Context<ConcertinaStore | null>;
|
|
41
41
|
|
|
42
42
|
type Axis = "width" | "height" | "both";
|
|
43
|
-
interface StableSlotProps extends HTMLAttributes<
|
|
43
|
+
interface StableSlotProps extends HTMLAttributes<HTMLElement> {
|
|
44
44
|
/** Which axis to stabilize. Default: "both". */
|
|
45
45
|
axis?: Axis;
|
|
46
|
+
/** HTML element to render. Use "span" inside buttons. Default: "div". */
|
|
47
|
+
as?: ElementType;
|
|
46
48
|
}
|
|
47
49
|
/**
|
|
48
50
|
* Grid container that auto-sizes to the largest child.
|
|
@@ -51,25 +53,25 @@ interface StableSlotProps extends HTMLAttributes<HTMLDivElement> {
|
|
|
51
53
|
*
|
|
52
54
|
* Zero JS measurement — pure CSS grid sizing.
|
|
53
55
|
*/
|
|
54
|
-
declare const StableSlot: react.ForwardRefExoticComponent<StableSlotProps & react.RefAttributes<
|
|
56
|
+
declare const StableSlot: react.ForwardRefExoticComponent<StableSlotProps & react.RefAttributes<HTMLElement>>;
|
|
55
57
|
|
|
56
|
-
interface SlotProps extends HTMLAttributes<
|
|
58
|
+
interface SlotProps extends HTMLAttributes<HTMLElement> {
|
|
57
59
|
/** Whether this slot is the active (visible) variant. */
|
|
58
60
|
active: boolean;
|
|
61
|
+
/** HTML element to render. Use "span" inside buttons. Default: "div". */
|
|
62
|
+
as?: ElementType;
|
|
59
63
|
}
|
|
60
64
|
/**
|
|
61
65
|
* A single variant inside a <StableSlot>.
|
|
62
66
|
* All slots overlap via CSS grid. Inactive slots are hidden
|
|
63
67
|
* but still contribute to grid cell sizing.
|
|
64
68
|
*
|
|
65
|
-
*
|
|
69
|
+
* Three things work together:
|
|
66
70
|
* 1. grid-area: 1/1 — all slots overlap in the same cell
|
|
67
71
|
* 2. visibility: hidden — invisible but in layout flow
|
|
68
72
|
* 3. inert — no focus, no clicks, no screen reader
|
|
69
|
-
* 4. max-height/max-width: 0 — axis-aware collapse
|
|
70
|
-
* 5. overflow: hidden — prevents content bleed from collapsed axis
|
|
71
73
|
*/
|
|
72
|
-
declare const Slot: react.ForwardRefExoticComponent<SlotProps & react.RefAttributes<
|
|
74
|
+
declare const Slot: react.ForwardRefExoticComponent<SlotProps & react.RefAttributes<HTMLElement>>;
|
|
73
75
|
|
|
74
76
|
interface UseStableSlotOptions {
|
|
75
77
|
/** Which axis to ratchet. Default: "both". */
|
|
@@ -113,6 +115,60 @@ declare function useTransitionLock(): {
|
|
|
113
115
|
readonly lock: () => void;
|
|
114
116
|
};
|
|
115
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
|
+
|
|
116
172
|
/**
|
|
117
173
|
* Scroll `el` to the top of its nearest scrollable ancestor,
|
|
118
174
|
* clearing any sticky headers. Only adjusts one container's
|
|
@@ -155,4 +211,4 @@ interface UseConcertinaReturn {
|
|
|
155
211
|
*/
|
|
156
212
|
declare function useConcertina(): UseConcertinaReturn;
|
|
157
213
|
|
|
158
|
-
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
|
@@ -1137,9 +1137,9 @@ import {
|
|
|
1137
1137
|
import { jsx as jsx11 } from "react/jsx-runtime";
|
|
1138
1138
|
var AxisContext = createContext4("both");
|
|
1139
1139
|
var StableSlot = forwardRef7(
|
|
1140
|
-
function StableSlot2({ axis = "both", className, children, ...props }, ref) {
|
|
1140
|
+
function StableSlot2({ axis = "both", as: Tag = "div", className, style, children, ...props }, ref) {
|
|
1141
1141
|
const merged = className ? `concertina-stable-slot ${className}` : "concertina-stable-slot";
|
|
1142
|
-
return /* @__PURE__ */ jsx11(AxisContext.Provider, { value: axis, children: /* @__PURE__ */ jsx11(
|
|
1142
|
+
return /* @__PURE__ */ jsx11(AxisContext.Provider, { value: axis, children: /* @__PURE__ */ jsx11(Tag, { ref, className: merged, style, ...props, children }) });
|
|
1143
1143
|
}
|
|
1144
1144
|
);
|
|
1145
1145
|
|
|
@@ -1149,21 +1149,15 @@ import {
|
|
|
1149
1149
|
useContext as useContext5
|
|
1150
1150
|
} from "react";
|
|
1151
1151
|
import { jsx as jsx12 } from "react/jsx-runtime";
|
|
1152
|
-
function inactiveStyle(
|
|
1153
|
-
|
|
1154
|
-
if (axis === "width") {
|
|
1155
|
-
base.maxHeight = 0;
|
|
1156
|
-
} else if (axis === "height") {
|
|
1157
|
-
base.maxWidth = 0;
|
|
1158
|
-
}
|
|
1159
|
-
return base;
|
|
1152
|
+
function inactiveStyle(_axis) {
|
|
1153
|
+
return { visibility: "hidden" };
|
|
1160
1154
|
}
|
|
1161
1155
|
var Slot = forwardRef8(
|
|
1162
|
-
function Slot2({ active, style, children, ...props }, ref) {
|
|
1156
|
+
function Slot2({ active, as: Tag = "div", style, children, ...props }, ref) {
|
|
1163
1157
|
const axis = useContext5(AxisContext);
|
|
1164
1158
|
const merged = active ? { ...style } : { ...inactiveStyle(axis), ...style };
|
|
1165
1159
|
return /* @__PURE__ */ jsx12(
|
|
1166
|
-
|
|
1160
|
+
Tag,
|
|
1167
1161
|
{
|
|
1168
1162
|
ref,
|
|
1169
1163
|
inert: !active || void 0,
|
|
@@ -1228,19 +1222,107 @@ function useStableSlot(options = {}) {
|
|
|
1228
1222
|
return { ref, style };
|
|
1229
1223
|
}
|
|
1230
1224
|
|
|
1231
|
-
// src/
|
|
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 } 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__ */ jsx14("div", { className: "concertina-warmup-bone" }, i));
|
|
1256
|
+
return /* @__PURE__ */ jsx14(
|
|
1257
|
+
Tag,
|
|
1258
|
+
{
|
|
1259
|
+
ref,
|
|
1260
|
+
className: merged,
|
|
1261
|
+
style: { gridTemplateColumns: `repeat(${columns}, 1fr)` },
|
|
1262
|
+
...props,
|
|
1263
|
+
children: cells
|
|
1264
|
+
}
|
|
1265
|
+
);
|
|
1266
|
+
}
|
|
1267
|
+
);
|
|
1268
|
+
|
|
1269
|
+
// src/glide.tsx
|
|
1232
1270
|
import {
|
|
1271
|
+
forwardRef as forwardRef11,
|
|
1233
1272
|
useState as useState7,
|
|
1234
|
-
|
|
1273
|
+
useEffect as useEffect6,
|
|
1274
|
+
useCallback as useCallback9
|
|
1275
|
+
} from "react";
|
|
1276
|
+
import { jsx as jsx15 } from "react/jsx-runtime";
|
|
1277
|
+
var Glide = forwardRef11(
|
|
1278
|
+
function Glide2({ show, as: Tag = "div", className, children, ...props }, ref) {
|
|
1279
|
+
const [mounted, setMounted] = useState7(show);
|
|
1280
|
+
const [phase, setPhase] = useState7(show ? "entered" : "exiting");
|
|
1281
|
+
useEffect6(() => {
|
|
1282
|
+
if (show) {
|
|
1283
|
+
setMounted(true);
|
|
1284
|
+
setPhase("entering");
|
|
1285
|
+
} else if (mounted) {
|
|
1286
|
+
setPhase("exiting");
|
|
1287
|
+
}
|
|
1288
|
+
}, [show]);
|
|
1289
|
+
const onAnimationEnd = useCallback9(
|
|
1290
|
+
(e) => {
|
|
1291
|
+
if (e.target !== e.currentTarget) return;
|
|
1292
|
+
if (phase === "entering") setPhase("entered");
|
|
1293
|
+
if (phase === "exiting") setMounted(false);
|
|
1294
|
+
},
|
|
1295
|
+
[phase]
|
|
1296
|
+
);
|
|
1297
|
+
if (!mounted) return null;
|
|
1298
|
+
const phaseClass = phase === "entering" ? "concertina-glide-entering" : phase === "exiting" ? "concertina-glide-exiting" : "";
|
|
1299
|
+
const merged = ["concertina-glide", phaseClass, className].filter(Boolean).join(" ");
|
|
1300
|
+
return /* @__PURE__ */ jsx15(
|
|
1301
|
+
Tag,
|
|
1302
|
+
{
|
|
1303
|
+
ref,
|
|
1304
|
+
className: merged,
|
|
1305
|
+
onAnimationEnd,
|
|
1306
|
+
...props,
|
|
1307
|
+
children
|
|
1308
|
+
}
|
|
1309
|
+
);
|
|
1310
|
+
}
|
|
1311
|
+
);
|
|
1312
|
+
|
|
1313
|
+
// src/use-concertina.ts
|
|
1314
|
+
import {
|
|
1315
|
+
useState as useState8,
|
|
1316
|
+
useCallback as useCallback10,
|
|
1235
1317
|
useRef as useRef7,
|
|
1236
1318
|
useLayoutEffect as useLayoutEffect4,
|
|
1237
|
-
useEffect as
|
|
1319
|
+
useEffect as useEffect7
|
|
1238
1320
|
} from "react";
|
|
1239
1321
|
function useConcertina() {
|
|
1240
|
-
const [value, setValue] =
|
|
1241
|
-
const [switching, setSwitching] =
|
|
1322
|
+
const [value, setValue] = useState8("");
|
|
1323
|
+
const [switching, setSwitching] = useState8(false);
|
|
1242
1324
|
const itemRefs = useRef7({});
|
|
1243
|
-
const onValueChange =
|
|
1325
|
+
const onValueChange = useCallback10(
|
|
1244
1326
|
(newValue) => {
|
|
1245
1327
|
if (!newValue) {
|
|
1246
1328
|
setSwitching(false);
|
|
@@ -1256,10 +1338,10 @@ function useConcertina() {
|
|
|
1256
1338
|
if (!value) return;
|
|
1257
1339
|
pinToScrollTop(itemRefs.current[value]);
|
|
1258
1340
|
}, [value]);
|
|
1259
|
-
|
|
1341
|
+
useEffect7(() => {
|
|
1260
1342
|
if (switching) setSwitching(false);
|
|
1261
1343
|
}, [switching]);
|
|
1262
|
-
const getItemRef =
|
|
1344
|
+
const getItemRef = useCallback10(
|
|
1263
1345
|
(id) => (el) => {
|
|
1264
1346
|
itemRefs.current[id] = el;
|
|
1265
1347
|
},
|
|
@@ -1276,12 +1358,15 @@ export {
|
|
|
1276
1358
|
ConcertinaContext,
|
|
1277
1359
|
ConcertinaStore,
|
|
1278
1360
|
Content3 as Content,
|
|
1361
|
+
Gigbag,
|
|
1362
|
+
Glide,
|
|
1279
1363
|
Header,
|
|
1280
1364
|
Item2 as Item,
|
|
1281
1365
|
Root3 as Root,
|
|
1282
1366
|
Slot,
|
|
1283
1367
|
StableSlot,
|
|
1284
1368
|
Trigger2 as Trigger,
|
|
1369
|
+
Warmup,
|
|
1285
1370
|
pinToScrollTop,
|
|
1286
1371
|
useConcertina,
|
|
1287
1372
|
useExpanded,
|
package/dist/styles.css
CHANGED
|
@@ -54,10 +54,62 @@
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
/* StableSlot — all children overlap in the same grid cell.
|
|
57
|
-
Grid auto-sizes to the largest child.
|
|
57
|
+
Grid auto-sizes to the largest child.
|
|
58
|
+
Slots use flex-column so their content stretches to fill
|
|
59
|
+
the reserved width — the visual footprint is constant. */
|
|
58
60
|
.concertina-stable-slot {
|
|
59
61
|
display: grid;
|
|
60
62
|
}
|
|
61
63
|
.concertina-stable-slot > * {
|
|
62
64
|
grid-area: 1 / 1;
|
|
65
|
+
display: flex;
|
|
66
|
+
flex-direction: column;
|
|
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
|
+
height: var(--concertina-warmup-bone-height, 1rem);
|
|
84
|
+
border-radius: var(--concertina-warmup-bone-radius, 0.25rem);
|
|
85
|
+
background: var(--concertina-warmup-bone-color, #e5e7eb);
|
|
86
|
+
animation: concertina-shimmer 1.5s ease-in-out infinite;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
@keyframes concertina-shimmer {
|
|
90
|
+
0%, 100% { opacity: 1; }
|
|
91
|
+
50% { opacity: 0.4; }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* Glide — enter/exit animation wrapper. */
|
|
95
|
+
.concertina-glide {
|
|
96
|
+
--concertina-glide-duration: 200ms;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.concertina-glide-entering {
|
|
100
|
+
animation: concertina-glide-in var(--concertina-glide-duration) ease-out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.concertina-glide-exiting {
|
|
104
|
+
animation: concertina-glide-out var(--concertina-glide-duration) ease-out forwards;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
@keyframes concertina-glide-in {
|
|
108
|
+
from { opacity: 0; max-height: 0; overflow: hidden; }
|
|
109
|
+
to { opacity: 1; max-height: var(--concertina-glide-height, 1000px); overflow: hidden; }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
@keyframes concertina-glide-out {
|
|
113
|
+
from { opacity: 1; max-height: var(--concertina-glide-height, 1000px); overflow: hidden; }
|
|
114
|
+
to { opacity: 0; max-height: 0; overflow: hidden; }
|
|
63
115
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "concertina",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "React toolkit for layout stability.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
],
|
|
28
28
|
"scripts": {
|
|
29
29
|
"build": "tsup && cp src/styles.css dist/styles.css",
|
|
30
|
+
"prepare": "npm run build",
|
|
30
31
|
"typecheck": "tsc --noEmit",
|
|
31
32
|
"prepublishOnly": "npm run build"
|
|
32
33
|
},
|