stablekit.ts 0.4.0 → 0.5.1

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
@@ -48,10 +48,10 @@ If a component depends on async data, its bounding box is declared synchronously
48
48
 
49
49
  ### Spatial Pre-allocation
50
50
 
51
- If a UI region has multiple states, all states render simultaneously in a CSS grid overlap. The container sizes to the largest. `LayoutMap` renders a dictionary of views, toggles visibility with `[inert]` + `data-state`, and never changes dimensions. `StateSwap` does the same for boolean content inside buttons and labels.
51
+ If a UI region has multiple states, all states render simultaneously in a CSS grid overlap. The container sizes to the largest. `StateMap` renders a dictionary of views, toggles visibility with `[inert]` + `data-state`, and never changes dimensions. `StateSwap` is a boolean convenience wrapper around `StateMap` for two-state toggles.
52
52
 
53
53
  ```tsx
54
- <LayoutMap value={activeTab} map={{
54
+ <StateMap value={activeTab} map={{
55
55
  profile: <Profile />,
56
56
  invoices: <Invoices />,
57
57
  settings: <Settings />,
@@ -74,9 +74,10 @@ Once a container expands, it cannot shrink unless explicitly reset. `SizeRatchet
74
74
  | `StableText` | Typography + skeleton in one tag |
75
75
  | `MediaSkeleton` | Aspect-ratio placeholder that constrains its child |
76
76
  | `CollectionSkeleton` | Loading-aware list with forced stub count |
77
- | `LayoutMap` | Type-safe dictionary of views with stable dimensions |
78
- | `LayoutGroup` + `LayoutView` | Multi-state spatial container (use LayoutMap when possible) |
79
- | `StateSwap` | Boolean content swap both options rendered, zero shift |
77
+ | `StateMap` | Multi-state content swap keyed dictionary, inline, zero shift |
78
+ | `StateSwap` | Boolean content swap thin wrapper around StateMap |
79
+ | `LayoutMap` | Block-level dictionary of views (tab panels, wizards) |
80
+ | `LayoutGroup` + `LayoutView` | Manual multi-state container (use StateMap or LayoutMap) |
80
81
  | `StableCounter` | Numeric/text width pre-allocation via ghost reserve |
81
82
  | `StableField` | Form error height pre-allocation via ghost reserve |
82
83
  | `SizeRatchet` | Container that never shrinks (ResizeObserver ratchet) |
package/dist/eslint.cjs CHANGED
@@ -190,7 +190,7 @@ function createArchitectureLint(options) {
190
190
  // --- 4. Geometric instability (conditional content) ---
191
191
  {
192
192
  selector: ":matches(JSXElement, JSXFragment) > JSXExpressionContainer > ConditionalExpression",
193
- message: "Conditional content in JSX children. Extract to a const above the return (the linter won't flag a plain variable). Do NOT replace with the hidden attribute. For state-driven swaps, use <StateSwap> for text, <LayoutMap> for keyed views, or <LoadingBoundary> for async states."
193
+ message: "Conditional content in JSX children. Extract to a const above the return (the linter won't flag a plain variable). Do NOT replace with the hidden attribute. For state-driven swaps, use <StateSwap> for text, <StateMap> for keyed views, or <LoadingBoundary> for async states."
194
194
  },
195
195
  {
196
196
  selector: ":matches(JSXElement, JSXFragment) > JSXExpressionContainer > LogicalExpression[operator='&&']",
@@ -208,6 +208,11 @@ function createArchitectureLint(options) {
208
208
  selector: ":matches(JSXElement, JSXFragment) > JSXExpressionContainer > TemplateLiteral",
209
209
  message: "Interpolated text in JSX children. Extract to a const above the return (the linter won't flag a plain variable). For state-driven values, use <StableCounter> for numbers or <StateSwap> for text variants."
210
210
  },
211
+ // --- 4b. Conditional hidden prop (geometric instability) ---
212
+ {
213
+ selector: "JSXAttribute[name.name='hidden'] > JSXExpressionContainer > :not(Literal)",
214
+ message: "Conditional hidden prop causes layout shift \u2014 the element occupies zero space when hidden, then expands when shown. Use <StateSwap> to pre-allocate geometry for all visual states."
215
+ },
211
216
  // --- 5. className on firewalled components ---
212
217
  ...classNameBlocked.length ? [
213
218
  {
package/dist/eslint.d.cts CHANGED
@@ -22,9 +22,10 @@
22
22
  *
23
23
  * 4. Geometric instability — conditional content in JSX children that
24
24
  * causes layout shift: ternary swaps, && mounts, || / ?? fallbacks,
25
- * and interpolated template literals. Each message guides toward
26
- * extracting the expression to a variable (for data transforms) or
27
- * using a StableKit component (for state-driven swaps). Always on.
25
+ * interpolated template literals, and conditional hidden props.
26
+ * Each message guides toward extracting the expression to a variable
27
+ * (for data transforms) or using a StableKit component (for
28
+ * state-driven swaps). Always on.
28
29
  *
29
30
  * 5. className on custom components — passing className to a PascalCase
30
31
  * component is a presentation leak across the Structure boundary.
package/dist/eslint.d.ts CHANGED
@@ -22,9 +22,10 @@
22
22
  *
23
23
  * 4. Geometric instability — conditional content in JSX children that
24
24
  * causes layout shift: ternary swaps, && mounts, || / ?? fallbacks,
25
- * and interpolated template literals. Each message guides toward
26
- * extracting the expression to a variable (for data transforms) or
27
- * using a StableKit component (for state-driven swaps). Always on.
25
+ * interpolated template literals, and conditional hidden props.
26
+ * Each message guides toward extracting the expression to a variable
27
+ * (for data transforms) or using a StableKit component (for
28
+ * state-driven swaps). Always on.
28
29
  *
29
30
  * 5. className on custom components — passing className to a PascalCase
30
31
  * component is a presentation leak across the Structure boundary.
package/dist/eslint.js CHANGED
@@ -166,7 +166,7 @@ function createArchitectureLint(options) {
166
166
  // --- 4. Geometric instability (conditional content) ---
167
167
  {
168
168
  selector: ":matches(JSXElement, JSXFragment) > JSXExpressionContainer > ConditionalExpression",
169
- message: "Conditional content in JSX children. Extract to a const above the return (the linter won't flag a plain variable). Do NOT replace with the hidden attribute. For state-driven swaps, use <StateSwap> for text, <LayoutMap> for keyed views, or <LoadingBoundary> for async states."
169
+ message: "Conditional content in JSX children. Extract to a const above the return (the linter won't flag a plain variable). Do NOT replace with the hidden attribute. For state-driven swaps, use <StateSwap> for text, <StateMap> for keyed views, or <LoadingBoundary> for async states."
170
170
  },
171
171
  {
172
172
  selector: ":matches(JSXElement, JSXFragment) > JSXExpressionContainer > LogicalExpression[operator='&&']",
@@ -184,6 +184,11 @@ function createArchitectureLint(options) {
184
184
  selector: ":matches(JSXElement, JSXFragment) > JSXExpressionContainer > TemplateLiteral",
185
185
  message: "Interpolated text in JSX children. Extract to a const above the return (the linter won't flag a plain variable). For state-driven values, use <StableCounter> for numbers or <StateSwap> for text variants."
186
186
  },
187
+ // --- 4b. Conditional hidden prop (geometric instability) ---
188
+ {
189
+ selector: "JSXAttribute[name.name='hidden'] > JSXExpressionContainer > :not(Literal)",
190
+ message: "Conditional hidden prop causes layout shift \u2014 the element occupies zero space when hidden, then expands when shown. Use <StateSwap> to pre-allocate geometry for all visual states."
191
+ },
187
192
  // --- 5. className on firewalled components ---
188
193
  ...classNameBlocked.length ? [
189
194
  {
package/dist/index.cjs CHANGED
@@ -32,6 +32,7 @@ __export(index_exports, {
32
32
  StableCounter: () => StableCounter,
33
33
  StableField: () => StableField,
34
34
  StableText: () => StableText,
35
+ StateMap: () => StateMap,
35
36
  StateSwap: () => StateSwap,
36
37
  TextSkeleton: () => TextSkeleton,
37
38
  createPrimitive: () => createPrimitive,
@@ -180,25 +181,39 @@ var LayoutView = (0, import_react2.forwardRef)(
180
181
  }
181
182
  );
182
183
 
183
- // src/components/state-swap.tsx
184
+ // src/components/state-map.tsx
184
185
  var import_jsx_runtime3 = require("react/jsx-runtime");
186
+ function StateMap({
187
+ value,
188
+ map,
189
+ as: Tag = "span",
190
+ ...props
191
+ }) {
192
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(LayoutGroup, { as: Tag, value, axis: "both", "data-inline": true, ...props, children: Object.entries(map).map(([key, node]) => /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(LayoutView, { as: "span", name: key, children: node }, key)) });
193
+ }
194
+
195
+ // src/components/state-swap.tsx
196
+ var import_jsx_runtime4 = require("react/jsx-runtime");
185
197
  function StateSwap({
186
198
  state,
187
199
  true: onTrue,
188
200
  false: onFalse,
189
- as: Tag = "span",
190
201
  ...props
191
202
  }) {
192
- return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(LayoutGroup, { as: Tag, value: state ? "true" : "false", axis: "both", "data-inline": true, ...props, children: [
193
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(LayoutView, { as: "span", name: "true", children: onTrue }),
194
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(LayoutView, { as: "span", name: "false", children: onFalse })
195
- ] });
203
+ return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
204
+ StateMap,
205
+ {
206
+ value: state ? "true" : "false",
207
+ map: { true: onTrue, false: onFalse },
208
+ ...props
209
+ }
210
+ );
196
211
  }
197
212
 
198
213
  // src/components/layout-map.tsx
199
- var import_jsx_runtime4 = require("react/jsx-runtime");
214
+ var import_jsx_runtime5 = require("react/jsx-runtime");
200
215
  function LayoutMap({ value, map, ...props }) {
201
- return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(LayoutGroup, { value, ...props, children: Object.entries(map).map(([key, node]) => /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(LayoutView, { name: key, children: node }, key)) });
216
+ return /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(LayoutGroup, { value, ...props, children: Object.entries(map).map(([key, node]) => /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(LayoutView, { name: key, children: node }, key)) });
202
217
  }
203
218
 
204
219
  // src/components/size-ratchet.tsx
@@ -267,13 +282,13 @@ function useStableSlot(options = {}) {
267
282
  }
268
283
 
269
284
  // src/components/size-ratchet.tsx
270
- var import_jsx_runtime5 = require("react/jsx-runtime");
285
+ var import_jsx_runtime6 = require("react/jsx-runtime");
271
286
  var SizeRatchet = (0, import_react4.forwardRef)(
272
287
  function SizeRatchet2({ axis = "height", resetKey, as: Tag = "div", className, style, children, ...props }, fwdRef) {
273
288
  (0, import_react4.useInsertionEffect)(injectStyles, []);
274
289
  const { ref: ratchetRef, style: ratchetStyle } = useStableSlot({ axis, resetKey });
275
290
  const merged = className ? `sk-size-ratchet ${className}` : "sk-size-ratchet";
276
- return /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(
291
+ return /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
277
292
  Tag,
278
293
  {
279
294
  ref: mergeRefs(ratchetRef, fwdRef),
@@ -288,7 +303,7 @@ var SizeRatchet = (0, import_react4.forwardRef)(
288
303
 
289
304
  // src/components/stable-counter.tsx
290
305
  var import_react5 = require("react");
291
- var import_jsx_runtime6 = require("react/jsx-runtime");
306
+ var import_jsx_runtime7 = require("react/jsx-runtime");
292
307
  var GHOST_STYLE = {
293
308
  visibility: "hidden",
294
309
  gridArea: "1 / 1",
@@ -299,7 +314,7 @@ var VALUE_STYLE = {
299
314
  };
300
315
  var StableCounter = (0, import_react5.forwardRef)(
301
316
  function StableCounter2({ value, reserve, as: Tag = "span", style, className, ...props }, ref) {
302
- return /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)(
317
+ return /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)(
303
318
  Tag,
304
319
  {
305
320
  ref,
@@ -307,8 +322,8 @@ var StableCounter = (0, import_react5.forwardRef)(
307
322
  style: { ...style, display: "inline-grid" },
308
323
  ...props,
309
324
  children: [
310
- /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("span", { "aria-hidden": "true", style: GHOST_STYLE, children: reserve }),
311
- /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("span", { style: VALUE_STYLE, children: value })
325
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("span", { "aria-hidden": "true", style: GHOST_STYLE, children: reserve }),
326
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("span", { style: VALUE_STYLE, children: value })
312
327
  ]
313
328
  }
314
329
  );
@@ -317,7 +332,7 @@ var StableCounter = (0, import_react5.forwardRef)(
317
332
 
318
333
  // src/components/stable-field.tsx
319
334
  var import_react6 = require("react");
320
- var import_jsx_runtime7 = require("react/jsx-runtime");
335
+ var import_jsx_runtime8 = require("react/jsx-runtime");
321
336
  var GHOST_STYLE2 = {
322
337
  visibility: "hidden",
323
338
  gridArea: "1 / 1",
@@ -333,11 +348,11 @@ var ERROR_HIDDEN_STYLE = {
333
348
  var StableField = (0, import_react6.forwardRef)(
334
349
  function StableField2({ error, reserve, children, style, className, ...props }, ref) {
335
350
  const hasError = error != null && error !== false && error !== "";
336
- return /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("div", { ref, className, style, ...props, children: [
351
+ return /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("div", { ref, className, style, ...props, children: [
337
352
  children,
338
- /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("div", { style: { display: "grid" }, children: [
339
- /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("span", { "aria-hidden": "true", style: GHOST_STYLE2, children: reserve }),
340
- /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
353
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("div", { style: { display: "grid" }, children: [
354
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("span", { "aria-hidden": "true", style: GHOST_STYLE2, children: reserve }),
355
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
341
356
  "span",
342
357
  {
343
358
  role: hasError ? "alert" : void 0,
@@ -355,17 +370,17 @@ var import_react8 = require("react");
355
370
 
356
371
  // src/components/loading-context.tsx
357
372
  var import_react7 = require("react");
358
- var import_jsx_runtime8 = require("react/jsx-runtime");
373
+ var import_jsx_runtime9 = require("react/jsx-runtime");
359
374
  var LoadingStateContext = (0, import_react7.createContext)(false);
360
375
  function useLoadingState() {
361
376
  return (0, import_react7.useContext)(LoadingStateContext);
362
377
  }
363
378
  function LoadingContext({ loading, children }) {
364
- return /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(LoadingStateContext.Provider, { value: loading, children });
379
+ return /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(LoadingStateContext.Provider, { value: loading, children });
365
380
  }
366
381
 
367
382
  // src/components/loading-boundary.tsx
368
- var import_jsx_runtime9 = require("react/jsx-runtime");
383
+ var import_jsx_runtime10 = require("react/jsx-runtime");
369
384
  var LoadingBoundary = (0, import_react8.forwardRef)(
370
385
  function LoadingBoundary2({ loading, exitDuration, as: Tag = "div", className, style, children, ...props }, ref) {
371
386
  (0, import_react8.useInsertionEffect)(injectStyles, []);
@@ -373,28 +388,28 @@ var LoadingBoundary = (0, import_react8.forwardRef)(
373
388
  ...style,
374
389
  "--sk-loading-exit-duration": `${exitDuration}ms`
375
390
  };
376
- return /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(SizeRatchet, { ref, as: Tag, className, style: merged, ...props, children: /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(LoadingContext, { loading, children }) });
391
+ return /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(SizeRatchet, { ref, as: Tag, className, style: merged, ...props, children: /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(LoadingContext, { loading, children }) });
377
392
  }
378
393
  );
379
394
 
380
395
  // src/components/text-skeleton.tsx
381
396
  var import_react9 = require("react");
382
- var import_jsx_runtime10 = require("react/jsx-runtime");
397
+ var import_jsx_runtime11 = require("react/jsx-runtime");
383
398
  function TextSkeleton({ loading, as: Tag = "span", className, style, children, ...props }) {
384
399
  (0, import_react9.useInsertionEffect)(injectStyles, []);
385
400
  const contextLoading = useLoadingState();
386
401
  const isLoading = loading ?? contextLoading;
387
- return /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)(Tag, { className, style: { ...style, display: "inline-grid" }, ...props, children: [
388
- /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(
402
+ return /* @__PURE__ */ (0, import_jsx_runtime11.jsxs)(Tag, { className, style: { ...style, display: "inline-grid" }, ...props, children: [
403
+ /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(
389
404
  "span",
390
405
  {
391
406
  className: "sk-shimmer-line sk-loading-layer",
392
407
  "aria-hidden": "true",
393
408
  style: { opacity: isLoading ? 1 : 0 },
394
- children: /* @__PURE__ */ (0, import_jsx_runtime10.jsx)("span", { inert: true, children })
409
+ children: /* @__PURE__ */ (0, import_jsx_runtime11.jsx)("span", { inert: true, children })
395
410
  }
396
411
  ),
397
- /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(
412
+ /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(
398
413
  "span",
399
414
  {
400
415
  className: "sk-loading-layer",
@@ -407,19 +422,19 @@ function TextSkeleton({ loading, as: Tag = "span", className, style, children, .
407
422
  }
408
423
 
409
424
  // src/components/stable-text.tsx
410
- var import_jsx_runtime11 = require("react/jsx-runtime");
425
+ var import_jsx_runtime12 = require("react/jsx-runtime");
411
426
  function StableText({
412
427
  as: Tag = "p",
413
428
  loading,
414
429
  children,
415
430
  ...props
416
431
  }) {
417
- return /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(Tag, { ...props, children: /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(TextSkeleton, { loading, children }) });
432
+ return /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(Tag, { ...props, children: /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(TextSkeleton, { loading, children }) });
418
433
  }
419
434
 
420
435
  // src/components/media-skeleton.tsx
421
436
  var import_react10 = require("react");
422
- var import_jsx_runtime12 = require("react/jsx-runtime");
437
+ var import_jsx_runtime13 = require("react/jsx-runtime");
423
438
  var CHILD_STYLE = {
424
439
  position: "absolute",
425
440
  inset: 0,
@@ -473,8 +488,8 @@ function MediaSkeleton({
473
488
  ...child.props.style
474
489
  }
475
490
  }) : child;
476
- return /* @__PURE__ */ (0, import_jsx_runtime12.jsxs)("div", { className: containerClass, style: containerStyle, ...props, children: [
477
- /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(
491
+ return /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)("div", { className: containerClass, style: containerStyle, ...props, children: [
492
+ /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(
478
493
  "div",
479
494
  {
480
495
  className: "sk-media-shimmer",
@@ -491,18 +506,18 @@ var import_react12 = require("react");
491
506
 
492
507
  // src/components/skeleton-grid.tsx
493
508
  var import_react11 = require("react");
494
- var import_jsx_runtime13 = require("react/jsx-runtime");
509
+ var import_jsx_runtime14 = require("react/jsx-runtime");
495
510
  var SkeletonGrid = (0, import_react11.forwardRef)(
496
511
  function SkeletonGrid2({ rows, columns, as: Tag = "div", className, children, ...props }, ref) {
497
512
  (0, import_react11.useInsertionEffect)(injectStyles, []);
498
513
  const merged = className ? `sk-skeleton-grid ${className}` : "sk-skeleton-grid";
499
514
  const count = columns ? rows * columns : rows;
500
- const cells = Array.from({ length: count }, (_, i) => /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)("div", { className: "sk-skeleton-bone", children: [
501
- /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("div", { className: "sk-shimmer-line" }),
502
- /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("div", { className: "sk-shimmer-line" })
515
+ const cells = Array.from({ length: count }, (_, i) => /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("div", { className: "sk-skeleton-bone", children: [
516
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("div", { className: "sk-shimmer-line" }),
517
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("div", { className: "sk-shimmer-line" })
503
518
  ] }, i));
504
519
  const gridStyle = columns ? { gridTemplateColumns: `repeat(${columns}, auto)` } : void 0;
505
- return /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(
520
+ return /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(
506
521
  Tag,
507
522
  {
508
523
  ref,
@@ -516,7 +531,7 @@ var SkeletonGrid = (0, import_react11.forwardRef)(
516
531
  );
517
532
 
518
533
  // src/components/collection-skeleton.tsx
519
- var import_jsx_runtime14 = require("react/jsx-runtime");
534
+ var import_jsx_runtime15 = require("react/jsx-runtime");
520
535
  function CollectionSkeletonInner({
521
536
  items,
522
537
  loading,
@@ -534,17 +549,17 @@ function CollectionSkeletonInner({
534
549
  display: "grid",
535
550
  "--sk-loading-exit-duration": `${exitDuration}ms`
536
551
  };
537
- return /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(SizeRatchet, { ref, axis: "height", as: Tag, className, style: merged, ...props, children: [
538
- /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(
552
+ return /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)(SizeRatchet, { ref, axis: "height", as: Tag, className, style: merged, ...props, children: [
553
+ /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(
539
554
  "div",
540
555
  {
541
556
  className: "sk-loading-layer",
542
557
  "aria-hidden": "true",
543
558
  style: { opacity: loading ? 1 : 0 },
544
- children: /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(SkeletonGrid, { rows: stubCount })
559
+ children: /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(SkeletonGrid, { rows: stubCount })
545
560
  }
546
561
  ),
547
- /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(
562
+ /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(
548
563
  "div",
549
564
  {
550
565
  className: "sk-loading-layer",
@@ -585,7 +600,7 @@ function usePresence(show) {
585
600
  }
586
601
 
587
602
  // src/components/fade-transition.tsx
588
- var import_jsx_runtime15 = require("react/jsx-runtime");
603
+ var import_jsx_runtime16 = require("react/jsx-runtime");
589
604
  var FadeTransition = (0, import_react14.forwardRef)(
590
605
  function FadeTransition2({ show, as: Tag = "div", className, style, children, ...props }, fwdRef) {
591
606
  (0, import_react14.useInsertionEffect)(injectStyles, []);
@@ -593,7 +608,7 @@ var FadeTransition = (0, import_react14.forwardRef)(
593
608
  if (!mounted) return null;
594
609
  const phaseClass = phase === "entering" ? "sk-fade-entering" : phase === "exiting" ? "sk-fade-exiting" : "";
595
610
  const merged = ["sk-fade", phaseClass, className].filter(Boolean).join(" ");
596
- return /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(
611
+ return /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
597
612
  Tag,
598
613
  {
599
614
  ref: fwdRef,
@@ -638,6 +653,7 @@ function createPrimitive(tag, baseClass, variants) {
638
653
  StableCounter,
639
654
  StableField,
640
655
  StableText,
656
+ StateMap,
641
657
  StateSwap,
642
658
  TextSkeleton,
643
659
  createPrimitive,
package/dist/index.d.cts CHANGED
@@ -15,6 +15,7 @@ interface StateSwapProps extends HTMLAttributes<HTMLElement> {
15
15
  /**
16
16
  * Boolean content swap with zero layout shift.
17
17
  *
18
+ * Thin wrapper around StateMap with two keys ("true" / "false").
18
19
  * Reserves the width of the wider option so the container never changes
19
20
  * dimensions when toggling between states.
20
21
  *
@@ -27,17 +28,8 @@ interface StateSwapProps extends HTMLAttributes<HTMLElement> {
27
28
  * <StateSwap state={open} true="Close" false="Open" />
28
29
  * </button>
29
30
  * ```
30
- *
31
- * @example
32
- * ```tsx
33
- * <StateSwap
34
- * state={expanded}
35
- * true={<ChevronUp />}
36
- * false={<ChevronDown />}
37
- * />
38
- * ```
39
31
  */
40
- declare function StateSwap({ state, true: onTrue, false: onFalse, as: Tag, ...props }: StateSwapProps): react_jsx_runtime.JSX.Element;
32
+ declare function StateSwap({ state, true: onTrue, false: onFalse, ...props }: StateSwapProps): react_jsx_runtime.JSX.Element;
41
33
 
42
34
  type Axis = "width" | "height" | "both";
43
35
  interface LayoutGroupProps extends HTMLAttributes<HTMLElement> {
@@ -147,6 +139,37 @@ interface LayoutMapProps<K extends string> extends HTMLAttributes<HTMLElement> {
147
139
  */
148
140
  declare function LayoutMap<K extends string>({ value, map, ...props }: LayoutMapProps<K>): react_jsx_runtime.JSX.Element;
149
141
 
142
+ interface StateMapProps<K extends string> extends HTMLAttributes<HTMLElement> {
143
+ /**
144
+ * The active key — must match one of the keys in `map`.
145
+ *
146
+ * Uses `NoInfer<K>` so TypeScript infers the key union from `map`
147
+ * and checks `value` against it — typos are compile-time errors.
148
+ */
149
+ value: NoInfer<K>;
150
+ /** Dictionary of views keyed by state name. TypeScript infers and enforces the keys. */
151
+ map: Record<K, ReactNode>;
152
+ /** HTML element to render. Default: "span" (safe inside buttons). */
153
+ as?: ElementType;
154
+ }
155
+ /**
156
+ * Multi-state content swap with zero layout shift.
157
+ *
158
+ * The keyed counterpart to StateSwap — same inline defaults,
159
+ * same grid overlap physics, but for N states instead of two.
160
+ *
161
+ * @example
162
+ * ```tsx
163
+ * <StateMap value={phase} map={{
164
+ * loading: <Shimmer />,
165
+ * unconfirmed: <Button intent="secondary">Deliver All</Button>,
166
+ * confirmed: <span><CheckCircle /> All confirmed</span>,
167
+ * chargeable: <Button intent="primary">Charge All</Button>,
168
+ * }} />
169
+ * ```
170
+ */
171
+ declare function StateMap<K extends string>({ value, map, as: Tag, ...props }: StateMapProps<K>): react_jsx_runtime.JSX.Element;
172
+
150
173
  interface SizeRatchetProps extends HTMLAttributes<HTMLElement> {
151
174
  /** Which axis to ratchet. Default: "height". */
152
175
  axis?: Axis;
@@ -470,4 +493,4 @@ declare const FadeTransition: react.ForwardRefExoticComponent<FadeTransitionProp
470
493
  */
471
494
  declare function createPrimitive<Tag extends keyof JSX.IntrinsicElements, const Variants extends Record<string, readonly string[]> = {}>(tag: Tag, baseClass: string, variants?: Variants): (props: Omit<JSX.IntrinsicElements[Tag], "style" | "className" | keyof Variants> & { [K in keyof Variants]: Variants[K][number]; }) => react.DOMElement<Record<string, unknown>, Element>;
472
495
 
473
- export { type Axis, CollectionSkeleton, type CollectionSkeletonProps, FadeTransition, type FadeTransitionProps, LayoutGroup, type LayoutGroupProps, LayoutMap, type LayoutMapProps, LayoutView, type LayoutViewProps, LoadingBoundary, type LoadingBoundaryProps, LoadingContext, type LoadingContextProps, MediaSkeleton, type MediaSkeletonProps, SizeRatchet, type SizeRatchetProps, StableCounter, type StableCounterProps, StableField, type StableFieldProps, StableText, type StableTextProps, StateSwap, type StateSwapProps, TextSkeleton, type TextSkeletonProps, createPrimitive, useLoadingState };
496
+ export { type Axis, CollectionSkeleton, type CollectionSkeletonProps, FadeTransition, type FadeTransitionProps, LayoutGroup, type LayoutGroupProps, LayoutMap, type LayoutMapProps, LayoutView, type LayoutViewProps, LoadingBoundary, type LoadingBoundaryProps, LoadingContext, type LoadingContextProps, MediaSkeleton, type MediaSkeletonProps, SizeRatchet, type SizeRatchetProps, StableCounter, type StableCounterProps, StableField, type StableFieldProps, StableText, type StableTextProps, StateMap, type StateMapProps, StateSwap, type StateSwapProps, TextSkeleton, type TextSkeletonProps, createPrimitive, useLoadingState };
package/dist/index.d.ts CHANGED
@@ -15,6 +15,7 @@ interface StateSwapProps extends HTMLAttributes<HTMLElement> {
15
15
  /**
16
16
  * Boolean content swap with zero layout shift.
17
17
  *
18
+ * Thin wrapper around StateMap with two keys ("true" / "false").
18
19
  * Reserves the width of the wider option so the container never changes
19
20
  * dimensions when toggling between states.
20
21
  *
@@ -27,17 +28,8 @@ interface StateSwapProps extends HTMLAttributes<HTMLElement> {
27
28
  * <StateSwap state={open} true="Close" false="Open" />
28
29
  * </button>
29
30
  * ```
30
- *
31
- * @example
32
- * ```tsx
33
- * <StateSwap
34
- * state={expanded}
35
- * true={<ChevronUp />}
36
- * false={<ChevronDown />}
37
- * />
38
- * ```
39
31
  */
40
- declare function StateSwap({ state, true: onTrue, false: onFalse, as: Tag, ...props }: StateSwapProps): react_jsx_runtime.JSX.Element;
32
+ declare function StateSwap({ state, true: onTrue, false: onFalse, ...props }: StateSwapProps): react_jsx_runtime.JSX.Element;
41
33
 
42
34
  type Axis = "width" | "height" | "both";
43
35
  interface LayoutGroupProps extends HTMLAttributes<HTMLElement> {
@@ -147,6 +139,37 @@ interface LayoutMapProps<K extends string> extends HTMLAttributes<HTMLElement> {
147
139
  */
148
140
  declare function LayoutMap<K extends string>({ value, map, ...props }: LayoutMapProps<K>): react_jsx_runtime.JSX.Element;
149
141
 
142
+ interface StateMapProps<K extends string> extends HTMLAttributes<HTMLElement> {
143
+ /**
144
+ * The active key — must match one of the keys in `map`.
145
+ *
146
+ * Uses `NoInfer<K>` so TypeScript infers the key union from `map`
147
+ * and checks `value` against it — typos are compile-time errors.
148
+ */
149
+ value: NoInfer<K>;
150
+ /** Dictionary of views keyed by state name. TypeScript infers and enforces the keys. */
151
+ map: Record<K, ReactNode>;
152
+ /** HTML element to render. Default: "span" (safe inside buttons). */
153
+ as?: ElementType;
154
+ }
155
+ /**
156
+ * Multi-state content swap with zero layout shift.
157
+ *
158
+ * The keyed counterpart to StateSwap — same inline defaults,
159
+ * same grid overlap physics, but for N states instead of two.
160
+ *
161
+ * @example
162
+ * ```tsx
163
+ * <StateMap value={phase} map={{
164
+ * loading: <Shimmer />,
165
+ * unconfirmed: <Button intent="secondary">Deliver All</Button>,
166
+ * confirmed: <span><CheckCircle /> All confirmed</span>,
167
+ * chargeable: <Button intent="primary">Charge All</Button>,
168
+ * }} />
169
+ * ```
170
+ */
171
+ declare function StateMap<K extends string>({ value, map, as: Tag, ...props }: StateMapProps<K>): react_jsx_runtime.JSX.Element;
172
+
150
173
  interface SizeRatchetProps extends HTMLAttributes<HTMLElement> {
151
174
  /** Which axis to ratchet. Default: "height". */
152
175
  axis?: Axis;
@@ -470,4 +493,4 @@ declare const FadeTransition: react.ForwardRefExoticComponent<FadeTransitionProp
470
493
  */
471
494
  declare function createPrimitive<Tag extends keyof JSX.IntrinsicElements, const Variants extends Record<string, readonly string[]> = {}>(tag: Tag, baseClass: string, variants?: Variants): (props: Omit<JSX.IntrinsicElements[Tag], "style" | "className" | keyof Variants> & { [K in keyof Variants]: Variants[K][number]; }) => react.DOMElement<Record<string, unknown>, Element>;
472
495
 
473
- export { type Axis, CollectionSkeleton, type CollectionSkeletonProps, FadeTransition, type FadeTransitionProps, LayoutGroup, type LayoutGroupProps, LayoutMap, type LayoutMapProps, LayoutView, type LayoutViewProps, LoadingBoundary, type LoadingBoundaryProps, LoadingContext, type LoadingContextProps, MediaSkeleton, type MediaSkeletonProps, SizeRatchet, type SizeRatchetProps, StableCounter, type StableCounterProps, StableField, type StableFieldProps, StableText, type StableTextProps, StateSwap, type StateSwapProps, TextSkeleton, type TextSkeletonProps, createPrimitive, useLoadingState };
496
+ export { type Axis, CollectionSkeleton, type CollectionSkeletonProps, FadeTransition, type FadeTransitionProps, LayoutGroup, type LayoutGroupProps, LayoutMap, type LayoutMapProps, LayoutView, type LayoutViewProps, LoadingBoundary, type LoadingBoundaryProps, LoadingContext, type LoadingContextProps, MediaSkeleton, type MediaSkeletonProps, SizeRatchet, type SizeRatchetProps, StableCounter, type StableCounterProps, StableField, type StableFieldProps, StableText, type StableTextProps, StateMap, type StateMapProps, StateSwap, type StateSwapProps, TextSkeleton, type TextSkeletonProps, createPrimitive, useLoadingState };
package/dist/index.js CHANGED
@@ -152,25 +152,39 @@ var LayoutView = forwardRef2(
152
152
  }
153
153
  );
154
154
 
155
+ // src/components/state-map.tsx
156
+ import { jsx as jsx3 } from "react/jsx-runtime";
157
+ function StateMap({
158
+ value,
159
+ map,
160
+ as: Tag = "span",
161
+ ...props
162
+ }) {
163
+ return /* @__PURE__ */ jsx3(LayoutGroup, { as: Tag, value, axis: "both", "data-inline": true, ...props, children: Object.entries(map).map(([key, node]) => /* @__PURE__ */ jsx3(LayoutView, { as: "span", name: key, children: node }, key)) });
164
+ }
165
+
155
166
  // src/components/state-swap.tsx
156
- import { jsx as jsx3, jsxs } from "react/jsx-runtime";
167
+ import { jsx as jsx4 } from "react/jsx-runtime";
157
168
  function StateSwap({
158
169
  state,
159
170
  true: onTrue,
160
171
  false: onFalse,
161
- as: Tag = "span",
162
172
  ...props
163
173
  }) {
164
- return /* @__PURE__ */ jsxs(LayoutGroup, { as: Tag, value: state ? "true" : "false", axis: "both", "data-inline": true, ...props, children: [
165
- /* @__PURE__ */ jsx3(LayoutView, { as: "span", name: "true", children: onTrue }),
166
- /* @__PURE__ */ jsx3(LayoutView, { as: "span", name: "false", children: onFalse })
167
- ] });
174
+ return /* @__PURE__ */ jsx4(
175
+ StateMap,
176
+ {
177
+ value: state ? "true" : "false",
178
+ map: { true: onTrue, false: onFalse },
179
+ ...props
180
+ }
181
+ );
168
182
  }
169
183
 
170
184
  // src/components/layout-map.tsx
171
- import { jsx as jsx4 } from "react/jsx-runtime";
185
+ import { jsx as jsx5 } from "react/jsx-runtime";
172
186
  function LayoutMap({ value, map, ...props }) {
173
- return /* @__PURE__ */ jsx4(LayoutGroup, { value, ...props, children: Object.entries(map).map(([key, node]) => /* @__PURE__ */ jsx4(LayoutView, { name: key, children: node }, key)) });
187
+ return /* @__PURE__ */ jsx5(LayoutGroup, { value, ...props, children: Object.entries(map).map(([key, node]) => /* @__PURE__ */ jsx5(LayoutView, { name: key, children: node }, key)) });
174
188
  }
175
189
 
176
190
  // src/components/size-ratchet.tsx
@@ -239,13 +253,13 @@ function useStableSlot(options = {}) {
239
253
  }
240
254
 
241
255
  // src/components/size-ratchet.tsx
242
- import { jsx as jsx5 } from "react/jsx-runtime";
256
+ import { jsx as jsx6 } from "react/jsx-runtime";
243
257
  var SizeRatchet = forwardRef3(
244
258
  function SizeRatchet2({ axis = "height", resetKey, as: Tag = "div", className, style, children, ...props }, fwdRef) {
245
259
  useInsertionEffect3(injectStyles, []);
246
260
  const { ref: ratchetRef, style: ratchetStyle } = useStableSlot({ axis, resetKey });
247
261
  const merged = className ? `sk-size-ratchet ${className}` : "sk-size-ratchet";
248
- return /* @__PURE__ */ jsx5(
262
+ return /* @__PURE__ */ jsx6(
249
263
  Tag,
250
264
  {
251
265
  ref: mergeRefs(ratchetRef, fwdRef),
@@ -260,7 +274,7 @@ var SizeRatchet = forwardRef3(
260
274
 
261
275
  // src/components/stable-counter.tsx
262
276
  import { forwardRef as forwardRef4 } from "react";
263
- import { jsx as jsx6, jsxs as jsxs2 } from "react/jsx-runtime";
277
+ import { jsx as jsx7, jsxs } from "react/jsx-runtime";
264
278
  var GHOST_STYLE = {
265
279
  visibility: "hidden",
266
280
  gridArea: "1 / 1",
@@ -271,7 +285,7 @@ var VALUE_STYLE = {
271
285
  };
272
286
  var StableCounter = forwardRef4(
273
287
  function StableCounter2({ value, reserve, as: Tag = "span", style, className, ...props }, ref) {
274
- return /* @__PURE__ */ jsxs2(
288
+ return /* @__PURE__ */ jsxs(
275
289
  Tag,
276
290
  {
277
291
  ref,
@@ -279,8 +293,8 @@ var StableCounter = forwardRef4(
279
293
  style: { ...style, display: "inline-grid" },
280
294
  ...props,
281
295
  children: [
282
- /* @__PURE__ */ jsx6("span", { "aria-hidden": "true", style: GHOST_STYLE, children: reserve }),
283
- /* @__PURE__ */ jsx6("span", { style: VALUE_STYLE, children: value })
296
+ /* @__PURE__ */ jsx7("span", { "aria-hidden": "true", style: GHOST_STYLE, children: reserve }),
297
+ /* @__PURE__ */ jsx7("span", { style: VALUE_STYLE, children: value })
284
298
  ]
285
299
  }
286
300
  );
@@ -289,7 +303,7 @@ var StableCounter = forwardRef4(
289
303
 
290
304
  // src/components/stable-field.tsx
291
305
  import { forwardRef as forwardRef5 } from "react";
292
- import { jsx as jsx7, jsxs as jsxs3 } from "react/jsx-runtime";
306
+ import { jsx as jsx8, jsxs as jsxs2 } from "react/jsx-runtime";
293
307
  var GHOST_STYLE2 = {
294
308
  visibility: "hidden",
295
309
  gridArea: "1 / 1",
@@ -305,11 +319,11 @@ var ERROR_HIDDEN_STYLE = {
305
319
  var StableField = forwardRef5(
306
320
  function StableField2({ error, reserve, children, style, className, ...props }, ref) {
307
321
  const hasError = error != null && error !== false && error !== "";
308
- return /* @__PURE__ */ jsxs3("div", { ref, className, style, ...props, children: [
322
+ return /* @__PURE__ */ jsxs2("div", { ref, className, style, ...props, children: [
309
323
  children,
310
- /* @__PURE__ */ jsxs3("div", { style: { display: "grid" }, children: [
311
- /* @__PURE__ */ jsx7("span", { "aria-hidden": "true", style: GHOST_STYLE2, children: reserve }),
312
- /* @__PURE__ */ jsx7(
324
+ /* @__PURE__ */ jsxs2("div", { style: { display: "grid" }, children: [
325
+ /* @__PURE__ */ jsx8("span", { "aria-hidden": "true", style: GHOST_STYLE2, children: reserve }),
326
+ /* @__PURE__ */ jsx8(
313
327
  "span",
314
328
  {
315
329
  role: hasError ? "alert" : void 0,
@@ -330,17 +344,17 @@ import {
330
344
 
331
345
  // src/components/loading-context.tsx
332
346
  import { createContext as createContext2, useContext as useContext2 } from "react";
333
- import { jsx as jsx8 } from "react/jsx-runtime";
347
+ import { jsx as jsx9 } from "react/jsx-runtime";
334
348
  var LoadingStateContext = createContext2(false);
335
349
  function useLoadingState() {
336
350
  return useContext2(LoadingStateContext);
337
351
  }
338
352
  function LoadingContext({ loading, children }) {
339
- return /* @__PURE__ */ jsx8(LoadingStateContext.Provider, { value: loading, children });
353
+ return /* @__PURE__ */ jsx9(LoadingStateContext.Provider, { value: loading, children });
340
354
  }
341
355
 
342
356
  // src/components/loading-boundary.tsx
343
- import { jsx as jsx9 } from "react/jsx-runtime";
357
+ import { jsx as jsx10 } from "react/jsx-runtime";
344
358
  var LoadingBoundary = forwardRef6(
345
359
  function LoadingBoundary2({ loading, exitDuration, as: Tag = "div", className, style, children, ...props }, ref) {
346
360
  useInsertionEffect4(injectStyles, []);
@@ -348,28 +362,28 @@ var LoadingBoundary = forwardRef6(
348
362
  ...style,
349
363
  "--sk-loading-exit-duration": `${exitDuration}ms`
350
364
  };
351
- return /* @__PURE__ */ jsx9(SizeRatchet, { ref, as: Tag, className, style: merged, ...props, children: /* @__PURE__ */ jsx9(LoadingContext, { loading, children }) });
365
+ return /* @__PURE__ */ jsx10(SizeRatchet, { ref, as: Tag, className, style: merged, ...props, children: /* @__PURE__ */ jsx10(LoadingContext, { loading, children }) });
352
366
  }
353
367
  );
354
368
 
355
369
  // src/components/text-skeleton.tsx
356
370
  import { useInsertionEffect as useInsertionEffect5 } from "react";
357
- import { jsx as jsx10, jsxs as jsxs4 } from "react/jsx-runtime";
371
+ import { jsx as jsx11, jsxs as jsxs3 } from "react/jsx-runtime";
358
372
  function TextSkeleton({ loading, as: Tag = "span", className, style, children, ...props }) {
359
373
  useInsertionEffect5(injectStyles, []);
360
374
  const contextLoading = useLoadingState();
361
375
  const isLoading = loading ?? contextLoading;
362
- return /* @__PURE__ */ jsxs4(Tag, { className, style: { ...style, display: "inline-grid" }, ...props, children: [
363
- /* @__PURE__ */ jsx10(
376
+ return /* @__PURE__ */ jsxs3(Tag, { className, style: { ...style, display: "inline-grid" }, ...props, children: [
377
+ /* @__PURE__ */ jsx11(
364
378
  "span",
365
379
  {
366
380
  className: "sk-shimmer-line sk-loading-layer",
367
381
  "aria-hidden": "true",
368
382
  style: { opacity: isLoading ? 1 : 0 },
369
- children: /* @__PURE__ */ jsx10("span", { inert: true, children })
383
+ children: /* @__PURE__ */ jsx11("span", { inert: true, children })
370
384
  }
371
385
  ),
372
- /* @__PURE__ */ jsx10(
386
+ /* @__PURE__ */ jsx11(
373
387
  "span",
374
388
  {
375
389
  className: "sk-loading-layer",
@@ -382,14 +396,14 @@ function TextSkeleton({ loading, as: Tag = "span", className, style, children, .
382
396
  }
383
397
 
384
398
  // src/components/stable-text.tsx
385
- import { jsx as jsx11 } from "react/jsx-runtime";
399
+ import { jsx as jsx12 } from "react/jsx-runtime";
386
400
  function StableText({
387
401
  as: Tag = "p",
388
402
  loading,
389
403
  children,
390
404
  ...props
391
405
  }) {
392
- return /* @__PURE__ */ jsx11(Tag, { ...props, children: /* @__PURE__ */ jsx11(TextSkeleton, { loading, children }) });
406
+ return /* @__PURE__ */ jsx12(Tag, { ...props, children: /* @__PURE__ */ jsx12(TextSkeleton, { loading, children }) });
393
407
  }
394
408
 
395
409
  // src/components/media-skeleton.tsx
@@ -403,7 +417,7 @@ import {
403
417
  useRef as useRef4,
404
418
  useState as useState2
405
419
  } from "react";
406
- import { jsx as jsx12, jsxs as jsxs5 } from "react/jsx-runtime";
420
+ import { jsx as jsx13, jsxs as jsxs4 } from "react/jsx-runtime";
407
421
  var CHILD_STYLE = {
408
422
  position: "absolute",
409
423
  inset: 0,
@@ -457,8 +471,8 @@ function MediaSkeleton({
457
471
  ...child.props.style
458
472
  }
459
473
  }) : child;
460
- return /* @__PURE__ */ jsxs5("div", { className: containerClass, style: containerStyle, ...props, children: [
461
- /* @__PURE__ */ jsx12(
474
+ return /* @__PURE__ */ jsxs4("div", { className: containerClass, style: containerStyle, ...props, children: [
475
+ /* @__PURE__ */ jsx13(
462
476
  "div",
463
477
  {
464
478
  className: "sk-media-shimmer",
@@ -478,18 +492,18 @@ import {
478
492
 
479
493
  // src/components/skeleton-grid.tsx
480
494
  import { forwardRef as forwardRef7, useInsertionEffect as useInsertionEffect7 } from "react";
481
- import { jsx as jsx13, jsxs as jsxs6 } from "react/jsx-runtime";
495
+ import { jsx as jsx14, jsxs as jsxs5 } from "react/jsx-runtime";
482
496
  var SkeletonGrid = forwardRef7(
483
497
  function SkeletonGrid2({ rows, columns, as: Tag = "div", className, children, ...props }, ref) {
484
498
  useInsertionEffect7(injectStyles, []);
485
499
  const merged = className ? `sk-skeleton-grid ${className}` : "sk-skeleton-grid";
486
500
  const count = columns ? rows * columns : rows;
487
- const cells = Array.from({ length: count }, (_, i) => /* @__PURE__ */ jsxs6("div", { className: "sk-skeleton-bone", children: [
488
- /* @__PURE__ */ jsx13("div", { className: "sk-shimmer-line" }),
489
- /* @__PURE__ */ jsx13("div", { className: "sk-shimmer-line" })
501
+ const cells = Array.from({ length: count }, (_, i) => /* @__PURE__ */ jsxs5("div", { className: "sk-skeleton-bone", children: [
502
+ /* @__PURE__ */ jsx14("div", { className: "sk-shimmer-line" }),
503
+ /* @__PURE__ */ jsx14("div", { className: "sk-shimmer-line" })
490
504
  ] }, i));
491
505
  const gridStyle = columns ? { gridTemplateColumns: `repeat(${columns}, auto)` } : void 0;
492
- return /* @__PURE__ */ jsx13(
506
+ return /* @__PURE__ */ jsx14(
493
507
  Tag,
494
508
  {
495
509
  ref,
@@ -503,7 +517,7 @@ var SkeletonGrid = forwardRef7(
503
517
  );
504
518
 
505
519
  // src/components/collection-skeleton.tsx
506
- import { jsx as jsx14, jsxs as jsxs7 } from "react/jsx-runtime";
520
+ import { jsx as jsx15, jsxs as jsxs6 } from "react/jsx-runtime";
507
521
  function CollectionSkeletonInner({
508
522
  items,
509
523
  loading,
@@ -521,17 +535,17 @@ function CollectionSkeletonInner({
521
535
  display: "grid",
522
536
  "--sk-loading-exit-duration": `${exitDuration}ms`
523
537
  };
524
- return /* @__PURE__ */ jsxs7(SizeRatchet, { ref, axis: "height", as: Tag, className, style: merged, ...props, children: [
525
- /* @__PURE__ */ jsx14(
538
+ return /* @__PURE__ */ jsxs6(SizeRatchet, { ref, axis: "height", as: Tag, className, style: merged, ...props, children: [
539
+ /* @__PURE__ */ jsx15(
526
540
  "div",
527
541
  {
528
542
  className: "sk-loading-layer",
529
543
  "aria-hidden": "true",
530
544
  style: { opacity: loading ? 1 : 0 },
531
- children: /* @__PURE__ */ jsx14(SkeletonGrid, { rows: stubCount })
545
+ children: /* @__PURE__ */ jsx15(SkeletonGrid, { rows: stubCount })
532
546
  }
533
547
  ),
534
- /* @__PURE__ */ jsx14(
548
+ /* @__PURE__ */ jsx15(
535
549
  "div",
536
550
  {
537
551
  className: "sk-loading-layer",
@@ -579,7 +593,7 @@ function usePresence(show) {
579
593
  }
580
594
 
581
595
  // src/components/fade-transition.tsx
582
- import { jsx as jsx15 } from "react/jsx-runtime";
596
+ import { jsx as jsx16 } from "react/jsx-runtime";
583
597
  var FadeTransition = forwardRef9(
584
598
  function FadeTransition2({ show, as: Tag = "div", className, style, children, ...props }, fwdRef) {
585
599
  useInsertionEffect9(injectStyles, []);
@@ -587,7 +601,7 @@ var FadeTransition = forwardRef9(
587
601
  if (!mounted) return null;
588
602
  const phaseClass = phase === "entering" ? "sk-fade-entering" : phase === "exiting" ? "sk-fade-exiting" : "";
589
603
  const merged = ["sk-fade", phaseClass, className].filter(Boolean).join(" ");
590
- return /* @__PURE__ */ jsx15(
604
+ return /* @__PURE__ */ jsx16(
591
605
  Tag,
592
606
  {
593
607
  ref: fwdRef,
@@ -631,6 +645,7 @@ export {
631
645
  StableCounter,
632
646
  StableField,
633
647
  StableText,
648
+ StateMap,
634
649
  StateSwap,
635
650
  TextSkeleton,
636
651
  createPrimitive,
package/llms.txt CHANGED
@@ -21,7 +21,7 @@ This means:
21
21
  `stubCount`, `StableText` reserves space at line-height.)
22
22
  - **Spatial pre-allocation:** If a UI region has multiple states, all
23
23
  states must render simultaneously in a CSS grid overlap. The container
24
- sizes to the largest. (`LayoutMap`, `LayoutGroup`, `StateSwap`)
24
+ sizes to the largest. (`StateMap`, `StateSwap`, `LayoutMap`, `LayoutGroup`)
25
25
  - **Monotonic geometry:** Once a container expands, it cannot shrink
26
26
  unless explicitly reset. (`SizeRatchet` with optional `resetKey`)
27
27
  - **Structural Integrity over Temporal Hacks:** Never unmount a state
@@ -46,7 +46,7 @@ rendering for the same UI region.
46
46
  | `StateSwap` | Spatial | Boolean content swap, zero shift |
47
47
  | `LayoutGroup` | Spatial | Multi-state spatial stability container |
48
48
  | `LayoutView` | Spatial | Single view inside a LayoutGroup |
49
- | `LayoutMap` | Spatial | Dictionary-based state mapping (typo-proof) |
49
+ | `StateMap` | Spatial | Multi-state content swap, keyed, inline |
50
50
  | `StableCounter` | Spatial | Numeric/text width pre-allocation |
51
51
  | `StableField` | Spatial | Form error height pre-allocation |
52
52
  | `SizeRatchet` | Monotonic | Container that never shrinks |
@@ -125,7 +125,7 @@ CORRECT — one button, swap content inside it:
125
125
  Use LayoutGroup/LayoutView only when swapping structurally different
126
126
  components (e.g. tab panels, multi-step forms).
127
127
 
128
- ### 5. Do not use LayoutGroup/LayoutView for tab content — use LayoutMap
128
+ ### 5. Do not use LayoutGroup/LayoutView for tab content — use StateMap
129
129
 
130
130
  WRONG — string typos silently break rendering:
131
131
  ```tsx
@@ -137,7 +137,7 @@ WRONG — string typos silently break rendering:
137
137
 
138
138
  CORRECT — dictionary keys are checked by TypeScript:
139
139
  ```tsx
140
- <LayoutMap value={activeTab} map={{
140
+ <StateMap value={activeTab} map={{
141
141
  profile: <Profile />,
142
142
  invoices: <Invoices />,
143
143
  }} />
@@ -311,11 +311,15 @@ change to structure requires editing `.css`, a boundary has leaked.
311
311
  The linter also enforces geometric stability by banning all conditional
312
312
  content in JSX children: ternary swaps (`{x ? <A/> : <B/>}`), conditional
313
313
  mounting (`{x && <Panel/>}`), fallback content (`{x || "default"}`),
314
- nullish fallbacks (`{x ?? "loading"}`), and interpolated template literals
315
- (`` {`text ${var}`} ``). Each error message guides toward the right fix:
314
+ nullish fallbacks (`{x ?? "loading"}`), interpolated template literals
315
+ (`` {`text ${var}`} ``), and conditional `hidden` props
316
+ (`hidden={x || undefined}`). The `hidden` attribute causes an element to
317
+ occupy zero space — toggling it conditionally is a layout shift. Use
318
+ `<StateSwap>` to pre-allocate geometry for all visual states instead.
319
+ Each error message guides toward the right fix:
316
320
  extract the expression to a variable above the JSX (for data transforms),
317
321
  or use a StableKit component (for state-driven swaps — StateSwap,
318
- LayoutMap, LoadingBoundary, FadeTransition, StableField, StableCounter,
322
+ StateMap, LoadingBoundary, FadeTransition, StableField, StableCounter,
319
323
  LayoutGroup). These rules are always on.
320
324
  `classNameBlocked` declares your firewalled primitives (e.g.
321
325
  `["Badge", "Button", "Card", "Input"]`). Only those components are flagged
@@ -428,6 +432,32 @@ currently behaving.
428
432
  .sk-badge[data-variant="active"] { color: var(--color-success); }
429
433
  ```
430
434
 
435
+ ### 12. Do not use conditional hidden props to swap between states
436
+
437
+ WRONG — hidden element takes zero space, causes layout shift when toggled:
438
+ ```tsx
439
+ <Button hidden={status !== "active" || undefined}>
440
+ <Pause size={14} /> Pause
441
+ </Button>
442
+ <Button hidden={status !== "paused" || undefined}>
443
+ <Play size={14} /> Resume
444
+ </Button>
445
+ ```
446
+
447
+ CORRECT — StateSwap pre-allocates geometry for the wider option:
448
+ ```tsx
449
+ <StateSwap
450
+ state={status === "active"}
451
+ true={<Button><Pause size={14} /> Pause</Button>}
452
+ false={<Button><Play size={14} /> Resume</Button>}
453
+ />
454
+ ```
455
+
456
+ The `hidden` attribute sets `display: none` — the element is removed from
457
+ flow entirely. When siblings toggle `hidden` with mutually exclusive
458
+ conditions, the container resizes every time. StateSwap renders both states
459
+ in a CSS grid overlap so the bounding box stays stable.
460
+
431
461
  ## Out of Scope: Font-Swap CLS
432
462
 
433
463
  Do NOT build a component to solve font-swap layout shift. StableKit solves
@@ -444,7 +474,7 @@ or `next/font`.
444
474
  |--------------------------------------------|----------------------|
445
475
  | Button text changes on toggle | `StateSwap` |
446
476
  | Icon changes on toggle | `StateSwap` |
447
- | Tab panels with stable height | `LayoutMap` |
477
+ | Tab panels with stable height | `StateMap` |
448
478
  | Multi-step wizard with stable dimensions | `LayoutGroup` + `LayoutView` |
449
479
  | Text loading from API | `StableText` inside `LoadingBoundary` |
450
480
  | Image/video loading causes shift | `MediaSkeleton` |
@@ -465,7 +495,7 @@ import {
465
495
  StableText,
466
496
  MediaSkeleton,
467
497
  StateSwap,
468
- LayoutMap,
498
+ StateMap,
469
499
  SizeRatchet,
470
500
  StableCounter,
471
501
  StableField,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stablekit.ts",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "React toolkit for layout stability — zero-shift components for loading states, content swaps, and spatial containers.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",