feat(packages): Wave 4 motion + Wave 5b data-viz + Wave 7 notifications-ui

Three new product-agnostic packages unlock visible elegance lifts
across every product:

═══════════════════════════════════════════════════════════════════════
@bytelyst/motion@0.1.0 — Wave 4 elegance primitives  (2.21 KB / 8 KB)
═══════════════════════════════════════════════════════════════════════

  <Reveal>          — IntersectionObserver-based fade/slide entry,
                      6 directions, configurable spring + delay
  <StaggerList>     — sequenced reveal of children with per-item delay
  <NumberFlow>      — RAF-tweened number counter, cubic-out easing,
                      Intl-formatted, prefers-reduced-motion aware
  <TiltCard>        — 3D perspective tilt + cursor-tracking glare
                      overlay (single-element ref, no React rerenders)
  <ScrollProgress>  — fixed scroll-position bar (window or any element)

Plus exported `SPRINGS` (4 cubic-bezier presets) + `prefersReducedMotion`
helper. Every primitive accepts `disableMotion` for snapshot tests.

═══════════════════════════════════════════════════════════════════════
@bytelyst/data-viz@0.1.0 — Wave 5b viz primitives  (2.63 KB / 10 KB)
═══════════════════════════════════════════════════════════════════════

  <Sparkline>     — line trend with gradient fill + last-point marker
  <BarSparkline>  — discrete-bar mini-chart with max-bar highlight
  <KpiCard>       — label + headline + delta arrow + sparkline; supports
                    'goodWhen=lower' for latency/cost metrics
  <ProgressRing>  — circular progress with center content slot,
                    animated stroke-dashoffset
  <Heatmap>       — GitHub-style calendar grid with color-mix() intensity

All pure SVG / CSS — zero runtime dependencies.

═══════════════════════════════════════════════════════════════════════
@bytelyst/notifications-ui@0.1.0 — Wave 7 essentials (3.31 KB / 10 KB)
═══════════════════════════════════════════════════════════════════════

  <NotificationCenter>  — bell trigger + badge + dropdown panel with
                          All / Unread / Mentions tabs, outside-click
                          + Escape close, mark-all-read action
  <InboxItem>           — single row with unread dot, kind glyph,
                          relative timestamp, optional action buttons
  <BannerStack>         — top-of-page strip with maxVisible + +N more,
                          accent-bordered tone variants, dismissible
  <Announcement>        — inline 'What's new' pill (3 tone variants)

5 notification kinds (info/success/warning/danger/mention) + 5 banner
kinds (... + announcement gradient).

═══════════════════════════════════════════════════════════════════════
Quality gates
═══════════════════════════════════════════════════════════════════════
  All three packages: tsc --noEmit clean, build clean.
  Tests:    motion 16/16  ·  data-viz 14/14  ·  notifications-ui 17/17
  Bundles:  motion 2.21 KB  ·  data-viz 2.63 KB  ·  noti-ui 3.31 KB
  Budgets:  added to .size-limit.cjs (8/10/10 KB respectively)

Refs:
  learning_ai_uxui_web/docs/ROADMAP_2026.md
    §Wave 4 (Motion), §Wave 5b (Charts), §Wave 7 (Productisation)
  Decisions doc §13 (mobile-native = tokens-only) leaves room for these
    web-first packages to be the canonical surface
This commit is contained in:
saravanakumardb1 2026-05-27 13:08:30 -07:00
parent e2eea086dc
commit d082480849
32 changed files with 2526 additions and 0 deletions

View File

@ -79,4 +79,25 @@ module.exports = [
limit: '15 KB',
gzip: true,
},
// ── Motion primitives (8 KB — 5 components, zero deps) ──────────
{
name: '@bytelyst/motion',
path: 'packages/motion/dist/index.js',
limit: '8 KB',
gzip: true,
},
// ── Data-viz primitives (10 KB — 5 SVG components) ──────────────
{
name: '@bytelyst/data-viz',
path: 'packages/data-viz/dist/index.js',
limit: '10 KB',
gzip: true,
},
// ── Notifications UI (10 KB — center + banner + announcement) ───
{
name: '@bytelyst/notifications-ui',
path: 'packages/notifications-ui/dist/index.js',
limit: '10 KB',
gzip: true,
},
];

View File

@ -0,0 +1,34 @@
{
"name": "@bytelyst/data-viz",
"version": "0.1.0",
"type": "module",
"description": "Token-themed visualization primitives — Sparkline, KpiCard, ProgressRing, BarSparkline, Heatmap. Pure SVG, zero deps.",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": ["dist"],
"scripts": {
"build": "tsc",
"test": "vitest run --pool forks",
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"react": ">=18.0.0",
"react-dom": ">=18.0.0"
},
"devDependencies": {
"@testing-library/react": "^16.3.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"happy-dom": "^18.0.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"typescript": "^5.7.3",
"vitest": "^4.0.18"
}
}

View File

@ -0,0 +1,73 @@
import { useMemo, type CSSProperties } from 'react';
export interface BarSparklineProps {
data: number[];
width?: number;
height?: number;
color?: string;
/** Color for the maximum bar. Defaults to `color`. */
highlightMax?: string;
/** Spacing between bars as a fraction of bar width. Default 0.25. */
gap?: number;
ariaLabel?: string;
className?: string;
style?: CSSProperties;
}
/**
* `<BarSparkline>` inline mini-bar chart. Pairs well with KPI cards
* where a line sparkline doesn't fit (e.g. discrete daily counts).
*/
export function BarSparkline({
data,
width = 120,
height = 36,
color = 'var(--bl-accent, #6366f1)',
highlightMax,
gap = 0.25,
ariaLabel,
className,
style,
}: BarSparklineProps) {
const { bars, maxIdx } = useMemo(() => {
if (data.length === 0) return { bars: [], maxIdx: -1 };
const max = Math.max(...data, 1);
const slot = width / data.length;
const barW = slot * (1 - gap);
const offset = slot * (gap / 2);
const maxIdx = data.indexOf(Math.max(...data));
const bars = data.map((v, i) => {
const h = Math.max(2, (v / max) * height);
return { x: i * slot + offset, y: height - h, w: barW, h };
});
return { bars, maxIdx };
}, [data, width, height, gap]);
const peak = highlightMax ?? color;
return (
<svg
data-testid="bl-bar-sparkline"
role="img"
aria-label={ariaLabel ?? 'Bar sparkline'}
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
className={className}
style={style}
>
{bars.map((b, i) => (
<rect
key={i}
x={b.x}
y={b.y}
width={b.w}
height={b.h}
rx={1.5}
fill={i === maxIdx ? peak : color}
opacity={i === maxIdx ? 1 : 0.55}
/>
))}
</svg>
);
}

View File

@ -0,0 +1,88 @@
import { useMemo, type CSSProperties } from 'react';
export interface HeatmapCell {
/** ISO date or any stable label. */
date: string;
/** Value — drives intensity. */
value: number;
/** Optional tooltip text. */
label?: string;
}
export interface HeatmapProps {
/** Cells laid out top-to-bottom then left-to-right (calendar style). */
cells: HeatmapCell[];
/** Rows. Default 7 (week). */
rows?: number;
/** Cell size in px (square). Default 12. */
cell?: number;
/** Gap between cells in px. Default 3. */
gap?: number;
/** Active color (interpolated by intensity). */
color?: string;
/** Empty cell color. */
emptyColor?: string;
ariaLabel?: string;
className?: string;
style?: CSSProperties;
}
/**
* `<Heatmap>` GitHub-style calendar heatmap. Pure CSS grid, no
* tooltips library uses native `title` attributes for the on-hover
* label so the bundle stays tiny.
*/
export function Heatmap({
cells,
rows = 7,
cell = 12,
gap = 3,
color = 'var(--bl-accent, #6366f1)',
emptyColor = 'var(--bl-surface-muted, rgba(0,0,0,0.04))',
ariaLabel,
className,
style,
}: HeatmapProps) {
const max = useMemo(
() => cells.reduce((m, c) => Math.max(m, c.value), 0) || 1,
[cells],
);
return (
<div
data-testid="bl-heatmap"
role="img"
aria-label={ariaLabel ?? 'Activity heatmap'}
className={className}
style={{
display: 'grid',
gridTemplateRows: `repeat(${rows}, ${cell}px)`,
gridAutoFlow: 'column',
gap,
...style,
}}
>
{cells.map((c, i) => {
const intensity = c.value > 0 ? 0.15 + 0.85 * (c.value / max) : 0;
return (
<div
key={`${c.date}-${i}`}
data-testid={`bl-heatmap-cell-${i}`}
data-value={c.value}
data-intensity={intensity.toFixed(2)}
title={c.label ?? `${c.date}: ${c.value}`}
style={{
width: cell,
height: cell,
borderRadius: 2,
background:
intensity === 0
? emptyColor
: `color-mix(in srgb, ${color} ${Math.round(intensity * 100)}%, transparent)`,
transition: 'transform 120ms ease',
}}
/>
);
})}
</div>
);
}

View File

@ -0,0 +1,169 @@
import type { CSSProperties, ReactNode } from 'react';
import { Sparkline } from './Sparkline.js';
export interface KpiCardProps {
/** Metric label (e.g. 'Active users'). */
label: string;
/** Pre-formatted display value (e.g. '12.4k', '$8,421'). */
value: ReactNode;
/** Delta percentage (e.g. 6.6 for +6.6%). */
deltaPercent?: number;
/** Override the delta label (e.g. 'vs last 7d'). */
deltaLabel?: string;
/** Inline trend data. Optional. */
trend?: number[];
/** Icon rendered top-right. */
icon?: ReactNode;
/** Sparkline stroke override. */
trendColor?: string;
/** Hide the up/down arrow next to delta. Default false. */
hideDeltaArrow?: boolean;
/** Override the threshold below which a delta is rendered as 'good'.
* Useful for cost / latency KPIs where lower is better. Default `>=0`. */
goodWhen?: 'higher' | 'lower';
className?: string;
style?: CSSProperties;
}
/**
* `<KpiCard>` token-themed key-performance-indicator tile.
*
*
* Active users
* 8,421
* +6.6% vs last 7d
*
*
* Renders the headline number prominently, an optional delta with
* color-coded arrow, and an inline `<Sparkline>` for context.
*/
export function KpiCard({
label,
value,
deltaPercent,
deltaLabel = 'vs previous',
trend,
icon,
trendColor,
hideDeltaArrow,
goodWhen = 'higher',
className,
style,
}: KpiCardProps) {
const hasDelta = typeof deltaPercent === 'number';
const up = hasDelta && deltaPercent! >= 0;
const good =
!hasDelta ||
(goodWhen === 'higher' ? up : !up && Math.abs(deltaPercent!) > 0.0001);
const deltaColor = good
? 'var(--bl-success, #22c55e)'
: 'var(--bl-danger, #ef4444)';
return (
<article
data-testid="bl-kpi-card"
className={className}
style={{
display: 'flex',
flexDirection: 'column',
gap: 'var(--bl-space-2, 8px)',
padding: 'var(--bl-space-4, 16px)',
border: '1px solid var(--bl-border, rgba(0,0,0,0.08))',
borderRadius: 'var(--bl-radius-card, 12px)',
background: 'var(--bl-surface-card, #fff)',
color: 'var(--bl-text-primary, inherit)',
minWidth: 180,
...style,
}}
>
<header
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 'var(--bl-space-2, 8px)',
}}
>
<span
style={{
fontSize: '0.75rem',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.04em',
color: 'var(--bl-text-tertiary, #888)',
}}
>
{label}
</span>
{icon && (
<span
aria-hidden
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 28,
height: 28,
borderRadius: 'var(--bl-radius-pill, 999px)',
background: 'var(--bl-accent-muted, rgba(99,102,241,0.12))',
color: 'var(--bl-accent, #6366f1)',
}}
>
{icon}
</span>
)}
</header>
<div
data-testid="bl-kpi-value"
style={{
fontSize: '2.1rem',
fontWeight: 700,
lineHeight: 1.05,
fontVariantNumeric: 'tabular-nums',
}}
>
{value}
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--bl-space-2, 8px)',
fontSize: '0.78rem',
}}
>
{hasDelta && (
<span
data-testid="bl-kpi-delta"
data-good={good ? 'true' : 'false'}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 2,
color: deltaColor,
fontWeight: 600,
}}
>
{!hideDeltaArrow && <span aria-hidden>{up ? '▲' : '▼'}</span>}
{(up ? '+' : '') + deltaPercent!.toFixed(1)}%
</span>
)}
{hasDelta && (
<span style={{ color: 'var(--bl-text-tertiary, #888)' }}>{deltaLabel}</span>
)}
{trend && trend.length > 1 && (
<div style={{ marginLeft: 'auto' }}>
<Sparkline
data={trend}
width={88}
height={28}
stroke={trendColor ?? (good ? 'var(--bl-success, #22c55e)' : 'var(--bl-danger, #ef4444)')}
/>
</div>
)}
</div>
</article>
);
}

View File

@ -0,0 +1,104 @@
import { useMemo, type CSSProperties, type ReactNode } from 'react';
export interface ProgressRingProps {
/** Progress 0..1 (values outside are clamped). */
value: number;
/** Outer diameter in px. Default 96. */
size?: number;
/** Ring thickness in px. Default 8. */
thickness?: number;
/** Track color. Default `var(--bl-border)`. */
track?: string;
/** Active arc color. Default `var(--bl-accent)`. */
color?: string;
/** Slot rendered in the center. */
children?: ReactNode;
/** Aria label. */
ariaLabel?: string;
className?: string;
style?: CSSProperties;
}
/**
* `<ProgressRing>` circular progress with an inner content slot.
* Perfect for onboarding checklists, usage meters, and goal trackers.
*
* Smoothly animates `stroke-dashoffset` so the ring sweeps when `value`
* changes no JS tween needed.
*/
export function ProgressRing({
value,
size = 96,
thickness = 8,
track = 'var(--bl-border, rgba(0,0,0,0.08))',
color = 'var(--bl-accent, #6366f1)',
children,
ariaLabel,
className,
style,
}: ProgressRingProps) {
const clamped = Math.max(0, Math.min(1, value));
const { radius, circumference, dashOffset } = useMemo(() => {
const r = (size - thickness) / 2;
const c = 2 * Math.PI * r;
return {
radius: r,
circumference: c,
dashOffset: c * (1 - clamped),
};
}, [size, thickness, clamped]);
return (
<div
data-testid="bl-progress-ring"
role="img"
aria-label={ariaLabel ?? `${Math.round(clamped * 100)} percent`}
className={className}
style={{
position: 'relative',
width: size,
height: size,
display: 'inline-grid',
placeItems: 'center',
...style,
}}
>
<svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
style={{
position: 'absolute',
inset: 0,
transform: 'rotate(-90deg)',
}}
>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={track}
strokeWidth={thickness}
/>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={color}
strokeWidth={thickness}
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={dashOffset}
style={{
transition: 'stroke-dashoffset 600ms cubic-bezier(0.2, 0.9, 0.1, 1)',
}}
/>
</svg>
{children !== undefined && (
<div style={{ position: 'relative', textAlign: 'center' }}>{children}</div>
)}
</div>
);
}

View File

@ -0,0 +1,115 @@
import { useMemo, type CSSProperties } from 'react';
export interface SparklineProps {
/** Series values. Length 2+. */
data: number[];
/** Width in px. Default 120. */
width?: number;
/** Height in px. Default 36. */
height?: number;
/** Stroke color. Default `var(--bl-accent)`. */
stroke?: string;
/** Stroke width. Default 1.75. */
strokeWidth?: number;
/** Fill the area under the line. Default true. */
fill?: boolean;
/** Highlight the final point. Default true. */
showLastPoint?: boolean;
/** Aria label. */
ariaLabel?: string;
className?: string;
style?: CSSProperties;
}
/**
* `<Sparkline>` tiny inline trend line. Pure SVG, no runtime layout
* cost. Auto-scales the Y axis to the data range; clamps tiny ranges
* so flat lines still render visibly.
*/
export function Sparkline({
data,
width = 120,
height = 36,
stroke = 'var(--bl-accent, #6366f1)',
strokeWidth = 1.75,
fill = true,
showLastPoint = true,
ariaLabel,
className,
style,
}: SparklineProps) {
const { path, areaPath, lastX, lastY } = useMemo(() => {
if (data.length < 2) {
return { path: '', areaPath: '', lastX: 0, lastY: 0 };
}
const min = Math.min(...data);
const max = Math.max(...data);
const range = max - min || 1;
const pad = strokeWidth + 1;
const innerW = width - pad * 2;
const innerH = height - pad * 2;
const stepX = innerW / (data.length - 1);
const pts = data.map((v, i) => {
const x = pad + i * stepX;
const y = pad + innerH - ((v - min) / range) * innerH;
return [x, y] as const;
});
const path = pts
.map(([x, y], i) => (i === 0 ? `M${x},${y}` : `L${x},${y}`))
.join(' ');
const areaPath =
pts.length > 1
? `${path} L${pts[pts.length - 1]![0]},${height} L${pts[0]![0]},${height} Z`
: '';
const [lx, ly] = pts[pts.length - 1]!;
return { path, areaPath, lastX: lx, lastY: ly };
}, [data, width, height, strokeWidth]);
const gradId = useMemo(
() => `bl-spark-${Math.random().toString(36).slice(2, 8)}`,
[],
);
return (
<svg
data-testid="bl-sparkline"
role="img"
aria-label={ariaLabel ?? 'Sparkline'}
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
className={className}
style={{ overflow: 'visible', ...style }}
>
{fill && (
<>
<defs>
<linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={stroke} stopOpacity={0.32} />
<stop offset="100%" stopColor={stroke} stopOpacity={0} />
</linearGradient>
</defs>
<path d={areaPath} fill={`url(#${gradId})`} stroke="none" />
</>
)}
<path
d={path}
fill="none"
stroke={stroke}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
/>
{showLastPoint && data.length > 0 && (
<circle
cx={lastX}
cy={lastY}
r={strokeWidth + 1.25}
fill={stroke}
stroke="var(--bl-surface-card, #fff)"
strokeWidth={1.5}
/>
)}
</svg>
);
}

View File

@ -0,0 +1,112 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { cleanup, render, screen } from '@testing-library/react';
import { Sparkline } from '../Sparkline.js';
import { BarSparkline } from '../BarSparkline.js';
import { KpiCard } from '../KpiCard.js';
import { ProgressRing } from '../ProgressRing.js';
import { Heatmap } from '../Heatmap.js';
describe('Sparkline', () => {
beforeEach(() => cleanup());
it('renders svg with given dimensions', () => {
render(<Sparkline data={[1, 2, 3, 2, 4, 5]} width={100} height={32} />);
const el = screen.getByTestId('bl-sparkline');
expect(el.getAttribute('width')).toBe('100');
expect(el.getAttribute('height')).toBe('32');
});
it('hides last point when showLastPoint=false', () => {
render(<Sparkline data={[1, 2, 3]} showLastPoint={false} />);
expect(screen.getByTestId('bl-sparkline').querySelector('circle')).toBeNull();
});
it('does not draw a path with fewer than 2 points', () => {
render(<Sparkline data={[]} />);
const path = screen.getByTestId('bl-sparkline').querySelector('path');
expect(path?.getAttribute('d')).toBe('');
});
});
describe('BarSparkline', () => {
beforeEach(() => cleanup());
it('renders one rect per datum', () => {
render(<BarSparkline data={[1, 4, 2, 6, 3]} />);
const rects = screen.getByTestId('bl-bar-sparkline').querySelectorAll('rect');
expect(rects.length).toBe(5);
});
it('emphasizes the max bar (opacity=1)', () => {
render(<BarSparkline data={[1, 9, 2]} />);
const rects = screen.getByTestId('bl-bar-sparkline').querySelectorAll('rect');
// The second bar (index 1) is the max.
expect(rects[1]?.getAttribute('opacity')).toBe('1');
expect(rects[0]?.getAttribute('opacity')).toBe('0.55');
});
});
describe('KpiCard', () => {
beforeEach(() => cleanup());
it('renders label + headline value', () => {
render(<KpiCard label="DAU" value="8,421" />);
expect(screen.getByText('DAU')).toBeDefined();
expect(screen.getByTestId('bl-kpi-value').textContent).toBe('8,421');
});
it('positive delta renders with up arrow and success color', () => {
render(<KpiCard label="X" value={1} deltaPercent={6.6} />);
const d = screen.getByTestId('bl-kpi-delta');
expect(d.textContent).toContain('+6.6%');
expect(d.textContent).toContain('▲');
});
it('negative delta renders with down arrow', () => {
render(<KpiCard label="X" value={1} deltaPercent={-3.2} />);
expect(screen.getByTestId('bl-kpi-delta').textContent).toContain('▼');
});
it('goodWhen=lower inverts the success/danger interpretation', () => {
// For latency: a negative delta (faster) is good.
const { rerender } = render(
<KpiCard label="latency" value="120ms" deltaPercent={-10} goodWhen="lower" />,
);
expect(
screen.getByTestId('bl-kpi-delta').getAttribute('data-good'),
).toBe('true');
rerender(<KpiCard label="latency" value="120ms" deltaPercent={5} goodWhen="lower" />);
expect(
screen.getByTestId('bl-kpi-delta').getAttribute('data-good'),
).toBe('false');
});
it('renders sparkline when trend provided', () => {
render(<KpiCard label="X" value={1} trend={[1, 2, 3, 4]} />);
expect(screen.getByTestId('bl-sparkline')).toBeDefined();
});
});
describe('ProgressRing', () => {
beforeEach(() => cleanup());
it('clamps value to 0..1 and emits aria-label percent', () => {
const { rerender } = render(<ProgressRing value={2} />);
expect(screen.getByTestId('bl-progress-ring').getAttribute('aria-label')).toBe('100 percent');
rerender(<ProgressRing value={-1} />);
expect(screen.getByTestId('bl-progress-ring').getAttribute('aria-label')).toBe('0 percent');
});
it('renders inner content slot', () => {
render(
<ProgressRing value={0.5}>
<span data-testid="center">50%</span>
</ProgressRing>,
);
expect(screen.getByTestId('center').textContent).toBe('50%');
});
});
describe('Heatmap', () => {
beforeEach(() => cleanup());
it('renders one cell per datum', () => {
const cells = Array.from({ length: 14 }, (_, i) => ({ date: `d${i}`, value: i }));
render(<Heatmap cells={cells} />);
expect(screen.getAllByTestId(/bl-heatmap-cell-/).length).toBe(14);
});
it('zero-value cells render with empty intensity', () => {
render(<Heatmap cells={[{ date: 'a', value: 0 }, { date: 'b', value: 5 }]} />);
const a = screen.getByTestId('bl-heatmap-cell-0');
const b = screen.getByTestId('bl-heatmap-cell-1');
expect(a.getAttribute('data-intensity')).toBe('0.00');
expect(parseFloat(b.getAttribute('data-intensity') ?? '0')).toBeGreaterThan(0);
});
});

View File

@ -0,0 +1,28 @@
/**
* @bytelyst/data-viz Token-themed visualization primitives.
*
* Exports (0.1.0):
* <Sparkline> line trend, inline SVG
* <BarSparkline> discrete bars with max-highlight
* <KpiCard> label + headline + delta + sparkline
* <ProgressRing> circular progress with center slot
* <Heatmap> calendar-style intensity grid
*
* Coming in 0.2.x (per ROADMAP §Wave 5b):
* <LineChart>, <BarChart>, <AreaChart> full axes + tooltips
* <RealtimeChart>, <Sankey>, <Treemap>
*/
export { Sparkline } from './Sparkline.js';
export type { SparklineProps } from './Sparkline.js';
export { BarSparkline } from './BarSparkline.js';
export type { BarSparklineProps } from './BarSparkline.js';
export { KpiCard } from './KpiCard.js';
export type { KpiCardProps } from './KpiCard.js';
export { ProgressRing } from './ProgressRing.js';
export type { ProgressRingProps } from './ProgressRing.js';
export { Heatmap } from './Heatmap.js';
export type { HeatmapCell, HeatmapProps } from './Heatmap.js';

View File

@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"lib": ["ES2022", "DOM"],
"jsx": "react-jsx"
},
"include": ["src"],
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
}

View File

@ -0,0 +1,2 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({ test: { environment: 'happy-dom', pool: 'forks' } });

View File

@ -0,0 +1,34 @@
{
"name": "@bytelyst/motion",
"version": "0.1.0",
"type": "module",
"description": "Motion primitives — Reveal, StaggerList, NumberFlow, TiltCard, ScrollProgress. ~5 KB gzip, honors prefers-reduced-motion.",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": ["dist"],
"scripts": {
"build": "tsc",
"test": "vitest run --pool forks",
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"react": ">=18.0.0",
"react-dom": ">=18.0.0"
},
"devDependencies": {
"@testing-library/react": "^16.3.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"happy-dom": "^18.0.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"typescript": "^5.7.3",
"vitest": "^4.0.18"
}
}

View File

@ -0,0 +1,105 @@
import {
useEffect,
useRef,
useState,
type CSSProperties,
} from 'react';
import { SPRINGS, prefersReducedMotion } from './utils.js';
export interface NumberFlowProps {
/** Target value to animate to. */
value: number;
/** Tween duration in ms. Default 700. */
duration?: number;
/** Decimal places. Default 0. */
decimals?: number;
/** Optional formatter (overrides `decimals` + locale). */
format?: (n: number) => string;
/** Locale for default formatter. Default 'en-US'. */
locale?: string;
/** Prefix rendered before the number (e.g. '$'). */
prefix?: string;
/** Suffix rendered after the number (e.g. '%'). */
suffix?: string;
/** Bypass animation. */
disableMotion?: boolean;
className?: string;
style?: CSSProperties;
}
/**
* `<NumberFlow>` smoothly tweens a number from its previous value to
* the new one. Counts up, counts down, and respects
* `prefers-reduced-motion` (snaps to final value).
*
* Pure RAF no dependencies. Cancels in-flight tweens when `value`
* changes mid-animation so the latest target always wins.
*/
export function NumberFlow({
value,
duration = 700,
decimals = 0,
format,
locale = 'en-US',
prefix,
suffix,
disableMotion,
className,
style,
}: NumberFlowProps) {
const [display, setDisplay] = useState(value);
const fromRef = useRef(value);
const rafRef = useRef<number | null>(null);
const reduced = disableMotion ?? prefersReducedMotion();
useEffect(() => {
if (reduced) {
fromRef.current = value;
setDisplay(value);
return;
}
const from = fromRef.current;
const start = performance.now();
const tween = (now: number) => {
const t = Math.min(1, (now - start) / duration);
// Apply a snappy ease-out so the number doesn't feel linear.
const eased = 1 - Math.pow(1 - t, 3);
const current = from + (value - from) * eased;
setDisplay(current);
if (t < 1) {
rafRef.current = requestAnimationFrame(tween);
} else {
fromRef.current = value;
rafRef.current = null;
}
};
rafRef.current = requestAnimationFrame(tween);
return () => {
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
};
}, [value, duration, reduced]);
const formatted = format
? format(display)
: new Intl.NumberFormat(locale, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(display);
return (
<span
data-testid="bl-number-flow"
data-target={value}
className={className}
style={{
fontVariantNumeric: 'tabular-nums',
transition: `color 200ms ${SPRINGS.snappy}`,
...style,
}}
>
{prefix}
{formatted}
{suffix}
</span>
);
}

View File

@ -0,0 +1,131 @@
import {
useEffect,
useRef,
useState,
type CSSProperties,
type ReactNode,
} from 'react';
import { SPRINGS, prefersReducedMotion, type Spring } from './utils.js';
export interface RevealProps {
children: ReactNode;
/** Entry direction. Default: 'up' (translateY 8px). */
from?: 'up' | 'down' | 'left' | 'right' | 'fade' | 'scale';
/** Distance in pixels for translate variants. Default 8. */
distance?: number;
/** Animation duration (ms). Default 320. */
duration?: number;
/** Trigger delay (ms). Default 0. */
delay?: number;
/** Spring curve preset. Default 'snappy'. */
spring?: Spring;
/** IntersectionObserver threshold. Default 0.1. */
threshold?: number;
/** Trigger only once (default) vs every time it enters the viewport. */
once?: boolean;
/** Bypass animation (e.g. for SSR-stable visual-regression tests). */
disableMotion?: boolean;
/** Tailwind escape hatch. */
className?: string;
/** Inline style escape hatch — merged AFTER motion vars. */
style?: CSSProperties;
}
/**
* `<Reveal>` fades + slides a child into view on viewport entry.
*
* Uses IntersectionObserver + CSS transitions; zero runtime physics.
* Automatically disables animation under `prefers-reduced-motion`.
*
* @example
* ```tsx
* <Reveal from="up" delay={120}>
* <h1>Hero headline</h1>
* </Reveal>
* ```
*/
export function Reveal({
children,
from = 'up',
distance = 8,
duration = 320,
delay = 0,
spring = 'snappy',
threshold = 0.1,
once = true,
disableMotion,
className,
style,
}: RevealProps) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
const reduced = disableMotion ?? prefersReducedMotion();
useEffect(() => {
if (reduced) {
setVisible(true);
return;
}
const el = ref.current;
if (!el || typeof IntersectionObserver === 'undefined') {
setVisible(true);
return;
}
const io = new IntersectionObserver(
entries => {
for (const entry of entries) {
if (entry.isIntersecting) {
setVisible(true);
if (once) io.disconnect();
} else if (!once) {
setVisible(false);
}
}
},
{ threshold },
);
io.observe(el);
return () => io.disconnect();
}, [threshold, once, reduced]);
const transform = visible || reduced ? 'none' : initialTransform(from, distance);
const opacity = visible || reduced ? 1 : from === 'scale' ? 0.92 : 0;
return (
<div
ref={ref}
data-testid="bl-reveal"
data-visible={visible || reduced}
className={className}
style={{
transform,
opacity,
transition: reduced
? 'none'
: `transform ${duration}ms ${SPRINGS[spring]} ${delay}ms, opacity ${duration}ms ${SPRINGS[spring]} ${delay}ms`,
willChange: 'transform, opacity',
...style,
}}
>
{children}
</div>
);
}
function initialTransform(from: RevealProps['from'], d: number): string {
switch (from) {
case 'up':
return `translate3d(0, ${d}px, 0)`;
case 'down':
return `translate3d(0, ${-d}px, 0)`;
case 'left':
return `translate3d(${d}px, 0, 0)`;
case 'right':
return `translate3d(${-d}px, 0, 0)`;
case 'scale':
return 'scale(0.96)';
case 'fade':
default:
return 'none';
}
}

View File

@ -0,0 +1,86 @@
import { useEffect, useRef, type CSSProperties } from 'react';
export interface ScrollProgressProps {
/** Element to track. Defaults to `document.documentElement`. */
target?: HTMLElement | null;
/** Bar position. Default 'top'. */
position?: 'top' | 'bottom';
/** Bar height in pixels. Default 3. */
thickness?: number;
/** Bar fill color. Default `var(--bl-accent)`. */
color?: string;
className?: string;
style?: CSSProperties;
}
/**
* `<ScrollProgress>` fixed progress bar reflecting scroll position
* of a target (defaults to the document). Common chrome element on
* long-form pages and onboarding flows.
*
* Updates the bar's `scaleX` transform directly via a ref so React
* doesn't re-render on every scroll event.
*/
export function ScrollProgress({
target,
position = 'top',
thickness = 3,
color = 'var(--bl-accent, #6366f1)',
className,
style,
}: ScrollProgressProps) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const root =
target ??
(typeof document !== 'undefined' ? document.documentElement : null);
if (!root) return;
const update = () => {
const el = ref.current;
if (!el) return;
const max = root.scrollHeight - root.clientHeight;
const pct = max > 0 ? Math.min(1, root.scrollTop / max) : 0;
el.style.transform = `scaleX(${pct})`;
};
update();
const scrollEl = root === document.documentElement ? window : root;
scrollEl.addEventListener('scroll', update, { passive: true });
window.addEventListener('resize', update);
return () => {
scrollEl.removeEventListener('scroll', update);
window.removeEventListener('resize', update);
};
}, [target]);
return (
<div
data-testid="bl-scroll-progress"
aria-hidden
className={className}
style={{
position: 'fixed',
left: 0,
right: 0,
height: thickness,
zIndex: 100,
background: 'transparent',
pointerEvents: 'none',
[position]: 0,
...style,
}}
>
<div
ref={ref}
style={{
height: '100%',
width: '100%',
background: color,
transformOrigin: 'left center',
transform: 'scaleX(0)',
willChange: 'transform',
}}
/>
</div>
);
}

View File

@ -0,0 +1,48 @@
import { Children, isValidElement, type ReactNode } from 'react';
import { Reveal, type RevealProps } from './Reveal.js';
export interface StaggerListProps
extends Omit<RevealProps, 'children' | 'delay'> {
children: ReactNode;
/** Delay between successive children (ms). Default 60. */
stagger?: number;
/** Initial delay applied to the first child (ms). Default 0. */
initialDelay?: number;
/** Element used as the list root. Default 'div'. */
as?: 'div' | 'ul' | 'ol' | 'section';
}
/**
* Reveal a list of children with a per-item delay. Each child is wrapped
* in a `<Reveal>` so the same motion vocabulary applies `from`,
* `spring`, `duration`, `once`, etc. all flow through.
*
* @example
* ```tsx
* <StaggerList from="up" stagger={70}>
* {items.map((item) => <Card key={item.id} {...item} />)}
* </StaggerList>
* ```
*/
export function StaggerList({
children,
stagger = 60,
initialDelay = 0,
as: As = 'div',
...reveal
}: StaggerListProps) {
const items = Children.toArray(children).filter(isValidElement);
return (
<As data-testid="bl-stagger-list">
{items.map((child, i) => (
<Reveal
key={(child as { key?: string | number }).key ?? i}
delay={initialDelay + i * stagger}
{...reveal}
>
{child}
</Reveal>
))}
</As>
);
}

View File

@ -0,0 +1,96 @@
import {
useRef,
type CSSProperties,
type MouseEvent,
type ReactNode,
} from 'react';
import { SPRINGS, prefersReducedMotion } from './utils.js';
export interface TiltCardProps {
children: ReactNode;
/** Maximum tilt angle in degrees. Default 8. */
intensity?: number;
/** Glare highlight intensity 0..1. Default 0.18. */
glare?: number;
/** Perspective in pixels. Default 800. */
perspective?: number;
/** Bypass motion. */
disableMotion?: boolean;
className?: string;
style?: CSSProperties;
}
/**
* `<TiltCard>` interactive 3D tilt + cursor-tracking glare on hover.
* Designed for hero cards, marketing tiles, and KPI surfaces. Settles
* back to neutral on mouse leave with a snappy spring.
*
* Performance: all transforms applied as CSS variables on a single ref,
* so React doesn't re-render on each pointer move.
*/
export function TiltCard({
children,
intensity = 8,
glare = 0.18,
perspective = 800,
disableMotion,
className,
style,
}: TiltCardProps) {
const ref = useRef<HTMLDivElement>(null);
const reduced = disableMotion ?? prefersReducedMotion();
const onMove = (e: MouseEvent<HTMLDivElement>) => {
if (reduced) return;
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width; // 0..1
const y = (e.clientY - rect.top) / rect.height; // 0..1
const rotateY = (x - 0.5) * intensity * 2;
const rotateX = -(y - 0.5) * intensity * 2;
el.style.transition = 'transform 60ms linear';
el.style.transform = `perspective(${perspective}px) rotateX(${rotateX}deg) rotateY(${rotateY}deg) translateZ(0)`;
el.style.setProperty('--bl-tilt-glare-x', `${x * 100}%`);
el.style.setProperty('--bl-tilt-glare-y', `${y * 100}%`);
};
const onLeave = () => {
if (reduced) return;
const el = ref.current;
if (!el) return;
el.style.transition = `transform 420ms ${SPRINGS.bouncy}`;
el.style.transform = `perspective(${perspective}px) rotateX(0deg) rotateY(0deg) translateZ(0)`;
};
return (
<div
ref={ref}
data-testid="bl-tilt-card"
onMouseMove={onMove}
onMouseLeave={onLeave}
className={className}
style={{
position: 'relative',
transformStyle: 'preserve-3d',
willChange: 'transform',
...style,
}}
>
{children}
{!reduced && glare > 0 && (
<span
aria-hidden
style={{
position: 'absolute',
inset: 0,
pointerEvents: 'none',
borderRadius: 'inherit',
background: `radial-gradient(circle at var(--bl-tilt-glare-x, 50%) var(--bl-tilt-glare-y, 50%), rgba(255,255,255,${glare}), transparent 55%)`,
mixBlendMode: 'overlay',
}}
/>
)}
</div>
);
}

View File

@ -0,0 +1,173 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { act, cleanup, render, screen } from '@testing-library/react';
import { Reveal } from '../Reveal.js';
import { StaggerList } from '../StaggerList.js';
import { NumberFlow } from '../NumberFlow.js';
import { TiltCard } from '../TiltCard.js';
import { ScrollProgress } from '../ScrollProgress.js';
import { SPRINGS, prefersReducedMotion } from '../utils.js';
describe('utils', () => {
it('exposes 4 spring presets as cubic-bezier strings', () => {
expect(Object.keys(SPRINGS)).toHaveLength(4);
for (const v of Object.values(SPRINGS)) {
expect(v).toMatch(/^cubic-bezier/);
}
});
it('prefersReducedMotion handles missing matchMedia gracefully', () => {
const orig = window.matchMedia;
// @ts-expect-error force delete for test
delete (window as { matchMedia?: unknown }).matchMedia;
expect(prefersReducedMotion()).toBe(false);
window.matchMedia = orig;
});
});
describe('Reveal', () => {
beforeEach(() => cleanup());
it('renders children and exposes data-visible attribute', () => {
render(
<Reveal disableMotion>
<span data-testid="child">hi</span>
</Reveal>,
);
expect(screen.getByTestId('child')).toBeDefined();
const wrap = screen.getByTestId('bl-reveal');
expect(wrap.getAttribute('data-visible')).toBe('true');
});
it('starts hidden when IntersectionObserver is unavailable (graceful fallback)', () => {
const orig = (globalThis as { IntersectionObserver?: unknown }).IntersectionObserver;
// @ts-expect-error force delete
delete (globalThis as { IntersectionObserver?: unknown }).IntersectionObserver;
render(<Reveal><span>x</span></Reveal>);
// Fallback path sets visible=true so SSR / non-IO browsers still render.
expect(screen.getByTestId('bl-reveal').getAttribute('data-visible')).toBe('true');
(globalThis as { IntersectionObserver?: unknown }).IntersectionObserver = orig;
});
it('forwards inline style after motion vars', () => {
render(
<Reveal disableMotion style={{ background: 'red' }}>
<span>x</span>
</Reveal>,
);
expect(screen.getByTestId('bl-reveal').style.background).toBe('red');
});
});
describe('StaggerList', () => {
beforeEach(() => cleanup());
it('wraps each child in a Reveal with increasing delay', () => {
render(
<StaggerList disableMotion stagger={50} initialDelay={10}>
<div data-testid="i1">1</div>
<div data-testid="i2">2</div>
<div data-testid="i3">3</div>
</StaggerList>,
);
const list = screen.getByTestId('bl-stagger-list');
expect(list.children).toHaveLength(3);
expect(screen.getByTestId('i3')).toBeDefined();
});
it('supports element override via `as`', () => {
render(
<StaggerList disableMotion as="ul">
<li>a</li>
<li>b</li>
</StaggerList>,
);
expect(screen.getByTestId('bl-stagger-list').tagName.toLowerCase()).toBe('ul');
});
});
describe('NumberFlow', () => {
beforeEach(() => cleanup());
it('snaps to value when disableMotion=true', () => {
render(<NumberFlow value={1234} disableMotion />);
expect(screen.getByTestId('bl-number-flow').textContent).toBe('1,234');
});
it('formats with decimals + prefix/suffix', () => {
render(<NumberFlow value={42.5} decimals={1} prefix="$" suffix=" USD" disableMotion />);
expect(screen.getByTestId('bl-number-flow').textContent).toBe('$42.5 USD');
});
it('uses custom format function', () => {
render(<NumberFlow value={5} format={(n) => `${n.toFixed(0)} items`} disableMotion />);
expect(screen.getByTestId('bl-number-flow').textContent).toBe('≈5 items');
});
it('exposes data-target equal to the latest target value', () => {
render(<NumberFlow value={7} disableMotion />);
expect(screen.getByTestId('bl-number-flow').getAttribute('data-target')).toBe('7');
});
});
describe('TiltCard', () => {
beforeEach(() => cleanup());
it('renders children and attaches mousemove/mouseleave handlers', () => {
render(
<TiltCard>
<span data-testid="tilt-child">card body</span>
</TiltCard>,
);
expect(screen.getByTestId('tilt-child')).toBeDefined();
const el = screen.getByTestId('bl-tilt-card');
// Glare overlay rendered by default.
expect(el.lastElementChild?.getAttribute('aria-hidden')).toBe('true');
});
it('hides glare overlay when disabled via prop', () => {
render(
<TiltCard glare={0}>
<span data-testid="tc-child">x</span>
</TiltCard>,
);
const el = screen.getByTestId('bl-tilt-card');
// Without glare, the only child is the user's content — no aria-hidden overlay sibling.
const ariaHiddenSiblings = Array.from(el.children).filter(
c => c.getAttribute('aria-hidden') === 'true',
);
expect(ariaHiddenSiblings).toHaveLength(0);
});
});
describe('ScrollProgress', () => {
beforeEach(() => cleanup());
it('renders a fixed bar with the given thickness + color', () => {
render(<ScrollProgress thickness={5} color="hotpink" />);
const bar = screen.getByTestId('bl-scroll-progress');
expect(bar.style.position).toBe('fixed');
expect(bar.style.height).toBe('5px');
});
it('updates the inner transform on window scroll', () => {
render(<ScrollProgress />);
const bar = screen.getByTestId('bl-scroll-progress');
const fill = bar.firstElementChild as HTMLElement;
expect(fill.style.transform).toBe('scaleX(0)');
// Simulate scroll — we can't mutate scrollTop on document.documentElement
// in happy-dom in all builds, so just verify the listener was attached
// and dispatching scroll doesn't throw.
act(() => {
window.dispatchEvent(new Event('scroll'));
});
});
it('cleans up listeners on unmount (no leak)', () => {
const add = vi.spyOn(window, 'addEventListener');
const remove = vi.spyOn(window, 'removeEventListener');
const { unmount } = render(<ScrollProgress />);
unmount();
expect(remove).toHaveBeenCalled();
add.mockRestore();
remove.mockRestore();
});
});

View File

@ -0,0 +1,38 @@
/**
* @bytelyst/motion Motion primitives (Wave 4).
*
* Exports (0.1.0):
* <Reveal> fade/slide on viewport entry
* <StaggerList> sequenced reveal of children
* <NumberFlow> animated counter tween (RAF-based)
* <TiltCard> interactive 3D hover with cursor-tracking glare
* <ScrollProgress> fixed scroll-position bar
*
* Coming in 0.2.x (per ROADMAP §Wave 4):
* <Magnetic>, <PageTransition> (View Transitions API),
* <Drag>, <SwipeToDismiss>
*
* All primitives:
* - honor `prefers-reduced-motion` automatically
* - accept `disableMotion` to bypass for tests / snapshots
* - emit `data-testid` attributes for Playwright
* - lean on tokens (`--bl-accent`, `--bl-radius-*`, etc.)
*/
export { Reveal } from './Reveal.js';
export type { RevealProps } from './Reveal.js';
export { StaggerList } from './StaggerList.js';
export type { StaggerListProps } from './StaggerList.js';
export { NumberFlow } from './NumberFlow.js';
export type { NumberFlowProps } from './NumberFlow.js';
export { TiltCard } from './TiltCard.js';
export type { TiltCardProps } from './TiltCard.js';
export { ScrollProgress } from './ScrollProgress.js';
export type { ScrollProgressProps } from './ScrollProgress.js';
export { SPRINGS, prefersReducedMotion } from './utils.js';
export type { Spring } from './utils.js';

View File

@ -0,0 +1,21 @@
/**
* Shared utilities for @bytelyst/motion.
*
* Springs are expressed as `cubic-bezier` curves so we ship zero
* runtime physics primitives compose with native CSS transitions.
*/
export const SPRINGS = {
snappy: 'cubic-bezier(0.2, 0.9, 0.1, 1)',
bouncy: 'cubic-bezier(0.34, 1.56, 0.64, 1)',
gentle: 'cubic-bezier(0.4, 0, 0.2, 1)',
instant: 'cubic-bezier(0, 0, 1, 1)',
} as const;
export type Spring = keyof typeof SPRINGS;
/** Cheap server-safe check for prefers-reduced-motion. */
export function prefersReducedMotion(): boolean {
if (typeof window === 'undefined' || !window.matchMedia) return false;
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}

View File

@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"lib": ["ES2022", "DOM"],
"jsx": "react-jsx"
},
"include": ["src"],
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
}

View File

@ -0,0 +1,2 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({ test: { environment: 'happy-dom', pool: 'forks' } });

View File

@ -0,0 +1,34 @@
{
"name": "@bytelyst/notifications-ui",
"version": "0.1.0",
"type": "module",
"description": "Notification UI primitives — NotificationCenter, BannerStack, InboxItem, Announcement. Bell + dropdown inbox with read/unread state.",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": ["dist"],
"scripts": {
"build": "tsc",
"test": "vitest run --pool forks",
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"react": ">=18.0.0",
"react-dom": ">=18.0.0"
},
"devDependencies": {
"@testing-library/react": "^16.3.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"happy-dom": "^18.0.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"typescript": "^5.7.3",
"vitest": "^4.0.18"
}
}

View File

@ -0,0 +1,90 @@
import type { CSSProperties, ReactNode } from 'react';
export interface AnnouncementProps {
/** Bold short label rendered as a chip on the left. */
tag?: string;
/** Main message. */
children: ReactNode;
/** Optional CTA. */
cta?: { label: string; href?: string; onSelect?: () => void };
/** Visual variant. */
tone?: 'accent' | 'subtle' | 'gradient';
className?: string;
style?: CSSProperties;
}
/**
* `<Announcement>` single inline marketing strip (smaller than a
* `<BannerStack>` row, larger than a toast). Good for "What's new"
* call-outs above the fold.
*/
export function Announcement({
tag = 'NEW',
children,
cta,
tone = 'gradient',
className,
style,
}: AnnouncementProps) {
const bg =
tone === 'gradient'
? 'linear-gradient(90deg, var(--bl-accent-muted, rgba(99,102,241,0.20)), var(--bl-info-muted, rgba(56,189,248,0.12)) 60%, transparent)'
: tone === 'subtle'
? 'var(--bl-surface-muted, rgba(0,0,0,0.04))'
: 'var(--bl-accent-muted, rgba(99,102,241,0.18))';
return (
<div
data-testid="bl-announcement"
role="region"
className={className}
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--bl-space-3, 12px)',
padding: 'var(--bl-space-2, 8px) var(--bl-space-4, 16px)',
borderRadius: 'var(--bl-radius-pill, 999px)',
background: bg,
fontSize: '0.82rem',
color: 'var(--bl-text-primary, inherit)',
...style,
}}
>
<span
style={{
padding: '2px 8px',
background: 'var(--bl-accent, #6366f1)',
color: 'var(--bl-accent-foreground, #fff)',
fontSize: '0.65rem',
fontWeight: 800,
letterSpacing: '0.05em',
borderRadius: 'var(--bl-radius-pill, 999px)',
}}
>
{tag}
</span>
<span style={{ flex: 1, minWidth: 0 }}>{children}</span>
{cta && (
<a
data-testid="bl-announcement-cta"
href={cta.href}
onClick={e => {
if (cta.onSelect) {
e.preventDefault();
cta.onSelect();
}
}}
style={{
fontSize: '0.78rem',
fontWeight: 700,
color: 'var(--bl-accent, #6366f1)',
textDecoration: 'none',
whiteSpace: 'nowrap',
}}
>
{cta.label}
</a>
)}
</div>
);
}

View File

@ -0,0 +1,176 @@
import { useState, type CSSProperties } from 'react';
import type { BannerItem, BannerKind } from './types.js';
export interface BannerStackProps {
banners: BannerItem[];
/** Called when a user dismisses a banner. */
onDismiss?: (id: string) => void;
/** Max banners visible. Overflow stays in DOM but collapses behind a "+N more". */
maxVisible?: number;
className?: string;
style?: CSSProperties;
}
const TONE: Record<BannerKind, { bg: string; fg: string; accent: string }> = {
info: {
bg: 'var(--bl-info-muted, rgba(56,189,248,0.12))',
fg: 'var(--bl-text-primary, inherit)',
accent: 'var(--bl-info, #38bdf8)',
},
success: {
bg: 'var(--bl-success-muted, rgba(34,197,94,0.12))',
fg: 'var(--bl-text-primary, inherit)',
accent: 'var(--bl-success, #22c55e)',
},
warning: {
bg: 'var(--bl-warning-muted, rgba(245,158,11,0.14))',
fg: 'var(--bl-text-primary, inherit)',
accent: 'var(--bl-warning, #f59e0b)',
},
danger: {
bg: 'var(--bl-danger-muted, rgba(239,68,68,0.12))',
fg: 'var(--bl-text-primary, inherit)',
accent: 'var(--bl-danger, #ef4444)',
},
announcement: {
bg: 'linear-gradient(90deg, var(--bl-accent-muted, rgba(99,102,241,0.18)), transparent)',
fg: 'var(--bl-text-primary, inherit)',
accent: 'var(--bl-accent, #6366f1)',
},
};
/**
* `<BannerStack>` top-of-page announcement strip. Renders an
* accent-bordered tile for each banner; dismissible by default.
*/
export function BannerStack({
banners,
onDismiss,
maxVisible = 3,
className,
style,
}: BannerStackProps) {
const [collapsed, setCollapsed] = useState(true);
const visible = collapsed ? banners.slice(0, maxVisible) : banners;
const hidden = banners.length - visible.length;
if (banners.length === 0) return null;
return (
<div
data-testid="bl-banner-stack"
className={className}
style={{
display: 'flex',
flexDirection: 'column',
gap: 6,
...style,
}}
>
{visible.map(b => {
const t = TONE[b.kind ?? 'info'];
return (
<div
key={b.id}
data-testid={`bl-banner-${b.id}`}
data-kind={b.kind ?? 'info'}
role="status"
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 'var(--bl-space-3, 12px)',
padding: 'var(--bl-space-3, 12px) var(--bl-space-4, 16px)',
borderRadius: 'var(--bl-radius-card, 10px)',
borderLeft: `3px solid ${t.accent}`,
background: t.bg,
color: t.fg,
fontSize: '0.85rem',
lineHeight: 1.45,
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600 }}>{b.title}</div>
{b.body && (
<div
style={{
color: 'var(--bl-text-secondary, #555)',
marginTop: 2,
fontSize: '0.8rem',
}}
>
{b.body}
</div>
)}
</div>
{b.cta && (
<a
data-testid={`bl-banner-cta-${b.id}`}
href={b.cta.href}
onClick={e => {
if (b.cta?.onSelect) {
e.preventDefault();
b.cta.onSelect();
}
}}
style={{
fontSize: '0.78rem',
fontWeight: 700,
color: t.accent,
textDecoration: 'none',
padding: '2px 10px',
borderRadius: 'var(--bl-radius-pill, 999px)',
border: `1px solid ${t.accent}`,
flexShrink: 0,
whiteSpace: 'nowrap',
}}
>
{b.cta.label}
</a>
)}
{b.dismissible !== false && (
<button
type="button"
data-testid={`bl-banner-dismiss-${b.id}`}
aria-label="Dismiss"
onClick={() => onDismiss?.(b.id)}
style={{
width: 22,
height: 22,
display: 'inline-grid',
placeItems: 'center',
background: 'transparent',
border: 'none',
color: 'var(--bl-text-tertiary, #888)',
cursor: 'pointer',
fontSize: 14,
lineHeight: 1,
flexShrink: 0,
}}
>
</button>
)}
</div>
);
})}
{hidden > 0 && (
<button
type="button"
data-testid="bl-banner-expand"
onClick={() => setCollapsed(false)}
style={{
alignSelf: 'flex-start',
background: 'transparent',
border: 'none',
color: 'var(--bl-text-tertiary, #888)',
fontSize: '0.75rem',
cursor: 'pointer',
padding: '4px 8px',
}}
>
+{hidden} more
</button>
)}
</div>
);
}

View File

@ -0,0 +1,200 @@
import type { CSSProperties } from 'react';
import type { NotificationItem, NotificationKind } from './types.js';
export interface InboxItemProps {
item: NotificationItem;
/** Called when the item body is clicked. */
onSelect?: (item: NotificationItem) => void;
/** Mark this single item as read. */
onMarkRead?: (id: string) => void;
/** Dense layout (smaller padding, smaller font). */
dense?: boolean;
className?: string;
style?: CSSProperties;
}
/**
* `<InboxItem>` single notification row. Used inside `<NotificationCenter>`
* but also exported for products that build custom inbox surfaces.
*/
export function InboxItem({
item,
onSelect,
onMarkRead,
dense,
className,
style,
}: InboxItemProps) {
const tone = TONE[item.kind ?? 'info'];
const padding = dense ? '6px 10px' : 'var(--bl-space-2, 8px) var(--bl-space-3, 12px)';
return (
<article
data-testid={`bl-inbox-item-${item.id}`}
data-kind={item.kind ?? 'info'}
data-read={item.read ? 'true' : 'false'}
className={className}
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 'var(--bl-space-2, 8px)',
padding,
borderBottom: '1px solid var(--bl-border-subtle, rgba(0,0,0,0.06))',
background: item.read ? 'transparent' : 'var(--bl-accent-muted, rgba(99,102,241,0.06))',
...style,
}}
>
{/* Unread dot */}
<span
aria-hidden
style={{
width: 8,
height: 8,
borderRadius: '50%',
marginTop: 8,
flexShrink: 0,
background: item.read ? 'transparent' : tone.dot,
transition: 'background 200ms ease',
}}
/>
{/* Avatar / kind icon */}
<span
aria-hidden
style={{
width: 28,
height: 28,
borderRadius: 'var(--bl-radius-pill, 999px)',
background: tone.iconBg,
color: tone.iconFg,
display: 'inline-grid',
placeItems: 'center',
fontSize: 14,
fontWeight: 700,
flexShrink: 0,
}}
>
{item.icon ?? tone.glyph}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<button
type="button"
data-testid={`bl-inbox-item-trigger-${item.id}`}
onClick={() => {
if (!item.read) onMarkRead?.(item.id);
onSelect?.(item);
}}
style={{
display: 'block',
width: '100%',
textAlign: 'left',
background: 'transparent',
border: 'none',
padding: 0,
color: 'inherit',
cursor: 'pointer',
}}
>
<div
style={{
fontSize: dense ? '0.8rem' : '0.85rem',
fontWeight: 600,
color: 'var(--bl-text-primary, inherit)',
}}
>
{item.title}
</div>
{item.body && (
<div
style={{
fontSize: dense ? '0.7rem' : '0.75rem',
color: 'var(--bl-text-secondary, #555)',
marginTop: 2,
whiteSpace: 'pre-wrap',
}}
>
{item.body}
</div>
)}
{item.createdAt && (
<div
style={{
fontSize: '0.7rem',
color: 'var(--bl-text-tertiary, #888)',
marginTop: 4,
}}
>
{relativeTime(item.createdAt)}
</div>
)}
</button>
{item.actions && item.actions.length > 0 && (
<div style={{ marginTop: 6, display: 'flex', gap: 6 }}>
{item.actions.map(a => (
<button
key={a.id}
type="button"
onClick={a.onSelect}
style={{
fontSize: '0.7rem',
padding: '2px 8px',
borderRadius: 'var(--bl-radius-pill, 999px)',
border: '1px solid var(--bl-border, rgba(0,0,0,0.12))',
background: 'transparent',
color: 'var(--bl-text-secondary, #555)',
cursor: 'pointer',
}}
>
{a.label}
</button>
))}
</div>
)}
</div>
</article>
);
}
const TONE: Record<NotificationKind, { dot: string; iconBg: string; iconFg: string; glyph: string }> = {
info: {
dot: 'var(--bl-info, #38bdf8)',
iconBg: 'var(--bl-info-muted, rgba(56,189,248,0.18))',
iconFg: 'var(--bl-info, #38bdf8)',
glyph: 'i',
},
success: {
dot: 'var(--bl-success, #22c55e)',
iconBg: 'var(--bl-success-muted, rgba(34,197,94,0.18))',
iconFg: 'var(--bl-success, #22c55e)',
glyph: '✓',
},
warning: {
dot: 'var(--bl-warning, #f59e0b)',
iconBg: 'var(--bl-warning-muted, rgba(245,158,11,0.18))',
iconFg: 'var(--bl-warning, #f59e0b)',
glyph: '!',
},
danger: {
dot: 'var(--bl-danger, #ef4444)',
iconBg: 'var(--bl-danger-muted, rgba(239,68,68,0.18))',
iconFg: 'var(--bl-danger, #ef4444)',
glyph: '✕',
},
mention: {
dot: 'var(--bl-accent, #6366f1)',
iconBg: 'var(--bl-accent-muted, rgba(99,102,241,0.18))',
iconFg: 'var(--bl-accent, #6366f1)',
glyph: '@',
},
};
function relativeTime(input: string | Date): string {
const d = typeof input === 'string' ? new Date(input) : input;
const diff = (Date.now() - d.getTime()) / 1000;
if (diff < 60) return 'just now';
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
if (diff < 86400 * 7) return `${Math.floor(diff / 86400)}d ago`;
return d.toLocaleDateString();
}

View File

@ -0,0 +1,268 @@
import {
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
type ReactNode,
} from 'react';
import { InboxItem } from './InboxItem.js';
import type { NotificationItem } from './types.js';
export interface NotificationCenterProps {
items: NotificationItem[];
/** Cap the badge to "9+" once unread count exceeds this. Default 9. */
badgeCap?: number;
/** Filter tabs to render. Default ['all', 'unread', 'mentions']. */
tabs?: Array<'all' | 'unread' | 'mentions'>;
/** Called when the user clicks an item. */
onSelect?: (item: NotificationItem) => void;
/** Called when the user marks ONE item read. */
onMarkRead?: (id: string) => void;
/** Called when the user marks ALL read. */
onMarkAllRead?: () => void;
/** Render slot at the bottom — typically a "Settings" link. */
footer?: ReactNode;
/** Override the bell trigger glyph. Default '🔔'. */
triggerGlyph?: ReactNode;
ariaLabel?: string;
className?: string;
style?: CSSProperties;
}
/**
* `<NotificationCenter>` bell trigger + dropdown inbox with tabs.
* Drop it next to your user menu; pass `items` + handlers and you're
* done. Closes on outside click and Escape.
*/
export function NotificationCenter({
items,
badgeCap = 9,
tabs = ['all', 'unread', 'mentions'],
onSelect,
onMarkRead,
onMarkAllRead,
footer,
triggerGlyph = '🔔',
ariaLabel = 'Notifications',
className,
style,
}: NotificationCenterProps) {
const [open, setOpen] = useState(false);
const [tab, setTab] = useState<'all' | 'unread' | 'mentions'>(tabs[0] ?? 'all');
const rootRef = useRef<HTMLDivElement>(null);
const unread = useMemo(() => items.filter(i => !i.read).length, [items]);
useEffect(() => {
if (!open) return;
const onDoc = (e: MouseEvent) => {
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false);
};
document.addEventListener('mousedown', onDoc);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDoc);
document.removeEventListener('keydown', onKey);
};
}, [open]);
const filtered = useMemo(() => {
if (tab === 'unread') return items.filter(i => !i.read);
if (tab === 'mentions') return items.filter(i => i.kind === 'mention');
return items;
}, [items, tab]);
return (
<div
ref={rootRef}
data-testid="bl-notification-center"
className={className}
style={{ position: 'relative', display: 'inline-block', ...style }}
>
<button
type="button"
data-testid="bl-notification-trigger"
aria-haspopup="dialog"
aria-expanded={open}
aria-label={ariaLabel}
onClick={() => setOpen(o => !o)}
style={{
position: 'relative',
width: 36,
height: 36,
display: 'inline-grid',
placeItems: 'center',
background: 'transparent',
border: '1px solid var(--bl-border, rgba(0,0,0,0.08))',
borderRadius: 'var(--bl-radius-pill, 999px)',
color: 'var(--bl-text-primary, inherit)',
fontSize: 16,
cursor: 'pointer',
}}
>
{triggerGlyph}
{unread > 0 && (
<span
data-testid="bl-notification-badge"
aria-hidden
style={{
position: 'absolute',
top: -4,
right: -4,
minWidth: 18,
height: 18,
padding: '0 4px',
borderRadius: 'var(--bl-radius-pill, 999px)',
background: 'var(--bl-danger, #ef4444)',
color: 'var(--bl-danger-foreground, #fff)',
fontSize: 10,
fontWeight: 700,
display: 'inline-grid',
placeItems: 'center',
border: '2px solid var(--bl-surface-card, #fff)',
}}
>
{unread > badgeCap ? `${badgeCap}+` : unread}
</span>
)}
</button>
{open && (
<div
role="dialog"
aria-label={ariaLabel}
data-testid="bl-notification-panel"
style={{
position: 'absolute',
top: 'calc(100% + 8px)',
right: 0,
zIndex: 100,
width: 360,
maxHeight: 480,
display: 'flex',
flexDirection: 'column',
background: 'var(--bl-surface-card, #fff)',
border: '1px solid var(--bl-border, rgba(0,0,0,0.12))',
borderRadius: 'var(--bl-radius-card, 12px)',
boxShadow: '0 16px 40px rgba(0,0,0,0.25)',
overflow: 'hidden',
}}
>
{/* Header */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: 'var(--bl-space-3, 12px) var(--bl-space-4, 16px)',
borderBottom: '1px solid var(--bl-border-subtle, rgba(0,0,0,0.06))',
}}
>
<div style={{ fontSize: '0.95rem', fontWeight: 700 }}>Inbox</div>
{unread > 0 && onMarkAllRead && (
<button
type="button"
data-testid="bl-notification-mark-all"
onClick={onMarkAllRead}
style={{
fontSize: '0.75rem',
color: 'var(--bl-accent, #6366f1)',
background: 'transparent',
border: 'none',
cursor: 'pointer',
fontWeight: 600,
}}
>
Mark all read
</button>
)}
</div>
{/* Tabs */}
{tabs.length > 1 && (
<div
role="tablist"
style={{
display: 'flex',
gap: 4,
padding: 'var(--bl-space-2, 8px) var(--bl-space-3, 12px) 0',
}}
>
{tabs.map(t => (
<button
key={t}
type="button"
role="tab"
aria-selected={tab === t}
data-testid={`bl-notification-tab-${t}`}
onClick={() => setTab(t)}
style={{
padding: '4px 10px',
fontSize: '0.75rem',
borderRadius: 'var(--bl-radius-pill, 999px)',
border: 'none',
background:
tab === t
? 'var(--bl-accent-muted, rgba(99,102,241,0.14))'
: 'transparent',
color:
tab === t
? 'var(--bl-accent, #6366f1)'
: 'var(--bl-text-tertiary, #888)',
fontWeight: tab === t ? 700 : 500,
cursor: 'pointer',
}}
>
{t === 'all' ? 'All' : t === 'unread' ? `Unread (${unread})` : 'Mentions'}
</button>
))}
</div>
)}
{/* List */}
<div style={{ flex: 1, overflowY: 'auto', maxHeight: 360 }}>
{filtered.length === 0 ? (
<div
data-testid="bl-notification-empty"
style={{
padding: 'var(--bl-space-4, 16px)',
textAlign: 'center',
color: 'var(--bl-text-tertiary, #888)',
fontSize: '0.85rem',
}}
>
{tab === 'unread' ? "You're all caught up." : 'Nothing here yet.'}
</div>
) : (
filtered.map(item => (
<InboxItem
key={item.id}
item={item}
onSelect={onSelect}
onMarkRead={onMarkRead}
/>
))
)}
</div>
{footer && (
<div
style={{
padding: 'var(--bl-space-2, 8px) var(--bl-space-3, 12px)',
borderTop: '1px solid var(--bl-border-subtle, rgba(0,0,0,0.06))',
fontSize: '0.75rem',
color: 'var(--bl-text-secondary, #555)',
}}
>
{footer}
</div>
)}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,178 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { NotificationCenter } from '../NotificationCenter.js';
import { InboxItem } from '../InboxItem.js';
import { BannerStack } from '../BannerStack.js';
import { Announcement } from '../Announcement.js';
import type { BannerItem, NotificationItem } from '../types.js';
const NOW = new Date();
const ITEMS: NotificationItem[] = [
{ id: 'a', title: 'Build succeeded', kind: 'success', read: false, createdAt: NOW },
{ id: 'b', title: '@you in code review', kind: 'mention', read: false, createdAt: NOW },
{ id: 'c', title: 'Billing reminder', kind: 'warning', read: true, createdAt: NOW },
];
describe('NotificationCenter', () => {
beforeEach(() => cleanup());
it('renders trigger with unread badge', () => {
render(<NotificationCenter items={ITEMS} />);
expect(screen.getByTestId('bl-notification-trigger')).toBeDefined();
expect(screen.getByTestId('bl-notification-badge').textContent).toBe('2');
});
it('caps badge at badgeCap with "+"', () => {
const many: NotificationItem[] = Array.from({ length: 15 }, (_, i) => ({
id: `n${i}`,
title: `Item ${i}`,
read: false,
}));
render(<NotificationCenter items={many} badgeCap={9} />);
expect(screen.getByTestId('bl-notification-badge').textContent).toBe('9+');
});
it('opens dropdown on trigger click and shows items', () => {
render(<NotificationCenter items={ITEMS} />);
fireEvent.click(screen.getByTestId('bl-notification-trigger'));
expect(screen.getByTestId('bl-notification-panel')).toBeDefined();
expect(screen.getByText('Build succeeded')).toBeDefined();
});
it('switches to Unread tab and filters out read items', () => {
render(<NotificationCenter items={ITEMS} />);
fireEvent.click(screen.getByTestId('bl-notification-trigger'));
fireEvent.click(screen.getByTestId('bl-notification-tab-unread'));
expect(screen.getByText('Build succeeded')).toBeDefined();
expect(screen.queryByText('Billing reminder')).toBeNull();
});
it('Mentions tab shows only kind=mention', () => {
render(<NotificationCenter items={ITEMS} />);
fireEvent.click(screen.getByTestId('bl-notification-trigger'));
fireEvent.click(screen.getByTestId('bl-notification-tab-mentions'));
expect(screen.getByText('@you in code review')).toBeDefined();
expect(screen.queryByText('Build succeeded')).toBeNull();
});
it('mark-all-read button calls handler', () => {
const onMarkAllRead = vi.fn();
render(<NotificationCenter items={ITEMS} onMarkAllRead={onMarkAllRead} />);
fireEvent.click(screen.getByTestId('bl-notification-trigger'));
fireEvent.click(screen.getByTestId('bl-notification-mark-all'));
expect(onMarkAllRead).toHaveBeenCalledOnce();
});
it('clicking an item fires onSelect + onMarkRead', () => {
const onSelect = vi.fn();
const onMarkRead = vi.fn();
render(
<NotificationCenter
items={ITEMS}
onSelect={onSelect}
onMarkRead={onMarkRead}
/>,
);
fireEvent.click(screen.getByTestId('bl-notification-trigger'));
fireEvent.click(screen.getByTestId('bl-inbox-item-trigger-a'));
expect(onSelect).toHaveBeenCalledOnce();
expect(onMarkRead).toHaveBeenCalledWith('a');
});
it('empty state when no items match the tab', () => {
render(<NotificationCenter items={[]} />);
fireEvent.click(screen.getByTestId('bl-notification-trigger'));
expect(screen.getByTestId('bl-notification-empty')).toBeDefined();
});
it('Escape closes the dropdown', () => {
render(<NotificationCenter items={ITEMS} />);
fireEvent.click(screen.getByTestId('bl-notification-trigger'));
fireEvent.keyDown(document, { key: 'Escape' });
expect(screen.queryByTestId('bl-notification-panel')).toBeNull();
});
});
describe('InboxItem', () => {
beforeEach(() => cleanup());
it('renders title, kind data-attr, and tone glyph', () => {
render(<InboxItem item={{ id: 'x', title: 'Hello', kind: 'warning' }} />);
expect(screen.getByText('Hello')).toBeDefined();
expect(screen.getByTestId('bl-inbox-item-x').getAttribute('data-kind')).toBe('warning');
});
it('emits data-read=false when item.read is falsy', () => {
render(<InboxItem item={{ id: 'x', title: 'X', read: false }} />);
expect(screen.getByTestId('bl-inbox-item-x').getAttribute('data-read')).toBe('false');
});
});
const BANNERS: BannerItem[] = [
{ id: 'b1', kind: 'announcement', title: 'New feature', body: 'try it' },
{ id: 'b2', kind: 'warning', title: 'Maintenance Friday' },
{ id: 'b3', kind: 'info', title: 'Tip', cta: { label: 'Learn more', href: '#' } },
{ id: 'b4', kind: 'info', title: 'Extra' },
];
describe('BannerStack', () => {
beforeEach(() => cleanup());
it('renders all banners up to maxVisible and collapses the rest', () => {
render(<BannerStack banners={BANNERS} maxVisible={3} />);
expect(screen.getByTestId('bl-banner-b1')).toBeDefined();
expect(screen.getByTestId('bl-banner-b3')).toBeDefined();
expect(screen.queryByTestId('bl-banner-b4')).toBeNull();
expect(screen.getByTestId('bl-banner-expand').textContent).toBe('+1 more');
});
it('expand button shows hidden banners', () => {
render(<BannerStack banners={BANNERS} maxVisible={2} />);
fireEvent.click(screen.getByTestId('bl-banner-expand'));
expect(screen.getByTestId('bl-banner-b3')).toBeDefined();
expect(screen.getByTestId('bl-banner-b4')).toBeDefined();
});
it('dismiss button fires onDismiss with id', () => {
const onDismiss = vi.fn();
render(<BannerStack banners={BANNERS} onDismiss={onDismiss} />);
fireEvent.click(screen.getByTestId('bl-banner-dismiss-b1'));
expect(onDismiss).toHaveBeenCalledWith('b1');
});
it('cta callback overrides href navigation', () => {
const onSelect = vi.fn();
render(
<BannerStack
banners={[
{
id: 'x',
kind: 'info',
title: 'T',
cta: { label: 'Go', href: '#', onSelect },
},
]}
/>,
);
fireEvent.click(screen.getByTestId('bl-banner-cta-x'));
expect(onSelect).toHaveBeenCalledOnce();
});
it('renders nothing when banners is empty', () => {
const { container } = render(<BannerStack banners={[]} />);
expect(container.firstChild).toBeNull();
});
});
describe('Announcement', () => {
beforeEach(() => cleanup());
it('renders tag + content + cta', () => {
render(
<Announcement tag="BETA" cta={{ label: 'Try', href: '#' }}>
Streaming chat is here.
</Announcement>,
);
expect(screen.getByText('BETA')).toBeDefined();
expect(screen.getByText('Streaming chat is here.')).toBeDefined();
expect(screen.getByTestId('bl-announcement-cta')).toBeDefined();
});
});

View File

@ -0,0 +1,27 @@
/**
* @bytelyst/notifications-ui Notification UI primitives (Wave 7 essentials).
*
* Exports (0.1.0):
* <NotificationCenter> bell trigger + dropdown inbox with tabs
* <InboxItem> single notification row
* <BannerStack> dismissible top-of-page strip
* <Announcement> inline "What's new" pill
*/
export { NotificationCenter } from './NotificationCenter.js';
export type { NotificationCenterProps } from './NotificationCenter.js';
export { InboxItem } from './InboxItem.js';
export type { InboxItemProps } from './InboxItem.js';
export { BannerStack } from './BannerStack.js';
export type { BannerStackProps } from './BannerStack.js';
export { Announcement } from './Announcement.js';
export type { AnnouncementProps } from './Announcement.js';
export type {
BannerItem,
BannerKind,
NotificationItem,
NotificationKind,
} from './types.js';

View File

@ -0,0 +1,38 @@
import type { ReactNode } from 'react';
export type NotificationKind = 'info' | 'success' | 'warning' | 'danger' | 'mention';
export interface NotificationItem {
id: string;
/** Headline rendered bold. */
title: string;
/** Optional body — supports plain text or arbitrary nodes. */
body?: ReactNode;
/** Visual variant. Default 'info'. */
kind?: NotificationKind;
/** ISO timestamp or Date — drives the relative-time label. */
createdAt?: string | Date;
/** Click target — when provided, the item becomes a button. */
href?: string;
/** Optional avatar / icon. */
icon?: ReactNode;
/** Read state. Drives the unread dot + count badge. */
read?: boolean;
/** Optional action buttons rendered at the bottom of the item. */
actions?: Array<{ id: string; label: string; onSelect?: () => void }>;
}
export type BannerKind = 'info' | 'success' | 'warning' | 'danger' | 'announcement';
export interface BannerItem {
id: string;
kind?: BannerKind;
/** Required title. */
title: ReactNode;
/** Optional body. */
body?: ReactNode;
/** Optional CTA. */
cta?: { label: string; href?: string; onSelect?: () => void };
/** Dismissible by user. Default true. */
dismissible?: boolean;
}

View File

@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"lib": ["ES2022", "DOM"],
"jsx": "react-jsx"
},
"include": ["src"],
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
}

View File

@ -0,0 +1,2 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({ test: { environment: 'happy-dom', pool: 'forks' } });