stablekit.ts 0.2.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.
@@ -0,0 +1,473 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+ import * as react from 'react';
3
+ import { HTMLAttributes, ReactNode, ElementType, Ref, ReactElement, JSX } from 'react';
4
+
5
+ interface StateSwapProps extends HTMLAttributes<HTMLElement> {
6
+ /** The boolean state that drives which content is visible. */
7
+ state: boolean;
8
+ /** Content shown when `state` is true. */
9
+ true: ReactNode;
10
+ /** Content shown when `state` is false. */
11
+ false: ReactNode;
12
+ /** HTML element to render. Default: "span" (safe inside buttons). */
13
+ as?: ElementType;
14
+ }
15
+ /**
16
+ * Boolean content swap with zero layout shift.
17
+ *
18
+ * Reserves the width of the wider option so the container never changes
19
+ * dimensions when toggling between states.
20
+ *
21
+ * Renders as an inline `<span>` by default — safe inside buttons,
22
+ * table cells, and any inline context.
23
+ *
24
+ * @example
25
+ * ```tsx
26
+ * <button onClick={toggle}>
27
+ * <StateSwap state={open} true="Close" false="Open" />
28
+ * </button>
29
+ * ```
30
+ *
31
+ * @example
32
+ * ```tsx
33
+ * <StateSwap
34
+ * state={expanded}
35
+ * true={<ChevronUp />}
36
+ * false={<ChevronDown />}
37
+ * />
38
+ * ```
39
+ */
40
+ declare function StateSwap({ state, true: onTrue, false: onFalse, as: Tag, ...props }: StateSwapProps): react_jsx_runtime.JSX.Element;
41
+
42
+ type Axis = "width" | "height" | "both";
43
+ interface LayoutGroupProps extends HTMLAttributes<HTMLElement> {
44
+ /** Which axis to stabilize. Default: "both". */
45
+ axis?: Axis;
46
+ /**
47
+ * Active value identifier. LayoutViews with a matching `name` prop become visible.
48
+ *
49
+ * **AI agent note:** LayoutGroup is a spatial stability container.
50
+ * All children overlap via CSS grid. The container auto-sizes to the
51
+ * largest child. Use LayoutView as children, not arbitrary elements.
52
+ * For boolean swaps, prefer StateSwap instead.
53
+ * For dictionary-based state mapping, prefer LayoutMap instead.
54
+ */
55
+ value?: string;
56
+ /** HTML element to render. Use "span" inside buttons. Default: "div". */
57
+ as?: ElementType;
58
+ }
59
+ /**
60
+ * Multi-state spatial stability container.
61
+ *
62
+ * All children overlap in the same grid cell (1/1). The container
63
+ * auto-sizes to the largest child — zero JS measurement, pure CSS grid.
64
+ *
65
+ * Use `<LayoutView name="...">` as children. The view whose `name`
66
+ * matches the group's `value` becomes active; others are hidden via
67
+ * `[inert]` + inline styles.
68
+ *
69
+ * Focus handoff: tracks whether focus is inside the container via native
70
+ * focusin/focusout listeners. When `inert` ejects focus (relatedTarget is
71
+ * null), the flag stays set so the incoming active LayoutView can reclaim it.
72
+ *
73
+ * @example
74
+ * ```tsx
75
+ * <LayoutGroup value={step}>
76
+ * <LayoutView name="shipping"><ShippingForm /></LayoutView>
77
+ * <LayoutView name="payment"><PaymentForm /></LayoutView>
78
+ * <LayoutView name="confirm"><Confirmation /></LayoutView>
79
+ * </LayoutGroup>
80
+ * ```
81
+ */
82
+ declare const LayoutGroup: react.ForwardRefExoticComponent<LayoutGroupProps & react.RefAttributes<HTMLElement>>;
83
+
84
+ interface LayoutViewProps extends HTMLAttributes<HTMLElement> {
85
+ /**
86
+ * Whether this view is the active (visible) variant.
87
+ * Overrides context-based matching. Use for manual control.
88
+ */
89
+ active?: boolean;
90
+ /**
91
+ * View name. Active when it matches the parent LayoutGroup's `value`.
92
+ *
93
+ * **AI agent note:** LayoutView must be a direct child of LayoutGroup.
94
+ * Do not use LayoutView outside of a LayoutGroup — it has no effect.
95
+ * For boolean swaps, use StateSwap instead.
96
+ */
97
+ name?: string;
98
+ /** HTML element to render. Use "span" inside buttons. Default: "div". */
99
+ as?: ElementType;
100
+ }
101
+ /**
102
+ * Single view inside a LayoutGroup.
103
+ *
104
+ * All views overlap via CSS grid. Inactive views are hidden but still
105
+ * contribute to grid cell sizing — the container never changes dimensions.
106
+ *
107
+ * Inactive hiding uses a `data-state` attribute driven by CSS
108
+ * (`.sk-layout-view[data-state="inactive"]`) plus the `[inert]` attribute
109
+ * for accessibility (non-focusable, non-interactive). Because hiding is
110
+ * CSS-driven rather than inline-style-driven, consumers can override
111
+ * transitions on the `.sk-layout-view` class without specificity fights.
112
+ *
113
+ * Active views get tabIndex={-1} so they are programmatically focusable
114
+ * (but excluded from the tab ring) for focus handoff.
115
+ */
116
+ declare const LayoutView: react.ForwardRefExoticComponent<LayoutViewProps & react.RefAttributes<HTMLElement>>;
117
+
118
+ interface LayoutMapProps<K extends string> extends HTMLAttributes<HTMLElement> {
119
+ /**
120
+ * The active key — must match one of the keys in `map`.
121
+ *
122
+ * Uses `NoInfer<K>` so TypeScript infers the key union from `map`
123
+ * and checks `value` against it — typos are compile-time errors.
124
+ */
125
+ value: NoInfer<K>;
126
+ /** Dictionary of views keyed by state name. TypeScript infers and enforces the keys. */
127
+ map: Record<K, ReactNode>;
128
+ }
129
+ /**
130
+ * Typo-proof dictionary-based state mapping.
131
+ *
132
+ * Replaces manual LayoutGroup + LayoutView trees with a single component.
133
+ * TypeScript infers the key union from `map` and enforces that `value`
134
+ * matches — no string typos, no orphaned views.
135
+ *
136
+ * @example
137
+ * ```tsx
138
+ * <LayoutMap
139
+ * value={activeTab}
140
+ * map={{
141
+ * profile: <ProfileTab />,
142
+ * invoices: <InvoicesTab />,
143
+ * settings: <SettingsTab />,
144
+ * }}
145
+ * />
146
+ * ```
147
+ */
148
+ declare function LayoutMap<K extends string>({ value, map, ...props }: LayoutMapProps<K>): react_jsx_runtime.JSX.Element;
149
+
150
+ interface SizeRatchetProps extends HTMLAttributes<HTMLElement> {
151
+ /** Which axis to ratchet. Default: "height". */
152
+ axis?: Axis;
153
+ /** When this key changes, the ratchet floor resets so the container can shrink to its new intrinsic size. */
154
+ resetKey?: unknown;
155
+ /** HTML element to render. Default: "div". */
156
+ as?: ElementType;
157
+ }
158
+ /**
159
+ * Container that never shrinks.
160
+ *
161
+ * Remembers its largest-ever size (ResizeObserver ratchet) and applies
162
+ * min-width/min-height so the container only grows. Swap a spinner for
163
+ * a table inside — no reflow upstream.
164
+ *
165
+ * Uses `contain: layout style` to isolate internal reflow from ancestors.
166
+ *
167
+ * @example
168
+ * ```tsx
169
+ * <SizeRatchet>
170
+ * {isLoading ? <Spinner /> : <DataTable rows={rows} />}
171
+ * </SizeRatchet>
172
+ * ```
173
+ */
174
+ declare const SizeRatchet: react.ForwardRefExoticComponent<SizeRatchetProps & react.RefAttributes<HTMLElement>>;
175
+
176
+ interface StableCounterProps extends HTMLAttributes<HTMLElement> {
177
+ /** The actual value to display. */
178
+ value: ReactNode;
179
+ /** The maximum expected string/node to pre-allocate width (e.g., "999" or "$99,999"). */
180
+ reserve: ReactNode;
181
+ /** HTML element to render. Default: "span" (safe inside inline contexts). */
182
+ as?: ElementType;
183
+ }
184
+ /**
185
+ * Numeric/text content swap with zero horizontal layout shift.
186
+ *
187
+ * Uses CSS Grid overlap to render a hidden `reserve` node that props open
188
+ * the bounding box to its maximum expected width. The visible `value` node
189
+ * renders on top. The container never changes dimensions regardless of
190
+ * what `value` displays.
191
+ *
192
+ * @example
193
+ * ```tsx
194
+ * <StableCounter value={count} reserve="999" />
195
+ * ```
196
+ *
197
+ * @example
198
+ * ```tsx
199
+ * <StableCounter value={`$${revenue.toLocaleString()}`} reserve="$99,999" />
200
+ * ```
201
+ */
202
+ declare const StableCounter: react.ForwardRefExoticComponent<StableCounterProps & react.RefAttributes<HTMLElement>>;
203
+
204
+ interface StableFieldProps extends HTMLAttributes<HTMLElement> {
205
+ /** The actual error message to display. `undefined`/`null` hides the error. */
206
+ error?: ReactNode;
207
+ /** The string/node used to permanently reserve the vertical height of the error slot. */
208
+ reserve: ReactNode;
209
+ }
210
+ /**
211
+ * Form field wrapper that pre-allocates vertical space for error messages.
212
+ *
213
+ * Uses CSS Grid overlap to render a hidden `reserve` node that props open
214
+ * the error slot to its maximum expected height. The actual error renders
215
+ * on top when present. The field container never changes height when errors
216
+ * appear or disappear.
217
+ *
218
+ * @example
219
+ * ```tsx
220
+ * <StableField error={errors.email} reserve="Please enter a valid email address">
221
+ * <label htmlFor="email">Email</label>
222
+ * <input id="email" type="email" />
223
+ * </StableField>
224
+ * ```
225
+ */
226
+ declare const StableField: react.ForwardRefExoticComponent<StableFieldProps & react.RefAttributes<HTMLDivElement>>;
227
+
228
+ interface LoadingBoundaryProps extends HTMLAttributes<HTMLElement> {
229
+ /** Whether data is loading. Sets LoadingContext for all nested components. */
230
+ loading: boolean;
231
+ /**
232
+ * Crossfade duration in ms. Sets `--sk-loading-exit-duration` on the container
233
+ * so all nested skeleton transitions use the same timing.
234
+ */
235
+ exitDuration: number;
236
+ /** HTML element to render. Default: "div". */
237
+ as?: ElementType;
238
+ }
239
+ /**
240
+ * Loading orchestrator — context + ratchet in one component.
241
+ *
242
+ * Composes two behaviors:
243
+ * - **LoadingContext**: every nested `<TextSkeleton>`, `<StableText>`,
244
+ * and `<MediaSkeleton>` reads loading state automatically.
245
+ * - **SizeRatchet**: container never shrinks during transitions.
246
+ *
247
+ * Skeleton components handle their own crossfade via CSS opacity
248
+ * transitions on permanently-mounted layers. The `exitDuration` prop
249
+ * sets `--sk-loading-exit-duration` on the container so all nested transitions
250
+ * use the same timing.
251
+ *
252
+ * @example
253
+ * ```tsx
254
+ * <LoadingBoundary loading={isLoading} exitDuration={150}>
255
+ * <MediaSkeleton aspectRatio={1} className="w-16 rounded-full">
256
+ * <img src={user.avatar} alt={user.name} />
257
+ * </MediaSkeleton>
258
+ * <StableText as="h2" className="text-xl font-bold">{user.name}</StableText>
259
+ * <StableText as="p" className="text-sm text-muted">{user.email}</StableText>
260
+ * </LoadingBoundary>
261
+ * ```
262
+ */
263
+ declare const LoadingBoundary: react.ForwardRefExoticComponent<LoadingBoundaryProps & react.RefAttributes<HTMLElement>>;
264
+
265
+ /**
266
+ * Read the nearest LoadingContext's loading state.
267
+ * Returns `false` when no LoadingContext ancestor exists.
268
+ */
269
+ declare function useLoadingState(): boolean;
270
+ interface LoadingContextProps {
271
+ /** Whether the subtree is in a loading state. */
272
+ loading: boolean;
273
+ children: ReactNode;
274
+ }
275
+ /**
276
+ * Ambient loading provider.
277
+ *
278
+ * Wrapping a subtree in `<LoadingContext loading>` lets every nested
279
+ * `<TextSkeleton>` and `<StableText>` pick up the loading state
280
+ * automatically, without threading an explicit `loading` prop
281
+ * through every component.
282
+ *
283
+ * @example
284
+ * ```tsx
285
+ * <LoadingContext loading={isLoading}>
286
+ * <StableText as="h2">{user.name}</StableText>
287
+ * <StableText as="p">{user.bio}</StableText>
288
+ * </LoadingContext>
289
+ * ```
290
+ */
291
+ declare function LoadingContext({ loading, children }: LoadingContextProps): react_jsx_runtime.JSX.Element;
292
+
293
+ interface StableTextProps extends HTMLAttributes<HTMLElement> {
294
+ /** HTML element to render (p, h1, h2, span, etc.). Default: "p". */
295
+ as?: ElementType;
296
+ /** Explicit loading override. Falls back to nearest LoadingContext. */
297
+ loading?: boolean;
298
+ /** Forwarded ref (React 19 style). */
299
+ ref?: Ref<HTMLElement>;
300
+ }
301
+ /**
302
+ * Typography + skeleton in one tag.
303
+ *
304
+ * Combines the semantic HTML wrapper and loading shimmer into one component.
305
+ * Inside a `<LoadingBoundary>`, every `<StableText>` automatically shimmers.
306
+ * No forgetting to wrap text in `<TextSkeleton>`. No Swiss-cheese loading states.
307
+ *
308
+ * @example
309
+ * ```tsx
310
+ * <LoadingBoundary loading={isLoading} exitDuration={150}>
311
+ * <StableText as="h1" className="text-2xl font-bold">{user.name}</StableText>
312
+ * <StableText className="text-sm text-muted">{user.bio}</StableText>
313
+ * </LoadingBoundary>
314
+ * ```
315
+ */
316
+ declare function StableText({ as: Tag, loading, children, ...props }: StableTextProps): react_jsx_runtime.JSX.Element;
317
+
318
+ interface TextSkeletonProps extends HTMLAttributes<HTMLElement> {
319
+ /**
320
+ * Whether data is loading. Shows shimmer when true, children when false.
321
+ * When omitted, falls back to the nearest `<LoadingContext>` ancestor's loading state.
322
+ */
323
+ loading?: boolean;
324
+ /** HTML element to render. Default: "span". */
325
+ as?: ElementType;
326
+ /** Forwarded ref (React 19 style). */
327
+ ref?: Ref<HTMLElement>;
328
+ }
329
+ /**
330
+ * Inline loading shimmer for text.
331
+ *
332
+ * Both shimmer and content layers are permanently mounted in the DOM,
333
+ * stacked via `display: inline-grid` at `grid-area: 1/1`. Loading
334
+ * state controls only opacity and interactivity (`inert`). CSS
335
+ * transitions handle the crossfade — no JS state machine, no
336
+ * structural mutation, no flash.
337
+ *
338
+ * The className is passed through so `1lh` inherits the correct font
339
+ * metrics from the consuming context.
340
+ *
341
+ * When no explicit `loading` prop is provided, TextSkeleton reads from
342
+ * the nearest `<LoadingContext>` ancestor.
343
+ *
344
+ * @example
345
+ * ```tsx
346
+ * <TextSkeleton loading={isLoading}>{user.name}</TextSkeleton>
347
+ * ```
348
+ */
349
+ declare function TextSkeleton({ loading, as: Tag, className, style, children, ...props }: TextSkeletonProps): react_jsx_runtime.JSX.Element;
350
+
351
+ interface MediaSkeletonProps extends HTMLAttributes<HTMLElement> {
352
+ /**
353
+ * Aspect ratio for the media container (e.g. `16/9`, `1`, `4/3`).
354
+ * Applied as the CSS `aspect-ratio` property, so the container
355
+ * reserves exact space before any content loads.
356
+ */
357
+ aspectRatio: number;
358
+ /**
359
+ * Whether media is loading. Shows shimmer when true, children when false.
360
+ * When omitted, falls back to the nearest `<LoadingContext>` ancestor's loading state.
361
+ */
362
+ loading?: boolean;
363
+ /**
364
+ * Must be a single React element (img, video, etc.).
365
+ *
366
+ * **Do not add dimension classes to the child.** MediaSkeleton enforces
367
+ * child constraints automatically via React.cloneElement. The child
368
+ * cannot break out of the frame.
369
+ */
370
+ children: ReactElement;
371
+ }
372
+ /**
373
+ * Aspect-ratio media placeholder with automatic child constraints.
374
+ *
375
+ * Reserves space via `aspect-ratio` so the bounding box is stable
376
+ * before media loads. Enforces child constraints via `cloneElement`
377
+ * inline styles — no CSS `!important`, no developer discipline needed.
378
+ *
379
+ * The shimmer stays visible until **both** the loading context resolves
380
+ * AND the child media fires `onLoad`. This prevents an empty-frame
381
+ * flash between skeleton exit and image paint.
382
+ *
383
+ * The child can override `objectFit` (e.g. `contain`) via its own
384
+ * inline `style` prop, but positional constraints are non-negotiable.
385
+ *
386
+ * @example
387
+ * ```tsx
388
+ * <MediaSkeleton aspectRatio={16/9}>
389
+ * <img src={src} alt={alt} />
390
+ * </MediaSkeleton>
391
+ * ```
392
+ *
393
+ * @example
394
+ * ```tsx
395
+ * <MediaSkeleton aspectRatio={1} className="rounded-full">
396
+ * <img src={avatar} alt={name} />
397
+ * </MediaSkeleton>
398
+ * ```
399
+ */
400
+ declare function MediaSkeleton({ aspectRatio, loading, className, style, children, ...props }: MediaSkeletonProps): react_jsx_runtime.JSX.Element;
401
+
402
+ interface CollectionSkeletonProps<T> extends Omit<HTMLAttributes<HTMLElement>, "children"> {
403
+ /** Data items to render. */
404
+ items: T[];
405
+ /** Whether data is loading. Shows skeleton stubs when true. */
406
+ loading: boolean;
407
+ /** Render function for each item. */
408
+ renderItem: (item: T, index: number) => ReactNode;
409
+ /** Number of placeholder rows during loading. */
410
+ stubCount: number;
411
+ /** Crossfade duration in ms. Sets --sk-loading-exit-duration for the opacity transition. */
412
+ exitDuration: number;
413
+ /** HTML element to render. Default: "div". */
414
+ as?: ElementType;
415
+ }
416
+ /**
417
+ * Loading-aware list with automatic skeleton stubs.
418
+ *
419
+ * Both the skeleton grid and rendered items are permanently mounted
420
+ * in the DOM, stacked via CSS grid overlap. Loading state controls
421
+ * only opacity and interactivity. CSS transitions handle the crossfade.
422
+ *
423
+ * Wrapped in a SizeRatchet so the container never shrinks.
424
+ *
425
+ * @example
426
+ * ```tsx
427
+ * <CollectionSkeleton
428
+ * items={users}
429
+ * loading={isLoading}
430
+ * stubCount={5}
431
+ * exitDuration={150}
432
+ * renderItem={(user) => <UserRow key={user.id} user={user} />}
433
+ * />
434
+ * ```
435
+ */
436
+ declare const CollectionSkeleton: <T>(props: CollectionSkeletonProps<T> & {
437
+ ref?: Ref<HTMLElement>;
438
+ }) => ReactElement | null;
439
+
440
+ interface FadeTransitionProps extends HTMLAttributes<HTMLElement> {
441
+ /** Whether the content is visible. */
442
+ show: boolean;
443
+ /** HTML element to render. Default: "div". */
444
+ as?: ElementType;
445
+ }
446
+ /**
447
+ * Enter/exit animation wrapper.
448
+ *
449
+ * Mounts when `show` becomes true, plays enter animation.
450
+ * When `show` becomes false, plays exit animation then unmounts.
451
+ *
452
+ * CSS classes applied: `sk-fade-entering`, `sk-fade-exiting`.
453
+ * Duration controlled via `--sk-fade-duration` CSS variable.
454
+ *
455
+ * @example
456
+ * ```tsx
457
+ * <FadeTransition show={isOpen}>
458
+ * <DropdownPanel />
459
+ * </FadeTransition>
460
+ * ```
461
+ */
462
+ declare const FadeTransition: react.ForwardRefExoticComponent<FadeTransitionProps & react.RefAttributes<HTMLElement>>;
463
+
464
+ /**
465
+ * Creates a firewalled UI primitive.
466
+ *
467
+ * - className and style are blocked at the type level
468
+ * - Variant props are auto-mapped to data-attributes
469
+ * - All other HTML attributes pass through
470
+ */
471
+ 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
+
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 };