concertina 0.4.1 → 0.5.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/dist/index.cjs CHANGED
@@ -36,15 +36,19 @@ __export(index_exports, {
36
36
  Header: () => Header,
37
37
  Item: () => Item2,
38
38
  Root: () => Root3,
39
+ Slot: () => Slot,
40
+ StableSlot: () => StableSlot,
39
41
  Trigger: () => Trigger2,
40
42
  pinToScrollTop: () => pinToScrollTop,
41
43
  useConcertina: () => useConcertina,
42
- useExpanded: () => useExpanded
44
+ useExpanded: () => useExpanded,
45
+ useStableSlot: () => useStableSlot,
46
+ useTransitionLock: () => useTransitionLock
43
47
  });
44
48
  module.exports = __toCommonJS(index_exports);
45
49
 
46
50
  // src/root.tsx
47
- var import_react5 = require("react");
51
+ var import_react6 = require("react");
48
52
 
49
53
  // node_modules/@radix-ui/react-accordion/dist/index.mjs
50
54
  var import_react3 = __toESM(require("react"), 1);
@@ -156,7 +160,7 @@ var import_jsx_runtime2 = require("react/jsx-runtime");
156
160
  // @__NO_SIDE_EFFECTS__
157
161
  function createSlot(ownerName) {
158
162
  const SlotClone = /* @__PURE__ */ createSlotClone(ownerName);
159
- const Slot2 = React3.forwardRef((props, forwardedRef) => {
163
+ const Slot22 = React3.forwardRef((props, forwardedRef) => {
160
164
  const { children, ...slotProps } = props;
161
165
  const childrenArray = React3.Children.toArray(children);
162
166
  const slottable = childrenArray.find(isSlottable);
@@ -174,8 +178,8 @@ function createSlot(ownerName) {
174
178
  }
175
179
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(SlotClone, { ...slotProps, ref: forwardedRef, children });
176
180
  });
177
- Slot2.displayName = `${ownerName}.Slot`;
178
- return Slot2;
181
+ Slot22.displayName = `${ownerName}.Slot`;
182
+ return Slot22;
179
183
  }
180
184
  // @__NO_SIDE_EFFECTS__
181
185
  function createSlotClone(ownerName) {
@@ -413,10 +417,10 @@ var NODES = [
413
417
  "ul"
414
418
  ];
415
419
  var Primitive = NODES.reduce((primitive, node) => {
416
- const Slot = createSlot(`Primitive.${node}`);
420
+ const Slot3 = createSlot(`Primitive.${node}`);
417
421
  const Node2 = React7.forwardRef((props, forwardedRef) => {
418
422
  const { asChild, ...primitiveProps } = props;
419
- const Comp = asChild ? Slot : node;
423
+ const Comp = asChild ? Slot3 : node;
420
424
  if (typeof window !== "undefined") {
421
425
  window[/* @__PURE__ */ Symbol.for("radix-ui")] = true;
422
426
  }
@@ -1010,7 +1014,6 @@ var import_react4 = require("react");
1010
1014
  var ConcertinaStore = class {
1011
1015
  constructor() {
1012
1016
  this._value = "";
1013
- this._switching = false;
1014
1017
  this._itemRefs = {};
1015
1018
  this._listeners = /* @__PURE__ */ new Set();
1016
1019
  this.subscribe = (listener) => {
@@ -1018,22 +1021,14 @@ var ConcertinaStore = class {
1018
1021
  return () => this._listeners.delete(listener);
1019
1022
  };
1020
1023
  this.getValue = () => this._value;
1021
- this.getSwitching = () => this._switching;
1022
1024
  }
1023
1025
  _notify() {
1024
1026
  for (const listener of this._listeners) listener();
1025
1027
  }
1026
1028
  setValue(newValue) {
1027
- const wasSwitching = !!this._value && this._value !== newValue && !!newValue;
1028
- this._switching = wasSwitching;
1029
1029
  this._value = newValue || "";
1030
1030
  this._notify();
1031
1031
  }
1032
- clearSwitching() {
1033
- if (!this._switching) return;
1034
- this._switching = false;
1035
- this._notify();
1036
- }
1037
1032
  getItemRef(id) {
1038
1033
  return this._itemRefs[id] ?? null;
1039
1034
  }
@@ -1073,36 +1068,44 @@ function pinToScrollTop(el) {
1073
1068
  }
1074
1069
  }
1075
1070
 
1071
+ // src/use-transition-lock.ts
1072
+ var import_react5 = require("react");
1073
+ function useTransitionLock() {
1074
+ const [locked, setLocked] = (0, import_react5.useState)(false);
1075
+ const lock = (0, import_react5.useCallback)(() => setLocked(true), []);
1076
+ (0, import_react5.useEffect)(() => {
1077
+ if (locked) setLocked(false);
1078
+ }, [locked]);
1079
+ return { locked, lock };
1080
+ }
1081
+
1076
1082
  // src/root.tsx
1077
1083
  var import_jsx_runtime9 = require("react/jsx-runtime");
1078
- var Root3 = (0, import_react5.forwardRef)(
1084
+ var Root3 = (0, import_react6.forwardRef)(
1079
1085
  function Root4({ collapsible = true, children, ...props }, forwardedRef) {
1080
- const storeRef = (0, import_react5.useRef)(null);
1086
+ const storeRef = (0, import_react6.useRef)(null);
1081
1087
  if (!storeRef.current) {
1082
1088
  storeRef.current = new ConcertinaStore();
1083
1089
  }
1084
1090
  const store = storeRef.current;
1085
- const value = (0, import_react5.useSyncExternalStore)(
1091
+ const value = (0, import_react6.useSyncExternalStore)(
1086
1092
  store.subscribe,
1087
1093
  store.getValue,
1088
1094
  store.getValue
1089
1095
  );
1090
- const switching = (0, import_react5.useSyncExternalStore)(
1091
- store.subscribe,
1092
- store.getSwitching,
1093
- store.getSwitching
1094
- );
1095
- const onValueChange = (0, import_react5.useCallback)(
1096
- (newValue) => store.setValue(newValue),
1097
- [store]
1096
+ const { locked, lock } = useTransitionLock();
1097
+ const onValueChange = (0, import_react6.useCallback)(
1098
+ (newValue) => {
1099
+ const isSwitching = !!store.getValue() && store.getValue() !== newValue && !!newValue;
1100
+ if (isSwitching) lock();
1101
+ store.setValue(newValue);
1102
+ },
1103
+ [store, lock]
1098
1104
  );
1099
- (0, import_react5.useLayoutEffect)(() => {
1105
+ (0, import_react6.useLayoutEffect)(() => {
1100
1106
  if (!value) return;
1101
1107
  pinToScrollTop(store.getItemRef(value));
1102
1108
  }, [value, store]);
1103
- (0, import_react5.useEffect)(() => {
1104
- if (switching) store.clearSwitching();
1105
- }, [switching, store]);
1106
1109
  return /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(ConcertinaContext.Provider, { value: store, children: /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
1107
1110
  Root2,
1108
1111
  {
@@ -1111,7 +1114,7 @@ var Root3 = (0, import_react5.forwardRef)(
1111
1114
  collapsible,
1112
1115
  value,
1113
1116
  onValueChange,
1114
- "data-switching": switching || void 0,
1117
+ "data-switching": locked || void 0,
1115
1118
  ...props,
1116
1119
  children
1117
1120
  }
@@ -1120,11 +1123,11 @@ var Root3 = (0, import_react5.forwardRef)(
1120
1123
  );
1121
1124
 
1122
1125
  // src/item.tsx
1123
- var import_react6 = require("react");
1126
+ var import_react7 = require("react");
1124
1127
  var import_jsx_runtime10 = require("react/jsx-runtime");
1125
- var Item2 = (0, import_react6.forwardRef)(function Item3({ value, ...props }, forwardedRef) {
1126
- const store = (0, import_react6.useContext)(ConcertinaContext);
1127
- const mergedRef = (0, import_react6.useCallback)(
1128
+ var Item2 = (0, import_react7.forwardRef)(function Item3({ value, ...props }, forwardedRef) {
1129
+ const store = (0, import_react7.useContext)(ConcertinaContext);
1130
+ const mergedRef = (0, import_react7.useCallback)(
1128
1131
  (el) => {
1129
1132
  if (typeof forwardedRef === "function") {
1130
1133
  forwardedRef(el);
@@ -1139,17 +1142,17 @@ var Item2 = (0, import_react6.forwardRef)(function Item3({ value, ...props }, fo
1139
1142
  });
1140
1143
 
1141
1144
  // src/content.tsx
1142
- var import_react7 = require("react");
1145
+ var import_react8 = require("react");
1143
1146
  var import_jsx_runtime11 = require("react/jsx-runtime");
1144
- var Content3 = (0, import_react7.forwardRef)(function Content4({ className, ...props }, ref) {
1147
+ var Content3 = (0, import_react8.forwardRef)(function Content4({ className, ...props }, ref) {
1145
1148
  const merged = className ? `concertina-content ${className}` : "concertina-content";
1146
1149
  return /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(Content2, { ref, className: merged, ...props });
1147
1150
  });
1148
1151
 
1149
1152
  // src/use-expanded.ts
1150
- var import_react8 = require("react");
1153
+ var import_react9 = require("react");
1151
1154
  function useStore() {
1152
- const store = (0, import_react8.useContext)(ConcertinaContext);
1155
+ const store = (0, import_react9.useContext)(ConcertinaContext);
1153
1156
  if (!store) {
1154
1157
  throw new Error("useExpanded must be used inside <Concertina.Root>");
1155
1158
  }
@@ -1157,7 +1160,7 @@ function useStore() {
1157
1160
  }
1158
1161
  function useExpanded(id) {
1159
1162
  const store = useStore();
1160
- return (0, import_react8.useSyncExternalStore)(
1163
+ return (0, import_react9.useSyncExternalStore)(
1161
1164
  store.subscribe,
1162
1165
  () => store.getValue() === id,
1163
1166
  () => false
@@ -1165,13 +1168,106 @@ function useExpanded(id) {
1165
1168
  );
1166
1169
  }
1167
1170
 
1171
+ // src/stable-slot.tsx
1172
+ var import_react10 = require("react");
1173
+ var import_jsx_runtime12 = require("react/jsx-runtime");
1174
+ var AxisContext = (0, import_react10.createContext)("both");
1175
+ var StableSlot = (0, import_react10.forwardRef)(
1176
+ function StableSlot2({ axis = "both", className, children, ...props }, ref) {
1177
+ 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 }) });
1179
+ }
1180
+ );
1181
+
1182
+ // src/slot.tsx
1183
+ var import_react11 = require("react");
1184
+ 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;
1193
+ }
1194
+ var Slot = (0, import_react11.forwardRef)(
1195
+ function Slot2({ active, style, children, ...props }, ref) {
1196
+ const axis = (0, import_react11.useContext)(AxisContext);
1197
+ const merged = active ? { ...style } : { ...inactiveStyle(axis), ...style };
1198
+ return /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(
1199
+ "div",
1200
+ {
1201
+ ref,
1202
+ inert: !active || void 0,
1203
+ style: merged,
1204
+ ...props,
1205
+ children
1206
+ }
1207
+ );
1208
+ }
1209
+ );
1210
+
1211
+ // src/use-stable-slot.ts
1212
+ var import_react12 = require("react");
1213
+ function useStableSlot(options = {}) {
1214
+ const { axis = "both" } = options;
1215
+ const [style, setStyle] = (0, import_react12.useState)({});
1216
+ const maxRef = (0, import_react12.useRef)({ w: 0, h: 0 });
1217
+ const observerRef = (0, import_react12.useRef)(null);
1218
+ const ref = (0, import_react12.useCallback)(
1219
+ (el) => {
1220
+ if (observerRef.current) {
1221
+ observerRef.current.disconnect();
1222
+ observerRef.current = null;
1223
+ }
1224
+ if (!el || typeof ResizeObserver === "undefined") return;
1225
+ const observer = new ResizeObserver((entries) => {
1226
+ for (const entry of entries) {
1227
+ let w;
1228
+ let h;
1229
+ if (entry.borderBoxSize?.length) {
1230
+ const box = entry.borderBoxSize[0];
1231
+ w = box.inlineSize;
1232
+ h = box.blockSize;
1233
+ } else {
1234
+ const rect = entry.target.getBoundingClientRect();
1235
+ w = rect.width;
1236
+ h = rect.height;
1237
+ }
1238
+ const max = maxRef.current;
1239
+ let grew = false;
1240
+ if ((axis === "width" || axis === "both") && w > max.w) {
1241
+ max.w = w;
1242
+ grew = true;
1243
+ }
1244
+ if ((axis === "height" || axis === "both") && h > max.h) {
1245
+ max.h = h;
1246
+ grew = true;
1247
+ }
1248
+ if (grew) {
1249
+ const next = {};
1250
+ if (axis === "width" || axis === "both") next.minWidth = max.w;
1251
+ if (axis === "height" || axis === "both") next.minHeight = max.h;
1252
+ setStyle(next);
1253
+ }
1254
+ }
1255
+ });
1256
+ observer.observe(el, { box: "border-box" });
1257
+ observerRef.current = observer;
1258
+ },
1259
+ [axis]
1260
+ );
1261
+ return { ref, style };
1262
+ }
1263
+
1168
1264
  // src/use-concertina.ts
1169
- var import_react9 = require("react");
1265
+ var import_react13 = require("react");
1170
1266
  function useConcertina() {
1171
- const [value, setValue] = (0, import_react9.useState)("");
1172
- const [switching, setSwitching] = (0, import_react9.useState)(false);
1173
- const itemRefs = (0, import_react9.useRef)({});
1174
- const onValueChange = (0, import_react9.useCallback)(
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)(
1175
1271
  (newValue) => {
1176
1272
  if (!newValue) {
1177
1273
  setSwitching(false);
@@ -1183,14 +1279,14 @@ function useConcertina() {
1183
1279
  },
1184
1280
  [value]
1185
1281
  );
1186
- (0, import_react9.useLayoutEffect)(() => {
1282
+ (0, import_react13.useLayoutEffect)(() => {
1187
1283
  if (!value) return;
1188
1284
  pinToScrollTop(itemRefs.current[value]);
1189
1285
  }, [value]);
1190
- (0, import_react9.useEffect)(() => {
1286
+ (0, import_react13.useEffect)(() => {
1191
1287
  if (switching) setSwitching(false);
1192
1288
  }, [switching]);
1193
- const getItemRef = (0, import_react9.useCallback)(
1289
+ const getItemRef = (0, import_react13.useCallback)(
1194
1290
  (id) => (el) => {
1195
1291
  itemRefs.current[id] = el;
1196
1292
  },
@@ -1211,8 +1307,12 @@ function useConcertina() {
1211
1307
  Header,
1212
1308
  Item,
1213
1309
  Root,
1310
+ Slot,
1311
+ StableSlot,
1214
1312
  Trigger,
1215
1313
  pinToScrollTop,
1216
1314
  useConcertina,
1217
- useExpanded
1315
+ useExpanded,
1316
+ useStableSlot,
1317
+ useTransitionLock
1218
1318
  });
package/dist/index.d.cts CHANGED
@@ -1,4 +1,5 @@
1
1
  import * as react from 'react';
2
+ import { HTMLAttributes, CSSProperties } from 'react';
2
3
  import * as Accordion from '@radix-ui/react-accordion';
3
4
  export { Header, Trigger } from '@radix-ui/react-accordion';
4
5
 
@@ -23,24 +24,108 @@ type Listener = () => void;
23
24
  /**
24
25
  * External store for concertina accordion state.
25
26
  * Lives outside React — one instance per Root.
26
- * Subscribers get notified on value or switching changes.
27
+ * Holds value + item refs. Switching logic moved to useTransitionLock.
27
28
  */
28
29
  declare class ConcertinaStore {
29
30
  private _value;
30
- private _switching;
31
31
  private _itemRefs;
32
32
  private _listeners;
33
33
  subscribe: (listener: Listener) => (() => void);
34
34
  private _notify;
35
35
  getValue: () => string;
36
- getSwitching: () => boolean;
37
36
  setValue(newValue: string): void;
38
- clearSwitching(): void;
39
37
  getItemRef(id: string): HTMLElement | null;
40
38
  setItemRef(id: string, el: HTMLElement | null): void;
41
39
  }
42
40
  declare const ConcertinaContext: react.Context<ConcertinaStore | null>;
43
41
 
42
+ type Axis = "width" | "height" | "both";
43
+ interface StableSlotProps extends HTMLAttributes<HTMLDivElement> {
44
+ /** Which axis to stabilize. Default: "both". */
45
+ axis?: Axis;
46
+ }
47
+ /**
48
+ * Grid container that auto-sizes to the largest child.
49
+ * All children overlap in the same grid cell (1/1).
50
+ * Use <Slot active={bool}> as children.
51
+ *
52
+ * Zero JS measurement — pure CSS grid sizing.
53
+ */
54
+ declare const StableSlot: react.ForwardRefExoticComponent<StableSlotProps & react.RefAttributes<HTMLDivElement>>;
55
+
56
+ interface SlotProps extends HTMLAttributes<HTMLDivElement> {
57
+ /** Whether this slot is the active (visible) variant. */
58
+ active: boolean;
59
+ }
60
+ /**
61
+ * A single variant inside a <StableSlot>.
62
+ * All slots overlap via CSS grid. Inactive slots are hidden
63
+ * but still contribute to grid cell sizing.
64
+ *
65
+ * Five things work together:
66
+ * 1. grid-area: 1/1 — all slots overlap in the same cell
67
+ * 2. visibility: hidden — invisible but in layout flow
68
+ * 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
+ */
72
+ declare const Slot: react.ForwardRefExoticComponent<SlotProps & react.RefAttributes<HTMLDivElement>>;
73
+
74
+ interface UseStableSlotOptions {
75
+ /** Which axis to ratchet. Default: "both". */
76
+ axis?: Axis;
77
+ }
78
+ interface UseStableSlotReturn {
79
+ /** RefCallback — attach to the container element. */
80
+ ref: (el: HTMLElement | null) => void;
81
+ /** Spread onto the element: { minWidth?, minHeight? } */
82
+ style: CSSProperties;
83
+ }
84
+ /**
85
+ * ResizeObserver ratchet for dynamic content.
86
+ *
87
+ * Watches the element, tracks maximum width/height ever observed,
88
+ * applies min-width/min-height that only ratchets up.
89
+ *
90
+ * Five things work together:
91
+ * 1. ResizeObserver uses borderBoxSize — includes padding/border
92
+ * 2. Ratchet is one-way — max only increases, never resets
93
+ * 3. setStyle only called when ratchet grows — no infinite loops
94
+ * 4. RefCallback disconnects observer on unmount — no leak
95
+ * 5. SSR graceful no-op — typeof ResizeObserver guard
96
+ */
97
+ declare function useStableSlot(options?: UseStableSlotOptions): UseStableSlotReturn;
98
+
99
+ /**
100
+ * Suppress CSS transitions during batched state changes.
101
+ *
102
+ * Three things work together:
103
+ * 1. lock() sets the flag synchronously — batched with state changes in React 18
104
+ * 2. After DOM commit (useLayoutEffect window) — consumer does measurement/scroll/pin work
105
+ * 3. useEffect auto-clears the flag after paint — transitions re-enable
106
+ *
107
+ * Usage:
108
+ * const { locked, lock } = useTransitionLock();
109
+ * <div data-locked={locked || undefined}>...</div>
110
+ */
111
+ declare function useTransitionLock(): {
112
+ readonly locked: boolean;
113
+ readonly lock: () => void;
114
+ };
115
+
116
+ /**
117
+ * Scroll `el` to the top of its nearest scrollable ancestor,
118
+ * clearing any sticky headers. Only adjusts one container's
119
+ * scrollTop. Never cascades to the viewport, which matters on
120
+ * mobile where scrollIntoView pulls the whole page.
121
+ *
122
+ * Skips elements that have overflow: auto/scroll in CSS but
123
+ * don't actually scroll (scrollHeight <= clientHeight). Without
124
+ * this check, a non-scrolling ancestor with overflow-auto traps
125
+ * the walk and the real scroll container never gets adjusted.
126
+ */
127
+ declare function pinToScrollTop(el: HTMLElement | null): void;
128
+
44
129
  interface ConcertinaRootProps {
45
130
  value: string;
46
131
  onValueChange: (value: string) => void;
@@ -70,17 +155,4 @@ interface UseConcertinaReturn {
70
155
  */
71
156
  declare function useConcertina(): UseConcertinaReturn;
72
157
 
73
- /**
74
- * Scroll `el` to the top of its nearest scrollable ancestor,
75
- * clearing any sticky headers. Only adjusts one container's
76
- * scrollTop. Never cascades to the viewport, which matters on
77
- * mobile where scrollIntoView pulls the whole page.
78
- *
79
- * Skips elements that have overflow: auto/scroll in CSS but
80
- * don't actually scroll (scrollHeight <= clientHeight). Without
81
- * this check, a non-scrolling ancestor with overflow-auto traps
82
- * the walk and the real scroll container never gets adjusted.
83
- */
84
- declare function pinToScrollTop(el: HTMLElement | null): void;
85
-
86
- export { ConcertinaContext, type ConcertinaRootProps, ConcertinaStore, Content, Item, Root, type UseConcertinaReturn, pinToScrollTop, useConcertina, useExpanded };
158
+ export { type Axis, ConcertinaContext, type ConcertinaRootProps, ConcertinaStore, Content, Item, Root, Slot, type SlotProps, StableSlot, type StableSlotProps, type UseConcertinaReturn, pinToScrollTop, useConcertina, useExpanded, useStableSlot, useTransitionLock };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import * as react from 'react';
2
+ import { HTMLAttributes, CSSProperties } from 'react';
2
3
  import * as Accordion from '@radix-ui/react-accordion';
3
4
  export { Header, Trigger } from '@radix-ui/react-accordion';
4
5
 
@@ -23,24 +24,108 @@ type Listener = () => void;
23
24
  /**
24
25
  * External store for concertina accordion state.
25
26
  * Lives outside React — one instance per Root.
26
- * Subscribers get notified on value or switching changes.
27
+ * Holds value + item refs. Switching logic moved to useTransitionLock.
27
28
  */
28
29
  declare class ConcertinaStore {
29
30
  private _value;
30
- private _switching;
31
31
  private _itemRefs;
32
32
  private _listeners;
33
33
  subscribe: (listener: Listener) => (() => void);
34
34
  private _notify;
35
35
  getValue: () => string;
36
- getSwitching: () => boolean;
37
36
  setValue(newValue: string): void;
38
- clearSwitching(): void;
39
37
  getItemRef(id: string): HTMLElement | null;
40
38
  setItemRef(id: string, el: HTMLElement | null): void;
41
39
  }
42
40
  declare const ConcertinaContext: react.Context<ConcertinaStore | null>;
43
41
 
42
+ type Axis = "width" | "height" | "both";
43
+ interface StableSlotProps extends HTMLAttributes<HTMLDivElement> {
44
+ /** Which axis to stabilize. Default: "both". */
45
+ axis?: Axis;
46
+ }
47
+ /**
48
+ * Grid container that auto-sizes to the largest child.
49
+ * All children overlap in the same grid cell (1/1).
50
+ * Use <Slot active={bool}> as children.
51
+ *
52
+ * Zero JS measurement — pure CSS grid sizing.
53
+ */
54
+ declare const StableSlot: react.ForwardRefExoticComponent<StableSlotProps & react.RefAttributes<HTMLDivElement>>;
55
+
56
+ interface SlotProps extends HTMLAttributes<HTMLDivElement> {
57
+ /** Whether this slot is the active (visible) variant. */
58
+ active: boolean;
59
+ }
60
+ /**
61
+ * A single variant inside a <StableSlot>.
62
+ * All slots overlap via CSS grid. Inactive slots are hidden
63
+ * but still contribute to grid cell sizing.
64
+ *
65
+ * Five things work together:
66
+ * 1. grid-area: 1/1 — all slots overlap in the same cell
67
+ * 2. visibility: hidden — invisible but in layout flow
68
+ * 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
+ */
72
+ declare const Slot: react.ForwardRefExoticComponent<SlotProps & react.RefAttributes<HTMLDivElement>>;
73
+
74
+ interface UseStableSlotOptions {
75
+ /** Which axis to ratchet. Default: "both". */
76
+ axis?: Axis;
77
+ }
78
+ interface UseStableSlotReturn {
79
+ /** RefCallback — attach to the container element. */
80
+ ref: (el: HTMLElement | null) => void;
81
+ /** Spread onto the element: { minWidth?, minHeight? } */
82
+ style: CSSProperties;
83
+ }
84
+ /**
85
+ * ResizeObserver ratchet for dynamic content.
86
+ *
87
+ * Watches the element, tracks maximum width/height ever observed,
88
+ * applies min-width/min-height that only ratchets up.
89
+ *
90
+ * Five things work together:
91
+ * 1. ResizeObserver uses borderBoxSize — includes padding/border
92
+ * 2. Ratchet is one-way — max only increases, never resets
93
+ * 3. setStyle only called when ratchet grows — no infinite loops
94
+ * 4. RefCallback disconnects observer on unmount — no leak
95
+ * 5. SSR graceful no-op — typeof ResizeObserver guard
96
+ */
97
+ declare function useStableSlot(options?: UseStableSlotOptions): UseStableSlotReturn;
98
+
99
+ /**
100
+ * Suppress CSS transitions during batched state changes.
101
+ *
102
+ * Three things work together:
103
+ * 1. lock() sets the flag synchronously — batched with state changes in React 18
104
+ * 2. After DOM commit (useLayoutEffect window) — consumer does measurement/scroll/pin work
105
+ * 3. useEffect auto-clears the flag after paint — transitions re-enable
106
+ *
107
+ * Usage:
108
+ * const { locked, lock } = useTransitionLock();
109
+ * <div data-locked={locked || undefined}>...</div>
110
+ */
111
+ declare function useTransitionLock(): {
112
+ readonly locked: boolean;
113
+ readonly lock: () => void;
114
+ };
115
+
116
+ /**
117
+ * Scroll `el` to the top of its nearest scrollable ancestor,
118
+ * clearing any sticky headers. Only adjusts one container's
119
+ * scrollTop. Never cascades to the viewport, which matters on
120
+ * mobile where scrollIntoView pulls the whole page.
121
+ *
122
+ * Skips elements that have overflow: auto/scroll in CSS but
123
+ * don't actually scroll (scrollHeight <= clientHeight). Without
124
+ * this check, a non-scrolling ancestor with overflow-auto traps
125
+ * the walk and the real scroll container never gets adjusted.
126
+ */
127
+ declare function pinToScrollTop(el: HTMLElement | null): void;
128
+
44
129
  interface ConcertinaRootProps {
45
130
  value: string;
46
131
  onValueChange: (value: string) => void;
@@ -70,17 +155,4 @@ interface UseConcertinaReturn {
70
155
  */
71
156
  declare function useConcertina(): UseConcertinaReturn;
72
157
 
73
- /**
74
- * Scroll `el` to the top of its nearest scrollable ancestor,
75
- * clearing any sticky headers. Only adjusts one container's
76
- * scrollTop. Never cascades to the viewport, which matters on
77
- * mobile where scrollIntoView pulls the whole page.
78
- *
79
- * Skips elements that have overflow: auto/scroll in CSS but
80
- * don't actually scroll (scrollHeight <= clientHeight). Without
81
- * this check, a non-scrolling ancestor with overflow-auto traps
82
- * the walk and the real scroll container never gets adjusted.
83
- */
84
- declare function pinToScrollTop(el: HTMLElement | null): void;
85
-
86
- export { ConcertinaContext, type ConcertinaRootProps, ConcertinaStore, Content, Item, Root, type UseConcertinaReturn, pinToScrollTop, useConcertina, useExpanded };
158
+ export { type Axis, ConcertinaContext, type ConcertinaRootProps, ConcertinaStore, Content, Item, Root, Slot, type SlotProps, StableSlot, type StableSlotProps, type UseConcertinaReturn, pinToScrollTop, useConcertina, useExpanded, useStableSlot, useTransitionLock };
package/dist/index.js CHANGED
@@ -3,9 +3,8 @@ import {
3
3
  forwardRef as forwardRef4,
4
4
  useRef as useRef5,
5
5
  useLayoutEffect as useLayoutEffect3,
6
- useEffect as useEffect5,
7
6
  useSyncExternalStore,
8
- useCallback as useCallback5
7
+ useCallback as useCallback6
9
8
  } from "react";
10
9
 
11
10
  // node_modules/@radix-ui/react-accordion/dist/index.mjs
@@ -118,7 +117,7 @@ import { Fragment as Fragment2, jsx as jsx2 } from "react/jsx-runtime";
118
117
  // @__NO_SIDE_EFFECTS__
119
118
  function createSlot(ownerName) {
120
119
  const SlotClone = /* @__PURE__ */ createSlotClone(ownerName);
121
- const Slot2 = React3.forwardRef((props, forwardedRef) => {
120
+ const Slot22 = React3.forwardRef((props, forwardedRef) => {
122
121
  const { children, ...slotProps } = props;
123
122
  const childrenArray = React3.Children.toArray(children);
124
123
  const slottable = childrenArray.find(isSlottable);
@@ -136,8 +135,8 @@ function createSlot(ownerName) {
136
135
  }
137
136
  return /* @__PURE__ */ jsx2(SlotClone, { ...slotProps, ref: forwardedRef, children });
138
137
  });
139
- Slot2.displayName = `${ownerName}.Slot`;
140
- return Slot2;
138
+ Slot22.displayName = `${ownerName}.Slot`;
139
+ return Slot22;
141
140
  }
142
141
  // @__NO_SIDE_EFFECTS__
143
142
  function createSlotClone(ownerName) {
@@ -375,10 +374,10 @@ var NODES = [
375
374
  "ul"
376
375
  ];
377
376
  var Primitive = NODES.reduce((primitive, node) => {
378
- const Slot = createSlot(`Primitive.${node}`);
377
+ const Slot3 = createSlot(`Primitive.${node}`);
379
378
  const Node2 = React7.forwardRef((props, forwardedRef) => {
380
379
  const { asChild, ...primitiveProps } = props;
381
- const Comp = asChild ? Slot : node;
380
+ const Comp = asChild ? Slot3 : node;
382
381
  if (typeof window !== "undefined") {
383
382
  window[/* @__PURE__ */ Symbol.for("radix-ui")] = true;
384
383
  }
@@ -972,7 +971,6 @@ import { createContext as createContext3 } from "react";
972
971
  var ConcertinaStore = class {
973
972
  constructor() {
974
973
  this._value = "";
975
- this._switching = false;
976
974
  this._itemRefs = {};
977
975
  this._listeners = /* @__PURE__ */ new Set();
978
976
  this.subscribe = (listener) => {
@@ -980,22 +978,14 @@ var ConcertinaStore = class {
980
978
  return () => this._listeners.delete(listener);
981
979
  };
982
980
  this.getValue = () => this._value;
983
- this.getSwitching = () => this._switching;
984
981
  }
985
982
  _notify() {
986
983
  for (const listener of this._listeners) listener();
987
984
  }
988
985
  setValue(newValue) {
989
- const wasSwitching = !!this._value && this._value !== newValue && !!newValue;
990
- this._switching = wasSwitching;
991
986
  this._value = newValue || "";
992
987
  this._notify();
993
988
  }
994
- clearSwitching() {
995
- if (!this._switching) return;
996
- this._switching = false;
997
- this._notify();
998
- }
999
989
  getItemRef(id) {
1000
990
  return this._itemRefs[id] ?? null;
1001
991
  }
@@ -1035,6 +1025,17 @@ function pinToScrollTop(el) {
1035
1025
  }
1036
1026
  }
1037
1027
 
1028
+ // src/use-transition-lock.ts
1029
+ import { useState as useState5, useEffect as useEffect5, useCallback as useCallback5 } from "react";
1030
+ function useTransitionLock() {
1031
+ const [locked, setLocked] = useState5(false);
1032
+ const lock = useCallback5(() => setLocked(true), []);
1033
+ useEffect5(() => {
1034
+ if (locked) setLocked(false);
1035
+ }, [locked]);
1036
+ return { locked, lock };
1037
+ }
1038
+
1038
1039
  // src/root.tsx
1039
1040
  import { jsx as jsx8 } from "react/jsx-runtime";
1040
1041
  var Root3 = forwardRef4(
@@ -1049,22 +1050,19 @@ var Root3 = forwardRef4(
1049
1050
  store.getValue,
1050
1051
  store.getValue
1051
1052
  );
1052
- const switching = useSyncExternalStore(
1053
- store.subscribe,
1054
- store.getSwitching,
1055
- store.getSwitching
1056
- );
1057
- const onValueChange = useCallback5(
1058
- (newValue) => store.setValue(newValue),
1059
- [store]
1053
+ const { locked, lock } = useTransitionLock();
1054
+ const onValueChange = useCallback6(
1055
+ (newValue) => {
1056
+ const isSwitching = !!store.getValue() && store.getValue() !== newValue && !!newValue;
1057
+ if (isSwitching) lock();
1058
+ store.setValue(newValue);
1059
+ },
1060
+ [store, lock]
1060
1061
  );
1061
1062
  useLayoutEffect3(() => {
1062
1063
  if (!value) return;
1063
1064
  pinToScrollTop(store.getItemRef(value));
1064
1065
  }, [value, store]);
1065
- useEffect5(() => {
1066
- if (switching) store.clearSwitching();
1067
- }, [switching, store]);
1068
1066
  return /* @__PURE__ */ jsx8(ConcertinaContext.Provider, { value: store, children: /* @__PURE__ */ jsx8(
1069
1067
  Root2,
1070
1068
  {
@@ -1073,7 +1071,7 @@ var Root3 = forwardRef4(
1073
1071
  collapsible,
1074
1072
  value,
1075
1073
  onValueChange,
1076
- "data-switching": switching || void 0,
1074
+ "data-switching": locked || void 0,
1077
1075
  ...props,
1078
1076
  children
1079
1077
  }
@@ -1085,12 +1083,12 @@ var Root3 = forwardRef4(
1085
1083
  import {
1086
1084
  forwardRef as forwardRef5,
1087
1085
  useContext as useContext3,
1088
- useCallback as useCallback6
1086
+ useCallback as useCallback7
1089
1087
  } from "react";
1090
1088
  import { jsx as jsx9 } from "react/jsx-runtime";
1091
1089
  var Item2 = forwardRef5(function Item3({ value, ...props }, forwardedRef) {
1092
1090
  const store = useContext3(ConcertinaContext);
1093
- const mergedRef = useCallback6(
1091
+ const mergedRef = useCallback7(
1094
1092
  (el) => {
1095
1093
  if (typeof forwardedRef === "function") {
1096
1094
  forwardedRef(el);
@@ -1131,19 +1129,118 @@ function useExpanded(id) {
1131
1129
  );
1132
1130
  }
1133
1131
 
1132
+ // src/stable-slot.tsx
1133
+ import {
1134
+ forwardRef as forwardRef7,
1135
+ createContext as createContext4
1136
+ } from "react";
1137
+ import { jsx as jsx11 } from "react/jsx-runtime";
1138
+ var AxisContext = createContext4("both");
1139
+ var StableSlot = forwardRef7(
1140
+ function StableSlot2({ axis = "both", className, children, ...props }, ref) {
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 }) });
1143
+ }
1144
+ );
1145
+
1146
+ // src/slot.tsx
1147
+ import {
1148
+ forwardRef as forwardRef8,
1149
+ useContext as useContext5
1150
+ } from "react";
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;
1160
+ }
1161
+ var Slot = forwardRef8(
1162
+ function Slot2({ active, style, children, ...props }, ref) {
1163
+ const axis = useContext5(AxisContext);
1164
+ const merged = active ? { ...style } : { ...inactiveStyle(axis), ...style };
1165
+ return /* @__PURE__ */ jsx12(
1166
+ "div",
1167
+ {
1168
+ ref,
1169
+ inert: !active || void 0,
1170
+ style: merged,
1171
+ ...props,
1172
+ children
1173
+ }
1174
+ );
1175
+ }
1176
+ );
1177
+
1178
+ // src/use-stable-slot.ts
1179
+ import { useState as useState6, useCallback as useCallback8, useRef as useRef6 } from "react";
1180
+ function useStableSlot(options = {}) {
1181
+ const { axis = "both" } = options;
1182
+ const [style, setStyle] = useState6({});
1183
+ const maxRef = useRef6({ w: 0, h: 0 });
1184
+ const observerRef = useRef6(null);
1185
+ const ref = useCallback8(
1186
+ (el) => {
1187
+ if (observerRef.current) {
1188
+ observerRef.current.disconnect();
1189
+ observerRef.current = null;
1190
+ }
1191
+ if (!el || typeof ResizeObserver === "undefined") return;
1192
+ const observer = new ResizeObserver((entries) => {
1193
+ for (const entry of entries) {
1194
+ let w;
1195
+ let h;
1196
+ if (entry.borderBoxSize?.length) {
1197
+ const box = entry.borderBoxSize[0];
1198
+ w = box.inlineSize;
1199
+ h = box.blockSize;
1200
+ } else {
1201
+ const rect = entry.target.getBoundingClientRect();
1202
+ w = rect.width;
1203
+ h = rect.height;
1204
+ }
1205
+ const max = maxRef.current;
1206
+ let grew = false;
1207
+ if ((axis === "width" || axis === "both") && w > max.w) {
1208
+ max.w = w;
1209
+ grew = true;
1210
+ }
1211
+ if ((axis === "height" || axis === "both") && h > max.h) {
1212
+ max.h = h;
1213
+ grew = true;
1214
+ }
1215
+ if (grew) {
1216
+ const next = {};
1217
+ if (axis === "width" || axis === "both") next.minWidth = max.w;
1218
+ if (axis === "height" || axis === "both") next.minHeight = max.h;
1219
+ setStyle(next);
1220
+ }
1221
+ }
1222
+ });
1223
+ observer.observe(el, { box: "border-box" });
1224
+ observerRef.current = observer;
1225
+ },
1226
+ [axis]
1227
+ );
1228
+ return { ref, style };
1229
+ }
1230
+
1134
1231
  // src/use-concertina.ts
1135
1232
  import {
1136
- useState as useState5,
1137
- useCallback as useCallback7,
1138
- useRef as useRef6,
1233
+ useState as useState7,
1234
+ useCallback as useCallback9,
1235
+ useRef as useRef7,
1139
1236
  useLayoutEffect as useLayoutEffect4,
1140
1237
  useEffect as useEffect6
1141
1238
  } from "react";
1142
1239
  function useConcertina() {
1143
- const [value, setValue] = useState5("");
1144
- const [switching, setSwitching] = useState5(false);
1145
- const itemRefs = useRef6({});
1146
- const onValueChange = useCallback7(
1240
+ const [value, setValue] = useState7("");
1241
+ const [switching, setSwitching] = useState7(false);
1242
+ const itemRefs = useRef7({});
1243
+ const onValueChange = useCallback9(
1147
1244
  (newValue) => {
1148
1245
  if (!newValue) {
1149
1246
  setSwitching(false);
@@ -1162,7 +1259,7 @@ function useConcertina() {
1162
1259
  useEffect6(() => {
1163
1260
  if (switching) setSwitching(false);
1164
1261
  }, [switching]);
1165
- const getItemRef = useCallback7(
1262
+ const getItemRef = useCallback9(
1166
1263
  (id) => (el) => {
1167
1264
  itemRefs.current[id] = el;
1168
1265
  },
@@ -1182,8 +1279,12 @@ export {
1182
1279
  Header,
1183
1280
  Item2 as Item,
1184
1281
  Root3 as Root,
1282
+ Slot,
1283
+ StableSlot,
1185
1284
  Trigger2 as Trigger,
1186
1285
  pinToScrollTop,
1187
1286
  useConcertina,
1188
- useExpanded
1287
+ useExpanded,
1288
+ useStableSlot,
1289
+ useTransitionLock
1189
1290
  };
package/dist/styles.css CHANGED
@@ -52,3 +52,12 @@
52
52
  [data-switching] .concertina-content[data-state="open"] {
53
53
  animation-duration: 0s;
54
54
  }
55
+
56
+ /* StableSlot — all children overlap in the same grid cell.
57
+ Grid auto-sizes to the largest child. */
58
+ .concertina-stable-slot {
59
+ display: grid;
60
+ }
61
+ .concertina-stable-slot > * {
62
+ grid-area: 1 / 1;
63
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "concertina",
3
- "version": "0.4.1",
4
- "description": "Component API + hook for scroll-pinned Radix Accordion panels with per-item memoization.",
3
+ "version": "0.5.0",
4
+ "description": "Layout stability primitives + scroll-pinned Radix Accordion panels with per-item memoization.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
7
7
  "module": "./dist/index.js",
@@ -52,7 +52,10 @@
52
52
  "react",
53
53
  "hook",
54
54
  "memoization",
55
- "useSyncExternalStore"
55
+ "useSyncExternalStore",
56
+ "layout-stability",
57
+ "stable-slot",
58
+ "resize-observer"
56
59
  ],
57
60
  "license": "MIT",
58
61
  "repository": {