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 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 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. Axis-aware collapse (`max-height: 0` or `max-width: 0`) so only the relevant axis contributes to sizing
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
- | `className` | `string` | | Passed to wrapper div |
53
+ | `as` | `ElementType` | `"div"` | HTML element to render. Use `"span"` inside buttons. |
54
+ | `className` | `string` | — | Passed to wrapper element |
54
55
 
55
- All other div attributes are forwarded.
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)("div", { ref, className: merged, ...props, children }) });
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(axis) {
1186
- const base = { visibility: "hidden", overflow: "hidden" };
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
- "div",
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/use-concertina.ts
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, import_react13.useState)("");
1268
- const [switching, setSwitching] = (0, import_react13.useState)(false);
1269
- const itemRefs = (0, import_react13.useRef)({});
1270
- const onValueChange = (0, import_react13.useCallback)(
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, import_react13.useLayoutEffect)(() => {
1362
+ (0, import_react16.useLayoutEffect)(() => {
1283
1363
  if (!value) return;
1284
1364
  pinToScrollTop(itemRefs.current[value]);
1285
1365
  }, [value]);
1286
- (0, import_react13.useEffect)(() => {
1366
+ (0, import_react16.useEffect)(() => {
1287
1367
  if (switching) setSwitching(false);
1288
1368
  }, [switching]);
1289
- const getItemRef = (0, import_react13.useCallback)(
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<HTMLDivElement> {
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<HTMLDivElement>>;
56
+ declare const StableSlot: react.ForwardRefExoticComponent<StableSlotProps & react.RefAttributes<HTMLElement>>;
55
57
 
56
- interface SlotProps extends HTMLAttributes<HTMLDivElement> {
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
- * Five things work together:
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<HTMLDivElement>>;
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<HTMLDivElement> {
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<HTMLDivElement>>;
56
+ declare const StableSlot: react.ForwardRefExoticComponent<StableSlotProps & react.RefAttributes<HTMLElement>>;
55
57
 
56
- interface SlotProps extends HTMLAttributes<HTMLDivElement> {
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
- * Five things work together:
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<HTMLDivElement>>;
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("div", { ref, className: merged, ...props, children }) });
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(axis) {
1153
- const base = { visibility: "hidden", overflow: "hidden" };
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
- "div",
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/use-concertina.ts
1225
+ // src/gigbag.tsx
1226
+ import { forwardRef as forwardRef9 } from "react";
1227
+ import { jsx as jsx13 } from "react/jsx-runtime";
1228
+ var Gigbag = forwardRef9(
1229
+ function Gigbag2({ axis = "height", as: Tag = "div", className, style, children, ...props }, fwdRef) {
1230
+ const { ref: ratchetRef, style: ratchetStyle } = useStableSlot({ axis });
1231
+ const merged = className ? `concertina-gigbag ${className}` : "concertina-gigbag";
1232
+ return /* @__PURE__ */ jsx13(
1233
+ Tag,
1234
+ {
1235
+ ref: (el) => {
1236
+ ratchetRef(el);
1237
+ if (typeof fwdRef === "function") fwdRef(el);
1238
+ else if (fwdRef) fwdRef.current = el;
1239
+ },
1240
+ className: merged,
1241
+ style: { ...ratchetStyle, ...style },
1242
+ ...props,
1243
+ children
1244
+ }
1245
+ );
1246
+ }
1247
+ );
1248
+
1249
+ // src/warmup.tsx
1250
+ import { forwardRef as forwardRef10 } from "react";
1251
+ import { jsx as jsx14 } 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
- useCallback as useCallback9,
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 useEffect6
1319
+ useEffect as useEffect7
1238
1320
  } from "react";
1239
1321
  function useConcertina() {
1240
- const [value, setValue] = useState7("");
1241
- const [switching, setSwitching] = useState7(false);
1322
+ const [value, setValue] = useState8("");
1323
+ const [switching, setSwitching] = useState8(false);
1242
1324
  const itemRefs = useRef7({});
1243
- const onValueChange = useCallback9(
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
- useEffect6(() => {
1341
+ useEffect7(() => {
1260
1342
  if (switching) setSwitching(false);
1261
1343
  }, [switching]);
1262
- const getItemRef = useCallback9(
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.5.2",
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
  },