From d08248084912bf55767121d963feefed77efc98b Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Wed, 27 May 2026 13:08:30 -0700 Subject: [PATCH] feat(packages): Wave 4 motion + Wave 5b data-viz + Wave 7 notifications-ui MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) ═══════════════════════════════════════════════════════════════════════ — IntersectionObserver-based fade/slide entry, 6 directions, configurable spring + delay — sequenced reveal of children with per-item delay — RAF-tweened number counter, cubic-out easing, Intl-formatted, prefers-reduced-motion aware — 3D perspective tilt + cursor-tracking glare overlay (single-element ref, no React rerenders) — 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) ═══════════════════════════════════════════════════════════════════════ — line trend with gradient fill + last-point marker — discrete-bar mini-chart with max-bar highlight — label + headline + delta arrow + sparkline; supports 'goodWhen=lower' for latency/cost metrics — circular progress with center content slot, animated stroke-dashoffset — 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) ═══════════════════════════════════════════════════════════════════════ — bell trigger + badge + dropdown panel with All / Unread / Mentions tabs, outside-click + Escape close, mark-all-read action — single row with unread dot, kind glyph, relative timestamp, optional action buttons — top-of-page strip with maxVisible + +N more, accent-bordered tone variants, dismissible — 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 --- .size-limit.cjs | 21 ++ packages/data-viz/package.json | 34 +++ packages/data-viz/src/BarSparkline.tsx | 73 +++++ packages/data-viz/src/Heatmap.tsx | 88 ++++++ packages/data-viz/src/KpiCard.tsx | 169 +++++++++++ packages/data-viz/src/ProgressRing.tsx | 104 +++++++ packages/data-viz/src/Sparkline.tsx | 115 ++++++++ .../data-viz/src/__tests__/data-viz.test.tsx | 112 ++++++++ packages/data-viz/src/index.ts | 28 ++ packages/data-viz/tsconfig.json | 11 + packages/data-viz/vitest.config.ts | 2 + packages/motion/package.json | 34 +++ packages/motion/src/NumberFlow.tsx | 105 +++++++ packages/motion/src/Reveal.tsx | 131 +++++++++ packages/motion/src/ScrollProgress.tsx | 86 ++++++ packages/motion/src/StaggerList.tsx | 48 ++++ packages/motion/src/TiltCard.tsx | 96 +++++++ packages/motion/src/__tests__/motion.test.tsx | 173 +++++++++++ packages/motion/src/index.ts | 38 +++ packages/motion/src/utils.ts | 21 ++ packages/motion/tsconfig.json | 11 + packages/motion/vitest.config.ts | 2 + packages/notifications-ui/package.json | 34 +++ .../notifications-ui/src/Announcement.tsx | 90 ++++++ packages/notifications-ui/src/BannerStack.tsx | 176 ++++++++++++ packages/notifications-ui/src/InboxItem.tsx | 200 +++++++++++++ .../src/NotificationCenter.tsx | 268 ++++++++++++++++++ .../src/__tests__/notifications-ui.test.tsx | 178 ++++++++++++ packages/notifications-ui/src/index.ts | 27 ++ packages/notifications-ui/src/types.ts | 38 +++ packages/notifications-ui/tsconfig.json | 11 + packages/notifications-ui/vitest.config.ts | 2 + 32 files changed, 2526 insertions(+) create mode 100644 packages/data-viz/package.json create mode 100644 packages/data-viz/src/BarSparkline.tsx create mode 100644 packages/data-viz/src/Heatmap.tsx create mode 100644 packages/data-viz/src/KpiCard.tsx create mode 100644 packages/data-viz/src/ProgressRing.tsx create mode 100644 packages/data-viz/src/Sparkline.tsx create mode 100644 packages/data-viz/src/__tests__/data-viz.test.tsx create mode 100644 packages/data-viz/src/index.ts create mode 100644 packages/data-viz/tsconfig.json create mode 100644 packages/data-viz/vitest.config.ts create mode 100644 packages/motion/package.json create mode 100644 packages/motion/src/NumberFlow.tsx create mode 100644 packages/motion/src/Reveal.tsx create mode 100644 packages/motion/src/ScrollProgress.tsx create mode 100644 packages/motion/src/StaggerList.tsx create mode 100644 packages/motion/src/TiltCard.tsx create mode 100644 packages/motion/src/__tests__/motion.test.tsx create mode 100644 packages/motion/src/index.ts create mode 100644 packages/motion/src/utils.ts create mode 100644 packages/motion/tsconfig.json create mode 100644 packages/motion/vitest.config.ts create mode 100644 packages/notifications-ui/package.json create mode 100644 packages/notifications-ui/src/Announcement.tsx create mode 100644 packages/notifications-ui/src/BannerStack.tsx create mode 100644 packages/notifications-ui/src/InboxItem.tsx create mode 100644 packages/notifications-ui/src/NotificationCenter.tsx create mode 100644 packages/notifications-ui/src/__tests__/notifications-ui.test.tsx create mode 100644 packages/notifications-ui/src/index.ts create mode 100644 packages/notifications-ui/src/types.ts create mode 100644 packages/notifications-ui/tsconfig.json create mode 100644 packages/notifications-ui/vitest.config.ts diff --git a/.size-limit.cjs b/.size-limit.cjs index 1bc70f2c..1ff049d6 100644 --- a/.size-limit.cjs +++ b/.size-limit.cjs @@ -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, + }, ]; diff --git a/packages/data-viz/package.json b/packages/data-viz/package.json new file mode 100644 index 00000000..abfda671 --- /dev/null +++ b/packages/data-viz/package.json @@ -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" + } +} diff --git a/packages/data-viz/src/BarSparkline.tsx b/packages/data-viz/src/BarSparkline.tsx new file mode 100644 index 00000000..a7595425 --- /dev/null +++ b/packages/data-viz/src/BarSparkline.tsx @@ -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; +} + +/** + * `` — 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 ( + + {bars.map((b, i) => ( + + ))} + + ); +} diff --git a/packages/data-viz/src/Heatmap.tsx b/packages/data-viz/src/Heatmap.tsx new file mode 100644 index 00000000..47a422cd --- /dev/null +++ b/packages/data-viz/src/Heatmap.tsx @@ -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; +} + +/** + * `` — 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 ( +
+ {cells.map((c, i) => { + const intensity = c.value > 0 ? 0.15 + 0.85 * (c.value / max) : 0; + return ( +
+ ); + })} +
+ ); +} diff --git a/packages/data-viz/src/KpiCard.tsx b/packages/data-viz/src/KpiCard.tsx new file mode 100644 index 00000000..0d6b6f8b --- /dev/null +++ b/packages/data-viz/src/KpiCard.tsx @@ -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; +} + +/** + * `` — 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 `` 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 ( +
+
+ + {label} + + {icon && ( + + {icon} + + )} +
+ +
+ {value} +
+ +
+ {hasDelta && ( + + {!hideDeltaArrow && {up ? '▲' : '▼'}} + {(up ? '+' : '') + deltaPercent!.toFixed(1)}% + + )} + {hasDelta && ( + {deltaLabel} + )} + {trend && trend.length > 1 && ( +
+ +
+ )} +
+
+ ); +} diff --git a/packages/data-viz/src/ProgressRing.tsx b/packages/data-viz/src/ProgressRing.tsx new file mode 100644 index 00000000..d09ee59a --- /dev/null +++ b/packages/data-viz/src/ProgressRing.tsx @@ -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; +} + +/** + * `` — 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 ( +
+ + + + + {children !== undefined && ( +
{children}
+ )} +
+ ); +} diff --git a/packages/data-viz/src/Sparkline.tsx b/packages/data-viz/src/Sparkline.tsx new file mode 100644 index 00000000..bc89552e --- /dev/null +++ b/packages/data-viz/src/Sparkline.tsx @@ -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; +} + +/** + * `` — 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 ( + + {fill && ( + <> + + + + + + + + + )} + + {showLastPoint && data.length > 0 && ( + + )} + + ); +} diff --git a/packages/data-viz/src/__tests__/data-viz.test.tsx b/packages/data-viz/src/__tests__/data-viz.test.tsx new file mode 100644 index 00000000..cae70197 --- /dev/null +++ b/packages/data-viz/src/__tests__/data-viz.test.tsx @@ -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(); + 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(); + expect(screen.getByTestId('bl-sparkline').querySelector('circle')).toBeNull(); + }); + it('does not draw a path with fewer than 2 points', () => { + render(); + const path = screen.getByTestId('bl-sparkline').querySelector('path'); + expect(path?.getAttribute('d')).toBe(''); + }); +}); + +describe('BarSparkline', () => { + beforeEach(() => cleanup()); + it('renders one rect per datum', () => { + render(); + const rects = screen.getByTestId('bl-bar-sparkline').querySelectorAll('rect'); + expect(rects.length).toBe(5); + }); + it('emphasizes the max bar (opacity=1)', () => { + render(); + 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(); + 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(); + 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(); + 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( + , + ); + expect( + screen.getByTestId('bl-kpi-delta').getAttribute('data-good'), + ).toBe('true'); + rerender(); + expect( + screen.getByTestId('bl-kpi-delta').getAttribute('data-good'), + ).toBe('false'); + }); + it('renders sparkline when trend provided', () => { + render(); + expect(screen.getByTestId('bl-sparkline')).toBeDefined(); + }); +}); + +describe('ProgressRing', () => { + beforeEach(() => cleanup()); + it('clamps value to 0..1 and emits aria-label percent', () => { + const { rerender } = render(); + expect(screen.getByTestId('bl-progress-ring').getAttribute('aria-label')).toBe('100 percent'); + rerender(); + expect(screen.getByTestId('bl-progress-ring').getAttribute('aria-label')).toBe('0 percent'); + }); + it('renders inner content slot', () => { + render( + + 50% + , + ); + 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(); + expect(screen.getAllByTestId(/bl-heatmap-cell-/).length).toBe(14); + }); + it('zero-value cells render with empty intensity', () => { + render(); + 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); + }); +}); diff --git a/packages/data-viz/src/index.ts b/packages/data-viz/src/index.ts new file mode 100644 index 00000000..f670a97a --- /dev/null +++ b/packages/data-viz/src/index.ts @@ -0,0 +1,28 @@ +/** + * @bytelyst/data-viz — Token-themed visualization primitives. + * + * Exports (0.1.0): + * — line trend, inline SVG + * — discrete bars with max-highlight + * — label + headline + delta + sparkline + * — circular progress with center slot + * — calendar-style intensity grid + * + * Coming in 0.2.x (per ROADMAP §Wave 5b): + * , , — full axes + tooltips + * , , + */ +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'; diff --git a/packages/data-viz/tsconfig.json b/packages/data-viz/tsconfig.json new file mode 100644 index 00000000..4447784f --- /dev/null +++ b/packages/data-viz/tsconfig.json @@ -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"] +} diff --git a/packages/data-viz/vitest.config.ts b/packages/data-viz/vitest.config.ts new file mode 100644 index 00000000..73b69c6f --- /dev/null +++ b/packages/data-viz/vitest.config.ts @@ -0,0 +1,2 @@ +import { defineConfig } from 'vitest/config'; +export default defineConfig({ test: { environment: 'happy-dom', pool: 'forks' } }); diff --git a/packages/motion/package.json b/packages/motion/package.json new file mode 100644 index 00000000..c999f493 --- /dev/null +++ b/packages/motion/package.json @@ -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" + } +} diff --git a/packages/motion/src/NumberFlow.tsx b/packages/motion/src/NumberFlow.tsx new file mode 100644 index 00000000..483554ee --- /dev/null +++ b/packages/motion/src/NumberFlow.tsx @@ -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; +} + +/** + * `` — 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(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 ( + + {prefix} + {formatted} + {suffix} + + ); +} diff --git a/packages/motion/src/Reveal.tsx b/packages/motion/src/Reveal.tsx new file mode 100644 index 00000000..733f3095 --- /dev/null +++ b/packages/motion/src/Reveal.tsx @@ -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; +} + +/** + * `` — 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 + * + *

Hero headline

+ *
+ * ``` + */ +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(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 ( +
+ {children} +
+ ); +} + +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'; + } +} diff --git a/packages/motion/src/ScrollProgress.tsx b/packages/motion/src/ScrollProgress.tsx new file mode 100644 index 00000000..5460954b --- /dev/null +++ b/packages/motion/src/ScrollProgress.tsx @@ -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; +} + +/** + * `` — 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(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 ( +
+
+
+ ); +} diff --git a/packages/motion/src/StaggerList.tsx b/packages/motion/src/StaggerList.tsx new file mode 100644 index 00000000..df02ba6f --- /dev/null +++ b/packages/motion/src/StaggerList.tsx @@ -0,0 +1,48 @@ +import { Children, isValidElement, type ReactNode } from 'react'; +import { Reveal, type RevealProps } from './Reveal.js'; + +export interface StaggerListProps + extends Omit { + 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 `` so the same motion vocabulary applies — `from`, + * `spring`, `duration`, `once`, etc. all flow through. + * + * @example + * ```tsx + * + * {items.map((item) => )} + * + * ``` + */ +export function StaggerList({ + children, + stagger = 60, + initialDelay = 0, + as: As = 'div', + ...reveal +}: StaggerListProps) { + const items = Children.toArray(children).filter(isValidElement); + return ( + + {items.map((child, i) => ( + + {child} + + ))} + + ); +} diff --git a/packages/motion/src/TiltCard.tsx b/packages/motion/src/TiltCard.tsx new file mode 100644 index 00000000..1299f3cc --- /dev/null +++ b/packages/motion/src/TiltCard.tsx @@ -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; +} + +/** + * `` — 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(null); + const reduced = disableMotion ?? prefersReducedMotion(); + + const onMove = (e: MouseEvent) => { + 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 ( +
+ {children} + {!reduced && glare > 0 && ( + + )} +
+ ); +} diff --git a/packages/motion/src/__tests__/motion.test.tsx b/packages/motion/src/__tests__/motion.test.tsx new file mode 100644 index 00000000..13d79b1b --- /dev/null +++ b/packages/motion/src/__tests__/motion.test.tsx @@ -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( + + hi + , + ); + 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(x); + // 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( + + x + , + ); + expect(screen.getByTestId('bl-reveal').style.background).toBe('red'); + }); +}); + +describe('StaggerList', () => { + beforeEach(() => cleanup()); + + it('wraps each child in a Reveal with increasing delay', () => { + render( + +
1
+
2
+
3
+
, + ); + const list = screen.getByTestId('bl-stagger-list'); + expect(list.children).toHaveLength(3); + expect(screen.getByTestId('i3')).toBeDefined(); + }); + + it('supports element override via `as`', () => { + render( + +
  • a
  • +
  • b
  • +
    , + ); + expect(screen.getByTestId('bl-stagger-list').tagName.toLowerCase()).toBe('ul'); + }); +}); + +describe('NumberFlow', () => { + beforeEach(() => cleanup()); + + it('snaps to value when disableMotion=true', () => { + render(); + expect(screen.getByTestId('bl-number-flow').textContent).toBe('1,234'); + }); + + it('formats with decimals + prefix/suffix', () => { + render(); + expect(screen.getByTestId('bl-number-flow').textContent).toBe('$42.5 USD'); + }); + + it('uses custom format function', () => { + render( `≈${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(); + expect(screen.getByTestId('bl-number-flow').getAttribute('data-target')).toBe('7'); + }); +}); + +describe('TiltCard', () => { + beforeEach(() => cleanup()); + + it('renders children and attaches mousemove/mouseleave handlers', () => { + render( + + card body + , + ); + 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( + + x + , + ); + 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(); + 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(); + 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(); + unmount(); + expect(remove).toHaveBeenCalled(); + add.mockRestore(); + remove.mockRestore(); + }); +}); diff --git a/packages/motion/src/index.ts b/packages/motion/src/index.ts new file mode 100644 index 00000000..5a094736 --- /dev/null +++ b/packages/motion/src/index.ts @@ -0,0 +1,38 @@ +/** + * @bytelyst/motion — Motion primitives (Wave 4). + * + * Exports (0.1.0): + * — fade/slide on viewport entry + * — sequenced reveal of children + * — animated counter tween (RAF-based) + * — interactive 3D hover with cursor-tracking glare + * — fixed scroll-position bar + * + * Coming in 0.2.x (per ROADMAP §Wave 4): + * , (View Transitions API), + * , + * + * 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'; diff --git a/packages/motion/src/utils.ts b/packages/motion/src/utils.ts new file mode 100644 index 00000000..5ed38e5d --- /dev/null +++ b/packages/motion/src/utils.ts @@ -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; +} diff --git a/packages/motion/tsconfig.json b/packages/motion/tsconfig.json new file mode 100644 index 00000000..4447784f --- /dev/null +++ b/packages/motion/tsconfig.json @@ -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"] +} diff --git a/packages/motion/vitest.config.ts b/packages/motion/vitest.config.ts new file mode 100644 index 00000000..73b69c6f --- /dev/null +++ b/packages/motion/vitest.config.ts @@ -0,0 +1,2 @@ +import { defineConfig } from 'vitest/config'; +export default defineConfig({ test: { environment: 'happy-dom', pool: 'forks' } }); diff --git a/packages/notifications-ui/package.json b/packages/notifications-ui/package.json new file mode 100644 index 00000000..f82891a2 --- /dev/null +++ b/packages/notifications-ui/package.json @@ -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" + } +} diff --git a/packages/notifications-ui/src/Announcement.tsx b/packages/notifications-ui/src/Announcement.tsx new file mode 100644 index 00000000..2114794e --- /dev/null +++ b/packages/notifications-ui/src/Announcement.tsx @@ -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; +} + +/** + * `` — single inline marketing strip (smaller than a + * `` 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 ( + + ); +} diff --git a/packages/notifications-ui/src/BannerStack.tsx b/packages/notifications-ui/src/BannerStack.tsx new file mode 100644 index 00000000..8cd2e75c --- /dev/null +++ b/packages/notifications-ui/src/BannerStack.tsx @@ -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 = { + 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)', + }, +}; + +/** + * `` — 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 ( +
    + {visible.map(b => { + const t = TONE[b.kind ?? 'info']; + return ( +
    +
    +
    {b.title}
    + {b.body && ( +
    + {b.body} +
    + )} +
    + {b.cta && ( + { + 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} + + )} + {b.dismissible !== false && ( + + )} +
    + ); + })} + {hidden > 0 && ( + + )} +
    + ); +} diff --git a/packages/notifications-ui/src/InboxItem.tsx b/packages/notifications-ui/src/InboxItem.tsx new file mode 100644 index 00000000..04ca547c --- /dev/null +++ b/packages/notifications-ui/src/InboxItem.tsx @@ -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; +} + +/** + * `` — single notification row. Used inside `` + * 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 ( +
    + {/* Unread dot */} + + + {/* Avatar / kind icon */} + + {item.icon ?? tone.glyph} + + +
    + + {item.actions && item.actions.length > 0 && ( +
    + {item.actions.map(a => ( + + ))} +
    + )} +
    +
    + ); +} + +const TONE: Record = { + 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(); +} diff --git a/packages/notifications-ui/src/NotificationCenter.tsx b/packages/notifications-ui/src/NotificationCenter.tsx new file mode 100644 index 00000000..7cf886a4 --- /dev/null +++ b/packages/notifications-ui/src/NotificationCenter.tsx @@ -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; +} + +/** + * `` — 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(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 ( +
    + + + {open && ( +
    + {/* Header */} +
    +
    Inbox
    + {unread > 0 && onMarkAllRead && ( + + )} +
    + + {/* Tabs */} + {tabs.length > 1 && ( +
    + {tabs.map(t => ( + + ))} +
    + )} + + {/* List */} +
    + {filtered.length === 0 ? ( +
    + {tab === 'unread' ? "You're all caught up." : 'Nothing here yet.'} +
    + ) : ( + filtered.map(item => ( + + )) + )} +
    + + {footer && ( +
    + {footer} +
    + )} +
    + )} +
    + ); +} diff --git a/packages/notifications-ui/src/__tests__/notifications-ui.test.tsx b/packages/notifications-ui/src/__tests__/notifications-ui.test.tsx new file mode 100644 index 00000000..d6534ccd --- /dev/null +++ b/packages/notifications-ui/src/__tests__/notifications-ui.test.tsx @@ -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(); + 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(); + expect(screen.getByTestId('bl-notification-badge').textContent).toBe('9+'); + }); + + it('opens dropdown on trigger click and shows items', () => { + render(); + 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(); + 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(); + 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(); + 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( + , + ); + 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(); + fireEvent.click(screen.getByTestId('bl-notification-trigger')); + expect(screen.getByTestId('bl-notification-empty')).toBeDefined(); + }); + + it('Escape closes the dropdown', () => { + render(); + 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(); + 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(); + 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(); + 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(); + 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(); + fireEvent.click(screen.getByTestId('bl-banner-dismiss-b1')); + expect(onDismiss).toHaveBeenCalledWith('b1'); + }); + + it('cta callback overrides href navigation', () => { + const onSelect = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByTestId('bl-banner-cta-x')); + expect(onSelect).toHaveBeenCalledOnce(); + }); + + it('renders nothing when banners is empty', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); +}); + +describe('Announcement', () => { + beforeEach(() => cleanup()); + it('renders tag + content + cta', () => { + render( + + Streaming chat is here. + , + ); + expect(screen.getByText('BETA')).toBeDefined(); + expect(screen.getByText('Streaming chat is here.')).toBeDefined(); + expect(screen.getByTestId('bl-announcement-cta')).toBeDefined(); + }); +}); diff --git a/packages/notifications-ui/src/index.ts b/packages/notifications-ui/src/index.ts new file mode 100644 index 00000000..c20d88f5 --- /dev/null +++ b/packages/notifications-ui/src/index.ts @@ -0,0 +1,27 @@ +/** + * @bytelyst/notifications-ui — Notification UI primitives (Wave 7 essentials). + * + * Exports (0.1.0): + * — bell trigger + dropdown inbox with tabs + * — single notification row + * — dismissible top-of-page strip + * — 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'; diff --git a/packages/notifications-ui/src/types.ts b/packages/notifications-ui/src/types.ts new file mode 100644 index 00000000..bc582349 --- /dev/null +++ b/packages/notifications-ui/src/types.ts @@ -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; +} diff --git a/packages/notifications-ui/tsconfig.json b/packages/notifications-ui/tsconfig.json new file mode 100644 index 00000000..4447784f --- /dev/null +++ b/packages/notifications-ui/tsconfig.json @@ -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"] +} diff --git a/packages/notifications-ui/vitest.config.ts b/packages/notifications-ui/vitest.config.ts new file mode 100644 index 00000000..73b69c6f --- /dev/null +++ b/packages/notifications-ui/vitest.config.ts @@ -0,0 +1,2 @@ +import { defineConfig } from 'vitest/config'; +export default defineConfig({ test: { environment: 'happy-dom', pool: 'forks' } });