Adds 13 vitest cases for the Wave 9.D loading primitives; ui suite now 19/19. Removes the resolved ROADMAP-EXEC-TODO #2 marker from Skeleton.tsx. Verified: npx vitest run --pool forks (19 passed); npx tsc --noEmit (clean).
107 lines
3.1 KiB
TypeScript
107 lines
3.1 KiB
TypeScript
import * as React from 'react';
|
||
import { clsx } from 'clsx';
|
||
|
||
export interface SkeletonProps extends React.HTMLAttributes<HTMLDivElement> {
|
||
/**
|
||
* Visual variant.
|
||
* - `text` — short pill, fits a single line of body text.
|
||
* - `block` — full rect, default; suitable as a hero or paragraph placeholder.
|
||
* - `circle` — fixed 40 × 40 disc; ideal for avatar slots.
|
||
* - `card` — taller rounded panel, mirrors a `<Card>` footprint so swapping
|
||
* to the real content does not shift layout.
|
||
*/
|
||
shape?: 'block' | 'text' | 'circle' | 'card';
|
||
}
|
||
|
||
/**
|
||
* `<Skeleton>` — shimmer placeholder. Dimensions are tuned so the eventual
|
||
* content matches; this avoids the `bl-skeleton-shift` CLS anti-pattern
|
||
* codified in roadmap §3.8.
|
||
*/
|
||
export function Skeleton({ shape = 'block', className, ...props }: SkeletonProps) {
|
||
return (
|
||
<div
|
||
aria-hidden="true"
|
||
className={clsx(
|
||
'animate-pulse bg-[var(--bl-surface-muted)]',
|
||
shape === 'text' && 'h-4 rounded-full',
|
||
shape === 'block' && 'min-h-20 rounded-xl',
|
||
shape === 'circle' && 'h-10 w-10 rounded-full',
|
||
shape === 'card' &&
|
||
'min-h-32 rounded-2xl border border-[var(--bl-border-subtle,rgba(0,0,0,0.06))]',
|
||
className
|
||
)}
|
||
{...props}
|
||
/>
|
||
);
|
||
}
|
||
|
||
export function TableSkeleton({ rows = 5 }: { rows?: number }) {
|
||
return (
|
||
<div className="grid gap-2">
|
||
{Array.from({ length: rows }).map((_, index) => (
|
||
<Skeleton key={index} className="h-12 min-h-0" />
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export interface SkeletonGroupProps {
|
||
/** When `true`, render the skeleton block; when `false`, render `children`. */
|
||
loading: boolean;
|
||
/** Skeleton placeholder rendered while `loading` is true. */
|
||
fallback: React.ReactNode;
|
||
/** Real content rendered once `loading` flips false. */
|
||
children: React.ReactNode;
|
||
/**
|
||
* If true, keep `children` in the DOM after first load so re-fetches
|
||
* don't unmount it — the skeleton still overlays via opacity.
|
||
* Default: false (clean swap).
|
||
*/
|
||
keepContent?: boolean;
|
||
/** Fade duration in ms. Default 180. */
|
||
durationMs?: number;
|
||
}
|
||
|
||
/**
|
||
* `<SkeletonGroup>` — orchestrates the fade between a skeleton placeholder
|
||
* and the real content. Use one per "loadable region" rather than sprinkling
|
||
* `<Skeleton>` everywhere; the fade is unified and CLS-stable.
|
||
*
|
||
* @example
|
||
* ```tsx
|
||
* <SkeletonGroup loading={isFetching} fallback={<TableSkeleton rows={6} />}>
|
||
* <Table data={rows} />
|
||
* </SkeletonGroup>
|
||
* ```
|
||
*/
|
||
export function SkeletonGroup({
|
||
loading,
|
||
fallback,
|
||
children,
|
||
keepContent = false,
|
||
durationMs = 180,
|
||
}: SkeletonGroupProps) {
|
||
const style = { transition: `opacity ${durationMs}ms ease` } as const;
|
||
if (loading) {
|
||
return (
|
||
<div data-bl-skeleton-group="loading" style={style}>
|
||
{fallback}
|
||
{keepContent && (
|
||
<div
|
||
aria-hidden="true"
|
||
style={{ opacity: 0, pointerEvents: 'none', height: 0, overflow: 'hidden' }}
|
||
>
|
||
{children}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
return (
|
||
<div data-bl-skeleton-group="loaded" style={style}>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|