learning_ai_common_plat/packages/notifications-ui/src/BannerStack.tsx
saravanakumardb1 d082480849 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
2026-05-27 13:08:30 -07:00

177 lines
5.2 KiB
TypeScript

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>
);
}