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:
parent
e2eea086dc
commit
d082480849
@ -79,4 +79,25 @@ module.exports = [
|
|||||||
limit: '15 KB',
|
limit: '15 KB',
|
||||||
gzip: true,
|
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,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
34
packages/data-viz/package.json
Normal file
34
packages/data-viz/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
73
packages/data-viz/src/BarSparkline.tsx
Normal file
73
packages/data-viz/src/BarSparkline.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
88
packages/data-viz/src/Heatmap.tsx
Normal file
88
packages/data-viz/src/Heatmap.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
169
packages/data-viz/src/KpiCard.tsx
Normal file
169
packages/data-viz/src/KpiCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
104
packages/data-viz/src/ProgressRing.tsx
Normal file
104
packages/data-viz/src/ProgressRing.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
packages/data-viz/src/Sparkline.tsx
Normal file
115
packages/data-viz/src/Sparkline.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
112
packages/data-viz/src/__tests__/data-viz.test.tsx
Normal file
112
packages/data-viz/src/__tests__/data-viz.test.tsx
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
28
packages/data-viz/src/index.ts
Normal file
28
packages/data-viz/src/index.ts
Normal 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';
|
||||||
11
packages/data-viz/tsconfig.json
Normal file
11
packages/data-viz/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
2
packages/data-viz/vitest.config.ts
Normal file
2
packages/data-viz/vitest.config.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
export default defineConfig({ test: { environment: 'happy-dom', pool: 'forks' } });
|
||||||
34
packages/motion/package.json
Normal file
34
packages/motion/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
105
packages/motion/src/NumberFlow.tsx
Normal file
105
packages/motion/src/NumberFlow.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
131
packages/motion/src/Reveal.tsx
Normal file
131
packages/motion/src/Reveal.tsx
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
86
packages/motion/src/ScrollProgress.tsx
Normal file
86
packages/motion/src/ScrollProgress.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
packages/motion/src/StaggerList.tsx
Normal file
48
packages/motion/src/StaggerList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
packages/motion/src/TiltCard.tsx
Normal file
96
packages/motion/src/TiltCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
173
packages/motion/src/__tests__/motion.test.tsx
Normal file
173
packages/motion/src/__tests__/motion.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
38
packages/motion/src/index.ts
Normal file
38
packages/motion/src/index.ts
Normal 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';
|
||||||
21
packages/motion/src/utils.ts
Normal file
21
packages/motion/src/utils.ts
Normal 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;
|
||||||
|
}
|
||||||
11
packages/motion/tsconfig.json
Normal file
11
packages/motion/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
2
packages/motion/vitest.config.ts
Normal file
2
packages/motion/vitest.config.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
export default defineConfig({ test: { environment: 'happy-dom', pool: 'forks' } });
|
||||||
34
packages/notifications-ui/package.json
Normal file
34
packages/notifications-ui/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
90
packages/notifications-ui/src/Announcement.tsx
Normal file
90
packages/notifications-ui/src/Announcement.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
176
packages/notifications-ui/src/BannerStack.tsx
Normal file
176
packages/notifications-ui/src/BannerStack.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
200
packages/notifications-ui/src/InboxItem.tsx
Normal file
200
packages/notifications-ui/src/InboxItem.tsx
Normal 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();
|
||||||
|
}
|
||||||
268
packages/notifications-ui/src/NotificationCenter.tsx
Normal file
268
packages/notifications-ui/src/NotificationCenter.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
27
packages/notifications-ui/src/index.ts
Normal file
27
packages/notifications-ui/src/index.ts
Normal 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';
|
||||||
38
packages/notifications-ui/src/types.ts
Normal file
38
packages/notifications-ui/src/types.ts
Normal 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;
|
||||||
|
}
|
||||||
11
packages/notifications-ui/tsconfig.json
Normal file
11
packages/notifications-ui/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
2
packages/notifications-ui/vitest.config.ts
Normal file
2
packages/notifications-ui/vitest.config.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
export default defineConfig({ test: { environment: 'happy-dom', pool: 'forks' } });
|
||||||
Loading…
Reference in New Issue
Block a user