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',
|
||||
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