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:
parent
0e96f8c295
commit
a55b819533
@ -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.2–6, 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
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@bytelyst/ui",
|
||||
"version": "0.1.11",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"storybook": "storybook dev -p 6006",
|
||||
|
||||
62
packages/ui/src/components/LoadingDots.tsx
Normal file
62
packages/ui/src/components/LoadingDots.tsx
Normal 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';
|
||||
106
packages/ui/src/components/SearchInput.tsx
Normal file
106
packages/ui/src/components/SearchInput.tsx
Normal 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';
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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"]
|
||||
|
||||
Loading…
Reference in New Issue
Block a user