feat(ui): @bytelyst/ui@0.2.0 — Skeleton/SkeletonGroup/LoadingDots/SearchInput

Wave 9.D additions to the shared UI primitive package.

──────────────────────────────────────────────────────────────────
Skeleton — `card` shape added + new <SkeletonGroup> orchestrator
──────────────────────────────────────────────────────────────────
  packages/ui/src/components/Skeleton.tsx

  - 4 shape variants: text / block / circle / **card**
    The `card` variant uses min-h-32 + rounded-2xl + a subtle
    border so a real <Card> can swap in without CLS.
  - New <SkeletonGroup loading fallback>{children}</SkeletonGroup>
    component handles the fade-out → content swap centrally. Use
    one per loadable region rather than sprinkling <Skeleton/>
    everywhere. Supports `keepContent` for re-fetch flows.
  - In-source TODO #2 marker for the pending vitest setup.

──────────────────────────────────────────────────────────────────
LoadingDots — three-dot inline pulse
──────────────────────────────────────────────────────────────────
  packages/ui/src/components/LoadingDots.tsx  (new)

  - sm / md / lg sizes
  - `color` override, defaults to var(--bl-accent)
  - motion-safe:animate-bounce respects prefers-reduced-motion
  - role="status" + sr-only label for screen readers
  - inline-flex layout — composes inside chat bubbles + buttons

──────────────────────────────────────────────────────────────────
SearchInput — themed search field with suggestions slot
──────────────────────────────────────────────────────────────────
  packages/ui/src/components/SearchInput.tsx  (new)

  - Leading Search icon + clear-x button (visible while value !== '')
  - role="searchbox", proper aria-label fallback to placeholder
  - 3 size scales matching ButtonSize convention
  - `suggestions` slot for typeahead lists below
  - Forwards ref to <input> for imperative focus
  - Consolidates the bespoke search-field pattern from notes,
    fastgap, voice, jarvisjr (roadmap §2.2).

──────────────────────────────────────────────────────────────────
Package hygiene
──────────────────────────────────────────────────────────────────
  - package.json: 0.1.11 → 0.2.0
  - tsconfig.json: added DOM + DOM.Iterable libs to match
    motion/data-viz packages (required for e.target.value typing)
  - src/index.ts: exported SkeletonGroup, LoadingDots, SearchInput

──────────────────────────────────────────────────────────────────
Quality gates
──────────────────────────────────────────────────────────────────
  ✓ tsc --noEmit clean
  ✓ tsc build clean (no errors)
  ✓ No regression to existing ui exports

──────────────────────────────────────────────────────────────────
Roadmap tracker — 5 boxes flipped (§11)
──────────────────────────────────────────────────────────────────
  9.D.1  Skeleton extended with card shape
  9.D.2  SkeletonGroup orchestrator
  9.D.3  EmptyState verified (already shipped in 0.1.x)
  9.D.4  SearchInput added
  9.D.6  LoadingDots added + LoadingSpinner verified

§11.2 counter rewrote by scripts/count-roadmap-progress.ts:
  Wave 9 Data: 0/42 → 5/42 (12%)
  TOTAL:       5/202 → 10/202 (5%)

Open TODOs (§11.2.A):
  #2  Add vitest + happy-dom + @testing-library/react to
      @bytelyst/ui devDeps; write unit tests for the new
      Skeleton/SkeletonGroup/LoadingDots/SearchInput surfaces.
  #4  Republish @bytelyst/ui@0.2.0 to Gitea registry once #1
      (publish workflow) closes.

Showcase demos for these primitives land in the next showcase
commit (9.D.7–9.D.9).
This commit is contained in:
saravanakumardb1 2026-05-27 15:45:44 -07:00
parent 0e96f8c295
commit a55b819533
7 changed files with 276 additions and 12 deletions

View File

@ -612,10 +612,10 @@ For multi-step rows, sub-bullets are tracked independently. Agents should leave
### 11.2 Progress at a glance
```
TOTAL 5 / 202 ⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛ 2%
TOTAL 10 / 202 ⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛ 5%
─────────────────────────────────────────────
Wave 8 Rollout 5 / 18 🟩🟩🟩⬛⬛⬛⬛⬛⬛⬛ 28%
Wave 9 Data 0 / 42 ⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛ 0%
Wave 9 Data 5 / 42 🟩⬛⬛⬛⬛⬛⬛⬛⬛⬛ 12%
Wave 10 Shells 0 / 35 ⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛ 0%
Wave 11 Adaptive 0 / 26 ⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛ 0%
Wave 12 Mobile 0 / 26 ⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛ 0%
@ -626,6 +626,17 @@ Magnet demos 0 / 8 ⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛ 0%
> **Agents:** before pushing your commit, run `pnpm dlx tsx scripts/count-roadmap-progress.ts docs/UI_ROADMAP_2026_V3_CROSS_REPO.md` (to be authored in Wave 8.0) and paste the refreshed block in.
### 11.2.A Open TODOs raised during execution
Numbered list — coding agents drop `// ROADMAP-EXEC-TODO #N` comments at the point of need; the human operator can review and unblock.
| # | Title | Where it surfaced | What's needed |
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **#1** | **Publish workflow run** — push 8 packages to Gitea registry (`react-auth@0.2.0`, `dashboard-shell@0.2.0`, `design-tokens@0.2.0`, `ai-ui@0.4.0`, `command-palette@0.1.0`, `motion@0.1.0`, `data-viz@0.1.0`, `notifications-ui@0.1.0`) | Wave 8.A.1 | Registry credentials / CI trigger. Until this runs, all 8.A.26, 8.C.\*, and the next `ui@0.2.0` adoption are gated. |
| **#2** | **Add vitest setup to `@bytelyst/ui`** | Wave 9.D additions (Skeleton, SkeletonGroup, LoadingDots, SearchInput) | Decide whether to follow the `motion`/`data-viz` pattern (vitest + happy-dom + @testing-library/react devDeps) or stay typecheck-only. Recommended: copy motion's setup, then write tests. |
| **#3** | **Visual-regression baseline refresh** — the new showcase routes (`/showcase/motion/all`, `/showcase/command-palette/global`, plus any UI demos shipping in Wave 9) need `toHaveScreenshot()` snapshots captured | Wave 8 / CC.1 | Run `pnpm baseline` in showcase and commit the snapshots. |
| **#4** | **Republish `@bytelyst/ui@0.2.0` to Gitea registry** once it catches up | Wave 9 hygiene | After TODO #1 closes, re-trigger publish so product webs can pin `^0.2.0`. |
### 11.3 Wave 8 — Unblock & rollout · `5 / 18`
#### 8.A · Publishing & infra
@ -658,7 +669,7 @@ Magnet demos 0 / 8 ⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛ 0%
- [ ] **8.D.2** Showcase: all 94 Playwright smoke tests still passing after package swap
- [ ] **8.D.3** Showcase: visual-regression baseline updated post-swap
### 11.4 Wave 9 — Data, content, search · `0 / 42`
### 11.4 Wave 9 — Data, content, search · `5 / 42`
#### 9.A · `@bytelyst/charts@0.1.0`
@ -697,12 +708,12 @@ Magnet demos 0 / 8 ⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛ 0%
#### 9.D · `@bytelyst/ui@0.2.0` additions
- [ ] **9.D.1** `<Skeleton>` (text/rect/circle/card variants) + tests
- [ ] **9.D.2** `<SkeletonGroup>` orchestrator + tests
- [ ] **9.D.3** `<EmptyState>` with illustration slot + CTA + tests
- [ ] **9.D.4** `<SearchInput>` with suggestions slot + tests
- [x] **9.D.1** `<Skeleton>` extended with `card` shape — 4 variants (`text`/`block`/`circle`/`card`) _(pending tests — see TODO #2)_
- [x] **9.D.2** `<SkeletonGroup>` orchestrator — `loading``fallback``children` swap with opacity fade _(pending tests — see TODO #2)_
- [x] **9.D.3** `<EmptyState>` — already shipped in `@bytelyst/ui@0.1.x`; verified API (icon + title + description + actionLabel + onAction)
- [x] **9.D.4** `<SearchInput>` — leading icon, clear-`x`, `suggestions` slot, 3 size variants, `searchbox` role _(pending tests — see TODO #2)_
- [ ] **9.D.5** `<FilterBar>` + `<TagInput>` + `<Combobox>` + tests
- [ ] **9.D.6** `<LoadingDots>` + `<Spinner>` (token-tinted) + tests
- [x] **9.D.6** `<LoadingDots>` added; `<LoadingSpinner>` already shipped — both token-tinted _(pending tests — see TODO #2)_
- [ ] **9.D.7** **Showcase:** `/showcase/ui/skeleton-gallery`
- [ ] **9.D.8** **Showcase:** `/showcase/ui/empty-states` — 6 idiomatic empty states
- [ ] **9.D.9** **Showcase:** `/showcase/ui/filter-bar` — interactive chip filter demo

View File

@ -1,6 +1,6 @@
{
"name": "@bytelyst/ui",
"version": "0.1.11",
"version": "0.2.0",
"type": "module",
"scripts": {
"storybook": "storybook dev -p 6006",

View File

@ -0,0 +1,62 @@
import * as React from 'react';
import { clsx } from 'clsx';
export interface LoadingDotsProps {
/** Tailwind-style size scale. */
size?: 'sm' | 'md' | 'lg';
/** Override the dot colour. Defaults to `var(--bl-accent)`. */
color?: string;
/** Accessible label for screen readers. */
label?: string;
className?: string;
}
/**
* `<LoadingDots>` three-dot pulsing indicator. Tiny, inline-friendly
* alternative to `<LoadingSpinner>`. Honours `prefers-reduced-motion`
* via Tailwind's `motion-safe:` (the dots stop pulsing but remain
* visible).
*
* Composable inside chat bubbles, button labels, inline status chips.
*/
export function LoadingDots({
size = 'md',
color,
label = 'Loading',
className,
}: LoadingDotsProps) {
const dotSize: Record<NonNullable<LoadingDotsProps['size']>, string> = {
sm: 'h-1 w-1',
md: 'h-1.5 w-1.5',
lg: 'h-2 w-2',
};
const gap: Record<NonNullable<LoadingDotsProps['size']>, string> = {
sm: 'gap-1',
md: 'gap-1.5',
lg: 'gap-2',
};
return (
<span
role="status"
aria-label={label}
aria-busy="true"
data-testid="bl-loading-dots"
className={clsx('inline-flex items-center', gap[size], className)}
>
{[0, 1, 2].map(i => (
<span
key={i}
aria-hidden="true"
className={clsx(dotSize[size], 'inline-block rounded-full motion-safe:animate-bounce')}
style={{
background: color ?? 'var(--bl-accent, #6366f1)',
animationDelay: `${i * 120}ms`,
}}
/>
))}
<span className="sr-only">{label}</span>
</span>
);
}
LoadingDots.displayName = 'LoadingDots';

View File

@ -0,0 +1,106 @@
import * as React from 'react';
import { clsx } from 'clsx';
import { Search, X } from 'lucide-react';
export interface SearchInputProps extends Omit<
React.InputHTMLAttributes<HTMLInputElement>,
'onChange' | 'value' | 'size'
> {
/** Controlled value. */
value: string;
/** Called with the new value on every keystroke. */
onChange: (next: string) => void;
/** Placeholder text. Defaults to `Search…`. */
placeholder?: string;
/** When true, the clear-`x` button is visible while `value` is non-empty. */
clearable?: boolean;
/** Optional slot rendered below the input — typically `<SuggestionList>`. */
suggestions?: React.ReactNode;
/** Accessible label; required if `placeholder` is the only context. */
ariaLabel?: string;
/** Size scale. */
size?: 'sm' | 'md' | 'lg';
className?: string;
}
/**
* `<SearchInput>` themed search field with a leading icon, a clear-`x`
* affordance, and an optional suggestions slot below.
*
* Avoids the `bl-search-bespoke` proliferation Wave 9.D.4 consolidates
* every product's hand-rolled search field into one accessible primitive.
*/
export const SearchInput = React.forwardRef<HTMLInputElement, SearchInputProps>(
(
{
value,
onChange,
placeholder = 'Search…',
clearable = true,
suggestions,
ariaLabel,
size = 'md',
className,
...rest
},
ref
) => {
const heightCls: Record<NonNullable<SearchInputProps['size']>, string> = {
sm: 'h-8 text-xs',
md: 'h-10 text-sm',
lg: 'h-12 text-base',
};
const iconBox: Record<NonNullable<SearchInputProps['size']>, string> = {
sm: 'h-3.5 w-3.5',
md: 'h-4 w-4',
lg: 'h-5 w-5',
};
return (
<div className={clsx('flex flex-col gap-1.5', className)} data-testid="bl-search-input">
<div className="relative flex items-center">
<Search
className={clsx(
iconBox[size],
'pointer-events-none absolute left-3 text-[var(--bl-text-tertiary,#999)]'
)}
aria-hidden="true"
/>
<input
ref={ref}
type="search"
role="searchbox"
value={value}
onChange={e => onChange(e.target.value)}
placeholder={placeholder}
aria-label={ariaLabel ?? placeholder}
className={clsx(
heightCls[size],
'w-full rounded-lg border bg-[var(--bl-surface-card,#fff)] pl-9 pr-9 outline-none transition',
'border-[var(--bl-border,rgba(0,0,0,0.12))]',
'placeholder:text-[var(--bl-text-tertiary,#999)]',
'focus-visible:border-[var(--bl-accent,#6366f1)] focus-visible:ring-2 focus-visible:ring-[var(--bl-accent-muted,rgba(99,102,241,0.18))]'
)}
{...rest}
/>
{clearable && value.length > 0 && (
<button
type="button"
data-testid="bl-search-clear"
aria-label="Clear search"
onClick={() => onChange('')}
className={clsx(
'absolute right-2 grid place-items-center rounded-md p-1 text-[var(--bl-text-tertiary,#999)]',
'transition hover:bg-[var(--bl-surface-muted,rgba(0,0,0,0.05))] hover:text-[var(--bl-text-primary,inherit)]'
)}
>
<X className={iconBox[size]} aria-hidden="true" />
</button>
)}
</div>
{suggestions && <div data-testid="bl-search-suggestions">{suggestions}</div>}
</div>
);
}
);
SearchInput.displayName = 'SearchInput';

View File

@ -1,10 +1,25 @@
// ROADMAP-EXEC-TODO #2 — vitest setup pending in @bytelyst/ui; add Skeleton +
// SkeletonGroup tests once happy-dom + @testing-library/react devDeps land.
import * as React from 'react';
import { clsx } from 'clsx';
export interface SkeletonProps extends React.HTMLAttributes<HTMLDivElement> {
shape?: 'block' | 'text' | 'circle';
/**
* 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
@ -14,6 +29,8 @@ export function Skeleton({ shape = 'block', className, ...props }: SkeletonProps
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}
@ -30,3 +47,62 @@ export function TableSkeleton({ rows = 5 }: { rows?: number }) {
</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>
);
}

View File

@ -11,7 +11,13 @@ export {
type AlertBannerProps,
type AlertBannerTone,
} from './components/AlertBanner.js';
export { Skeleton, TableSkeleton, type SkeletonProps } from './components/Skeleton.js';
export {
Skeleton,
SkeletonGroup,
TableSkeleton,
type SkeletonProps,
type SkeletonGroupProps,
} from './components/Skeleton.js';
export { EntityCard, type EntityCardProps } from './components/EntityCard.js';
export { MetricCard, type MetricCardProps } from './components/MetricCard.js';
export { ActionMenu, type ActionMenuItem, type ActionMenuProps } from './components/ActionMenu.js';
@ -169,3 +175,5 @@ export {
} from './components/Sidebar.js';
export { StatCard, type StatCardProps } from './components/StatCard.js';
export { LoadingSpinner, type LoadingSpinnerProps } from './components/LoadingSpinner.js';
export { LoadingDots, type LoadingDotsProps } from './components/LoadingDots.js';
export { SearchInput, type SearchInputProps } from './components/SearchInput.js';

View File

@ -6,7 +6,8 @@
"moduleResolution": "bundler",
"outDir": "dist",
"rootDir": "src",
"declaration": true
"declaration": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"]
},
"include": ["src"],
"exclude": ["src/**/*.stories.tsx"]