learning_ai_common_plat/packages/ui/src/components/Skeleton.tsx
saravanakumardb1 6cd60e86e8 test(ui): cover Skeleton, SkeletonGroup, SearchInput, LoadingDots (TODO #2)
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).
2026-05-28 17:19:04 -07:00

107 lines
3.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}