concertina 0.11.0 → 0.12.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
@@ -188,14 +188,39 @@ import { Hum } from "concertina";
188
188
 
189
189
  The `className` is passed through to the shimmer so `1lh` inherits the correct font metrics. The shimmer is exactly as tall as the text it replaces because `1lh` resolves to the element's computed line-height. Not font-size, not a token, not a guess.
190
190
 
191
+ #### Vamp: ambient loading for entire subtrees
192
+
193
+ When many Hum instances share the same loading state (e.g. every cell in a table), threading `loading` to each one is boilerplate. Wrap the subtree in `<Vamp>` and every nested `<Hum>` picks it up automatically.
194
+
195
+ ```tsx
196
+ import { Vamp, Hum } from "concertina";
197
+
198
+ <Vamp loading={isLoading}>
199
+ <h2><Hum className="text-xl font-bold">{user?.name}</Hum></h2>
200
+ <p><Hum className="text-sm text-stone">{user?.email}</Hum></p>
201
+ <p><Hum className="text-sm">{user?.bio}</Hum></p>
202
+ </Vamp>
203
+ ```
204
+
205
+ No `loading` prop on any Hum. They all read from Vamp. An explicit `loading` prop on any individual Hum still overrides context.
206
+
207
+ Named after musical **vamping** — repeating a pattern while waiting for a cue.
208
+
191
209
  #### Hum props
192
210
 
193
211
  | Prop | Type | Default | Description |
194
212
  |------|------|---------|-------------|
195
- | `loading` | `boolean` | | Show shimmer (true) or children (false) |
213
+ | `loading` | `boolean` | Vamp context | Show shimmer (true) or children (false). Falls back to nearest `<Vamp>` when omitted. |
196
214
  | `as` | `ElementType` | `"span"` | HTML element to render |
197
215
  | `className` | `string` | | Applied to both shimmer and content states |
198
216
 
217
+ #### Vamp props
218
+
219
+ | Prop | Type | Description |
220
+ |------|------|-------------|
221
+ | `loading` | `boolean` | Whether the subtree is in a loading/warmup state |
222
+ | `children` | `ReactNode` | Content to wrap |
223
+
199
224
  > `StableText` is an alias for `Hum`.
200
225
 
201
226
  ---
@@ -382,6 +407,7 @@ Scrolls an element to the top of its nearest scrollable ancestor. Only touches `
382
407
  |---------|------|
383
408
  | Two variants swap in one slot | Bellows + Slot |
384
409
  | Line of text loading from API | Hum |
410
+ | Many Hum instances share one loading state | Vamp + Hum |
385
411
  | List loading from API | Ensemble |
386
412
  | Spinner replaced by loaded content | Gigbag + Warmup |
387
413
  | Accordion/table shimmer rows | Stub data + WarmupLine (wrapper-once pattern) |
@@ -1229,12 +1229,13 @@ var styles_default = `/* concertina \u2014 Radix Accordion expand/collapse with
1229
1229
  }
1230
1230
 
1231
1231
  /* Inactive Slot hiding \u2014 belt and suspenders.
1232
- Inline style sets visibility: hidden + opacity: 0 on the Slot div.
1233
- CSS backup catches edge cases where inline styles are overridden.
1232
+ Primary: inline style on the Slot element (visibility: hidden + opacity: 0).
1233
+ Inline styles can't be overridden by any CSS cascade \u2014 this is the
1234
+ bulletproof layer.
1235
+ Backup: CSS rules below catch edge cases (e.g. if inline styles are
1236
+ stripped by a framework or test harness).
1234
1237
  transition: none on descendants prevents children with transition-all
1235
- from animating the inherited visibility change (CSS visibility is
1236
- inherited; transition-all transitions it over the child's duration
1237
- instead of hiding instantly). */
1238
+ from animating the inherited visibility change. */
1238
1239
  .concertina-stable-slot > [inert] {
1239
1240
  visibility: hidden;
1240
1241
  opacity: 0;
package/dist/accordion.js CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  Trigger2,
9
9
  useConcertina,
10
10
  useExpanded
11
- } from "./chunk-4DDADLSW.js";
11
+ } from "./chunk-OGJMPKZX.js";
12
12
  export {
13
13
  ConcertinaContext,
14
14
  ConcertinaStore,
@@ -1190,12 +1190,13 @@ var styles_default = `/* concertina \u2014 Radix Accordion expand/collapse with
1190
1190
  }
1191
1191
 
1192
1192
  /* Inactive Slot hiding \u2014 belt and suspenders.
1193
- Inline style sets visibility: hidden + opacity: 0 on the Slot div.
1194
- CSS backup catches edge cases where inline styles are overridden.
1193
+ Primary: inline style on the Slot element (visibility: hidden + opacity: 0).
1194
+ Inline styles can't be overridden by any CSS cascade \u2014 this is the
1195
+ bulletproof layer.
1196
+ Backup: CSS rules below catch edge cases (e.g. if inline styles are
1197
+ stripped by a framework or test harness).
1195
1198
  transition: none on descendants prevents children with transition-all
1196
- from animating the inherited visibility change (CSS visibility is
1197
- inherited; transition-all transitions it over the child's duration
1198
- instead of hiding instantly). */
1199
+ from animating the inherited visibility change. */
1199
1200
  .concertina-stable-slot > [inert] {
1200
1201
  visibility: hidden;
1201
1202
  opacity: 0;
package/dist/index.cjs CHANGED
@@ -46,6 +46,8 @@ __export(index_exports, {
46
46
  StableSlot: () => Bellows,
47
47
  StableText: () => Hum,
48
48
  Trigger: () => Trigger2,
49
+ Vamp: () => Vamp,
50
+ VampContext: () => VampContext,
49
51
  Warmup: () => Warmup,
50
52
  WarmupLine: () => WarmupLine,
51
53
  pinToScrollTop: () => pinToScrollTop,
@@ -56,6 +58,7 @@ __export(index_exports, {
56
58
  useSize: () => useSize,
57
59
  useStableSlot: () => useStableSlot,
58
60
  useTransitionLock: () => useTransitionLock,
61
+ useVamp: () => useVamp,
59
62
  useWarmupExit: () => useWarmupExit
60
63
  });
61
64
  module.exports = __toCommonJS(index_exports);
@@ -1247,12 +1250,13 @@ var styles_default = `/* concertina \u2014 Radix Accordion expand/collapse with
1247
1250
  }
1248
1251
 
1249
1252
  /* Inactive Slot hiding \u2014 belt and suspenders.
1250
- Inline style sets visibility: hidden + opacity: 0 on the Slot div.
1251
- CSS backup catches edge cases where inline styles are overridden.
1253
+ Primary: inline style on the Slot element (visibility: hidden + opacity: 0).
1254
+ Inline styles can't be overridden by any CSS cascade \u2014 this is the
1255
+ bulletproof layer.
1256
+ Backup: CSS rules below catch edge cases (e.g. if inline styles are
1257
+ stripped by a framework or test harness).
1252
1258
  transition: none on descendants prevents children with transition-all
1253
- from animating the inherited visibility change (CSS visibility is
1254
- inherited; transition-all transitions it over the child's duration
1255
- instead of hiding instantly). */
1259
+ from animating the inherited visibility change. */
1256
1260
  .concertina-stable-slot > [inert] {
1257
1261
  visibility: hidden;
1258
1262
  opacity: 0;
@@ -1412,18 +1416,20 @@ var Bellows = (0, import_react11.forwardRef)(
1412
1416
  // src/components/slot.tsx
1413
1417
  var import_react12 = require("react");
1414
1418
  var import_jsx_runtime13 = require("react/jsx-runtime");
1419
+ var HIDDEN_STYLE = { visibility: "hidden", opacity: 0 };
1415
1420
  var Slot = (0, import_react12.forwardRef)(
1416
1421
  function Slot2({ active, note, as: Tag = "div", style, children, ...props }, ref) {
1417
1422
  (0, import_react12.useInsertionEffect)(injectStyles, []);
1418
1423
  (0, import_react12.useContext)(AxisContext);
1419
1424
  const activeNote = (0, import_react12.useContext)(ActiveNoteContext);
1420
- const isActive = active ?? (note != null && activeNote != null ? note === activeNote : true);
1425
+ const isActive = active ?? (note != null ? note === activeNote : true);
1426
+ const merged = isActive ? style : style ? { ...style, ...HIDDEN_STYLE } : HIDDEN_STYLE;
1421
1427
  return /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(
1422
1428
  Tag,
1423
1429
  {
1424
1430
  ref,
1425
1431
  inert: !isActive || void 0,
1426
- style,
1432
+ style: merged,
1427
1433
  ...props,
1428
1434
  children
1429
1435
  }
@@ -1432,34 +1438,49 @@ var Slot = (0, import_react12.forwardRef)(
1432
1438
  );
1433
1439
 
1434
1440
  // src/components/hum.tsx
1441
+ var import_react14 = require("react");
1442
+
1443
+ // src/components/vamp.tsx
1435
1444
  var import_react13 = require("react");
1436
1445
  var import_jsx_runtime14 = require("react/jsx-runtime");
1437
- var Hum = (0, import_react13.forwardRef)(
1446
+ var VampContext = (0, import_react13.createContext)(false);
1447
+ function useVamp() {
1448
+ return (0, import_react13.useContext)(VampContext);
1449
+ }
1450
+ function Vamp({ loading, children }) {
1451
+ return /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(VampContext.Provider, { value: loading, children });
1452
+ }
1453
+
1454
+ // src/components/hum.tsx
1455
+ var import_jsx_runtime15 = require("react/jsx-runtime");
1456
+ var Hum = (0, import_react14.forwardRef)(
1438
1457
  function Hum2({ loading, as: Tag = "span", className, children, ...props }, ref) {
1439
- (0, import_react13.useInsertionEffect)(injectStyles, []);
1440
- if (loading) {
1458
+ (0, import_react14.useInsertionEffect)(injectStyles, []);
1459
+ const vampLoading = useVamp();
1460
+ const isLoading = loading ?? vampLoading;
1461
+ if (isLoading) {
1441
1462
  const merged = className ? `concertina-warmup-line ${className}` : "concertina-warmup-line";
1442
- return /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(Tag, { ref, className: merged, ...props, children: /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(Tag, { inert: true, children }) });
1463
+ return /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(Tag, { ref, className: merged, ...props, children: /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(Tag, { inert: true, children }) });
1443
1464
  }
1444
- return /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(Tag, { ref, className, ...props, children });
1465
+ return /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(Tag, { ref, className, ...props, children });
1445
1466
  }
1446
1467
  );
1447
1468
 
1448
1469
  // src/components/ensemble.tsx
1449
- var import_react18 = require("react");
1470
+ var import_react19 = require("react");
1450
1471
 
1451
1472
  // src/components/gigbag.tsx
1452
- var import_react15 = require("react");
1473
+ var import_react16 = require("react");
1453
1474
 
1454
1475
  // src/primitives/use-stable-slot.ts
1455
- var import_react14 = require("react");
1476
+ var import_react15 = require("react");
1456
1477
  var RATCHET_FLOOR = -Infinity;
1457
1478
  function useStableSlot(options = {}) {
1458
1479
  const { axis = "both" } = options;
1459
- const [style, setStyle] = (0, import_react14.useState)({});
1460
- const maxRef = (0, import_react14.useRef)({ w: RATCHET_FLOOR, h: RATCHET_FLOOR });
1461
- const observerRef = (0, import_react14.useRef)(null);
1462
- const ref = (0, import_react14.useCallback)(
1480
+ const [style, setStyle] = (0, import_react15.useState)({});
1481
+ const maxRef = (0, import_react15.useRef)({ w: RATCHET_FLOOR, h: RATCHET_FLOOR });
1482
+ const observerRef = (0, import_react15.useRef)(null);
1483
+ const ref = (0, import_react15.useCallback)(
1463
1484
  (el) => {
1464
1485
  if (observerRef.current) {
1465
1486
  observerRef.current.disconnect();
@@ -1506,13 +1527,13 @@ function useStableSlot(options = {}) {
1506
1527
  }
1507
1528
 
1508
1529
  // src/components/gigbag.tsx
1509
- var import_jsx_runtime15 = require("react/jsx-runtime");
1510
- var Gigbag = (0, import_react15.forwardRef)(
1530
+ var import_jsx_runtime16 = require("react/jsx-runtime");
1531
+ var Gigbag = (0, import_react16.forwardRef)(
1511
1532
  function Gigbag2({ axis = "height", as: Tag = "div", className, style, children, ...props }, fwdRef) {
1512
- (0, import_react15.useInsertionEffect)(injectStyles, []);
1533
+ (0, import_react16.useInsertionEffect)(injectStyles, []);
1513
1534
  const { ref: ratchetRef, style: ratchetStyle } = useStableSlot({ axis });
1514
1535
  const merged = className ? `concertina-gigbag ${className}` : "concertina-gigbag";
1515
- return /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(
1536
+ return /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
1516
1537
  Tag,
1517
1538
  {
1518
1539
  ref: mergeRefs(ratchetRef, fwdRef),
@@ -1526,19 +1547,19 @@ var Gigbag = (0, import_react15.forwardRef)(
1526
1547
  );
1527
1548
 
1528
1549
  // src/components/warmup.tsx
1529
- var import_react16 = require("react");
1530
- var import_jsx_runtime16 = require("react/jsx-runtime");
1531
- var Warmup = (0, import_react16.forwardRef)(
1550
+ var import_react17 = require("react");
1551
+ var import_jsx_runtime17 = require("react/jsx-runtime");
1552
+ var Warmup = (0, import_react17.forwardRef)(
1532
1553
  function Warmup2({ rows, columns, as: Tag = "div", className, children, ...props }, ref) {
1533
- (0, import_react16.useInsertionEffect)(injectStyles, []);
1554
+ (0, import_react17.useInsertionEffect)(injectStyles, []);
1534
1555
  const merged = className ? `concertina-warmup ${className}` : "concertina-warmup";
1535
1556
  const count2 = columns ? rows * columns : rows;
1536
- const cells = Array.from({ length: count2 }, (_, i) => /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { className: "concertina-warmup-bone", children: [
1537
- /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { className: "concertina-warmup-line" }),
1538
- /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { className: "concertina-warmup-line" })
1557
+ const cells = Array.from({ length: count2 }, (_, i) => /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("div", { className: "concertina-warmup-bone", children: [
1558
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("div", { className: "concertina-warmup-line" }),
1559
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("div", { className: "concertina-warmup-line" })
1539
1560
  ] }, i));
1540
1561
  const gridStyle = columns ? { gridTemplateColumns: `repeat(${columns}, auto)`, gridTemplateAreas: `'${"chamber ".repeat(columns).trim()}'` } : { gridTemplateAreas: "'chamber'" };
1541
- return /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
1562
+ return /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(
1542
1563
  Tag,
1543
1564
  {
1544
1565
  ref,
@@ -1552,11 +1573,11 @@ var Warmup = (0, import_react16.forwardRef)(
1552
1573
  );
1553
1574
 
1554
1575
  // src/primitives/use-warmup-exit.ts
1555
- var import_react17 = require("react");
1576
+ var import_react18 = require("react");
1556
1577
  function useWarmupExit(loading, duration) {
1557
- const [exiting, setExiting] = (0, import_react17.useState)(false);
1558
- const prevLoading = (0, import_react17.useRef)(loading);
1559
- (0, import_react17.useEffect)(() => {
1578
+ const [exiting, setExiting] = (0, import_react18.useState)(false);
1579
+ const prevLoading = (0, import_react18.useRef)(loading);
1580
+ (0, import_react18.useEffect)(() => {
1560
1581
  if (prevLoading.current && !loading) {
1561
1582
  setExiting(true);
1562
1583
  const id = setTimeout(() => setExiting(false), duration);
@@ -1574,7 +1595,7 @@ function useWarmupExit(loading, duration) {
1574
1595
  }
1575
1596
 
1576
1597
  // src/components/ensemble.tsx
1577
- var import_jsx_runtime17 = require("react/jsx-runtime");
1598
+ var import_jsx_runtime18 = require("react/jsx-runtime");
1578
1599
  function EnsembleInner({
1579
1600
  items,
1580
1601
  loading,
@@ -1585,33 +1606,33 @@ function EnsembleInner({
1585
1606
  className,
1586
1607
  ...props
1587
1608
  }, ref) {
1588
- (0, import_react18.useInsertionEffect)(injectStyles, []);
1609
+ (0, import_react19.useInsertionEffect)(injectStyles, []);
1589
1610
  const { showWarmup, exiting } = useWarmupExit(loading, exitDuration);
1590
1611
  const warmupClass = exiting ? className ? `concertina-warmup-exiting ${className}` : "concertina-warmup-exiting" : className;
1591
- return /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(Gigbag, { ref, axis: "height", as: Tag, ...props, children: showWarmup ? /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(Warmup, { rows: stubCount, className: warmupClass }) : /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(Tag, { className, children: items.map(renderItem) }) });
1612
+ return /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(Gigbag, { ref, axis: "height", as: Tag, ...props, children: showWarmup ? /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(Warmup, { rows: stubCount, className: warmupClass }) : /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(Tag, { className, children: items.map(renderItem) }) });
1592
1613
  }
1593
- var Ensemble = (0, import_react18.forwardRef)(EnsembleInner);
1614
+ var Ensemble = (0, import_react19.forwardRef)(EnsembleInner);
1594
1615
 
1595
1616
  // src/components/warmup-line.tsx
1596
- var import_react19 = require("react");
1597
- var import_jsx_runtime18 = require("react/jsx-runtime");
1598
- var WarmupLine = (0, import_react19.forwardRef)(
1617
+ var import_react20 = require("react");
1618
+ var import_jsx_runtime19 = require("react/jsx-runtime");
1619
+ var WarmupLine = (0, import_react20.forwardRef)(
1599
1620
  function WarmupLine2({ as: Tag = "div", className, ...props }, ref) {
1600
- (0, import_react19.useInsertionEffect)(injectStyles, []);
1621
+ (0, import_react20.useInsertionEffect)(injectStyles, []);
1601
1622
  const merged = className ? `concertina-warmup-line ${className}` : "concertina-warmup-line";
1602
- return /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(Tag, { ref, className: merged, ...props });
1623
+ return /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(Tag, { ref, className: merged, ...props });
1603
1624
  }
1604
1625
  );
1605
1626
 
1606
1627
  // src/components/glide.tsx
1607
- var import_react21 = require("react");
1628
+ var import_react22 = require("react");
1608
1629
 
1609
1630
  // src/primitives/use-presence.ts
1610
- var import_react20 = require("react");
1631
+ var import_react21 = require("react");
1611
1632
  function usePresence2(show) {
1612
- const [mounted, setMounted] = (0, import_react20.useState)(show);
1613
- const [phase, setPhase] = (0, import_react20.useState)(show ? "entered" : "exiting");
1614
- (0, import_react20.useEffect)(() => {
1633
+ const [mounted, setMounted] = (0, import_react21.useState)(show);
1634
+ const [phase, setPhase] = (0, import_react21.useState)(show ? "entered" : "exiting");
1635
+ (0, import_react21.useEffect)(() => {
1615
1636
  if (show) {
1616
1637
  setMounted(true);
1617
1638
  setPhase("entering");
@@ -1619,7 +1640,7 @@ function usePresence2(show) {
1619
1640
  setPhase("exiting");
1620
1641
  }
1621
1642
  }, [show]);
1622
- const onAnimationEnd = (0, import_react20.useCallback)(
1643
+ const onAnimationEnd = (0, import_react21.useCallback)(
1623
1644
  (e) => {
1624
1645
  if (e.target !== e.currentTarget) return;
1625
1646
  if (phase === "entering") setPhase("entered");
@@ -1631,15 +1652,15 @@ function usePresence2(show) {
1631
1652
  }
1632
1653
 
1633
1654
  // src/components/glide.tsx
1634
- var import_jsx_runtime19 = require("react/jsx-runtime");
1635
- var Glide = (0, import_react21.forwardRef)(
1655
+ var import_jsx_runtime20 = require("react/jsx-runtime");
1656
+ var Glide = (0, import_react22.forwardRef)(
1636
1657
  function Glide2({ show, as: Tag = "div", className, children, ...props }, ref) {
1637
- (0, import_react21.useInsertionEffect)(injectStyles, []);
1658
+ (0, import_react22.useInsertionEffect)(injectStyles, []);
1638
1659
  const { mounted, phase, onAnimationEnd } = usePresence2(show);
1639
1660
  if (!mounted) return null;
1640
1661
  const phaseClass = phase === "entering" ? "concertina-glide-entering" : phase === "exiting" ? "concertina-glide-exiting" : "";
1641
1662
  const merged = ["concertina-glide", phaseClass, className].filter(Boolean).join(" ");
1642
- return /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(
1663
+ return /* @__PURE__ */ (0, import_jsx_runtime20.jsx)(
1643
1664
  Tag,
1644
1665
  {
1645
1666
  ref,
@@ -1653,12 +1674,12 @@ var Glide = (0, import_react21.forwardRef)(
1653
1674
  );
1654
1675
 
1655
1676
  // src/primitives/use-size.ts
1656
- var import_react22 = require("react");
1677
+ var import_react23 = require("react");
1657
1678
  var NO_OBSERVATION = { width: Number.NaN, height: Number.NaN };
1658
1679
  function useSize() {
1659
- const [size, setSize] = (0, import_react22.useState)(NO_OBSERVATION);
1660
- const observerRef = (0, import_react22.useRef)(null);
1661
- const ref = (0, import_react22.useCallback)((el) => {
1680
+ const [size, setSize] = (0, import_react23.useState)(NO_OBSERVATION);
1681
+ const observerRef = (0, import_react23.useRef)(null);
1682
+ const ref = (0, import_react23.useCallback)((el) => {
1662
1683
  if (observerRef.current) {
1663
1684
  observerRef.current.disconnect();
1664
1685
  observerRef.current = null;
@@ -1687,12 +1708,12 @@ function useSize() {
1687
1708
  }
1688
1709
 
1689
1710
  // src/accordion/use-concertina.ts
1690
- var import_react23 = require("react");
1711
+ var import_react24 = require("react");
1691
1712
  function useConcertina() {
1692
- const [value, setValue] = (0, import_react23.useState)("");
1693
- const [switching, setSwitching] = (0, import_react23.useState)(false);
1694
- const itemRefs = (0, import_react23.useRef)({});
1695
- const onValueChange = (0, import_react23.useCallback)(
1713
+ const [value, setValue] = (0, import_react24.useState)("");
1714
+ const [switching, setSwitching] = (0, import_react24.useState)(false);
1715
+ const itemRefs = (0, import_react24.useRef)({});
1716
+ const onValueChange = (0, import_react24.useCallback)(
1696
1717
  (newValue) => {
1697
1718
  if (!newValue) {
1698
1719
  setSwitching(false);
@@ -1704,14 +1725,14 @@ function useConcertina() {
1704
1725
  },
1705
1726
  [value]
1706
1727
  );
1707
- (0, import_react23.useLayoutEffect)(() => {
1728
+ (0, import_react24.useLayoutEffect)(() => {
1708
1729
  if (!value) return;
1709
1730
  pinToScrollTop(itemRefs.current[value]);
1710
1731
  }, [value]);
1711
- (0, import_react23.useEffect)(() => {
1732
+ (0, import_react24.useEffect)(() => {
1712
1733
  if (switching) setSwitching(false);
1713
1734
  }, [switching]);
1714
- const getItemRef = (0, import_react23.useCallback)(
1735
+ const getItemRef = (0, import_react24.useCallback)(
1715
1736
  (id) => (el) => {
1716
1737
  itemRefs.current[id] = el;
1717
1738
  },
@@ -1742,6 +1763,8 @@ function useConcertina() {
1742
1763
  StableSlot,
1743
1764
  StableText,
1744
1765
  Trigger,
1766
+ Vamp,
1767
+ VampContext,
1745
1768
  Warmup,
1746
1769
  WarmupLine,
1747
1770
  pinToScrollTop,
@@ -1752,5 +1775,6 @@ function useConcertina() {
1752
1775
  useSize,
1753
1776
  useStableSlot,
1754
1777
  useTransitionLock,
1778
+ useVamp,
1755
1779
  useWarmupExit
1756
1780
  });
package/dist/index.d.cts CHANGED
@@ -2,6 +2,7 @@ export { ConcertinaContext, ConcertinaRootProps, ConcertinaStore, Content, Item,
2
2
  export { Header, Trigger } from '@radix-ui/react-accordion';
3
3
  import * as react from 'react';
4
4
  import { HTMLAttributes, ElementType, ReactNode, Ref, ReactElement, AnimationEvent, DependencyList, CSSProperties } from 'react';
5
+ import * as react_jsx_runtime from 'react/jsx-runtime';
5
6
 
6
7
  type Axis = "width" | "height" | "both";
7
8
  interface BellowsProps extends HTMLAttributes<HTMLElement> {
@@ -34,15 +35,18 @@ interface SlotProps extends HTMLAttributes<HTMLElement> {
34
35
  * All slots overlap via CSS grid. Inactive slots are hidden
35
36
  * but still contribute to grid cell sizing.
36
37
  *
37
- * Inactive hiding is handled entirely by CSS via the [inert] attribute:
38
- * .concertina-stable-slot > [inert] { visibility: hidden; opacity: 0; }
39
- * No inline style overrides needed injectStyles guarantees the rules exist.
38
+ * Inactive hiding uses inline styles (can't be overridden by CSS cascade)
39
+ * plus the [inert] attribute for accessibility (non-focusable, non-interactive).
40
+ * CSS `.concertina-stable-slot > [inert]` serves as a backup.
40
41
  */
41
42
  declare const Slot: react.ForwardRefExoticComponent<SlotProps & react.RefAttributes<HTMLElement>>;
42
43
 
43
44
  interface HumProps extends HTMLAttributes<HTMLElement> {
44
- /** Whether data is loading. Shows shimmer when true, children when false. */
45
- loading: boolean;
45
+ /**
46
+ * Whether data is loading. Shows shimmer when true, children when false.
47
+ * When omitted, falls back to the nearest `<Vamp>` ancestor's loading state.
48
+ */
49
+ loading?: boolean;
46
50
  /** HTML element to render. Default: "span". */
47
51
  as?: ElementType;
48
52
  }
@@ -56,9 +60,35 @@ interface HumProps extends HTMLAttributes<HTMLElement> {
56
60
  *
57
61
  * The className is passed through so `1lh` inherits the correct font
58
62
  * metrics from the consuming context.
63
+ *
64
+ * When no explicit `loading` prop is provided, Hum reads from the
65
+ * nearest `<Vamp>` ancestor. This lets a single provider control
66
+ * shimmer state for an entire subtree.
59
67
  */
60
68
  declare const Hum: react.ForwardRefExoticComponent<HumProps & react.RefAttributes<HTMLElement>>;
61
69
 
70
+ /** Context carrying the ambient loading state set by Vamp. */
71
+ declare const VampContext: react.Context<boolean>;
72
+ /**
73
+ * Read the nearest Vamp's loading state.
74
+ * Returns `false` when no Vamp ancestor exists.
75
+ */
76
+ declare function useVamp(): boolean;
77
+ interface VampProps {
78
+ /** Whether the subtree is in a loading/warmup state. */
79
+ loading: boolean;
80
+ children: ReactNode;
81
+ }
82
+ /**
83
+ * Ambient loading provider — musical "vamping" (repeating a pattern
84
+ * while waiting for a cue).
85
+ *
86
+ * Wrapping a subtree in `<Vamp loading>` lets every nested `<Hum>`
87
+ * pick up the loading state automatically, without threading an
88
+ * explicit `loading` prop through every cell.
89
+ */
90
+ declare function Vamp({ loading, children }: VampProps): react_jsx_runtime.JSX.Element;
91
+
62
92
  interface EnsembleProps<T> extends Omit<HTMLAttributes<HTMLElement>, "children"> {
63
93
  /** Data items to render. */
64
94
  items: T[];
@@ -286,4 +316,4 @@ declare function useWarmupExit(loading: boolean, duration: number): {
286
316
  exiting: boolean;
287
317
  };
288
318
 
289
- export { type Axis, Bellows, type BellowsProps, Ensemble, type EnsembleProps, Gigbag, type GigbagProps, Glide, type GlideProps, Hum, type HumProps, type Phase, type Size, Slot, type SlotProps, Ensemble as StableCollection, type EnsembleProps as StableCollectionProps, Bellows as StableSlot, type BellowsProps as StableSlotProps, Hum as StableText, type HumProps as StableTextProps, type UsePresenceReturn, Warmup, WarmupLine, type WarmupLineProps, type WarmupProps, pinToScrollTop, usePresence, useScrollPin, useSize, useStableSlot, useTransitionLock, useWarmupExit };
319
+ export { type Axis, Bellows, type BellowsProps, Ensemble, type EnsembleProps, Gigbag, type GigbagProps, Glide, type GlideProps, Hum, type HumProps, type Phase, type Size, Slot, type SlotProps, Ensemble as StableCollection, type EnsembleProps as StableCollectionProps, Bellows as StableSlot, type BellowsProps as StableSlotProps, Hum as StableText, type HumProps as StableTextProps, type UsePresenceReturn, Vamp, VampContext, type VampProps, Warmup, WarmupLine, type WarmupLineProps, type WarmupProps, pinToScrollTop, usePresence, useScrollPin, useSize, useStableSlot, useTransitionLock, useVamp, useWarmupExit };
package/dist/index.d.ts CHANGED
@@ -2,6 +2,7 @@ export { ConcertinaContext, ConcertinaRootProps, ConcertinaStore, Content, Item,
2
2
  export { Header, Trigger } from '@radix-ui/react-accordion';
3
3
  import * as react from 'react';
4
4
  import { HTMLAttributes, ElementType, ReactNode, Ref, ReactElement, AnimationEvent, DependencyList, CSSProperties } from 'react';
5
+ import * as react_jsx_runtime from 'react/jsx-runtime';
5
6
 
6
7
  type Axis = "width" | "height" | "both";
7
8
  interface BellowsProps extends HTMLAttributes<HTMLElement> {
@@ -34,15 +35,18 @@ interface SlotProps extends HTMLAttributes<HTMLElement> {
34
35
  * All slots overlap via CSS grid. Inactive slots are hidden
35
36
  * but still contribute to grid cell sizing.
36
37
  *
37
- * Inactive hiding is handled entirely by CSS via the [inert] attribute:
38
- * .concertina-stable-slot > [inert] { visibility: hidden; opacity: 0; }
39
- * No inline style overrides needed injectStyles guarantees the rules exist.
38
+ * Inactive hiding uses inline styles (can't be overridden by CSS cascade)
39
+ * plus the [inert] attribute for accessibility (non-focusable, non-interactive).
40
+ * CSS `.concertina-stable-slot > [inert]` serves as a backup.
40
41
  */
41
42
  declare const Slot: react.ForwardRefExoticComponent<SlotProps & react.RefAttributes<HTMLElement>>;
42
43
 
43
44
  interface HumProps extends HTMLAttributes<HTMLElement> {
44
- /** Whether data is loading. Shows shimmer when true, children when false. */
45
- loading: boolean;
45
+ /**
46
+ * Whether data is loading. Shows shimmer when true, children when false.
47
+ * When omitted, falls back to the nearest `<Vamp>` ancestor's loading state.
48
+ */
49
+ loading?: boolean;
46
50
  /** HTML element to render. Default: "span". */
47
51
  as?: ElementType;
48
52
  }
@@ -56,9 +60,35 @@ interface HumProps extends HTMLAttributes<HTMLElement> {
56
60
  *
57
61
  * The className is passed through so `1lh` inherits the correct font
58
62
  * metrics from the consuming context.
63
+ *
64
+ * When no explicit `loading` prop is provided, Hum reads from the
65
+ * nearest `<Vamp>` ancestor. This lets a single provider control
66
+ * shimmer state for an entire subtree.
59
67
  */
60
68
  declare const Hum: react.ForwardRefExoticComponent<HumProps & react.RefAttributes<HTMLElement>>;
61
69
 
70
+ /** Context carrying the ambient loading state set by Vamp. */
71
+ declare const VampContext: react.Context<boolean>;
72
+ /**
73
+ * Read the nearest Vamp's loading state.
74
+ * Returns `false` when no Vamp ancestor exists.
75
+ */
76
+ declare function useVamp(): boolean;
77
+ interface VampProps {
78
+ /** Whether the subtree is in a loading/warmup state. */
79
+ loading: boolean;
80
+ children: ReactNode;
81
+ }
82
+ /**
83
+ * Ambient loading provider — musical "vamping" (repeating a pattern
84
+ * while waiting for a cue).
85
+ *
86
+ * Wrapping a subtree in `<Vamp loading>` lets every nested `<Hum>`
87
+ * pick up the loading state automatically, without threading an
88
+ * explicit `loading` prop through every cell.
89
+ */
90
+ declare function Vamp({ loading, children }: VampProps): react_jsx_runtime.JSX.Element;
91
+
62
92
  interface EnsembleProps<T> extends Omit<HTMLAttributes<HTMLElement>, "children"> {
63
93
  /** Data items to render. */
64
94
  items: T[];
@@ -286,4 +316,4 @@ declare function useWarmupExit(loading: boolean, duration: number): {
286
316
  exiting: boolean;
287
317
  };
288
318
 
289
- export { type Axis, Bellows, type BellowsProps, Ensemble, type EnsembleProps, Gigbag, type GigbagProps, Glide, type GlideProps, Hum, type HumProps, type Phase, type Size, Slot, type SlotProps, Ensemble as StableCollection, type EnsembleProps as StableCollectionProps, Bellows as StableSlot, type BellowsProps as StableSlotProps, Hum as StableText, type HumProps as StableTextProps, type UsePresenceReturn, Warmup, WarmupLine, type WarmupLineProps, type WarmupProps, pinToScrollTop, usePresence, useScrollPin, useSize, useStableSlot, useTransitionLock, useWarmupExit };
319
+ export { type Axis, Bellows, type BellowsProps, Ensemble, type EnsembleProps, Gigbag, type GigbagProps, Glide, type GlideProps, Hum, type HumProps, type Phase, type Size, Slot, type SlotProps, Ensemble as StableCollection, type EnsembleProps as StableCollectionProps, Bellows as StableSlot, type BellowsProps as StableSlotProps, Hum as StableText, type HumProps as StableTextProps, type UsePresenceReturn, Vamp, VampContext, type VampProps, Warmup, WarmupLine, type WarmupLineProps, type WarmupProps, pinToScrollTop, usePresence, useScrollPin, useSize, useStableSlot, useTransitionLock, useVamp, useWarmupExit };
package/dist/index.js CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  useExpanded,
14
14
  useScrollPin,
15
15
  useTransitionLock
16
- } from "./chunk-4DDADLSW.js";
16
+ } from "./chunk-OGJMPKZX.js";
17
17
 
18
18
  // src/components/bellows.tsx
19
19
  import {
@@ -39,18 +39,20 @@ import {
39
39
  useInsertionEffect as useInsertionEffect2
40
40
  } from "react";
41
41
  import { jsx as jsx2 } from "react/jsx-runtime";
42
+ var HIDDEN_STYLE = { visibility: "hidden", opacity: 0 };
42
43
  var Slot = forwardRef2(
43
44
  function Slot2({ active, note, as: Tag = "div", style, children, ...props }, ref) {
44
45
  useInsertionEffect2(injectStyles, []);
45
46
  useContext(AxisContext);
46
47
  const activeNote = useContext(ActiveNoteContext);
47
- const isActive = active ?? (note != null && activeNote != null ? note === activeNote : true);
48
+ const isActive = active ?? (note != null ? note === activeNote : true);
49
+ const merged = isActive ? style : style ? { ...style, ...HIDDEN_STYLE } : HIDDEN_STYLE;
48
50
  return /* @__PURE__ */ jsx2(
49
51
  Tag,
50
52
  {
51
53
  ref,
52
54
  inert: !isActive || void 0,
53
- style,
55
+ style: merged,
54
56
  ...props,
55
57
  children
56
58
  }
@@ -63,15 +65,30 @@ import {
63
65
  forwardRef as forwardRef3,
64
66
  useInsertionEffect as useInsertionEffect3
65
67
  } from "react";
68
+
69
+ // src/components/vamp.tsx
70
+ import { createContext as createContext2, useContext as useContext2 } from "react";
66
71
  import { jsx as jsx3 } from "react/jsx-runtime";
72
+ var VampContext = createContext2(false);
73
+ function useVamp() {
74
+ return useContext2(VampContext);
75
+ }
76
+ function Vamp({ loading, children }) {
77
+ return /* @__PURE__ */ jsx3(VampContext.Provider, { value: loading, children });
78
+ }
79
+
80
+ // src/components/hum.tsx
81
+ import { jsx as jsx4 } from "react/jsx-runtime";
67
82
  var Hum = forwardRef3(
68
83
  function Hum2({ loading, as: Tag = "span", className, children, ...props }, ref) {
69
84
  useInsertionEffect3(injectStyles, []);
70
- if (loading) {
85
+ const vampLoading = useVamp();
86
+ const isLoading = loading ?? vampLoading;
87
+ if (isLoading) {
71
88
  const merged = className ? `concertina-warmup-line ${className}` : "concertina-warmup-line";
72
- return /* @__PURE__ */ jsx3(Tag, { ref, className: merged, ...props, children: /* @__PURE__ */ jsx3(Tag, { inert: true, children }) });
89
+ return /* @__PURE__ */ jsx4(Tag, { ref, className: merged, ...props, children: /* @__PURE__ */ jsx4(Tag, { inert: true, children }) });
73
90
  }
74
- return /* @__PURE__ */ jsx3(Tag, { ref, className, ...props, children });
91
+ return /* @__PURE__ */ jsx4(Tag, { ref, className, ...props, children });
75
92
  }
76
93
  );
77
94
 
@@ -139,13 +156,13 @@ function useStableSlot(options = {}) {
139
156
  }
140
157
 
141
158
  // src/components/gigbag.tsx
142
- import { jsx as jsx4 } from "react/jsx-runtime";
159
+ import { jsx as jsx5 } from "react/jsx-runtime";
143
160
  var Gigbag = forwardRef4(
144
161
  function Gigbag2({ axis = "height", as: Tag = "div", className, style, children, ...props }, fwdRef) {
145
162
  useInsertionEffect4(injectStyles, []);
146
163
  const { ref: ratchetRef, style: ratchetStyle } = useStableSlot({ axis });
147
164
  const merged = className ? `concertina-gigbag ${className}` : "concertina-gigbag";
148
- return /* @__PURE__ */ jsx4(
165
+ return /* @__PURE__ */ jsx5(
149
166
  Tag,
150
167
  {
151
168
  ref: mergeRefs(ratchetRef, fwdRef),
@@ -160,18 +177,18 @@ var Gigbag = forwardRef4(
160
177
 
161
178
  // src/components/warmup.tsx
162
179
  import { forwardRef as forwardRef5, useInsertionEffect as useInsertionEffect5 } from "react";
163
- import { jsx as jsx5, jsxs } from "react/jsx-runtime";
180
+ import { jsx as jsx6, jsxs } from "react/jsx-runtime";
164
181
  var Warmup = forwardRef5(
165
182
  function Warmup2({ rows, columns, as: Tag = "div", className, children, ...props }, ref) {
166
183
  useInsertionEffect5(injectStyles, []);
167
184
  const merged = className ? `concertina-warmup ${className}` : "concertina-warmup";
168
185
  const count = columns ? rows * columns : rows;
169
186
  const cells = Array.from({ length: count }, (_, i) => /* @__PURE__ */ jsxs("div", { className: "concertina-warmup-bone", children: [
170
- /* @__PURE__ */ jsx5("div", { className: "concertina-warmup-line" }),
171
- /* @__PURE__ */ jsx5("div", { className: "concertina-warmup-line" })
187
+ /* @__PURE__ */ jsx6("div", { className: "concertina-warmup-line" }),
188
+ /* @__PURE__ */ jsx6("div", { className: "concertina-warmup-line" })
172
189
  ] }, i));
173
190
  const gridStyle = columns ? { gridTemplateColumns: `repeat(${columns}, auto)`, gridTemplateAreas: `'${"chamber ".repeat(columns).trim()}'` } : { gridTemplateAreas: "'chamber'" };
174
- return /* @__PURE__ */ jsx5(
191
+ return /* @__PURE__ */ jsx6(
175
192
  Tag,
176
193
  {
177
194
  ref,
@@ -207,7 +224,7 @@ function useWarmupExit(loading, duration) {
207
224
  }
208
225
 
209
226
  // src/components/ensemble.tsx
210
- import { jsx as jsx6 } from "react/jsx-runtime";
227
+ import { jsx as jsx7 } from "react/jsx-runtime";
211
228
  function EnsembleInner({
212
229
  items,
213
230
  loading,
@@ -221,18 +238,18 @@ function EnsembleInner({
221
238
  useInsertionEffect6(injectStyles, []);
222
239
  const { showWarmup, exiting } = useWarmupExit(loading, exitDuration);
223
240
  const warmupClass = exiting ? className ? `concertina-warmup-exiting ${className}` : "concertina-warmup-exiting" : className;
224
- return /* @__PURE__ */ jsx6(Gigbag, { ref, axis: "height", as: Tag, ...props, children: showWarmup ? /* @__PURE__ */ jsx6(Warmup, { rows: stubCount, className: warmupClass }) : /* @__PURE__ */ jsx6(Tag, { className, children: items.map(renderItem) }) });
241
+ return /* @__PURE__ */ jsx7(Gigbag, { ref, axis: "height", as: Tag, ...props, children: showWarmup ? /* @__PURE__ */ jsx7(Warmup, { rows: stubCount, className: warmupClass }) : /* @__PURE__ */ jsx7(Tag, { className, children: items.map(renderItem) }) });
225
242
  }
226
243
  var Ensemble = forwardRef6(EnsembleInner);
227
244
 
228
245
  // src/components/warmup-line.tsx
229
246
  import { forwardRef as forwardRef7, useInsertionEffect as useInsertionEffect7 } from "react";
230
- import { jsx as jsx7 } from "react/jsx-runtime";
247
+ import { jsx as jsx8 } from "react/jsx-runtime";
231
248
  var WarmupLine = forwardRef7(
232
249
  function WarmupLine2({ as: Tag = "div", className, ...props }, ref) {
233
250
  useInsertionEffect7(injectStyles, []);
234
251
  const merged = className ? `concertina-warmup-line ${className}` : "concertina-warmup-line";
235
- return /* @__PURE__ */ jsx7(Tag, { ref, className: merged, ...props });
252
+ return /* @__PURE__ */ jsx8(Tag, { ref, className: merged, ...props });
236
253
  }
237
254
  );
238
255
 
@@ -271,7 +288,7 @@ function usePresence(show) {
271
288
  }
272
289
 
273
290
  // src/components/glide.tsx
274
- import { jsx as jsx8 } from "react/jsx-runtime";
291
+ import { jsx as jsx9 } from "react/jsx-runtime";
275
292
  var Glide = forwardRef8(
276
293
  function Glide2({ show, as: Tag = "div", className, children, ...props }, ref) {
277
294
  useInsertionEffect8(injectStyles, []);
@@ -279,7 +296,7 @@ var Glide = forwardRef8(
279
296
  if (!mounted) return null;
280
297
  const phaseClass = phase === "entering" ? "concertina-glide-entering" : phase === "exiting" ? "concertina-glide-exiting" : "";
281
298
  const merged = ["concertina-glide", phaseClass, className].filter(Boolean).join(" ");
282
- return /* @__PURE__ */ jsx8(
299
+ return /* @__PURE__ */ jsx9(
283
300
  Tag,
284
301
  {
285
302
  ref,
@@ -342,6 +359,8 @@ export {
342
359
  Bellows as StableSlot,
343
360
  Hum as StableText,
344
361
  Trigger2 as Trigger,
362
+ Vamp,
363
+ VampContext,
345
364
  Warmup,
346
365
  WarmupLine,
347
366
  pinToScrollTop,
@@ -352,5 +371,6 @@ export {
352
371
  useSize,
353
372
  useStableSlot,
354
373
  useTransitionLock,
374
+ useVamp,
355
375
  useWarmupExit
356
376
  };
package/dist/styles.css CHANGED
@@ -67,12 +67,13 @@
67
67
  }
68
68
 
69
69
  /* Inactive Slot hiding — belt and suspenders.
70
- Inline style sets visibility: hidden + opacity: 0 on the Slot div.
71
- CSS backup catches edge cases where inline styles are overridden.
70
+ Primary: inline style on the Slot element (visibility: hidden + opacity: 0).
71
+ Inline styles can't be overridden by any CSS cascade — this is the
72
+ bulletproof layer.
73
+ Backup: CSS rules below catch edge cases (e.g. if inline styles are
74
+ stripped by a framework or test harness).
72
75
  transition: none on descendants prevents children with transition-all
73
- from animating the inherited visibility change (CSS visibility is
74
- inherited; transition-all transitions it over the child's duration
75
- instead of hiding instantly). */
76
+ from animating the inherited visibility change. */
76
77
  .concertina-stable-slot > [inert] {
77
78
  visibility: hidden;
78
79
  opacity: 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "concertina",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "React toolkit for layout stability.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",