From 69afb2337f1ca255c222d505ad69068a36d6a417 Mon Sep 17 00:00:00 2001 From: jaymantri Date: Sun, 1 Mar 2026 11:10:17 -0800 Subject: [PATCH 01/22] Add Calendar component with locale, controlled month, and renderDay support Compound component (Root, Header, Navigation, Grid, Controls, ControlItem, Footer) built on WAI-ARIA date picker grid pattern. Supports single and range selection, optional time inputs, locale-aware formatting via Intl, controlled or uncontrolled month navigation, accessibility label overrides, and custom day cell rendering. Includes Fieldset --fieldset-gap CSS variable for per-instance gap customization. Made-with: Cursor --- src/app/page.tsx | 102 ++ src/components/Calendar/Calendar.module.scss | 181 +++ src/components/Calendar/Calendar.stories.tsx | 261 ++++ .../Calendar/Calendar.test-stories.tsx | 545 ++++++++ src/components/Calendar/Calendar.test.tsx | 640 +++++++++ src/components/Calendar/index.ts | 19 + src/components/Calendar/parts.tsx | 1219 +++++++++++++++++ src/components/Fieldset/Fieldset.module.scss | 4 +- src/index.ts | 12 + 9 files changed, 2981 insertions(+), 2 deletions(-) create mode 100644 src/components/Calendar/Calendar.module.scss create mode 100644 src/components/Calendar/Calendar.stories.tsx create mode 100644 src/components/Calendar/Calendar.test-stories.tsx create mode 100644 src/components/Calendar/Calendar.test.tsx create mode 100644 src/components/Calendar/index.ts create mode 100644 src/components/Calendar/parts.tsx diff --git a/src/app/page.tsx b/src/app/page.tsx index 5be092b..4bedf1a 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -61,6 +61,8 @@ import { Popover } from '@/components/Popover'; import { PreviewCard } from '@/components/PreviewCard'; import { Logo } from '@/components/Logo'; import { Toggle, ToggleGroup } from '@/components/Toggle'; +import * as Calendar from '@/components/Calendar'; +import type { DateRange } from '@/components/Calendar'; // Data for combobox examples const fruits = ['Apple', 'Banana', 'Cherry', 'Date', 'Elderberry', 'Fig', 'Grape']; @@ -1625,6 +1627,103 @@ function DrawerDemo() { ); } +function CalendarDemo() { + const [singleDate, setSingleDate] = React.useState(null); + const [rangeValue, setRangeValue] = React.useState(null); + const [mode, setMode] = React.useState<'single' | 'range'>('range'); + const [includeTime, setIncludeTime] = React.useState(false); + + return ( +
+
+

Single date

+ setSingleDate(v as Date)}> + + + + + + + +
+
+

Date range

+ + + + + + + { + setMode(v ? 'range' : 'single'); + setRangeValue(null); + }} + /> + + + + + + + + + +
+
+

French (locale)

+ + + + + + + { + setMode(v ? 'range' : 'single'); + setRangeValue(null); + }} + /> + + + + + + + + + +
+
+ ); +} + export default function Home() { return (
@@ -1632,6 +1731,9 @@ export default function Home() {

Origin

Design system rebuild — Base UI + Figma-first approach.

+

Calendar

+ +

Accordion Component

diff --git a/src/components/Calendar/Calendar.module.scss b/src/components/Calendar/Calendar.module.scss new file mode 100644 index 0000000..c637d4e --- /dev/null +++ b/src/components/Calendar/Calendar.module.scss @@ -0,0 +1,181 @@ +@use '../../tokens/mixins' as *; +@use '../../tokens/text-styles' as *; + +.root { + display: flex; + flex-direction: column; + width: 270px; + overflow: hidden; + background: var(--surface-primary); + border: var(--stroke-xs) solid var(--border-primary); + @include smooth-corners(var(--corner-radius-sm)); + box-shadow: var(--shadow-lg); +} + +// Header: date/time input fields +.header { + display: flex; + flex-direction: column; + gap: var(--spacing-2xs); + padding: var(--spacing-sm); +} + +// Navigation: month/year title + prev/next buttons +.nav { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--spacing-3xs) var(--spacing-xs) var(--spacing-xs) var(--spacing-sm); +} + +.navTitle { + @include label; + color: var(--text-primary); +} + +.navButtons { + display: flex; + align-items: center; + gap: var(--spacing-2xs); +} + +.navButton { + @include button-reset; + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + @include smooth-corners(var(--corner-radius-sm)); + color: var(--icon-primary); + + @media (hover: hover) { + &:hover { + background: var(--surface-hover); + } + } + + &:focus-visible { + outline: 2px solid var(--border-focus); + outline-offset: -2px; + } +} + +// Grid (table) +.grid { + width: 100%; + border-collapse: separate; + border-spacing: 0; + padding: 0 var(--spacing-3xs) var(--spacing-3xs); + table-layout: fixed; +} + +.weekdayCell { + @include label-sm; + color: var(--text-tertiary); + text-align: center; + height: 28px; + vertical-align: middle; + font-weight: var(--font-weight-book); +} + +.dayCell { + padding: var(--spacing-3xs) 0; + text-align: center; + + .weekRow:first-child > & { + padding-top: 0; + } + + .weekRow:last-child > & { + padding-bottom: 0; + } +} + +.dayButton { + @include button-reset; + @include body; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 32px; + @include smooth-corners(var(--corner-radius-2xs)); + color: var(--text-primary); + user-select: none; + + @media (hover: hover) { + &:hover:not([data-disabled]):not([data-selected]):not([data-range-start]):not([data-range-end]) { + background: var(--surface-hover); + } + } + + &:focus-visible { + outline: 2px solid var(--border-focus); + outline-offset: -2px; + } + + &[data-selected] { + background: var(--surface-inverse); + color: var(--text-inverse); + } + + &[data-range-start] { + background: var(--surface-inverse); + color: var(--text-inverse); + border-radius: var(--corner-radius-2xs) 0 0 var(--corner-radius-2xs); + } + + &[data-range-end] { + background: var(--surface-inverse); + color: var(--text-inverse); + border-radius: 0 var(--corner-radius-2xs) var(--corner-radius-2xs) 0; + } + + &[data-range-start][data-range-end] { + @include smooth-corners(var(--corner-radius-2xs)); + } + + &[data-in-range] { + background: var(--surface-hover); + border-radius: 0; + } + + // Fade outside-month days unless they're part of the active selection/range + &[data-outside-month]:not([data-in-range]):not([data-range-start]):not([data-range-end]):not([data-selected]) { + opacity: calc(var(--opacity-50) / 100); + } + + &[data-disabled] { + opacity: calc(var(--opacity-50) / 100); + cursor: default; + pointer-events: none; + } +} + +// Controls: slot for toggle items +.controls { + display: flex; + flex-direction: column; + padding: var(--spacing-3xs) var(--spacing-xs); +} + +// ControlItem: label + trailing action +.controlItem { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--spacing-3xs) var(--spacing-4xs); +} + +.controlLabel { + @include body; + color: var(--text-secondary); +} + +// Footer: slot for action buttons +.footer { + display: flex; + flex-direction: column; + padding: var(--spacing-xs); +} diff --git a/src/components/Calendar/Calendar.stories.tsx b/src/components/Calendar/Calendar.stories.tsx new file mode 100644 index 0000000..f55633f --- /dev/null +++ b/src/components/Calendar/Calendar.stories.tsx @@ -0,0 +1,261 @@ +import { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import * as Calendar from './index'; +import type { DateRange, DayCellState } from './index'; +import { Switch } from '../Switch'; +import { Button } from '../Button'; + +function SingleCalendar() { + const [value, setValue] = useState(null); + return ( + setValue(v as Date)}> + + + + + + + + ); +} + +function RangeCalendar() { + const [mode, setMode] = useState<'single' | 'range'>('range'); + const [includeTime, setIncludeTime] = useState(false); + const [value, setValue] = useState(null); + + return ( + + + + + + + { + setMode(v ? 'range' : 'single'); + setValue(null); + }} + /> + + + + + + + + + + ); +} + +function WithTimeCalendar() { + const [value, setValue] = useState(null); + return ( + setValue(v as Date)} + includeTime + > + + + + + ); +} + +function RangeWithTimeCalendar() { + const [value, setValue] = useState(null); + return ( + setValue(v as DateRange)} + > + + + + + ); +} + +function ConstrainedCalendar() { + const [value, setValue] = useState(null); + const today = new Date(); + const max = new Date(today); + max.setMonth(max.getMonth() + 3); + + return ( + setValue(v as Date)} + min={today} + max={max} + > + + + + ); +} + +function WeekdaysOnlyCalendar() { + const [value, setValue] = useState(null); + return ( + setValue(v as Date)} + disabled={(date) => date.getDay() === 0 || date.getDay() === 6} + > + + + + ); +} + +function MondayStartCalendar() { + const [value, setValue] = useState(null); + return ( + setValue(v as Date)} + weekStartsOn={1} + > + + + + ); +} + +const meta: Meta = { + title: 'Components/Calendar', + parameters: { layout: 'centered' }, +}; + +export default meta; + +type Story = StoryObj; + +export const Single: Story = { + render: () => , +}; + +export const Range: Story = { + render: () => , +}; + +export const WithTime: Story = { + render: () => , +}; + +export const RangeWithTime: Story = { + render: () => , +}; + +export const Constrained: Story = { + render: () => , +}; + +export const WeekdaysOnly: Story = { + render: () => , +}; + +export const MondayStart: Story = { + render: () => , +}; + +function GermanCalendar() { + const [value, setValue] = useState(null); + return ( + setValue(v as Date)} + locale="de-DE" + weekStartsOn={1} + labels={{ + previousMonth: 'Vorheriger Monat', + nextMonth: 'Nächster Monat', + date: 'Datum', + }} + > + + + + + ); +} + +export const LocaleGerman: Story = { + render: () => , +}; + +function JapaneseCalendar() { + const [value, setValue] = useState(null); + return ( + setValue(v as Date)} + locale="ja-JP" + > + + + + + ); +} + +export const LocaleJapanese: Story = { + render: () => , +}; + +function EventDotsCalendar() { + const [value, setValue] = useState(null); + const eventDays = new Set([3, 7, 14, 21, 28]); + + return ( + setValue(v as Date)}> + + ( + + {date.getDate()} + {!state.isOutsideMonth && eventDays.has(date.getDate()) && ( + + )} + /> + + ); +} + +export const EventDots: Story = { + render: () => , +}; diff --git a/src/components/Calendar/Calendar.test-stories.tsx b/src/components/Calendar/Calendar.test-stories.tsx new file mode 100644 index 0000000..9c3e1a8 --- /dev/null +++ b/src/components/Calendar/Calendar.test-stories.tsx @@ -0,0 +1,545 @@ +import { useState } from 'react'; +import * as Calendar from './index'; +import type { DateRange, DayCellState } from './index'; +import { Switch } from '../Switch'; +import { Button } from '../Button'; + +export function TestDefault() { + const [value, setValue] = useState(null); + return ( + setValue(v as Date)} + defaultMonth={new Date(2026, 1, 1)} + > + + +
+ {value ? value.toISOString().split('T')[0] : 'none'} +
+
+ ); +} + +export function TestWithValue() { + const [value, setValue] = useState(new Date(2026, 1, 15)); + return ( + setValue(v as Date)} + defaultMonth={new Date(2026, 1, 1)} + > + + +
+ {value ? value.toISOString().split('T')[0] : 'none'} +
+
+ ); +} + +export function TestRange() { + const [value, setValue] = useState(null); + return ( + setValue(v as DateRange)} + defaultMonth={new Date(2026, 1, 1)} + > + + +
+ {value ? value.start.toISOString().split('T')[0] : 'none'} +
+
+ {value ? value.end.toISOString().split('T')[0] : 'none'} +
+
+ ); +} + +export function TestRangeWithValue() { + const [value, setValue] = useState({ + start: new Date(2026, 1, 11), + end: new Date(2026, 1, 15), + }); + return ( + setValue(v as DateRange)} + defaultMonth={new Date(2026, 1, 1)} + > + + + + ); +} + +export function TestDisabled() { + const [value, setValue] = useState(null); + return ( + setValue(v as Date)} + defaultMonth={new Date(2026, 1, 1)} + disabled={(date) => date.getDay() === 0 || date.getDay() === 6} + > + + +
+ {value ? value.toISOString().split('T')[0] : 'none'} +
+
+ ); +} + +export function TestMinMax() { + const [value, setValue] = useState(null); + return ( + setValue(v as Date)} + defaultMonth={new Date(2026, 1, 1)} + min={new Date(2026, 1, 5)} + max={new Date(2026, 1, 25)} + > + + +
+ {value ? value.toISOString().split('T')[0] : 'none'} +
+
+ ); +} + +export function TestFullFeatured() { + const [mode, setMode] = useState<'single' | 'range'>('range'); + const [includeTime, setIncludeTime] = useState(false); + const [value, setValue] = useState(null); + const [applied, setApplied] = useState(false); + + const rangeValue = value && !(value instanceof Date) ? value : null; + + return ( + + + + + + + { + setMode(v ? 'range' : 'single'); + setValue(null); + }} + data-testid="end-date-toggle" + /> + + + + + + + + +
{applied ? 'yes' : 'no'}
+
+ {rangeValue ? rangeValue.start.toISOString().split('T')[0] : 'none'} +
+
+ {rangeValue ? rangeValue.end.toISOString().split('T')[0] : 'none'} +
+
+ ); +} + +export function TestWithTime() { + const [value, setValue] = useState( + new Date(2026, 1, 11, 14, 30), + ); + return ( + setValue(v as Date)} + defaultMonth={new Date(2026, 1, 1)} + includeTime + > + + + +
+ {value ? value.toISOString() : 'none'} +
+
{value ? value.getHours() : ''}
+
+ {value ? value.getMinutes() : ''} +
+
+ ); +} + +export function TestModeSwitch() { + const [mode, setMode] = useState<'single' | 'range'>('range'); + const [value, setValue] = useState(null); + return ( + + + + +
{mode}
+
+ {value instanceof Date + ? value.toISOString().split('T')[0] + : value && !(value instanceof Date) + ? `${value.start.toISOString().split('T')[0]}|${value.end.toISOString().split('T')[0]}` + : 'none'} +
+
+ ); +} + +export function TestReverseRange() { + const [value, setValue] = useState(null); + return ( + setValue(v as DateRange)} + defaultMonth={new Date(2026, 1, 1)} + > + + +
+ {value ? value.start.toISOString().split('T')[0] : 'none'} +
+
+ {value ? value.end.toISOString().split('T')[0] : 'none'} +
+
+ ); +} + +export function TestSameDayRange() { + const [value, setValue] = useState(null); + return ( + setValue(v as DateRange)} + defaultMonth={new Date(2026, 1, 1)} + > + + +
+ {value ? value.start.toISOString().split('T')[0] : 'none'} +
+
+ {value ? value.end.toISOString().split('T')[0] : 'none'} +
+
+ ); +} + +export function TestDateInput() { + const [value, setValue] = useState(new Date(2026, 1, 11)); + return ( + setValue(v as Date)} + defaultMonth={new Date(2026, 1, 1)} + > + + + +
+ {value ? value.toISOString().split('T')[0] : 'none'} +
+
+ ); +} + +export function TestRangeWithTime() { + const [value, setValue] = useState({ + start: new Date(2026, 1, 11, 9, 0), + end: new Date(2026, 1, 15, 17, 30), + }); + return ( + setValue(v as DateRange)} + defaultMonth={new Date(2026, 1, 1)} + > + + + +
{value ? value.start.getHours() : ''}
+
{value ? value.start.getMinutes() : ''}
+
{value ? value.end.getHours() : ''}
+
{value ? value.end.getMinutes() : ''}
+
+ {value ? value.start.toISOString().split('T')[0] : 'none'} +
+
+ {value ? value.end.toISOString().split('T')[0] : 'none'} +
+
+ ); +} + +export function TestYearBoundary() { + const [value, setValue] = useState(null); + return ( + setValue(v as Date)} + defaultMonth={new Date(2026, 11, 1)} + > + + +
+ {value ? value.toISOString().split('T')[0] : 'none'} +
+
+ ); +} + +export function TestLeapYear() { + const [value, setValue] = useState(null); + return ( + setValue(v as Date)} + defaultMonth={new Date(2028, 1, 1)} + > + + +
+ {value ? value.toISOString().split('T')[0] : 'none'} +
+
+ ); +} + +export function TestMondayStart() { + const [value, setValue] = useState(null); + return ( + setValue(v as Date)} + defaultMonth={new Date(2026, 1, 1)} + weekStartsOn={1} + > + + + + ); +} + +export function TestMinEqualsMax() { + const [value, setValue] = useState(null); + return ( + setValue(v as Date)} + defaultMonth={new Date(2026, 1, 1)} + min={new Date(2026, 1, 15)} + max={new Date(2026, 1, 15)} + > + + +
+ {value ? value.toISOString().split('T')[0] : 'none'} +
+
+ ); +} + +export function TestLocaleDE() { + const [value, setValue] = useState(null); + return ( + setValue(v as Date)} + defaultMonth={new Date(2026, 1, 1)} + locale="de-DE" + labels={{ date: 'Datum', startDate: 'Startdatum', endDate: 'Enddatum' }} + > + + + +
+ {value ? value.toISOString().split('T')[0] : 'none'} +
+
+ ); +} + +export function TestLocaleJA() { + const [value, setValue] = useState(null); + return ( + setValue(v as Date)} + defaultMonth={new Date(2026, 1, 1)} + locale="ja-JP" + > + + + + ); +} + +export function TestControlledMonth() { + const [value, setValue] = useState(null); + const [month, setMonth] = useState(new Date(2026, 1, 1)); + return ( + setValue(v as Date)} + month={month} + onMonthChange={setMonth} + > + + + +
{month.getMonth()}
+
{month.getFullYear()}
+
+ {value ? value.toISOString().split('T')[0] : 'none'} +
+
+ ); +} + +export function TestOnMonthChange() { + const [value, setValue] = useState(null); + const [monthLog, setMonthLog] = useState([]); + return ( + setValue(v as Date)} + defaultMonth={new Date(2026, 1, 1)} + onMonthChange={(m) => { + setMonthLog((prev) => [...prev, `${m.getFullYear()}-${m.getMonth()}`]); + }} + > + + +
{monthLog.join(',')}
+
+ ); +} + +export function TestCustomLabels() { + const [value, setValue] = useState(null); + return ( + setValue(v as Date)} + defaultMonth={new Date(2026, 1, 1)} + labels={{ + previousMonth: 'Vorheriger Monat', + nextMonth: 'Nächster Monat', + }} + > + + + + ); +} + +export function TestRenderDay() { + const [value, setValue] = useState(null); + const specialDates = [5, 14, 20]; + return ( + setValue(v as Date)} + defaultMonth={new Date(2026, 1, 1)} + > + + ( + + {date.getDate()} + {!state.isOutsideMonth && specialDates.includes(date.getDate()) && ( + + )} + + )} + /> +
+ {value ? value.toISOString().split('T')[0] : 'none'} +
+
+ ); +} + +export function TestLocaleWithTime() { + const [value, setValue] = useState( + new Date(2026, 1, 11, 14, 30), + ); + return ( + setValue(v as Date)} + defaultMonth={new Date(2026, 1, 1)} + locale="de-DE" + includeTime + labels={{ date: 'Datum', time: 'Uhrzeit' }} + > + + + +
{value ? value.getHours() : ''}
+
+ {value ? value.getMinutes() : ''} +
+
+ ); +} diff --git a/src/components/Calendar/Calendar.test.tsx b/src/components/Calendar/Calendar.test.tsx new file mode 100644 index 0000000..7d33b3d --- /dev/null +++ b/src/components/Calendar/Calendar.test.tsx @@ -0,0 +1,640 @@ +import { test, expect } from '@playwright/experimental-ct-react'; +import { + TestDefault, + TestWithValue, + TestRange, + TestRangeWithValue, + TestDisabled, + TestMinMax, + TestFullFeatured, + TestWithTime, + TestModeSwitch, + TestReverseRange, + TestSameDayRange, + TestDateInput, + TestRangeWithTime, + TestYearBoundary, + TestLeapYear, + TestMondayStart, + TestMinEqualsMax, + TestLocaleDE, + TestLocaleJA, + TestControlledMonth, + TestOnMonthChange, + TestCustomLabels, + TestRenderDay, + TestLocaleWithTime, +} from './Calendar.test-stories'; + +test.describe('Calendar', () => { + test('renders current month with weekday headers and day grid', async ({ + mount, + page, + }) => { + await mount(); + + await expect(page.getByText('February 2026')).toBeVisible(); + + const grid = page.getByRole('grid', { name: 'February 2026' }); + await expect(grid).toBeVisible(); + + for (const abbr of ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']) { + await expect(page.getByRole('columnheader', { name: abbr })).toBeVisible(); + } + + await expect( + page.getByRole('button', { name: /Sunday, February 1, 2026/ }), + ).toBeVisible(); + await expect( + page.getByRole('button', { name: /Saturday, February 28, 2026/ }), + ).toBeVisible(); + }); + + test('navigates months with previous/next buttons', async ({ + mount, + page, + }) => { + await mount(); + + await expect(page.getByText('February 2026')).toBeVisible(); + + await page.getByRole('button', { name: 'Next month' }).click(); + await expect(page.getByText('March 2026')).toBeVisible(); + + await page.getByRole('button', { name: 'Previous month' }).click(); + await expect(page.getByText('February 2026')).toBeVisible(); + }); + + test('selects a date in single mode', async ({ mount, page }) => { + await mount(); + + const selected = page.getByTestId('selected'); + await expect(selected).toHaveText('none'); + + await page + .getByRole('button', { name: /Wednesday, February 11, 2026/ }) + .click(); + await expect(selected).toHaveText('2026-02-11'); + + const btn = page.getByRole('button', { + name: /Wednesday, February 11, 2026/, + }); + await expect(btn).toHaveAttribute('data-selected'); + }); + + test('renders with initial value', async ({ mount, page }) => { + await mount(); + + const selected = page.getByTestId('selected'); + await expect(selected).toHaveText('2026-02-15'); + + const btn = page.getByRole('button', { + name: /Sunday, February 15, 2026/, + }); + await expect(btn).toHaveAttribute('data-selected'); + }); + + test('selects a range with two clicks', async ({ mount, page }) => { + await mount(); + + const rangeStart = page.getByTestId('range-start'); + const rangeEnd = page.getByTestId('range-end'); + await expect(rangeStart).toHaveText('none'); + + await page + .getByRole('button', { name: /Wednesday, February 11, 2026/ }) + .click(); + + await page + .getByRole('button', { name: /Sunday, February 15, 2026/ }) + .click(); + + await expect(rangeStart).toHaveText('2026-02-11'); + await expect(rangeEnd).toHaveText('2026-02-15'); + }); + + test('renders existing range with data attributes', async ({ + mount, + page, + }) => { + await mount(); + + const startBtn = page.getByRole('button', { + name: /Wednesday, February 11, 2026/, + }); + const endBtn = page.getByRole('button', { + name: /Sunday, February 15, 2026/, + }); + const midBtn = page.getByRole('button', { + name: /Thursday, February 12, 2026/, + }); + + await expect(startBtn).toHaveAttribute('data-range-start'); + await expect(endBtn).toHaveAttribute('data-range-end'); + await expect(midBtn).toHaveAttribute('data-in-range'); + }); + + test('prevents selection of disabled dates', async ({ mount, page }) => { + await mount(); + + const selected = page.getByTestId('selected'); + + const sundayBtn = page.getByRole('button', { + name: /Sunday, February 1, 2026/, + }); + await expect(sundayBtn).toHaveAttribute('data-disabled'); + await sundayBtn.click({ force: true }); + await expect(selected).toHaveText('none'); + + await page + .getByRole('button', { name: /Monday, February 2, 2026/ }) + .click(); + await expect(selected).toHaveText('2026-02-02'); + }); + + test('respects min/max constraints', async ({ mount, page }) => { + await mount(); + + const selected = page.getByTestId('selected'); + + const beforeMin = page.getByRole('button', { + name: /Wednesday, February 4, 2026/, + }); + await expect(beforeMin).toHaveAttribute('data-disabled'); + + const afterMax = page.getByRole('button', { + name: /Thursday, February 26, 2026/, + }); + await expect(afterMax).toHaveAttribute('data-disabled'); + + await page + .getByRole('button', { name: /Tuesday, February 10, 2026/ }) + .click(); + await expect(selected).toHaveText('2026-02-10'); + }); + + test('outside-month days are faded but clickable', async ({ mount, page }) => { + await mount(); + + const marchDay = page.getByRole('button', { + name: /Sunday, March 1, 2026/, + }); + await expect(marchDay).toHaveAttribute('data-outside-month'); + + // Clicking navigates to that month and selects the day + await marchDay.click(); + await expect(page.getByText('March 2026')).toBeVisible(); + await expect(page.getByTestId('selected')).toHaveText('2026-03-01'); + }); + + test('keyboard navigation with arrow keys', async ({ mount, page }) => { + await mount(); + + await page + .getByRole('button', { name: /Sunday, February 15, 2026/ }) + .click(); + + await page.keyboard.press('ArrowRight'); + const feb16 = page.getByRole('button', { + name: /Monday, February 16, 2026/, + }); + await expect(feb16).toBeFocused(); + + await page.keyboard.press('ArrowDown'); + const feb23 = page.getByRole('button', { + name: /Monday, February 23, 2026/, + }); + await expect(feb23).toBeFocused(); + + await page.keyboard.press('Enter'); + await expect(page.getByTestId('selected')).toHaveText('2026-02-23'); + }); + + test('renders full-featured calendar with auto-rendered header inputs', async ({ + mount, + page, + }) => { + await mount(); + + // Auto-rendered header shows Start date / End date inputs + await expect(page.getByLabel('Start date')).toBeVisible(); + await expect(page.getByLabel('End date')).toBeVisible(); + + await expect(page.getByText('February 2026')).toBeVisible(); + + // Controls section + await expect(page.getByTestId('end-date-toggle')).toBeVisible(); + + const applyBtn = page.getByTestId('apply-btn'); + await expect(applyBtn).toBeVisible(); + + // Select a range + await page + .getByRole('button', { name: /Wednesday, February 11, 2026/ }) + .click(); + await page + .getByRole('button', { name: /Sunday, February 15, 2026/ }) + .click(); + + // Auto-rendered inputs should reflect values + await expect(page.getByLabel('Start date')).toHaveValue('02/11/2026'); + await expect(page.getByLabel('End date')).toHaveValue('02/15/2026'); + + await applyBtn.click(); + await expect(page.getByTestId('applied')).toHaveText('yes'); + }); + + test('time input is visible when includeTime is true', async ({ + mount, + page, + }) => { + await mount(); + + const dateInput = page.getByRole('textbox', { name: 'Date' }); + const timeInput = page.getByRole('textbox', { name: 'Time' }); + + await expect(dateInput).toBeVisible(); + await expect(timeInput).toBeVisible(); + + await expect(timeInput).toHaveValue('2:30 PM'); + }); + + test('changing time input updates the value hours/minutes', async ({ + mount, + page, + }) => { + await mount(); + + const timeInput = page.getByRole('textbox', { name: 'Time' }); + await timeInput.fill('9:15 AM'); + await timeInput.blur(); + + await expect(page.getByTestId('selected-hours')).toHaveText('9'); + await expect(page.getByTestId('selected-minutes')).toHaveText('15'); + }); + + test('selecting a new date preserves existing time', async ({ + mount, + page, + }) => { + await mount(); + + // Initial: Feb 11 at 14:30 + await expect(page.getByTestId('selected-hours')).toHaveText('14'); + await expect(page.getByTestId('selected-minutes')).toHaveText('30'); + + // Click a different day + await page + .getByRole('button', { name: /Friday, February 20, 2026/ }) + .click(); + + // Time should be preserved + await expect(page.getByTestId('selected-hours')).toHaveText('14'); + await expect(page.getByTestId('selected-minutes')).toHaveText('30'); + }); + + test('switching modes clears pending range state', async ({ + mount, + page, + }) => { + await mount(); + + // Start a range: click first day (pendingStart is set) + await page + .getByRole('button', { name: /Wednesday, February 11, 2026/ }) + .click(); + + // Switch to single, then back to range + await page.getByTestId('toggle-mode').click(); + await page.getByTestId('toggle-mode').click(); + await expect(page.getByTestId('mode')).toHaveText('range'); + + // Now click two fresh dates for a clean range + await page + .getByRole('button', { name: /Friday, February 20, 2026/ }) + .click(); + await page + .getByRole('button', { name: /Monday, February 23, 2026/ }) + .click(); + + await expect(page.getByTestId('selected')).toHaveText( + '2026-02-20|2026-02-23', + ); + }); + + test('reverse range reorders start and end', async ({ mount, page }) => { + await mount(); + + // Click later date first, then earlier date + await page + .getByRole('button', { name: /Sunday, February 22, 2026/ }) + .click(); + await page + .getByRole('button', { name: /Wednesday, February 11, 2026/ }) + .click(); + + await expect(page.getByTestId('range-start')).toHaveText('2026-02-11'); + await expect(page.getByTestId('range-end')).toHaveText('2026-02-22'); + }); + + test('same-day range (click same date twice)', async ({ mount, page }) => { + await mount(); + + await page + .getByRole('button', { name: /Sunday, February 15, 2026/ }) + .click(); + await page + .getByRole('button', { name: /Sunday, February 15, 2026/ }) + .click(); + + await expect(page.getByTestId('range-start')).toHaveText('2026-02-15'); + await expect(page.getByTestId('range-end')).toHaveText('2026-02-15'); + }); + + test('typing a date in the input updates calendar view and selection', async ({ + mount, + page, + }) => { + await mount(); + + const dateInput = page.getByRole('textbox', { name: 'Date' }); + await expect(dateInput).toHaveValue('02/11/2026'); + + // Type a date in a different month + await dateInput.fill('06/20/2026'); + await dateInput.blur(); + + await expect(page.getByTestId('selected')).toHaveText('2026-06-20'); + await expect(page.getByText('June 2026')).toBeVisible(); + }); + + test('invalid date input reverts to previous value', async ({ + mount, + page, + }) => { + await mount(); + + const dateInput = page.getByRole('textbox', { name: 'Date' }); + await expect(dateInput).toHaveValue('02/11/2026'); + + await dateInput.fill('99/99/9999'); + await dateInput.blur(); + + // Should revert to the previous valid value + await expect(dateInput).toHaveValue('02/11/2026'); + await expect(page.getByTestId('selected')).toHaveText('2026-02-11'); + }); + + test('range with time shows all four inputs', async ({ mount, page }) => { + await mount(); + + const startDate = page.getByRole('textbox', { name: 'Start date' }); + const startTime = page.getByRole('textbox', { name: 'Start time' }); + const endDate = page.getByRole('textbox', { name: 'End date' }); + const endTime = page.getByRole('textbox', { name: 'End time' }); + + await expect(startDate).toBeVisible(); + await expect(startTime).toBeVisible(); + await expect(endDate).toBeVisible(); + await expect(endTime).toBeVisible(); + + await expect(startTime).toHaveValue('9:00 AM'); + await expect(endTime).toHaveValue('5:30 PM'); + }); + + test('changing end time in range mode updates correctly', async ({ + mount, + page, + }) => { + await mount(); + + const endTime = page.getByRole('textbox', { name: 'End time' }); + await endTime.fill('11:45 PM'); + await endTime.blur(); + + await expect(page.getByTestId('end-hours')).toHaveText('23'); + await expect(page.getByTestId('end-minutes')).toHaveText('45'); + }); + + test('navigates from December to January across year boundary', async ({ + mount, + page, + }) => { + await mount(); + + await expect(page.getByText('December 2026')).toBeVisible(); + + await page.getByRole('button', { name: 'Next month' }).click(); + await expect(page.getByText('January 2027')).toBeVisible(); + + await page.getByRole('button', { name: 'Previous month' }).click(); + await expect(page.getByText('December 2026')).toBeVisible(); + }); + + test('Feb 29 is selectable in a leap year', async ({ mount, page }) => { + await mount(); + + await expect(page.getByText('February 2028')).toBeVisible(); + + const feb29 = page.getByRole('button', { + name: /Tuesday, February 29, 2028/, + }); + await expect(feb29).toBeVisible(); + await feb29.click(); + await expect(page.getByTestId('selected')).toHaveText('2028-02-29'); + }); + + test('weekStartsOn=1 renders Monday as first column', async ({ + mount, + page, + }) => { + await mount(); + + const headers = page.getByRole('columnheader'); + const first = headers.first(); + await expect(first).toHaveAttribute('abbr', 'Monday'); + }); + + test('min equals max allows only one selectable day', async ({ + mount, + page, + }) => { + await mount(); + + const feb14 = page.getByRole('button', { + name: /Saturday, February 14, 2026/, + }); + await expect(feb14).toHaveAttribute('data-disabled'); + + const feb16 = page.getByRole('button', { + name: /Monday, February 16, 2026/, + }); + await expect(feb16).toHaveAttribute('data-disabled'); + + const feb15 = page.getByRole('button', { + name: /Sunday, February 15, 2026/, + }); + await feb15.click(); + await expect(page.getByTestId('selected')).toHaveText('2026-02-15'); + }); + + test('Enter key does not select a disabled date', async ({ + mount, + page, + }) => { + await mount(); + + // Focus a weekday first + await page + .getByRole('button', { name: /Monday, February 2, 2026/ }) + .click(); + + // Arrow left to Sunday (disabled) + await page.keyboard.press('ArrowLeft'); + const sunday = page.getByRole('button', { + name: /Sunday, February 1, 2026/, + }); + await expect(sunday).toBeFocused(); + + await page.keyboard.press('Enter'); + await expect(page.getByTestId('selected')).toHaveText('2026-02-02'); + }); + + test('time input parses 24-hour format', async ({ mount, page }) => { + await mount(); + + const timeInput = page.getByRole('textbox', { name: 'Time' }); + await timeInput.fill('23:45'); + await timeInput.blur(); + + await expect(page.getByTestId('selected-hours')).toHaveText('23'); + await expect(page.getByTestId('selected-minutes')).toHaveText('45'); + }); + + test('time input parses shorthand meridiem', async ({ mount, page }) => { + await mount(); + + const timeInput = page.getByRole('textbox', { name: 'Time' }); + await timeInput.fill('3:00p'); + await timeInput.blur(); + + await expect(page.getByTestId('selected-hours')).toHaveText('15'); + await expect(page.getByTestId('selected-minutes')).toHaveText('0'); + }); + + test('locale=de-DE renders German month and weekday names', async ({ + mount, + page, + }) => { + await mount(); + + await expect(page.getByText('Februar 2026')).toBeVisible(); + + const headers = page.getByRole('columnheader'); + const first = headers.first(); + await expect(first).toHaveAttribute('abbr', 'Sonntag'); + }); + + test('locale=de-DE date input uses DD.MM.YYYY format', async ({ + mount, + page, + }) => { + await mount(); + + const dateInput = page.getByRole('textbox', { name: 'Datum' }); + await expect(dateInput).toBeVisible(); + + // Type a German-format date + await dateInput.fill('20.06.2026'); + await dateInput.blur(); + + await expect(page.getByTestId('selected')).toHaveText('2026-06-20'); + }); + + test('locale=ja-JP renders Japanese month names', async ({ + mount, + page, + }) => { + await mount(); + + await expect(page.getByText('2026年2月')).toBeVisible(); + }); + + test('controlled month prop drives the visible month', async ({ + mount, + page, + }) => { + await mount(); + + await expect(page.getByText('February 2026')).toBeVisible(); + + await page.getByTestId('jump-to-june').click(); + await expect(page.getByText('June 2026')).toBeVisible(); + await expect(page.getByTestId('view-month')).toHaveText('5'); + }); + + test('controlled month updates via navigation buttons', async ({ + mount, + page, + }) => { + await mount(); + + await page.getByRole('button', { name: 'Next month' }).click(); + await expect(page.getByText('March 2026')).toBeVisible(); + await expect(page.getByTestId('view-month')).toHaveText('2'); + }); + + test('onMonthChange fires when navigating', async ({ mount, page }) => { + await mount(); + + const log = page.getByTestId('month-log'); + await expect(log).toHaveText(''); + + await page.getByRole('button', { name: 'Next month' }).click(); + await expect(log).toHaveText('2026-2'); + + await page.getByRole('button', { name: 'Previous month' }).click(); + await expect(log).toHaveText('2026-2,2026-1'); + }); + + test('custom labels override navigation aria-labels', async ({ + mount, + page, + }) => { + await mount(); + + await expect( + page.getByRole('button', { name: 'Vorheriger Monat' }), + ).toBeVisible(); + await expect( + page.getByRole('button', { name: 'Nächster Monat' }), + ).toBeVisible(); + }); + + test('renderDay customizes day cell content', async ({ mount, page }) => { + await mount(); + + await expect(page.getByTestId('dot-5')).toBeVisible(); + await expect(page.getByTestId('dot-14')).toBeVisible(); + await expect(page.getByTestId('dot-20')).toBeVisible(); + + // Clicking a rendered day still selects it + await page + .getByRole('button', { name: /Thursday, February 5, 2026/ }) + .click(); + await expect(page.getByTestId('selected')).toHaveText('2026-02-05'); + }); + + test('locale=de-DE with time shows 24-hour format', async ({ + mount, + page, + }) => { + await mount(); + + const timeInput = page.getByRole('textbox', { name: 'Uhrzeit' }); + await expect(timeInput).toBeVisible(); + + const timeValue = await timeInput.inputValue(); + expect(timeValue).toContain('14:30'); + }); +}); diff --git a/src/components/Calendar/index.ts b/src/components/Calendar/index.ts new file mode 100644 index 0000000..d1b158f --- /dev/null +++ b/src/components/Calendar/index.ts @@ -0,0 +1,19 @@ +export { + Root, + Header, + Navigation, + Grid, + Controls, + ControlItem, + Footer, + type CalendarRootProps, + type CalendarHeaderProps, + type CalendarNavigationProps, + type CalendarGridProps, + type CalendarControlsProps, + type CalendarControlItemProps, + type CalendarFooterProps, + type CalendarLabels, + type DayCellState, + type DateRange, +} from './parts'; diff --git a/src/components/Calendar/parts.tsx b/src/components/Calendar/parts.tsx new file mode 100644 index 0000000..118427c --- /dev/null +++ b/src/components/Calendar/parts.tsx @@ -0,0 +1,1219 @@ +'use client'; + +import * as React from 'react'; +import clsx from 'clsx'; +import { CentralIcon } from '../Icon'; +import { Input } from '../Input'; +import { Fieldset } from '../Fieldset'; +import { useTrackedCallback } from '../Analytics/useTrackedCallback'; +import styles from './Calendar.module.scss'; + +function startOfDay(date: Date): Date { + const d = new Date(date); + d.setHours(0, 0, 0, 0); + return d; +} + +function isSameDay(a: Date, b: Date): boolean { + return ( + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate() + ); +} + +function isSameMonth(date: Date, year: number, month: number): boolean { + return date.getFullYear() === year && date.getMonth() === month; +} + +function isDateInRange(date: Date, start: Date, end: Date): boolean { + const t = startOfDay(date).getTime(); + return t > startOfDay(start).getTime() && t < startOfDay(end).getTime(); +} + +function isDateBefore(a: Date, b: Date): boolean { + return startOfDay(a).getTime() < startOfDay(b).getTime(); +} + +function addDays(date: Date, days: number): Date { + const d = new Date(date); + d.setDate(d.getDate() + days); + return d; +} + +function addMonths(date: Date, months: number): Date { + const d = new Date(date); + const targetMonth = d.getMonth() + months; + d.setMonth(targetMonth); + // Clamp day if overflowed (e.g. Jan 31 + 1 month → Mar 3 → clamp to Feb 28) + if (d.getMonth() !== ((targetMonth % 12) + 12) % 12) { + d.setDate(0); // last day of previous month + } + return d; +} + +function getMonthGrid(year: number, month: number, weekStartsOn: 0 | 1): Date[][] { + const firstDay = new Date(year, month, 1); + const offset = (firstDay.getDay() - weekStartsOn + 7) % 7; + const gridStart = addDays(firstDay, -offset); + + const weeks: Date[][] = []; + let current = new Date(gridStart); + for (let w = 0; w < 6; w++) { + const week: Date[] = []; + for (let d = 0; d < 7; d++) { + week.push(new Date(current)); + current = addDays(current, 1); + } + weeks.push(week); + } + return weeks; +} + +const KNOWN_SUNDAY = new Date(2024, 0, 7); // Jan 7 2024 is a Sunday +const DAY_MS = 86_400_000; + +const weekdayCache = new Map(); + +function getWeekdayLabels(locale: string): { narrow: string; long: string }[] { + const cached = weekdayCache.get(locale); + if (cached) return cached; + + const longFmt = new Intl.DateTimeFormat(locale, { weekday: 'long' }); + const narrowFmt = new Intl.DateTimeFormat(locale, { weekday: 'narrow' }); + const result = Array.from({ length: 7 }, (_, i) => { + const d = new Date(KNOWN_SUNDAY.getTime() + i * DAY_MS); + return { narrow: narrowFmt.format(d), long: longFmt.format(d) }; + }); + weekdayCache.set(locale, result); + return result; +} + +interface DateFormatInfo { + order: ('day' | 'month' | 'year')[]; + separator: string; + placeholder: string; +} + +const dateFormatCache = new Map(); + +function getDateFormat(locale: string): DateFormatInfo { + const cached = dateFormatCache.get(locale); + if (cached) return cached; + + const parts = new Intl.DateTimeFormat(locale, { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }).formatToParts(new Date(2024, 11, 25)); + + const order = parts + .filter( + (p): p is Intl.DateTimeFormatPart & { + type: 'day' | 'month' | 'year'; + } => p.type === 'day' || p.type === 'month' || p.type === 'year', + ) + .map((p) => p.type); + + const literal = parts.find((p) => p.type === 'literal'); + const separator = literal?.value ?? '/'; + const labels: Record = { day: 'DD', month: 'MM', year: 'YYYY' }; + const placeholder = order.map((p) => labels[p]).join(separator); + + const result: DateFormatInfo = { order, separator, placeholder }; + dateFormatCache.set(locale, result); + return result; +} + +function getTimePlaceholder(locale: string): string { + const resolved = new Intl.DateTimeFormat(locale, { + hour: 'numeric', + }).resolvedOptions(); + return resolved.hourCycle === 'h12' || resolved.hourCycle === 'h11' + ? '12:00 PM' + : '00:00'; +} + +export interface DateRange { + start: Date; + end: Date; +} + +export interface CalendarLabels { + previousMonth: string; + nextMonth: string; + date: string; + startDate: string; + endDate: string; + time: string; + startTime: string; + endTime: string; + dateRange: string; + dateAndTime: string; + startDateAndTime: string; + endDateAndTime: string; +} + +const DEFAULT_LABELS: CalendarLabels = { + previousMonth: 'Previous month', + nextMonth: 'Next month', + date: 'Date', + startDate: 'Start date', + endDate: 'End date', + time: 'Time', + startTime: 'Start time', + endTime: 'End time', + dateRange: 'Date range', + dateAndTime: 'Date and time', + startDateAndTime: 'Start date and time', + endDateAndTime: 'End date and time', +}; + +export interface DayCellState { + isToday: boolean; + isSelected: boolean; + isDisabled: boolean; + isOutsideMonth: boolean; + isRangeStart: boolean; + isRangeEnd: boolean; + isInRange: boolean; +} + +export interface CalendarRootProps extends React.ComponentPropsWithoutRef<'div'> { + /** Selection mode. */ + mode?: 'single' | 'range'; + /** Whether time inputs are shown in the header. */ + includeTime?: boolean; + /** Selected date (single) or range (range mode). */ + value?: Date | DateRange | null; + /** Called when selection changes. Receives Date in single mode, DateRange in range mode. */ + onValueChange?: (value: Date | DateRange) => void; + /** Controlled visible month. When provided, the consumer drives navigation. */ + month?: Date; + /** Initial month to display (uncontrolled). Defaults to selected date or current month. */ + defaultMonth?: Date; + /** Called when the visible month changes. */ + onMonthChange?: (month: Date) => void; + /** Earliest selectable date. */ + min?: Date; + /** Latest selectable date. */ + max?: Date; + /** Custom disable function. */ + disabled?: (date: Date) => boolean; + /** BCP 47 locale tag (e.g. "en-US", "de-DE", "ja-JP"). Defaults to "en-US". */ + locale?: string; + /** First day of week: 0 = Sunday, 1 = Monday. */ + weekStartsOn?: 0 | 1; + /** Override accessibility labels for navigation and inputs. */ + labels?: Partial; + /** Analytics tracking name. */ + analyticsName?: string; +} + +interface CalendarContextValue { + viewYear: number; + viewMonth: number; + goToPreviousMonth: () => void; + goToNextMonth: () => void; + + mode: 'single' | 'range'; + includeTime: boolean; + singleValue: Date | null; + rangeValue: DateRange | null; + pendingStart: Date | null; + hoveredDate: Date | null; + + focusedDate: Date; + setFocusedDate: (date: Date) => void; + + selectDate: (date: Date) => void; + setHoveredDate: (date: Date | null) => void; + // In single mode, `which` is always 'start'. + setDate: (which: 'start' | 'end', date: Date) => void; + setTime: (which: 'start' | 'end', hours: number, minutes: number) => void; + isDateDisabled: (date: Date) => boolean; + + locale: string; + weekStartsOn: 0 | 1; + labels: CalendarLabels; +} + +const CalendarContext = React.createContext( + undefined, +); + +function useCalendarContext() { + const context = React.useContext(CalendarContext); + if (context === undefined) { + throw new Error('Calendar parts must be placed within .'); + } + return context; +} + +export const Root = React.forwardRef( + function CalendarRoot(props, forwardedRef) { + const { + className, + children, + mode: modeProp, + includeTime: includeTimeProp, + value: valueProp, + onValueChange, + month: monthProp, + defaultMonth, + onMonthChange, + min, + max, + disabled, + locale = 'en-US', + weekStartsOn = 0, + labels: labelsProp, + analyticsName, + ...elementProps + } = props; + + if (process.env.NODE_ENV !== 'production') { + if (monthProp !== undefined && !onMonthChange) { + // eslint-disable-next-line no-console + console.warn( + 'Calendar: `month` prop provided without `onMonthChange`. ' + + 'The calendar will navigate internally but the controlled prop will become stale.', + ); + } + } + + const mode = modeProp ?? 'single'; + const includeTime = includeTimeProp ?? false; + const labels = React.useMemo( + () => ({ ...DEFAULT_LABELS, ...labelsProp }), + [labelsProp], + ); + + const singleValue = + mode === 'single' && valueProp instanceof Date ? valueProp : null; + const rangeValue = + mode === 'range' && valueProp && !(valueProp instanceof Date) + ? (valueProp as DateRange) + : null; + + // View state + const initialMonth = React.useMemo(() => { + if (monthProp) return monthProp; + if (defaultMonth) return defaultMonth; + if (singleValue) return singleValue; + if (rangeValue) return rangeValue.start; + return new Date(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const [viewDate, setViewDate] = React.useState( + () => new Date(initialMonth.getFullYear(), initialMonth.getMonth(), 1), + ); + + // Controlled month: sync external prop to internal state + React.useEffect(() => { + if (monthProp !== undefined) { + setViewDate( + new Date(monthProp.getFullYear(), monthProp.getMonth(), 1), + ); + } + }, [monthProp]); + + // Fire onMonthChange when view changes + const onMonthChangeRef = React.useRef(onMonthChange); + React.useEffect(() => { + onMonthChangeRef.current = onMonthChange; + }); + const prevViewRef = React.useRef(viewDate); + React.useEffect(() => { + if (viewDate.getTime() !== prevViewRef.current.getTime()) { + onMonthChangeRef.current?.(viewDate); + prevViewRef.current = viewDate; + } + }, [viewDate]); + + // Focus state + const [focusedDate, setFocusedDateState] = React.useState(() => { + if (singleValue) return startOfDay(singleValue); + if (rangeValue) return startOfDay(rangeValue.start); + const today = startOfDay(new Date()); + if ( + today.getFullYear() === initialMonth.getFullYear() && + today.getMonth() === initialMonth.getMonth() + ) { + return today; + } + return new Date(initialMonth.getFullYear(), initialMonth.getMonth(), 1); + }); + + // Range selection state + const [pendingStart, setPendingStart] = React.useState(null); + const [hoveredDate, setHoveredDate] = React.useState(null); + + React.useEffect(() => { + setPendingStart(null); + setHoveredDate(null); + }, [mode]); + + const viewYear = viewDate.getFullYear(); + const viewMonth = viewDate.getMonth(); + + const setFocusedDate = React.useCallback((date: Date) => { + const normalized = startOfDay(date); + setFocusedDateState(normalized); + setViewDate( + new Date(normalized.getFullYear(), normalized.getMonth(), 1), + ); + }, []); + + const goToMonth = React.useCallback((offset: number) => { + setViewDate((prev) => { + const next = addMonths(prev, offset); + setFocusedDateState((prevFocused) => { + const lastDay = new Date( + next.getFullYear(), + next.getMonth() + 1, + 0, + ).getDate(); + return new Date( + next.getFullYear(), + next.getMonth(), + Math.min(prevFocused.getDate(), lastDay), + ); + }); + return next; + }); + }, []); + + const goToPreviousMonth = React.useCallback(() => goToMonth(-1), [goToMonth]); + const goToNextMonth = React.useCallback(() => goToMonth(1), [goToMonth]); + + const isDateDisabled = React.useCallback( + (date: Date): boolean => { + if (disabled?.(date)) return true; + if (min && isDateBefore(date, min)) return true; + if (max && isDateBefore(max, date)) return true; + return false; + }, + [disabled, min, max], + ); + + const trackedSelect = useTrackedCallback( + analyticsName, + 'Calendar', + 'change', + onValueChange, + (val: Date | DateRange) => ({ + value: val instanceof Date ? val.toISOString() : undefined, + start: + val instanceof Date ? undefined : (val as DateRange).start.toISOString(), + end: + val instanceof Date ? undefined : (val as DateRange).end.toISOString(), + mode, + }), + ); + + const selectDate = React.useCallback( + (date: Date) => { + if (isDateDisabled(date)) return; + + function applyTime(target: Date, source: Date | null): Date { + if (!includeTime) return startOfDay(target); + const d = new Date(target); + d.setHours(0, 0, 0, 0); + const ref = source ?? new Date(); + d.setHours(ref.getHours(), ref.getMinutes()); + return d; + } + + if (mode === 'single') { + trackedSelect(applyTime(date, singleValue)); + return; + } + + // Range mode + if (pendingStart === null) { + setPendingStart(applyTime(date, rangeValue?.start ?? null)); + } else { + const a = applyTime(pendingStart, rangeValue?.start ?? null); + const b = applyTime(date, rangeValue?.end ?? null); + const start = isDateBefore(b, a) ? b : a; + const end = isDateBefore(b, a) ? a : b; + trackedSelect({ start, end }); + setPendingStart(null); + setHoveredDate(null); + } + }, + [mode, includeTime, pendingStart, singleValue, rangeValue, isDateDisabled, trackedSelect], + ); + + const setDate = React.useCallback( + (which: 'start' | 'end', date: Date) => { + setViewDate(new Date(date.getFullYear(), date.getMonth(), 1)); + + if (mode === 'single') { + const d = new Date(date); + if (includeTime && singleValue) { + d.setHours(singleValue.getHours(), singleValue.getMinutes(), 0, 0); + } + trackedSelect(d); + } else { + const current = rangeValue ?? { start: date, end: date }; + const newRange = { start: new Date(current.start), end: new Date(current.end) }; + const d = new Date(date); + const existing = which === 'start' ? current.start : current.end; + if (includeTime) { + d.setHours(existing.getHours(), existing.getMinutes(), 0, 0); + } + if (which === 'start') newRange.start = d; + else newRange.end = d; + if (isDateBefore(newRange.end, newRange.start)) { + const tmp = newRange.start; + newRange.start = newRange.end; + newRange.end = tmp; + } + trackedSelect(newRange); + } + }, + [mode, includeTime, singleValue, rangeValue, trackedSelect], + ); + + const setTime = React.useCallback( + (which: 'start' | 'end', hours: number, minutes: number) => { + if (mode === 'single') { + const base = singleValue ? new Date(singleValue) : startOfDay(new Date()); + base.setHours(hours, minutes, 0, 0); + trackedSelect(base); + } else { + const today = startOfDay(new Date()); + const current = rangeValue ?? { start: new Date(today), end: new Date(today) }; + const newRange = { start: new Date(current.start), end: new Date(current.end) }; + const target = which === 'start' ? newRange.start : newRange.end; + target.setHours(hours, minutes, 0, 0); + trackedSelect(newRange); + } + }, + [mode, singleValue, rangeValue, trackedSelect], + ); + + const contextValue = React.useMemo( + () => ({ + viewYear, + viewMonth, + goToPreviousMonth, + goToNextMonth, + mode, + includeTime, + singleValue, + rangeValue, + pendingStart, + hoveredDate, + focusedDate, + setFocusedDate, + selectDate, + setHoveredDate, + setDate, + setTime, + isDateDisabled, + locale, + weekStartsOn, + labels, + }), + [ + viewYear, + viewMonth, + goToPreviousMonth, + goToNextMonth, + mode, + includeTime, + singleValue, + rangeValue, + pendingStart, + hoveredDate, + focusedDate, + setFocusedDate, + selectDate, + setHoveredDate, + setDate, + setTime, + isDateDisabled, + locale, + weekStartsOn, + labels, + ], + ); + + return ( + +
+ {children} +
+
+ ); + }, +); + +const FIELDSET_GAP = { '--fieldset-gap': 'var(--spacing-2xs)' } as React.CSSProperties; + +function formatDateValue(date: Date | null, locale: string): string { + const d = date ?? new Date(); + return d.toLocaleDateString(locale, { + month: '2-digit', + day: '2-digit', + year: 'numeric', + }); +} + +function parseDateString(input: string, locale: string): Date | null { + const s = input.trim().replace(/\.$/, ''); + if (!s) return null; + + const match = s.match(/^(\d{1,4})[/\-.\s]+(\d{1,4})[/\-.\s]+(\d{1,4})$/); + if (!match) return null; + + const { order } = getDateFormat(locale); + const raw = [ + parseInt(match[1], 10), + parseInt(match[2], 10), + parseInt(match[3], 10), + ]; + + const values: Record = {}; + for (let i = 0; i < 3; i++) { + values[order[i]] = raw[i]; + } + + const month = values.month; + const day = values.day; + const year = values.year; + + if (month < 1 || month > 12 || day < 1 || day > 31 || year < 100) { + return null; + } + + const date = new Date(year, month - 1, day); + if ( + date.getFullYear() !== year || + date.getMonth() !== month - 1 || + date.getDate() !== day + ) { + return null; + } + + return date; +} + +function DateInput({ + date, + label, + which, +}: { + date: Date | null; + label: string; + which: 'start' | 'end'; +}) { + const ctx = useCalendarContext(); + const formatted = formatDateValue(date, ctx.locale); + const [draft, setDraft] = React.useState(formatted); + const [hasFocus, setHasFocus] = React.useState(false); + + React.useEffect(() => { + if (!hasFocus) setDraft(formatted); + }, [formatted, hasFocus]); + + const { placeholder } = getDateFormat(ctx.locale); + + function commit() { + if (draft === formatted) return; + const parsed = parseDateString(draft, ctx.locale); + if (parsed && !ctx.isDateDisabled(parsed)) { + ctx.setDate(which, parsed); + } else { + setDraft(formatted); + } + } + + return ( + ) => { + setDraft(e.target.value); + }} + onFocus={() => setHasFocus(true)} + onBlur={() => { + setHasFocus(false); + commit(); + }} + onKeyDown={(e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + commit(); + (e.target as HTMLInputElement).blur(); + } + }} + /> + ); +} + +function formatTimeValue(date: Date | null, locale: string): string { + const d = date ?? new Date(); + return d.toLocaleTimeString(locale, { + hour: 'numeric', + minute: '2-digit', + }); +} + +function parseTimeString(input: string): { hours: number; minutes: number } | null { + const s = input.trim(); + if (!s) return null; + + const match = s.match( + /^(\d{1,2})[:.](\d{2})\s*(am|pm|a|p)?$/i, + ); + if (!match) return null; + + let h = parseInt(match[1], 10); + const m = parseInt(match[2], 10); + const meridiem = match[3]?.toLowerCase(); + + if (m < 0 || m > 59) return null; + + if (meridiem) { + if (h < 1 || h > 12) return null; + if (meridiem.startsWith('p') && h !== 12) h += 12; + if (meridiem.startsWith('a') && h === 12) h = 0; + } else { + if (h < 0 || h > 23) return null; + } + + return { hours: h, minutes: m }; +} + +function TimeInput({ + date, + label, + locale, + onTimeChange, +}: { + date: Date | null; + label: string; + locale: string; + onTimeChange: (hours: number, minutes: number) => void; +}) { + const formatted = formatTimeValue(date, locale); + const [draft, setDraft] = React.useState(formatted); + const [hasFocus, setHasFocus] = React.useState(false); + + React.useEffect(() => { + if (!hasFocus) setDraft(formatted); + }, [formatted, hasFocus]); + + const placeholder = getTimePlaceholder(locale); + + return ( + ) => { + setDraft(e.target.value); + }} + onFocus={() => setHasFocus(true)} + onBlur={() => { + setHasFocus(false); + const parsed = parseTimeString(draft); + if (parsed) { + onTimeChange(parsed.hours, parsed.minutes); + } else { + setDraft(formatted); + } + }} + onKeyDown={(e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + const parsed = parseTimeString(draft); + if (parsed) { + onTimeChange(parsed.hours, parsed.minutes); + (e.target as HTMLInputElement).blur(); + } + } + }} + /> + ); +} + +function DateTimeRow({ + date, + which, + locale, + onTimeChange, + dateLabel, + timeLabel, + legendLabel, +}: { + date: Date | null; + which: 'start' | 'end'; + locale: string; + onTimeChange: (hours: number, minutes: number) => void; + dateLabel: string; + timeLabel: string; + legendLabel: string; +}) { + return ( + + {legendLabel} + + + + ); +} + +function HeaderAutoLayout() { + const ctx = useCalendarContext(); + const l = ctx.labels; + + if (ctx.mode === 'single' && !ctx.includeTime) { + return ( + + ); + } + + if (ctx.mode === 'range' && !ctx.includeTime) { + return ( + + {l.dateRange} + + + + ); + } + + if (ctx.mode === 'single' && ctx.includeTime) { + return ( + ctx.setTime('start', h, m)} + dateLabel={l.date} + timeLabel={l.time} + legendLabel={l.dateAndTime} + /> + ); + } + + // range + includeTime + return ( + <> + ctx.setTime('start', h, m)} + dateLabel={l.startDate} + timeLabel={l.startTime} + legendLabel={l.startDateAndTime} + /> + ctx.setTime('end', h, m)} + dateLabel={l.endDate} + timeLabel={l.endTime} + legendLabel={l.endDateAndTime} + /> + + ); +} + +export interface CalendarHeaderProps + extends React.ComponentPropsWithoutRef<'div'> {} + +export const Header = React.forwardRef( + function CalendarHeader({ className, children, ...props }, forwardedRef) { + return ( +
+ {children ?? } +
+ ); + }, +); + +export interface CalendarNavigationProps + extends React.ComponentPropsWithoutRef<'div'> {} + +export const Navigation = React.forwardRef< + HTMLDivElement, + CalendarNavigationProps +>(function CalendarNavigation(props, forwardedRef) { + const { className, ...elementProps } = props; + const { viewYear, viewMonth, goToPreviousMonth, goToNextMonth, locale, labels } = + useCalendarContext(); + + const monthLabel = new Date(viewYear, viewMonth, 1).toLocaleDateString( + locale, + { month: 'long', year: 'numeric' }, + ); + + return ( +
+
+ {monthLabel} +
+
+ + +
+
+ ); +}); + +export interface CalendarControlsProps + extends React.ComponentPropsWithoutRef<'div'> {} + +export const Controls = React.forwardRef( + function CalendarControls({ className, children, ...props }, forwardedRef) { + return ( +
+ {children} +
+ ); + }, +); + +export interface CalendarControlItemProps + extends React.ComponentPropsWithoutRef<'div'> { + /** Text label for the control. */ + label: string; +} + +export const ControlItem = React.forwardRef< + HTMLDivElement, + CalendarControlItemProps +>(function CalendarControlItem( + { className, label, children, ...props }, + forwardedRef, +) { + return ( +
+ {label} + {children} +
+ ); +}); + +export interface CalendarFooterProps + extends React.ComponentPropsWithoutRef<'div'> {} + +export const Footer = React.forwardRef( + function CalendarFooter({ className, children, ...props }, forwardedRef) { + return ( +
+ {children} +
+ ); + }, +); + +export interface CalendarGridProps + extends React.ComponentPropsWithoutRef<'table'> { + /** Custom render function for day cell content. */ + renderDay?: (date: Date, state: DayCellState) => React.ReactNode; +} + +export const Grid = React.forwardRef( + function CalendarGrid(props, forwardedRef) { + const { className, renderDay, ...elementProps } = props; + const ctx = useCalendarContext(); + + const gridRef = React.useRef(null); + const mergedRef = React.useCallback( + (node: HTMLTableElement | null) => { + (gridRef as React.MutableRefObject).current = + node; + if (typeof forwardedRef === 'function') forwardedRef(node); + else if (forwardedRef) + ( + forwardedRef as React.MutableRefObject + ).current = node; + }, + [forwardedRef], + ); + + const weeks = React.useMemo( + () => getMonthGrid(ctx.viewYear, ctx.viewMonth, ctx.weekStartsOn), + [ctx.viewYear, ctx.viewMonth, ctx.weekStartsOn], + ); + + const today = React.useMemo(() => startOfDay(new Date()), []); + + const allWeekdays = React.useMemo( + () => getWeekdayLabels(ctx.locale), + [ctx.locale], + ); + + const weekdays = React.useMemo(() => { + const days = []; + for (let i = 0; i < 7; i++) { + days.push(allWeekdays[(ctx.weekStartsOn + i) % 7]); + } + return days; + }, [allWeekdays, ctx.weekStartsOn]); + + function getCellState(date: Date): DayCellState { + const isToday = isSameDay(date, today); + const isOutsideMonth = !isSameMonth( + date, + ctx.viewYear, + ctx.viewMonth, + ); + const isDisabled = ctx.isDateDisabled(date); + + let isSelected = false; + let isRangeStart = false; + let isRangeEnd = false; + let isInRange = false; + + if (ctx.mode === 'single' && ctx.singleValue) { + isSelected = isSameDay(date, ctx.singleValue); + } + + if (ctx.mode === 'range') { + if (ctx.pendingStart) { + if (ctx.hoveredDate) { + const pStart = isDateBefore(ctx.hoveredDate, ctx.pendingStart) + ? ctx.hoveredDate + : ctx.pendingStart; + const pEnd = isDateBefore(ctx.hoveredDate, ctx.pendingStart) + ? ctx.pendingStart + : ctx.hoveredDate; + isRangeStart = isSameDay(date, pStart); + isRangeEnd = isSameDay(date, pEnd); + isInRange = isDateInRange(date, pStart, pEnd); + } else { + isSelected = isSameDay(date, ctx.pendingStart); + } + } else if (ctx.rangeValue) { + isRangeStart = isSameDay(date, ctx.rangeValue.start); + isRangeEnd = isSameDay(date, ctx.rangeValue.end); + isInRange = isDateInRange( + date, + ctx.rangeValue.start, + ctx.rangeValue.end, + ); + } + } + + return { + isToday, + isOutsideMonth, + isDisabled, + isSelected, + isRangeStart, + isRangeEnd, + isInRange, + }; + } + + function handleKeyDown(event: React.KeyboardEvent) { + let nextDate: Date | null = null; + + switch (event.key) { + case 'ArrowRight': + nextDate = addDays(ctx.focusedDate, 1); + break; + case 'ArrowLeft': + nextDate = addDays(ctx.focusedDate, -1); + break; + case 'ArrowDown': + nextDate = addDays(ctx.focusedDate, 7); + break; + case 'ArrowUp': + nextDate = addDays(ctx.focusedDate, -7); + break; + case 'PageDown': + nextDate = event.shiftKey + ? addMonths(ctx.focusedDate, 12) + : addMonths(ctx.focusedDate, 1); + break; + case 'PageUp': + nextDate = event.shiftKey + ? addMonths(ctx.focusedDate, -12) + : addMonths(ctx.focusedDate, -1); + break; + case 'Home': { + const dayOfWeek = ctx.focusedDate.getDay(); + const diff = (dayOfWeek - ctx.weekStartsOn + 7) % 7; + nextDate = addDays(ctx.focusedDate, -diff); + break; + } + case 'End': { + const dayOfWeek = ctx.focusedDate.getDay(); + const diff = (6 - dayOfWeek + ctx.weekStartsOn + 7) % 7; + nextDate = addDays(ctx.focusedDate, diff); + break; + } + case 'Enter': + case ' ': + event.preventDefault(); + if (!ctx.isDateDisabled(ctx.focusedDate)) { + ctx.selectDate(ctx.focusedDate); + } + return; + default: + return; + } + + if (nextDate) { + event.preventDefault(); + ctx.setFocusedDate(nextDate); + } + } + + // Keep DOM focus in sync with focusedDate when keyboard-navigating + React.useEffect(() => { + const grid = gridRef.current; + if (!grid || !grid.contains(document.activeElement)) return; + + const focusTarget = grid.querySelector( + 'button[tabindex="0"]', + ) as HTMLButtonElement | null; + focusTarget?.focus(); + }, [ctx.focusedDate]); + + const gridLabel = new Date(ctx.viewYear, ctx.viewMonth, 1).toLocaleDateString( + ctx.locale, + { month: 'long', year: 'numeric' }, + ); + + return ( + { + if (ctx.mode === 'range' && ctx.pendingStart) { + ctx.setHoveredDate(null); + } + }} + {...elementProps} + > + + + {weekdays.map((day, i) => ( + + ))} + + + + {weeks.map((week, wi) => ( + + {week.map((date) => { + const s = getCellState(date); + const isFocused = isSameDay(date, ctx.focusedDate); + return ( + + ); + })} + + ))} + +
+ {day.narrow} +
+ +
+ ); + }, +); + +if (process.env.NODE_ENV !== 'production') { + Root.displayName = 'Calendar.Root'; + Header.displayName = 'Calendar.Header'; + Navigation.displayName = 'Calendar.Navigation'; + Grid.displayName = 'Calendar.Grid'; + Controls.displayName = 'Calendar.Controls'; + ControlItem.displayName = 'Calendar.ControlItem'; + Footer.displayName = 'Calendar.Footer'; +} diff --git a/src/components/Fieldset/Fieldset.module.scss b/src/components/Fieldset/Fieldset.module.scss index 2ae13e8..ad59642 100644 --- a/src/components/Fieldset/Fieldset.module.scss +++ b/src/components/Fieldset/Fieldset.module.scss @@ -22,13 +22,13 @@ .vertical { display: flex; flex-direction: column; - gap: var(--spacing-xl); + gap: var(--fieldset-gap, var(--spacing-xl)); } .horizontal { display: flex; flex-direction: row; - gap: var(--spacing-xs); + gap: var(--fieldset-gap, var(--spacing-xs)); > * { flex: 1 1 0; diff --git a/src/index.ts b/src/index.ts index 7370cbb..3e05fe4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,18 @@ export { Accordion } from './components/Accordion'; export { AlertDialog } from './components/AlertDialog'; export { Autocomplete } from './components/Autocomplete'; export { Breadcrumb } from './components/Breadcrumb'; +export * as Calendar from './components/Calendar'; +export type { + CalendarRootProps, + CalendarHeaderProps, + CalendarNavigationProps, + CalendarGridProps, + CalendarControlsProps, + CalendarControlItemProps, + CalendarFooterProps, + DateRange, +} from './components/Calendar'; + export { Card } from './components/Card'; export { Collapsible } from './components/Collapsible'; export type { From 07f978c5ae4666cbf2f80608e82cece619a55240 Mon Sep 17 00:00:00 2001 From: jaymantri Date: Sun, 1 Mar 2026 18:39:12 -0800 Subject: [PATCH 02/22] Fix Calendar edge cases, accessibility, and add comprehensive tests Fix TimeInput blur phantom value creation, range time swap on reverse click and text input paths, aria-selected for in-range dates, impure state updater in goToMonth, and nav button disabling at min/max bounds. Add today indicator style, single commit path for Enter key, and tests for keyboard navigation, hover preview, input validation, and time preservation across date reordering. Made-with: Cursor --- src/components/Calendar/Calendar.module.scss | 10 + .../Calendar/Calendar.test-stories.tsx | 20 ++ src/components/Calendar/Calendar.test.tsx | 233 ++++++++++++++++++ src/components/Calendar/parts.tsx | 91 ++++--- 4 files changed, 318 insertions(+), 36 deletions(-) diff --git a/src/components/Calendar/Calendar.module.scss b/src/components/Calendar/Calendar.module.scss index c637d4e..ef224f4 100644 --- a/src/components/Calendar/Calendar.module.scss +++ b/src/components/Calendar/Calendar.module.scss @@ -59,6 +59,12 @@ outline: 2px solid var(--border-focus); outline-offset: -2px; } + + &:disabled { + opacity: calc(var(--opacity-50) / 100); + cursor: default; + pointer-events: none; + } } // Grid (table) @@ -115,6 +121,10 @@ outline-offset: -2px; } + &[data-today]:not([data-selected]):not([data-range-start]):not([data-range-end]):not([data-in-range]) { + background: var(--surface-hover); + } + &[data-selected] { background: var(--surface-inverse); color: var(--text-inverse); diff --git a/src/components/Calendar/Calendar.test-stories.tsx b/src/components/Calendar/Calendar.test-stories.tsx index 9c3e1a8..e3fc5f1 100644 --- a/src/components/Calendar/Calendar.test-stories.tsx +++ b/src/components/Calendar/Calendar.test-stories.tsx @@ -520,6 +520,26 @@ export function TestRenderDay() { ); } +export function TestDateInputMinMax() { + const [value, setValue] = useState(new Date(2026, 1, 11)); + return ( + setValue(v as Date)} + defaultMonth={new Date(2026, 1, 1)} + min={new Date(2026, 1, 5)} + max={new Date(2026, 1, 25)} + > + + + +
+ {value ? value.toISOString().split('T')[0] : 'none'} +
+
+ ); +} + export function TestLocaleWithTime() { const [value, setValue] = useState( new Date(2026, 1, 11, 14, 30), diff --git a/src/components/Calendar/Calendar.test.tsx b/src/components/Calendar/Calendar.test.tsx index 7d33b3d..e7301f9 100644 --- a/src/components/Calendar/Calendar.test.tsx +++ b/src/components/Calendar/Calendar.test.tsx @@ -24,6 +24,7 @@ import { TestCustomLabels, TestRenderDay, TestLocaleWithTime, + TestDateInputMinMax, } from './Calendar.test-stories'; test.describe('Calendar', () => { @@ -522,6 +523,72 @@ test.describe('Calendar', () => { await expect(page.getByTestId('selected-minutes')).toHaveText('0'); }); + test('aria-selected is set on in-range dates', async ({ mount, page }) => { + await mount(); + + const midBtn = page.getByRole('button', { + name: /Thursday, February 12, 2026/, + }); + await expect(midBtn).toHaveAttribute('aria-selected', 'true'); + + const midBtn2 = page.getByRole('button', { + name: /Friday, February 13, 2026/, + }); + await expect(midBtn2).toHaveAttribute('aria-selected', 'true'); + }); + + test('nav buttons are disabled when adjacent month is out of min/max', async ({ + mount, + page, + }) => { + await mount(); + + const prevBtn = page.getByRole('button', { name: 'Previous month' }); + const nextBtn = page.getByRole('button', { name: 'Next month' }); + + await expect(prevBtn).toBeDisabled(); + await expect(nextBtn).toBeDisabled(); + }); + + test('reverse range with time preserves correct start/end times', async ({ + mount, + page, + }) => { + await mount(); + + // Existing: start=Feb 11 9:00 AM, end=Feb 15 5:30 PM + // Click later date first, then earlier date (reverse order) + await page + .getByRole('button', { name: /Friday, February 20, 2026/ }) + .click(); + await page + .getByRole('button', { name: /Thursday, February 12, 2026/ }) + .click(); + + // Verify dates via data attributes (timezone-safe) + const startBtn = page.getByRole('button', { name: /Thursday, February 12, 2026/ }); + const endBtn = page.getByRole('button', { name: /Friday, February 20, 2026/ }); + await expect(startBtn).toHaveAttribute('data-range-start'); + await expect(endBtn).toHaveAttribute('data-range-end'); + + // After reverse ordering, start keeps start time (9:00), end keeps end time (17:30) + await expect(page.getByTestId('start-hours')).toHaveText('9'); + await expect(page.getByTestId('start-minutes')).toHaveText('0'); + await expect(page.getByTestId('end-hours')).toHaveText('17'); + await expect(page.getByTestId('end-minutes')).toHaveText('30'); + }); + + test('time input commits on Enter via blur', async ({ mount, page }) => { + await mount(); + + const timeInput = page.getByRole('textbox', { name: 'Time' }); + await timeInput.fill('4:00 PM'); + await timeInput.press('Enter'); + + await expect(page.getByTestId('selected-hours')).toHaveText('16'); + await expect(page.getByTestId('selected-minutes')).toHaveText('0'); + }); + test('locale=de-DE renders German month and weekday names', async ({ mount, page, @@ -637,4 +704,170 @@ test.describe('Calendar', () => { const timeValue = await timeInput.inputValue(); expect(timeValue).toContain('14:30'); }); + + test('date input rejects out-of-range dates and reverts', async ({ + mount, + page, + }) => { + await mount(); + + const dateInput = page.getByRole('textbox', { name: 'Date' }); + await expect(dateInput).toHaveValue('02/11/2026'); + + // Type a date before min (Feb 5) + await dateInput.fill('02/01/2026'); + await dateInput.blur(); + + await expect(dateInput).toHaveValue('02/11/2026'); + await expect(page.getByTestId('selected')).toHaveText('2026-02-11'); + + // Type a date after max (Feb 25) + await dateInput.fill('03/15/2026'); + await dateInput.blur(); + + await expect(dateInput).toHaveValue('02/11/2026'); + await expect(page.getByTestId('selected')).toHaveText('2026-02-11'); + }); + + test('invalid time input reverts to previous value', async ({ + mount, + page, + }) => { + await mount(); + + const timeInput = page.getByRole('textbox', { name: 'Time' }); + await expect(timeInput).toHaveValue('2:30 PM'); + + await timeInput.fill('abc'); + await timeInput.blur(); + + await expect(timeInput).toHaveValue('2:30 PM'); + await expect(page.getByTestId('selected-hours')).toHaveText('14'); + await expect(page.getByTestId('selected-minutes')).toHaveText('30'); + }); + + test('keyboard PageDown/PageUp navigates months', async ({ + mount, + page, + }) => { + await mount(); + + await page + .getByRole('button', { name: /Sunday, February 15, 2026/ }) + .click(); + + await page.keyboard.press('PageDown'); + await expect(page.getByText('March 2026')).toBeVisible(); + await expect( + page.getByRole('button', { name: /Sunday, March 15, 2026/ }), + ).toHaveAttribute('tabindex', '0'); + + // Re-focus the grid to enable PageUp + await page + .getByRole('button', { name: /Sunday, March 15, 2026/ }) + .focus(); + + await page.keyboard.press('PageUp'); + await expect(page.getByText('February 2026')).toBeVisible(); + await expect( + page.getByRole('button', { name: /Sunday, February 15, 2026/ }), + ).toHaveAttribute('tabindex', '0'); + }); + + test('keyboard Home moves to start of week, End to end', async ({ + mount, + page, + }) => { + await mount(); + + // Feb 11 2026 is Wednesday (weekStartsOn=0, so week starts Sunday) + await page + .getByRole('button', { name: /Wednesday, February 11, 2026/ }) + .click(); + + await page.keyboard.press('Home'); + await expect( + page.getByRole('button', { name: /Sunday, February 8, 2026/ }), + ).toBeFocused(); + + await page.keyboard.press('End'); + await expect( + page.getByRole('button', { name: /Saturday, February 14, 2026/ }), + ).toBeFocused(); + }); + + test('keyboard Space selects focused date', async ({ mount, page }) => { + await mount(); + + await page + .getByRole('button', { name: /Sunday, February 15, 2026/ }) + .click(); + + await page.keyboard.press('ArrowRight'); + await expect( + page.getByRole('button', { name: /Monday, February 16, 2026/ }), + ).toBeFocused(); + + await page.keyboard.press(' '); + await expect(page.getByTestId('selected')).toHaveText('2026-02-16'); + }); + + test('range hover preview shows in-range highlight', async ({ + mount, + page, + }) => { + await mount(); + + // First click sets pendingStart + await page + .getByRole('button', { name: /Wednesday, February 11, 2026/ }) + .click(); + + // Hover over a later date + await page + .getByRole('button', { name: /Sunday, February 15, 2026/ }) + .hover(); + + // Mid-range day should show in-range preview + const midBtn = page.getByRole('button', { + name: /Thursday, February 12, 2026/, + }); + await expect(midBtn).toHaveAttribute('data-in-range'); + + // Start and end should show range markers + const startBtn = page.getByRole('button', { + name: /Wednesday, February 11, 2026/, + }); + const endBtn = page.getByRole('button', { + name: /Sunday, February 15, 2026/, + }); + await expect(startBtn).toHaveAttribute('data-range-start'); + await expect(endBtn).toHaveAttribute('data-range-end'); + }); + + test('typing start date past end date swaps with correct times', async ({ + mount, + page, + }) => { + await mount(); + + // Existing: start=Feb 11 9:00 AM, end=Feb 15 5:30 PM + const startDate = page.getByRole('textbox', { name: 'Start date' }); + + // Type a start date after the end date + await startDate.fill('02/20/2026'); + await startDate.blur(); + + // Dates should swap: Feb 15 becomes start, Feb 20 becomes end + const newStartBtn = page.getByRole('button', { name: /Sunday, February 15, 2026/ }); + const newEndBtn = page.getByRole('button', { name: /Friday, February 20, 2026/ }); + await expect(newStartBtn).toHaveAttribute('data-range-start'); + await expect(newEndBtn).toHaveAttribute('data-range-end'); + + // Times should stay in their roles: start keeps 9:00, end keeps 17:30 + await expect(page.getByTestId('start-hours')).toHaveText('9'); + await expect(page.getByTestId('start-minutes')).toHaveText('0'); + await expect(page.getByTestId('end-hours')).toHaveText('17'); + await expect(page.getByTestId('end-minutes')).toHaveText('30'); + }); }); diff --git a/src/components/Calendar/parts.tsx b/src/components/Calendar/parts.tsx index 118427c..b08a8f7 100644 --- a/src/components/Calendar/parts.tsx +++ b/src/components/Calendar/parts.tsx @@ -232,6 +232,8 @@ interface CalendarContextValue { setDate: (which: 'start' | 'end', date: Date) => void; setTime: (which: 'start' | 'end', hours: number, minutes: number) => void; isDateDisabled: (date: Date) => boolean; + min?: Date; + max?: Date; locale: string; weekStartsOn: 0 | 1; @@ -367,21 +369,19 @@ export const Root = React.forwardRef( }, []); const goToMonth = React.useCallback((offset: number) => { - setViewDate((prev) => { - const next = addMonths(prev, offset); - setFocusedDateState((prevFocused) => { - const lastDay = new Date( - next.getFullYear(), - next.getMonth() + 1, - 0, - ).getDate(); - return new Date( - next.getFullYear(), - next.getMonth(), - Math.min(prevFocused.getDate(), lastDay), - ); - }); - return next; + setViewDate((prev) => addMonths(prev, offset)); + setFocusedDateState((prev) => { + const target = addMonths(prev, offset); + const lastDay = new Date( + target.getFullYear(), + target.getMonth() + 1, + 0, + ).getDate(); + return new Date( + target.getFullYear(), + target.getMonth(), + Math.min(prev.getDate(), lastDay), + ); }); }, []); @@ -433,12 +433,13 @@ export const Root = React.forwardRef( // Range mode if (pendingStart === null) { - setPendingStart(applyTime(date, rangeValue?.start ?? null)); + setPendingStart(startOfDay(date)); } else { - const a = applyTime(pendingStart, rangeValue?.start ?? null); - const b = applyTime(date, rangeValue?.end ?? null); - const start = isDateBefore(b, a) ? b : a; - const end = isDateBefore(b, a) ? a : b; + const reversed = isDateBefore(date, pendingStart); + const startDate = reversed ? date : pendingStart; + const endDate = reversed ? pendingStart : date; + const start = applyTime(startDate, rangeValue?.start ?? null); + const end = applyTime(endDate, rangeValue?.end ?? null); trackedSelect({ start, end }); setPendingStart(null); setHoveredDate(null); @@ -467,11 +468,16 @@ export const Root = React.forwardRef( } if (which === 'start') newRange.start = d; else newRange.end = d; - if (isDateBefore(newRange.end, newRange.start)) { + const swapped = isDateBefore(newRange.end, newRange.start); + if (swapped) { const tmp = newRange.start; newRange.start = newRange.end; newRange.end = tmp; } + if (swapped && includeTime) { + newRange.start.setHours(current.start.getHours(), current.start.getMinutes(), 0, 0); + newRange.end.setHours(current.end.getHours(), current.end.getMinutes(), 0, 0); + } trackedSelect(newRange); } }, @@ -515,6 +521,8 @@ export const Root = React.forwardRef( setDate, setTime, isDateDisabled, + min, + max, locale, weekStartsOn, labels, @@ -537,6 +545,8 @@ export const Root = React.forwardRef( setDate, setTime, isDateDisabled, + min, + max, locale, weekStartsOn, labels, @@ -652,7 +662,6 @@ function DateInput({ }} onKeyDown={(e: React.KeyboardEvent) => { if (e.key === 'Enter') { - commit(); (e.target as HTMLInputElement).blur(); } }} @@ -715,6 +724,16 @@ function TimeInput({ const placeholder = getTimePlaceholder(locale); + function commit() { + if (draft === formatted) return; + const parsed = parseTimeString(draft); + if (parsed) { + onTimeChange(parsed.hours, parsed.minutes); + } else { + setDraft(formatted); + } + } + return ( setHasFocus(true)} onBlur={() => { setHasFocus(false); - const parsed = parseTimeString(draft); - if (parsed) { - onTimeChange(parsed.hours, parsed.minutes); - } else { - setDraft(formatted); - } + commit(); }} onKeyDown={(e: React.KeyboardEvent) => { if (e.key === 'Enter') { - const parsed = parseTimeString(draft); - if (parsed) { - onTimeChange(parsed.hours, parsed.minutes); - (e.target as HTMLInputElement).blur(); - } + (e.target as HTMLInputElement).blur(); } }} /> @@ -856,14 +866,21 @@ export const Navigation = React.forwardRef< CalendarNavigationProps >(function CalendarNavigation(props, forwardedRef) { const { className, ...elementProps } = props; - const { viewYear, viewMonth, goToPreviousMonth, goToNextMonth, locale, labels } = - useCalendarContext(); + const ctx = useCalendarContext(); + const { viewYear, viewMonth, goToPreviousMonth, goToNextMonth, locale, labels } = ctx; const monthLabel = new Date(viewYear, viewMonth, 1).toLocaleDateString( locale, { month: 'long', year: 'numeric' }, ); + const isPrevDisabled = ctx.min + ? isDateBefore(new Date(viewYear, viewMonth, 0), ctx.min) + : false; + const isNextDisabled = ctx.max + ? isDateBefore(ctx.max, new Date(viewYear, viewMonth + 1, 1)) + : false; + return (
@@ -887,6 +905,7 @@ export const Navigation = React.forwardRef< className={styles.navButton} onClick={goToNextMonth} aria-label={labels.nextMonth} + disabled={isNextDisabled} > @@ -1174,7 +1193,7 @@ export const Grid = React.forwardRef( data-outside-month={s.isOutsideMonth || undefined} data-disabled={s.isDisabled || undefined} aria-selected={ - s.isSelected || s.isRangeStart || s.isRangeEnd || undefined + s.isSelected || s.isRangeStart || s.isRangeEnd || s.isInRange || undefined } aria-disabled={s.isDisabled || undefined} aria-label={date.toLocaleDateString(ctx.locale, { From 664c60a6dbc080f3bed442e96bee64aa16bd4441 Mon Sep 17 00:00:00 2001 From: jaymantri Date: Mon, 2 Mar 2026 21:55:15 -0800 Subject: [PATCH 03/22] Overhaul chart library: add new charts, keyboard a11y, and polish Add Scatter, Split, BarList, Funnel, Waterfall, and Sankey chart components. Remove LiveDot, LiveValue, and ActivityGrid. Consolidate Ranking into BarList. Keyboard accessibility: Enter/Space activation across all charts via useChartScrub onActivate callback, memoized handlers, tooltip positioning on arrow-key navigation, and focus-visible rings on all interactive chart elements. Fix broken shimmer animation on skeleton loading states (gradient instead of flat background-color). Rename pie-fade-in keyframe to chart-fade-in. Gate ariaLiveContent on showTooltip in StackedArea and Composed charts. Replace Split chart pop-in tooltip with legend swap on hover for zero layout shift. Add 8 Playwright keyboard interaction tests and a Bar chart test story. Fix pre-existing test locator issues (strict mode, color-contrast). Made-with: Cursor --- .cursor/rules/charts.mdc | 41 +- src/app/page.tsx | 239 +++++--- src/components/Chart/ActivityGrid.tsx | 182 ------ src/components/Chart/BarChart.tsx | 247 +++++++-- src/components/Chart/BarList.tsx | 66 ++- src/components/Chart/Chart.module.scss | 370 +++++++++---- src/components/Chart/Chart.stories.tsx | 235 ++++++-- src/components/Chart/Chart.test-stories.tsx | 163 ++++++ src/components/Chart/Chart.test.tsx | 335 +++++++++++ src/components/Chart/Chart.unit.test.ts | 101 ++++ src/components/Chart/ChartWrapper.tsx | 2 - src/components/Chart/ComposedChart.tsx | 94 +++- src/components/Chart/FunnelChart.tsx | 425 ++++++++++++++ src/components/Chart/LineChart.tsx | 230 +++++++- src/components/Chart/LiveChart.tsx | 26 +- src/components/Chart/LiveDot.tsx | 50 -- src/components/Chart/LiveValue.tsx | 116 ---- src/components/Chart/SankeyChart.tsx | 581 ++++++++++++++++++++ src/components/Chart/ScatterChart.tsx | 570 +++++++++++++++++++ src/components/Chart/SplitChart.tsx | 185 +++++++ src/components/Chart/StackedAreaChart.tsx | 50 +- src/components/Chart/WaterfallChart.tsx | 459 ++++++++++++++++ src/components/Chart/hooks.ts | 42 +- src/components/Chart/index.ts | 23 +- src/components/Chart/sankeyLayout.ts | 285 ++++++++++ src/components/Chart/types.ts | 13 + src/components/Chart/utils.ts | 8 + src/index.ts | 7 +- 28 files changed, 4423 insertions(+), 722 deletions(-) delete mode 100644 src/components/Chart/ActivityGrid.tsx create mode 100644 src/components/Chart/FunnelChart.tsx delete mode 100644 src/components/Chart/LiveDot.tsx delete mode 100644 src/components/Chart/LiveValue.tsx create mode 100644 src/components/Chart/SankeyChart.tsx create mode 100644 src/components/Chart/ScatterChart.tsx create mode 100644 src/components/Chart/SplitChart.tsx create mode 100644 src/components/Chart/WaterfallChart.tsx create mode 100644 src/components/Chart/sankeyLayout.ts diff --git a/.cursor/rules/charts.mdc b/.cursor/rules/charts.mdc index 5e1dcee..d634f96 100644 --- a/.cursor/rules/charts.mdc +++ b/.cursor/rules/charts.mdc @@ -14,15 +14,24 @@ globs: | `Chart.Line` | Line chart, area chart (via `fill` prop), sparkline-like | | `Chart.Sparkline` | Compact inline chart — no axes, no interaction | | `Chart.StackedArea` | Stacked cumulative area bands | -| `Chart.Bar` | Grouped or stacked bar chart | +| `Chart.Bar` | Grouped, stacked, or horizontal bar chart | | `Chart.Pie` | Donut chart with legend sidebar | | `Chart.Composed` | Mixed bar + line with dual Y-axes | +| `Chart.Gauge` | Arc gauge with thresholds and marker | +| `Chart.BarList` | Horizontal bars with labels — supports rank numbers, change indicators, secondary values | +| `Chart.Uptime` | Binary status timeline (up/down) | +| `Chart.Live` | Canvas streaming chart with `requestAnimationFrame` | +| `Chart.Scatter` | XY scatter plot — multi-series, nearest-point tooltip | +| `Chart.Split` | Segmented distribution bar — parts of a whole | +| `Chart.Sankey` | Flow diagram — multi-path node/link with contextual hover filtering | +| `Chart.Funnel` | Conversion funnel with tapered stages and drop-off rates | +| `Chart.Waterfall` | Waterfall chart — running total with increases, decreases, totals | ## Color Strategy ### Single series -Use `color` prop or `dataKey` — the component defaults to `var(--border-primary)`. +Use `color` prop or `dataKey` — the component defaults to `var(--stroke-primary)`. ```tsx @@ -82,7 +91,7 @@ Pattern: `var(--color-{hue}-{stop})` When no `color` is set on a series, the component auto-assigns from `SERIES_COLORS`: -1. `var(--border-primary)` (near-black) +1. `var(--stroke-primary)` (near-black) 2. `var(--text-secondary)` (gray) 3. `var(--surface-blue-strong)` 4. `var(--surface-purple-strong)` @@ -99,3 +108,29 @@ This palette is designed for distinct-hue multi-series. For stacked/grouped char - **Do not** hardcode hex colors — use tokens - **Do not** rely on the fallback palette for stacked charts — the distinct hues look incohesive when stacked - **Do not** use semantic surface tokens (`--surface-blue-strong`) when you need shade control — use the primitive scale instead + +## Sankey vs Funnel + +Use **Funnel** when the flow is strictly sequential — every user passes through every stage in order and you care about drop-off rates between stages. + +Use **Sankey** when the flow branches — users take different paths to different outcomes, and you need to show how volume splits and merges across multiple routes. + +| | Funnel | Sankey | +|---|---|---| +| Data shape | Linear sequence (A → B → C) | Directed graph (A → B, A → C, B → D) | +| Key metric | Conversion rate between stages | Volume per path | +| Best for | Single-path conversion funnels | Multi-path routing, budget allocation, attribution | + +## Interaction Contract + +Each chart type exposes a click handler matching its data model: + +| Component | Handler | +|---|---| +| `Chart.Line`, `Chart.Bar`, `Chart.Composed`, `Chart.StackedArea`, `Chart.Pie` | `onClickDatum(index: number, datum: Record) => void` | +| `Chart.Split` | `onClickDatum(segment: SplitSegment, index: number) => void` | +| `Chart.Scatter` | `onClickDatum(seriesKey: string, point: ScatterPoint, index: number) => void` | +| `Chart.BarList` | `onClickItem(item: BarListItem, index: number) => void` | +| `Chart.Funnel` | `onClickDatum(index: number, stage: FunnelStage) => void` | +| `Chart.Waterfall` | `onClickDatum(index: number, segment: WaterfallSegment) => void` | +| `Chart.Sankey` | `onClickNode(node: LayoutNode) => void` / `onClickLink(link: LayoutLink) => void` | diff --git a/src/app/page.tsx b/src/app/page.tsx index 4bedf1a..384c791 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1477,23 +1477,6 @@ function TableExamples() { ); } -function LiveValueDemo() { - const [value, setValue] = React.useState(12847); - React.useEffect(() => { - const interval = setInterval(() => { - setValue((v) => v + Math.floor(Math.random() * 5) + 1); - }, 800); - return () => clearInterval(interval); - }, []); - return ( - `$${Math.round(v).toLocaleString()}`} - style={{ fontSize: 32, fontWeight: 500 }} - /> - ); -} - function LiveDemo() { const [data, setData] = React.useState<{ time: number; value: number }[]>([]); const [value, setValue] = React.useState(100); @@ -2213,32 +2196,6 @@ export default function Home() {
-

Live Primitives

-
-
-

LiveValue

- -
-
-
-

active

- -
-
-

processing

- -
-
-

idle

- -
-
-

error

- -
-
-
-

Sparkline

@@ -2399,43 +2356,6 @@ export default function Home() {
-

Activity Grid

-
-
-

Weekly (with labels)

- `W${i + 1}`)} - showRowLabels - showColumnLabels - data={Array.from({ length: 20 }, (_, ci) => - ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map((day, di) => ({ - row: day, - col: `W${ci + 1}`, - value: ((ci * 7 + di * 3 + 5) % 10), - })), - ).flat()} - /> -
-
-

Hourly over 7 days

- `${i}h`)} - cellSize={10} - cellGap={1} - color="var(--color-green-500)" - data={Array.from({ length: 24 }, (_, ci) => - ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map((day, di) => ({ - row: day, - col: `${ci}h`, - value: ((ci * 3 + di * 7 + 2) % 19), - })), - ).flat()} - /> -
-
-

Gauge

@@ -2510,6 +2430,165 @@ export default function Home() { />
+ +

Scatter

+
+
+

Multi-series with grid

+ `${v}%`} + formatYLabel={(v) => `$${v}`} + /> +
+
+ +

Split (Distribution)

+
+
+

Shade ramp

+ `$${v.toLocaleString()}`} + showValues + /> +
+
+ +

BarList (ranked)

+
+
+

With rank, change indicators, and secondary values

+ `$${v.toLocaleString()}`} + formatSecondaryValue={(v) => `${v}%`} + showRank + /> +
+
+ +

Waterfall

+
+
+

Revenue breakdown

+ `$${v}`} + /> +
+
+ +

Funnel

+
+
+

Conversion pipeline

+ v.toLocaleString()} + /> +
+
+ +

Sankey

+
+
+

Budget allocation

+ `$${v}k`} + /> +
+

Checkbox Component

diff --git a/src/components/Chart/ActivityGrid.tsx b/src/components/Chart/ActivityGrid.tsx deleted file mode 100644 index 50d2f76..0000000 --- a/src/components/Chart/ActivityGrid.tsx +++ /dev/null @@ -1,182 +0,0 @@ -'use client'; - -import * as React from 'react'; -import clsx from 'clsx'; -import styles from './Chart.module.scss'; - -export interface ActivityCell { - /** Value determining the cell's color intensity. */ - value: number; - /** Row label (e.g., day name, hour). */ - row: string; - /** Column label (e.g., week number, date). */ - col: string; - /** Optional tooltip label. */ - label?: string; -} - -export interface ActivityGridProps extends React.ComponentPropsWithoutRef<'div'> { - /** Grid cells with value, row, and column identifiers. */ - data: ActivityCell[]; - /** Row labels in display order (e.g., ['Mon', 'Tue', ...] or ['00', '01', ...]). */ - rows: string[]; - /** Column labels in display order (e.g., week numbers, dates). */ - columns: string[]; - /** Cell size in px. */ - cellSize?: number; - /** Gap between cells in px. */ - cellGap?: number; - /** Color for the highest value. Shades are derived via opacity. */ - color?: string; - /** Show row labels on the left. */ - showRowLabels?: boolean; - /** Show column labels on top. */ - showColumnLabels?: boolean; - /** Accessible label. */ - ariaLabel?: string; - /** Called when a cell is hovered. */ - onHover?: (cell: ActivityCell | null) => void; - /** Called when a cell is clicked. */ - onClickCell?: (cell: ActivityCell) => void; -} - -export const ActivityGrid = React.forwardRef( - function ActivityGrid( - { - data, - rows, - columns, - cellSize = 12, - cellGap = 2, - color = 'var(--color-blue-500)', - showRowLabels = false, - showColumnLabels = false, - ariaLabel, - onHover, - onClickCell, - className, - ...props - }, - ref, - ) { - const [activeKey, setActiveKey] = React.useState(null); - - const cellMap = React.useMemo(() => { - const map = new Map(); - for (const cell of data) { - map.set(`${cell.row}:${cell.col}`, cell); - } - return map; - }, [data]); - - const maxValue = React.useMemo( - () => Math.max(...data.map((d) => d.value), 1), - [data], - ); - - const handleEnter = React.useCallback( - (cell: ActivityCell) => { - setActiveKey(`${cell.row}:${cell.col}`); - onHover?.(cell); - }, - [onHover], - ); - - const handleLeave = React.useCallback(() => { - setActiveKey(null); - onHover?.(null); - }, [onHover]); - - const colLabelStep = Math.max(1, Math.ceil(columns.length / 12)); - - return ( -
- {showColumnLabels && ( -
- {columns.map((col, ci) => ( - - {ci % colLabelStep === 0 ? col : ''} - - ))} -
- )} -
- {showRowLabels && ( -
- {rows.map((row) => ( - - {row} - - ))} -
- )} -
- {rows.map((row, ri) => - columns.map((col, ci) => { - const cell = cellMap.get(`${row}:${col}`); - const value = cell?.value ?? 0; - const intensity = value / maxValue; - const key = `${row}:${col}`; - const isActive = activeKey === null || activeKey === key; - - return ( -
0 ? color : 'var(--surface-secondary)', - opacity: value > 0 - ? (isActive ? 0.2 + intensity * 0.8 : 0.15) - : (isActive ? 1 : 0.5), - }} - title={cell?.label ?? `${row} ${col}: ${value}`} - onMouseEnter={cell ? () => handleEnter(cell) : undefined} - onMouseLeave={handleLeave} - onClick={cell && onClickCell ? () => onClickCell(cell) : undefined} - /> - ); - }), - )} -
-
-
- ); - }, -); - -if (process.env.NODE_ENV !== 'production') { - ActivityGrid.displayName = 'Chart.ActivityGrid'; -} diff --git a/src/components/Chart/BarChart.tsx b/src/components/Chart/BarChart.tsx index d581d0a..05f3067 100644 --- a/src/components/Chart/BarChart.tsx +++ b/src/components/Chart/BarChart.tsx @@ -9,11 +9,13 @@ import { type ResolvedSeries, type TooltipProp, type ReferenceLine, + type ReferenceBand, PAD_TOP, PAD_RIGHT, PAD_BOTTOM_AXIS, BAR_GROUP_GAP, BAR_ITEM_GAP, + TOOLTIP_GAP, resolveSeries, resolveTooltipMode, axisTickTarget, @@ -37,6 +39,8 @@ export interface BarChartProps extends React.ComponentPropsWithoutRef<'div'> { color?: string; /** Horizontal reference lines at specific y-values. */ referenceLines?: ReferenceLine[]; + /** Shaded bands spanning a value range. Rendered behind bars. */ + referenceBands?: ReferenceBand[]; ariaLabel?: string; onActiveChange?: ( index: number | null, @@ -77,6 +81,7 @@ export const Bar = React.forwardRef( stacked = false, color, referenceLines, + referenceBands, ariaLabel, onActiveChange, formatValue, @@ -121,8 +126,9 @@ export const Bar = React.forwardRef( const isHorizontal = orientation === 'horizontal'; const showCategoryAxis = Boolean(xKey); const showValueAxis = grid; + const barAnimClass = isHorizontal ? styles.barAnimateHorizontal : styles.barAnimate; - const padBottom = !isHorizontal && showCategoryAxis ? PAD_BOTTOM_AXIS : 0; + const padBottom = (isHorizontal ? showValueAxis : showCategoryAxis) ? PAD_BOTTOM_AXIS : 0; const plotHeight = Math.max(0, height - PAD_TOP - padBottom); // Value domain — split into raw max + tick generation so we can @@ -151,8 +157,14 @@ export const Bar = React.forwardRef( if (rl.value > max) max = rl.value; } } + if (referenceBands) { + for (const rb of referenceBands) { + const hi = Math.max(rb.from, rb.to); + if (hi > max) max = hi; + } + } return max === -Infinity ? 1 : max; - }, [data, series, stacked, referenceLines, yDomain]); + }, [data, series, stacked, referenceLines, referenceBands, yDomain]); // Vertical: compute ticks first, then measure labels for padLeft. // Horizontal: measure category labels for padLeft, then compute @@ -245,17 +257,24 @@ export const Bar = React.forwardRef( tip.style.transform = 'none'; } else { const absX = padLeft + (idx + 0.5) * slotSize; - const isLeftHalf = raw <= categoryLength / 2; + const totalW = padLeft + plotWidth + padRight; + tip.style.display = ''; + const tipW = tip.offsetWidth; + const fitsRight = absX + TOOLTIP_GAP + tipW <= totalW; + const fitsLeft = absX - TOOLTIP_GAP - tipW >= 0; + const preferRight = raw <= categoryLength / 2; tip.style.left = `${absX}px`; tip.style.top = `${PAD_TOP}px`; - tip.style.transform = isLeftHalf - ? 'translateX(12px)' - : 'translateX(calc(-100% - 12px))'; + if ((preferRight && fitsRight) || !fitsLeft) { + tip.style.transform = `translateX(${TOOLTIP_GAP}px)`; + } else { + tip.style.transform = `translateX(calc(-100% - ${TOOLTIP_GAP}px))`; + } } tip.style.display = ''; } }, - [data.length, categoryLength, padLeft, slotSize, isHorizontal, plotWidth], + [data.length, categoryLength, padLeft, padRight, slotSize, isHorizontal, plotWidth], ); const handleMouseLeave = React.useCallback(() => { @@ -289,6 +308,83 @@ export const Bar = React.forwardRef( return parts.join(', '); }, [activeIndex, data, series, xKey, fmtValue]); + const handleTouch = React.useCallback( + (e: React.TouchEvent) => { + if (!e.touches[0]) return; + const rect = e.currentTarget.getBoundingClientRect(); + const raw = isHorizontal + ? e.touches[0].clientY - rect.top - PAD_TOP + : e.touches[0].clientX - rect.left - padLeft; + const idx = Math.max(0, Math.min(data.length - 1, Math.floor(raw / slotSize))); + setActiveIndex(idx); + }, + [data.length, slotSize, padLeft, isHorizontal], + ); + + const handleKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + if (data.length === 0) return; + let next = activeIndex ?? -1; + switch (e.key) { + case 'ArrowRight': + case 'ArrowDown': + next = Math.min(data.length - 1, next + 1); + break; + case 'ArrowLeft': + case 'ArrowUp': + next = Math.max(0, next - 1); + break; + case 'Home': + next = 0; + break; + case 'End': + next = data.length - 1; + break; + case 'Enter': + case ' ': + if (onClickDatum && activeIndex !== null && activeIndex < data.length) { + e.preventDefault(); + onClickDatum(activeIndex, data[activeIndex]); + } + return; + case 'Escape': + handleMouseLeave(); + return; + default: + return; + } + e.preventDefault(); + setActiveIndex(next); + const tip = tooltipRef.current; + if (tip) { + if (isHorizontal) { + tip.style.top = `${PAD_TOP + (next + 0.5) * slotSize}px`; + tip.style.left = `${padLeft + plotWidth + 8}px`; + tip.style.transform = 'none'; + } else { + const absX = padLeft + (next + 0.5) * slotSize; + const totalW = padLeft + plotWidth + padRight; + tip.style.display = ''; + const tipW = tip.offsetWidth; + const fitsRight = absX + TOOLTIP_GAP + tipW <= totalW; + const fitsLeft = absX - TOOLTIP_GAP - tipW >= 0; + const preferRight = next < data.length / 2; + tip.style.left = `${absX}px`; + tip.style.top = `${PAD_TOP}px`; + if ((preferRight && fitsRight) || !fitsLeft) { + tip.style.transform = `translateX(${TOOLTIP_GAP}px)`; + } else { + tip.style.transform = `translateX(calc(-100% - ${TOOLTIP_GAP}px))`; + } + } + tip.style.display = ''; + } + }, + [activeIndex, data, slotSize, padLeft, padRight, plotWidth, isHorizontal, onClickDatum, handleMouseLeave], + ); + + const interactive = showTooltip || !!onClickDatum; + const handleClick = React.useCallback(() => { if (onClickDatum && activeIndex !== null && activeIndex < data.length) { onClickDatum(activeIndex, data[activeIndex]); @@ -303,7 +399,6 @@ export const Bar = React.forwardRef( height={height} legend={legend} series={series} - activeIndex={activeIndex} ariaLiveContent={showTooltip ? ariaLiveContent : undefined} >
( width={width} height={height} className={styles.svg} - tabIndex={0} - onMouseMove={handleMouseMove} - onMouseLeave={handleMouseLeave} - onTouchStart={(e) => { if (e.touches[0]) { const rect = e.currentTarget.getBoundingClientRect(); const raw = isHorizontal ? e.touches[0].clientY - rect.top - PAD_TOP : e.touches[0].clientX - rect.left - padLeft; const idx = Math.max(0, Math.min(data.length - 1, Math.floor(raw / slotSize))); setActiveIndex(idx); } }} - onTouchMove={(e) => { if (e.touches[0]) { const rect = e.currentTarget.getBoundingClientRect(); const raw = isHorizontal ? e.touches[0].clientY - rect.top - PAD_TOP : e.touches[0].clientX - rect.left - padLeft; const idx = Math.max(0, Math.min(data.length - 1, Math.floor(raw / slotSize))); setActiveIndex(idx); } }} - onTouchEnd={handleMouseLeave} - onTouchCancel={handleMouseLeave} - onKeyDown={(e) => { - if (data.length === 0) return; - let next = activeIndex ?? -1; - switch (e.key) { - case 'ArrowRight': case 'ArrowDown': next = Math.min(data.length - 1, next + 1); break; - case 'ArrowLeft': case 'ArrowUp': next = Math.max(0, next - 1); break; - case 'Home': next = 0; break; - case 'End': next = data.length - 1; break; - case 'Escape': handleMouseLeave(); return; - default: return; - } - e.preventDefault(); - setActiveIndex(next); - const tip = tooltipRef.current; - if (tip) { - if (isHorizontal) { - tip.style.top = `${PAD_TOP + (next + 0.5) * slotSize}px`; - tip.style.left = `${padLeft + plotWidth + 8}px`; - tip.style.transform = 'none'; - } else { - const absX = padLeft + (next + 0.5) * slotSize; - tip.style.left = `${absX}px`; - tip.style.top = `${PAD_TOP}px`; - tip.style.transform = next < data.length / 2 - ? 'translateX(12px)' - : 'translateX(calc(-100% - 12px))'; - } - tip.style.display = ''; - } - }} + tabIndex={interactive ? 0 : undefined} + onMouseMove={interactive ? handleMouseMove : undefined} + onMouseLeave={interactive ? handleMouseLeave : undefined} + onTouchStart={interactive ? handleTouch : undefined} + onTouchMove={interactive ? handleTouch : undefined} + onTouchEnd={interactive ? handleMouseLeave : undefined} + onTouchCancel={interactive ? handleMouseLeave : undefined} + onKeyDown={interactive ? handleKeyDown : undefined} onClick={onClickDatum ? handleClick : undefined} > {svgDesc && {svgDesc}} @@ -372,6 +438,51 @@ export const Bar = React.forwardRef( ), )} + {/* Reference bands */} + {referenceBands?.map((rb, i) => { + const bandColor = rb.color ?? 'var(--stroke-primary)'; + if (isHorizontal) { + const x1 = linearScale(rb.from, yMin, yMax, 0, plotWidth); + const x2 = linearScale(rb.to, yMin, yMax, 0, plotWidth); + const bx = Math.min(x1, x2); + const bw = Math.abs(x2 - x1); + return ( + + + {rb.label && ( + {rb.label} + )} + + ); + } + if (rb.axis === 'x') { + const bx1 = data.length > 0 ? rb.from * slotSize : 0; + const bx2 = data.length > 0 ? rb.to * slotSize : plotWidth; + const bx = Math.min(bx1, bx2); + const bw = Math.abs(bx2 - bx1); + return ( + + + {rb.label && ( + {rb.label} + )} + + ); + } + const y1 = linearScale(rb.from, yMin, yMax, plotHeight, 0); + const y2 = linearScale(rb.to, yMin, yMax, plotHeight, 0); + const by = Math.min(y1, y2); + const bh = Math.abs(y1 - y2); + return ( + + + {rb.label && ( + {rb.label} + )} + + ); + })} + {/* Reference lines */} {referenceLines?.map((rl, i) => { const rlColor = rl.color ?? 'var(--text-primary)'; @@ -438,14 +549,14 @@ export const Bar = React.forwardRef( const barX = linearScale(cum - v, yMin, yMax, 0, plotWidth); return ( + className={animate ? barAnimClass : undefined} style={animate ? { animationDelay: `${delay}ms` } : undefined} /> ); } const barH = ((v - yMin) / (yMax - yMin)) * plotHeight; const barY = linearScale(cum, yMin, yMax, plotHeight, 0); return ( + className={animate ? barAnimClass : undefined} style={animate ? { animationDelay: `${delay}ms` } : undefined} /> ); })} @@ -462,14 +573,14 @@ export const Bar = React.forwardRef( const barW = ((v - yMin) / (yMax - yMin)) * plotWidth; return ( + className={animate ? barAnimClass : undefined} style={animate ? { animationDelay: `${delay}ms` } : undefined} /> ); } const barH = ((v - yMin) / (yMax - yMin)) * plotHeight; const barY = plotHeight - barH; return ( + className={animate ? barAnimClass : undefined} style={animate ? { animationDelay: `${delay}ms` } : undefined} /> ); })} @@ -509,7 +620,11 @@ export const Bar = React.forwardRef( {showTooltip && (
( activeIndex < data.length && (tooltipMode === 'custom' && tooltipRender ? ( tooltipRender(data[activeIndex], series) + ) : tooltipMode === 'simple' ? ( + xKey && ( + + {formatXLabel + ? formatXLabel(data[activeIndex][xKey]) + : String(data[activeIndex][xKey] ?? '')} + + ) + ) : tooltipMode === 'compact' ? ( + <> + {series.map((s, i) => { + const v = Number(data[activeIndex!][s.key]); + return ( + + {i > 0 && {' · '}} + + {isNaN(v) ? '--' : fmtValue(v)} + + + ); + })} + {xKey && ( + <> + {' · '} + + {formatXLabel + ? formatXLabel(data[activeIndex][xKey]) + : String(data[activeIndex][xKey] ?? '')} + + + )} + ) : ( <> {xKey && ( diff --git a/src/components/Chart/BarList.tsx b/src/components/Chart/BarList.tsx index a62c8b1..eaa3ea0 100644 --- a/src/components/Chart/BarList.tsx +++ b/src/components/Chart/BarList.tsx @@ -5,16 +5,22 @@ import clsx from 'clsx'; import styles from './Chart.module.scss'; export interface BarListItem { + /** Optional stable key for React reconciliation. */ + key?: string; /** Row label (e.g., "/pricing", "US"). */ name: string; /** Numeric value that determines bar width proportionally. */ value: number; - /** Optional secondary value displayed after the bar (e.g., "0.34s"). */ + /** Optional secondary value displayed after the primary value. */ + secondaryValue?: number; + /** Optional pre-formatted string — overrides `formatValue` for this item. */ displayValue?: string; /** Optional bar color override. */ color?: string; /** Optional href — makes the name a link. */ href?: string; + /** Change indicator arrow. */ + change?: 'up' | 'down' | 'neutral'; } export interface BarListProps extends React.ComponentPropsWithoutRef<'div'> { @@ -24,23 +30,41 @@ export interface BarListProps extends React.ComponentPropsWithoutRef<'div'> { color?: string; /** Format the numeric value for display. Used when `displayValue` is not set. */ formatValue?: (value: number) => string; + /** Format the secondary value for display. */ + formatSecondaryValue?: (value: number) => string; /** Called when a row is clicked. */ onClickItem?: (item: BarListItem, index: number) => void; + /** Show numbered rank in front of each row. */ + showRank?: boolean; + /** Maximum number of items to display. */ + max?: number; /** Show loading skeleton. */ loading?: boolean; /** Content when data is empty. */ empty?: React.ReactNode; + /** Accessible label for the list. */ + ariaLabel?: string; } +const CHANGE_ARROWS: Record = { + up: '\u2191', + down: '\u2193', + neutral: '\u2013', +}; + export const BarList = React.forwardRef( function BarList( { data, color = 'var(--surface-secondary)', formatValue, + formatSecondaryValue, onClickItem, + showRank, + max, loading, empty, + ariaLabel, className, ...props }, @@ -58,7 +82,9 @@ export const BarList = React.forwardRef( ); } - if (data.length === 0 && empty !== undefined) { + const items = max ? data.slice(0, max) : data; + + if (items.length === 0 && empty !== undefined) { return (
@@ -68,26 +94,29 @@ export const BarList = React.forwardRef( ); } - const maxValue = Math.max(...data.map((d) => d.value), 1); + const maxValue = Math.max(...items.map((d) => d.value), 1); + const fmtValue = (v: number) => (formatValue ? formatValue(v) : String(v)); + const fmtSecondary = (v: number) => (formatSecondaryValue ? formatSecondaryValue(v) : String(v)); return (
- {data.map((item, i) => { + {items.map((item, i) => { const pct = (item.value / maxValue) * 100; const barColor = item.color ?? color; const clickable = Boolean(onClickItem || item.href); - const fmtVal = item.displayValue ?? (formatValue ? formatValue(item.value) : String(item.value)); + const display = item.displayValue ?? fmtValue(item.value); - const row = ( + return (
onClickItem(item, i) : undefined} onKeyDown={onClickItem ? (e: React.KeyboardEvent) => { @@ -96,6 +125,9 @@ export const BarList = React.forwardRef( >
+ {showRank && ( + {i + 1} + )} {item.href ? ( {item.name} @@ -103,12 +135,24 @@ export const BarList = React.forwardRef( item.name )} - {fmtVal} + + {item.change && ( + + {CHANGE_ARROWS[item.change]} + + )} + {display} + {item.secondaryValue !== undefined && ( + {fmtSecondary(item.secondaryValue)} + )} +
); - - return row; })}
); diff --git a/src/components/Chart/Chart.module.scss b/src/components/Chart/Chart.module.scss index b1e48ef..173edba 100644 --- a/src/components/Chart/Chart.module.scss +++ b/src/components/Chart/Chart.module.scss @@ -11,6 +11,15 @@ .svg { display: block; overflow: visible; + + &:focus { + outline: none; + } + + &:focus-visible { + outline: 2px solid var(--border-primary); + outline-offset: 2px; + } } // Grid @@ -165,6 +174,16 @@ } } +.barAnimateHorizontal { + animation: bar-grow-horizontal 500ms cubic-bezier(0.33, 1, 0.68, 1) both; + transform-box: fill-box; + transform-origin: left center; + + @media (prefers-reduced-motion: reduce) { + animation: none; + } +} + @keyframes bar-grow { from { transform: scaleY(0); @@ -174,6 +193,83 @@ } } +@keyframes bar-grow-horizontal { + from { + transform: scaleX(0); + } + to { + transform: scaleX(1); + } +} + +// Sankey + +.sankeyLink { + transition: stroke-opacity 200ms ease; + cursor: default; + + @media (prefers-reduced-motion: reduce) { + transition: none; + } +} + +.sankeyNode { + transition: opacity 200ms ease; + cursor: default; + + @media (prefers-reduced-motion: reduce) { + transition: none; + } +} + +.sankeyLabel { + @include label-chart; + + font-size: 11px; + fill: var(--text-primary); + pointer-events: none; +} + +.sankeyValueLabel { + fill-opacity: 0.45; + font-variant-numeric: tabular-nums; +} + +.sankeyStageLabel { + @include label-chart; + + font-size: 10px; + fill: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.sankeyAnimate { + animation: sankey-fade-in 400ms cubic-bezier(0.33, 1, 0.68, 1) both; + + @media (prefers-reduced-motion: reduce) { + animation: none; + } +} + +@keyframes sankey-fade-in { + from { + opacity: 0; + } +} + +// Scatter dots + +.scatterDotAnimate { + opacity: 0; + animation: chart-fade-in 400ms ease both; + + @media (prefers-reduced-motion: reduce) { + animation: none; + opacity: 1; + } +} + // Legend (shared across chart types) .legend { @@ -214,7 +310,7 @@ .pieSegment { opacity: 0; - animation: pie-fade-in 400ms ease both; + animation: chart-fade-in 400ms ease both; @media (prefers-reduced-motion: reduce) { animation: none; @@ -222,7 +318,7 @@ } } -@keyframes pie-fade-in { +@keyframes chart-fade-in { from { opacity: 0; } @@ -283,51 +379,6 @@ margin-inline-start: auto; } -// LiveValue - -.liveValue { - @include label-lg; - - font-variant-numeric: tabular-nums; - color: var(--text-primary); -} - -// LiveDot - -.liveDot { - display: inline-block; - width: var(--live-dot-size, 8px); - height: var(--live-dot-size, 8px); - border-radius: 50%; - background-color: var(--live-dot-color); - flex-shrink: 0; - position: relative; -} - -.liveDotPulse::before { - content: ''; - position: absolute; - inset: 0; - border-radius: 50%; - background-color: var(--live-dot-color); - animation: live-dot-pulse 1.5s ease-out infinite; - - @media (prefers-reduced-motion: reduce) { - animation: none; - } -} - -@keyframes live-dot-pulse { - 0% { - transform: scale(1); - opacity: 0.4; - } - 100% { - transform: scale(3); - opacity: 0; - } -} - // Loading / empty states .loading { @@ -374,6 +425,36 @@ font-size: 12px; } +// Shared empty and skeleton for HTML-based charts + +.chartEmpty { + display: flex; + align-items: center; + justify-content: center; + min-height: 80px; + color: var(--text-tertiary); + + @include label-chart; + + font-size: 12px; +} + +.chartSkeleton { + height: 32px; + background: linear-gradient( + 90deg, + var(--surface-secondary) 25%, + var(--surface-tertiary, var(--surface-secondary)) 50%, + var(--surface-secondary) 75% + ); + background-size: 200% 100%; + animation: skeleton-shimmer 1.5s ease-in-out infinite; + + @media (prefers-reduced-motion: reduce) { + animation: none; + } +} + // Screen reader only .srOnly { @@ -524,12 +605,20 @@ &:hover .barListBar { opacity: 0.8; } + + &:focus { + outline: none; + } + + &:focus-visible { + outline: 2px solid var(--border-primary); + outline-offset: -2px; + } } .barListBar { position: absolute; inset: 0; - border-radius: var(--corner-radius-xs); transition: opacity 150ms ease; @media (prefers-reduced-motion: reduce) { @@ -568,16 +657,55 @@ } } +.barListRank { + @include tooltip-chart; + + color: var(--text-tertiary); + font-variant-numeric: tabular-nums; + width: 20px; + text-align: right; + flex-shrink: 0; +} + +.barListValues { + display: flex; + align-items: center; + gap: var(--spacing-2xs); + margin-inline-start: auto; + flex-shrink: 0; +} + .barListValue { @include tooltip-chart; color: var(--bar-list-value-color, var(--text-primary)); font-size: var(--bar-list-value-size, var(--font-size-xs, 12px)); font-weight: var(--bar-list-value-weight, var(--font-weight-regular, 400)); - flex-shrink: 0; font-variant-numeric: tabular-nums; } +.barListSecondary { + @include body-sm; + + color: var(--text-tertiary); + font-variant-numeric: tabular-nums; +} + +.barListChange { + @include body-sm; + + color: var(--text-tertiary); + font-variant-numeric: tabular-nums; +} + +.barListChangeUp { + color: var(--text-success, var(--surface-green-strong)); +} + +.barListChangeDown { + color: var(--text-critical, var(--surface-red-strong, var(--text-tertiary))); +} + .barListEmpty { display: flex; align-items: center; @@ -592,8 +720,13 @@ .barListSkeleton { height: 32px; - background-color: var(--surface-secondary); - border-radius: var(--corner-radius-xs); + background: linear-gradient( + 90deg, + var(--surface-secondary) 25%, + var(--surface-tertiary, var(--surface-secondary)) 50%, + var(--surface-secondary) 75% + ); + background-size: 200% 100%; animation: skeleton-shimmer 1.5s ease-in-out infinite; @media (prefers-reduced-motion: reduce) { @@ -651,82 +784,133 @@ color: var(--text-secondary); } -// Activity Grid +// Inline tooltip variants -.activityGrid { - display: flex; - flex-direction: column; - gap: var(--spacing-3xs); +%tooltipInlineBase { + @include tooltip-chart; + + line-height: 1; } -.activityColLabels { - overflow: visible; +.tooltipInlineValue { + @extend %tooltipInlineBase; + + color: var(--text-primary); } -.activityColLabel { - @include label-chart; +.tooltipInlineSep, +.tooltipInlineTime { + @extend %tooltipInlineBase; - font-size: 9px; color: var(--text-secondary); - text-align: left; - white-space: nowrap; } -.activityBody { - display: flex; - gap: var(--spacing-2xs); -} +// Split (Distribution) -.activityRowLabels { +.splitRoot { display: flex; flex-direction: column; + gap: var(--spacing-xs); } -.activityRowLabel { - @include label-chart; - - font-size: 9px; - color: var(--text-secondary); +.splitBarWrap { display: flex; - align-items: center; - justify-content: flex-end; - white-space: nowrap; -} + overflow: hidden; -.activityCells { - display: grid; + &:focus { + outline: none; + } + + &:focus-visible { + outline: 2px solid var(--border-primary); + outline-offset: 2px; + } } -.activityCell { - border-radius: 2px; - transition: opacity 100ms ease; +.splitSegment { + min-width: 2px; + transition: opacity 150ms ease; @media (prefers-reduced-motion: reduce) { transition: none; } } -.activityCellClickable { +.splitSegmentClickable { cursor: pointer; } -// Inline tooltip variants +.splitSkeleton { + width: 100%; + height: 100%; + background: linear-gradient( + 90deg, + var(--surface-secondary) 25%, + var(--surface-tertiary, var(--surface-secondary)) 50%, + var(--surface-secondary) 75% + ); + background-size: 200% 100%; + animation: skeleton-shimmer 1.5s ease-in-out infinite; -%tooltipInlineBase { - @include tooltip-chart; + @media (prefers-reduced-motion: reduce) { + animation: none; + } +} - line-height: 1; +.splitTooltipInline { + display: flex; + align-items: center; + gap: var(--spacing-2xs); } -.tooltipInlineValue { - @extend %tooltipInlineBase; +.splitTooltipLabel { + @include body-sm; + + color: var(--text-secondary); +} + +.splitTooltipValue { + @include tooltip-chart; color: var(--text-primary); + font-variant-numeric: tabular-nums; } -.tooltipInlineSep, -.tooltipInlineTime { - @extend %tooltipInlineBase; +// Funnel - color: var(--text-secondary); +.funnelFlowAnimate { + animation: funnel-flow-reveal 600ms cubic-bezier(0.33, 1, 0.68, 1) both; + + @media (prefers-reduced-motion: reduce) { + animation: none; + } +} + +@keyframes funnel-flow-reveal { + from { + clip-path: inset(0 100% 0 0); + } + to { + clip-path: inset(0 0 0 0); + } +} + +.funnelSkeletonWrap { + display: flex; + align-items: center; + width: 100%; + height: 100%; + gap: 0; } + +// Waterfall + +.waterfallConnector { + pointer-events: none; +} + +.waterfallValue { + fill-opacity: 0.7; + font-variant-numeric: tabular-nums; +} + diff --git a/src/components/Chart/Chart.stories.tsx b/src/components/Chart/Chart.stories.tsx index aa8ef6f..ac5fdf2 100644 --- a/src/components/Chart/Chart.stories.tsx +++ b/src/components/Chart/Chart.stories.tsx @@ -385,48 +385,6 @@ export const Live: Story = { ), }; -/* -------------------------------------------------------------------------- */ -/* 14. LiveValueDemo */ -/* -------------------------------------------------------------------------- */ - -function LiveValueWrapper() { - const [value, setValue] = React.useState(12847); - React.useEffect(() => { - const interval = setInterval(() => { - setValue((v) => v + Math.floor(Math.random() * 5) + 1); - }, 800); - return () => clearInterval(interval); - }, []); - return ( - `$${Math.round(v).toLocaleString()}`} - style={{ fontSize: 32, fontWeight: 500 }} - /> - ); -} - -export const LiveValueDemo: Story = { - render: () => , -}; - -/* -------------------------------------------------------------------------- */ -/* 15. LiveDotStates */ -/* -------------------------------------------------------------------------- */ - -export const LiveDotStates: Story = { - render: () => ( -
- {(['active', 'processing', 'idle', 'error'] as const).map((status) => ( -
-

{status}

- -
- ))} -
- ), -}; - /* -------------------------------------------------------------------------- */ /* 16. Gauge */ /* -------------------------------------------------------------------------- */ @@ -511,28 +469,185 @@ export const Uptime: Story = { }; /* -------------------------------------------------------------------------- */ -/* 20. ActivityGrid */ +/* 21. Scatter */ /* -------------------------------------------------------------------------- */ -const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; -const weeks = Array.from({ length: 20 }, (_, i) => `W${i + 1}`); +export const Scatter: Story = { + render: () => ( +
+ `${v}%`} + formatYLabel={(v) => `$${v}`} + /> +
+ ), +}; -const activityData = weeks.flatMap((week, ci) => - days.map((day) => ({ - row: day, - col: week, - value: ((ci * 7 + days.indexOf(day)) * 37) % 10, - })), -); +/* -------------------------------------------------------------------------- */ +/* 22. Split (Distribution) */ +/* -------------------------------------------------------------------------- */ -export const ActivityGrid: Story = { +export const Split: Story = { render: () => ( - +
+ `$${v.toLocaleString()}`} + showValues + /> +
+ ), +}; + +/* -------------------------------------------------------------------------- */ +/* 23. BarListRanked */ +/* -------------------------------------------------------------------------- */ + +export const BarListRanked: Story = { + render: () => ( +
+ `$${v.toLocaleString()}`} + formatSecondaryValue={(v) => `${v}%`} + showRank + /> +
+ ), +}; + + +/* -------------------------------------------------------------------------- */ +/* 27. Waterfall */ +/* -------------------------------------------------------------------------- */ + +export const Waterfall: Story = { + render: () => ( +
+ `$${v}`} + /> +
+ ), +}; + +/* -------------------------------------------------------------------------- */ +/* 28. Sankey */ +/* -------------------------------------------------------------------------- */ + +export const Funnel: Story = { + render: () => ( +
+ v.toLocaleString()} + /> +
+ ), +}; + +/* -------------------------------------------------------------------------- */ +/* 26. Sankey */ +/* -------------------------------------------------------------------------- */ + +export const Sankey: Story = { + render: () => ( +
+ `$${v}k`} + /> +
), }; diff --git a/src/components/Chart/Chart.test-stories.tsx b/src/components/Chart/Chart.test-stories.tsx index 69a72d7..aae3f7d 100644 --- a/src/components/Chart/Chart.test-stories.tsx +++ b/src/components/Chart/Chart.test-stories.tsx @@ -222,3 +222,166 @@ export function CustomTooltip() { /> ); } + +export function ScatterBasic() { + return ( + + ); +} + +export function ScatterMultiSeries() { + return ( + + ); +} + +export function SplitBasic() { + return ( + + ); +} + +export function BarListRanked() { + return ( + + ); +} + + +export function WaterfallBasic() { + return ( + + ); +} + +export function FunnelBasic() { + return ( + + ); +} + +export function BarBasic() { + return ( + + ); +} + +export function SankeyBasic() { + return ( + + ); +} diff --git a/src/components/Chart/Chart.test.tsx b/src/components/Chart/Chart.test.tsx index d1028da..8b26846 100644 --- a/src/components/Chart/Chart.test.tsx +++ b/src/components/Chart/Chart.test.tsx @@ -16,6 +16,14 @@ import { SimpleTooltip, DetailedTooltipExplicit, CustomTooltip, + ScatterBasic, + ScatterMultiSeries, + SplitBasic, + BarListRanked, + WaterfallBasic, + SankeyBasic, + FunnelBasic, + BarBasic, } from './Chart.test-stories'; const axeConfig = { @@ -447,3 +455,330 @@ test.describe('Chart props', () => { expect(cls).toBeTruthy(); }); }); + +// --------------------------------------------------------------------------- +// Scatter chart +// --------------------------------------------------------------------------- + +test.describe('Scatter chart', () => { + test('renders circles for data points', async ({ mount, page }) => { + await mount(); + const circles = page.locator('[data-testid="scatter-chart"] svg circle'); + const count = await circles.count(); + expect(count).toBe(4); + }); + + test('has role="img" and aria-label', async ({ mount, page }) => { + await mount(); + const svg = page.locator('[data-testid="scatter-chart"] svg'); + await expect(svg).toHaveAttribute('role', 'img'); + await expect(svg).toHaveAttribute('aria-label'); + }); + + test('renders grid lines when grid=true', async ({ mount, page }) => { + await mount(); + const lines = page.locator('[data-testid="scatter-chart"] svg line'); + const count = await lines.count(); + expect(count).toBeGreaterThanOrEqual(2); + }); + + test('multi-series renders legend when legend=true', async ({ mount, page }) => { + await mount(); + const legendItems = page.locator('[data-testid="scatter-chart"]').locator('..').getByText('Series A', { exact: true }); + await expect(legendItems).toBeVisible(); + }); + + test('has no accessibility violations', async ({ mount, page }) => { + await mount(); + const results = await new AxeBuilder({ page }) + .options(axeConfig) + .analyze(); + expect(results.violations).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// Split chart +// --------------------------------------------------------------------------- + +test.describe('Split chart', () => { + test('renders segments for data', async ({ mount, page }) => { + await mount(); + const root = page.locator('[data-testid="split-chart"]'); + await expect(root).toBeVisible(); + const barWrap = root.locator('[role="img"]'); + await expect(barWrap).toBeAttached(); + }); + + test('renders legend items', async ({ mount, page }) => { + await mount(); + const root = page.locator('[data-testid="split-chart"]'); + await expect(root.getByText('Payments')).toBeVisible(); + await expect(root.getByText('Transfers')).toBeVisible(); + await expect(root.getByText('Fees')).toBeVisible(); + }); + + test('has no accessibility violations', async ({ mount, page }) => { + await mount(); + const results = await new AxeBuilder({ page }) + .options({ + ...axeConfig, + rules: { ...axeConfig.rules, 'color-contrast': { enabled: false } }, + }) + .analyze(); + expect(results.violations).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// BarList ranked variant +// --------------------------------------------------------------------------- + +test.describe('BarList ranked variant', () => { + test('renders ranked rows with rank numbers', async ({ mount, page }) => { + await mount(); + const root = page.locator('[data-testid="barlist-ranked"]'); + await expect(root).toBeVisible(); + await expect(root.getByText('United States')).toBeVisible(); + await expect(root.getByText('Japan')).toBeVisible(); + await expect(root.getByText('1', { exact: true })).toBeVisible(); + }); + + test('has role="list"', async ({ mount, page }) => { + await mount(); + const root = page.locator('[data-testid="barlist-ranked"]'); + await expect(root).toHaveAttribute('role', 'list'); + }); + + test('shows change indicators', async ({ mount, page }) => { + await mount(); + const root = page.locator('[data-testid="barlist-ranked"]'); + const arrows = root.getByText('\u2191'); + await expect(arrows).toBeVisible(); + }); + + test('has no accessibility violations', async ({ mount, page }) => { + await mount(); + const results = await new AxeBuilder({ page }) + .options(axeConfig) + .analyze(); + expect(results.violations).toEqual([]); + }); +}); + + +// --------------------------------------------------------------------------- +// Waterfall chart +// --------------------------------------------------------------------------- + +test.describe('Waterfall chart', () => { + test('renders bars for each segment', async ({ mount, page }) => { + await mount(); + const rects = page.locator('[data-testid="waterfall-chart"] svg rect[fill]'); + const count = await rects.count(); + expect(count).toBeGreaterThanOrEqual(7); + }); + + test('renders connector lines when showConnectors=true', async ({ mount, page }) => { + await mount(); + const connectors = page.locator('[data-testid="waterfall-chart"] svg line[stroke-dasharray="2 2"]'); + const count = await connectors.count(); + expect(count).toBeGreaterThanOrEqual(1); + }); + + test('has role="img" and aria-label', async ({ mount, page }) => { + await mount(); + const svg = page.locator('[data-testid="waterfall-chart"] svg'); + await expect(svg).toHaveAttribute('role', 'img'); + await expect(svg).toHaveAttribute('aria-label'); + }); + + test('has no accessibility violations', async ({ mount, page }) => { + await mount(); + const results = await new AxeBuilder({ page }) + .options(axeConfig) + .analyze(); + expect(results.violations).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// Sankey chart +// --------------------------------------------------------------------------- + +test.describe('Sankey chart', () => { + test('renders nodes as rects', async ({ mount, page }) => { + await mount(); + const rects = page.locator('[data-testid="sankey-chart"] svg rect[role="graphics-symbol"]'); + const count = await rects.count(); + expect(count).toBe(4); + }); + + test('renders links as paths', async ({ mount, page }) => { + await mount(); + const paths = page.locator('[data-testid="sankey-chart"] svg path[role="graphics-symbol"]'); + const count = await paths.count(); + expect(count).toBe(4); + }); + + test('has role="graphics-document" and aria-roledescription', async ({ mount, page }) => { + await mount(); + const svg = page.locator('[data-testid="sankey-chart"] svg'); + await expect(svg).toHaveAttribute('role', 'graphics-document'); + await expect(svg).toHaveAttribute('aria-roledescription', 'Flow diagram'); + }); + + test('renders node labels', async ({ mount, page }) => { + await mount(); + const root = page.locator('[data-testid="sankey-chart"]'); + const labels = root.locator('svg text'); + const count = await labels.count(); + expect(count).toBe(4); + }); + + test('has no accessibility violations', async ({ mount, page }) => { + await mount(); + const results = await new AxeBuilder({ page }) + .options(axeConfig) + .analyze(); + expect(results.violations).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// Funnel chart +// --------------------------------------------------------------------------- + +test.describe('Funnel chart', () => { + test('renders a path for each stage', async ({ mount, page }) => { + await mount(); + const paths = page.locator( + '[data-testid="funnel-chart"] svg path[role="graphics-symbol"]', + ); + const count = await paths.count(); + expect(count).toBe(5); + }); + + test('shows conversion rate in tooltip on hover', async ({ mount, page }) => { + await mount(); + const svg = page.locator('[data-testid="funnel-chart"] svg'); + await svg.focus(); + await page.keyboard.press('ArrowRight'); + await page.keyboard.press('ArrowRight'); + const tooltip = page.locator('[data-testid="funnel-chart"] > div[class*="tooltip"]').first(); + await expect(tooltip).toBeVisible(); + await expect(tooltip).toContainText('42%'); + }); + + test('has role="img" and aria-label', async ({ mount, page }) => { + await mount(); + const svg = page.locator('[data-testid="funnel-chart"] svg'); + await expect(svg).toHaveAttribute('role', 'img'); + await expect(svg).toHaveAttribute('aria-label'); + }); + + test('has no accessibility violations', async ({ mount, page }) => { + await mount(); + const results = await new AxeBuilder({ page }) + .options(axeConfig) + .analyze(); + expect(results.violations).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// Keyboard interaction +// --------------------------------------------------------------------------- + +test.describe('Keyboard interaction', () => { + test('Line chart: arrow keys show tooltip, Escape dismisses', async ({ mount, page }) => { + await mount(); + const svg = page.locator('[data-testid="chart"] svg'); + await svg.focus(); + await page.keyboard.press('ArrowRight'); + await page.keyboard.press('ArrowRight'); + const tooltip = page.locator('[data-testid="chart"] > div[class*="tooltip"]').first(); + await expect(tooltip).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(tooltip).toBeHidden(); + }); + + test('Bar chart: arrow keys show tooltip, Escape dismisses', async ({ mount, page }) => { + await mount(); + const svg = page.locator('[data-testid="bar-chart"] svg'); + await svg.focus(); + await page.keyboard.press('ArrowRight'); + await page.keyboard.press('ArrowRight'); + const tooltip = page.locator('[data-testid="bar-chart"] > div[class*="tooltip"]').first(); + await expect(tooltip).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(tooltip).toBeHidden(); + }); + + test('Scatter chart: arrow keys show tooltip, Escape dismisses', async ({ mount, page }) => { + await mount(); + const svg = page.locator('[data-testid="scatter-chart"] svg'); + await svg.focus(); + await page.keyboard.press('ArrowRight'); + await page.keyboard.press('ArrowRight'); + const tooltip = page.locator('[data-testid="scatter-chart"] > div[class*="tooltip"]').first(); + await expect(tooltip).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(tooltip).toBeHidden(); + }); + + test('Split chart: arrow keys swap legend to active segment', async ({ mount, page }) => { + await mount(); + const bar = page.locator('[data-testid="split-chart"] [class*="splitBarWrap"]'); + const legend = page.locator('[data-testid="split-chart"] [class*="legend"]').first(); + await expect(legend).toContainText('Payments'); + await expect(legend).toContainText('Transfers'); + await bar.focus(); + await page.keyboard.press('ArrowRight'); + await expect(legend).toContainText('Payments'); + await expect(legend).not.toContainText('Transfers'); + await page.keyboard.press('Escape'); + await expect(legend).toContainText('Transfers'); + }); + + test('Funnel chart: arrow keys show tooltip, Escape dismisses', async ({ mount, page }) => { + await mount(); + const svg = page.locator('[data-testid="funnel-chart"] svg'); + await svg.focus(); + await page.keyboard.press('ArrowRight'); + const tooltip = page.locator('[data-testid="funnel-chart"] > div[class*="tooltip"]').first(); + await expect(tooltip).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(tooltip).toBeHidden(); + }); + + test('Waterfall chart: arrow keys show tooltip, Escape dismisses', async ({ mount, page }) => { + await mount(); + const svg = page.locator('[data-testid="waterfall-chart"] svg'); + await svg.focus(); + await page.keyboard.press('ArrowRight'); + const tooltip = page.locator('[data-testid="waterfall-chart"] > div[class*="tooltip"]').first(); + await expect(tooltip).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(tooltip).toBeHidden(); + }); + + test('Sankey chart: arrow keys show tooltip, Escape dismisses', async ({ mount, page }) => { + await mount(); + const svg = page.locator('[data-testid="sankey-chart"] svg'); + await svg.focus(); + await page.keyboard.press('ArrowRight'); + const tooltip = page.locator('[data-testid="sankey-chart"] > div[class*="tooltip"]').first(); + await expect(tooltip).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(tooltip).toBeHidden(); + }); + + test('focus-visible ring appears on SVG charts', async ({ mount, page }) => { + await mount(); + const svg = page.locator('[data-testid="chart"] svg'); + await svg.focus(); + const outline = await svg.evaluate((el) => getComputedStyle(el).outlineStyle); + expect(outline).not.toBe('none'); + }); +}); diff --git a/src/components/Chart/Chart.unit.test.ts b/src/components/Chart/Chart.unit.test.ts index 5212913..d003d1d 100644 --- a/src/components/Chart/Chart.unit.test.ts +++ b/src/components/Chart/Chart.unit.test.ts @@ -23,6 +23,7 @@ import { type Point, } from './utils'; import { resolveTooltipMode, resolveSeries, SERIES_COLORS, axisTickTarget } from './types'; +import { computeSankeyLayout, sankeyLinkPath } from './sankeyLayout'; // --------------------------------------------------------------------------- // linearScale @@ -725,3 +726,103 @@ describe('axisPadForLabels', () => { expect(withNeg).toBeGreaterThan(positive); }); }); + +// --------------------------------------------------------------------------- +// Sankey layout +// --------------------------------------------------------------------------- + +describe('computeSankeyLayout', () => { + const simpleData = { + nodes: [ + { id: 'a', label: 'A' }, + { id: 'b', label: 'B' }, + { id: 'c', label: 'C' }, + ], + links: [ + { source: 'a', target: 'c', value: 30 }, + { source: 'b', target: 'c', value: 20 }, + ], + }; + + it('returns all nodes and links', () => { + const result = computeSankeyLayout(simpleData, 400, 200, 12, 8); + expect(result.nodes).toHaveLength(3); + expect(result.links).toHaveLength(2); + }); + + it('assigns columns via BFS', () => { + const result = computeSankeyLayout(simpleData, 400, 200, 12, 8); + const nodeA = result.nodes.find((n) => n.id === 'a')!; + const nodeB = result.nodes.find((n) => n.id === 'b')!; + const nodeC = result.nodes.find((n) => n.id === 'c')!; + expect(nodeA.column).toBe(0); + expect(nodeB.column).toBe(0); + expect(nodeC.column).toBe(1); + }); + + it('source nodes are left of target nodes', () => { + const result = computeSankeyLayout(simpleData, 400, 200, 12, 8); + const nodeA = result.nodes.find((n) => n.id === 'a')!; + const nodeC = result.nodes.find((n) => n.id === 'c')!; + expect(nodeA.x1).toBeLessThanOrEqual(nodeC.x0); + }); + + it('node value equals max of in/out flow', () => { + const result = computeSankeyLayout(simpleData, 400, 200, 12, 8); + const nodeC = result.nodes.find((n) => n.id === 'c')!; + expect(nodeC.value).toBe(50); + }); + + it('node width matches nodeWidth param', () => { + const result = computeSankeyLayout(simpleData, 400, 200, 16, 8); + for (const node of result.nodes) { + expect(node.x1 - node.x0).toBe(16); + } + }); + + it('link widths are proportional to values', () => { + const result = computeSankeyLayout(simpleData, 400, 200, 12, 8); + const link30 = result.links.find((l) => l.value === 30)!; + const link20 = result.links.find((l) => l.value === 20)!; + expect(link30.width).toBeGreaterThan(link20.width); + }); + + it('handles empty input', () => { + const result = computeSankeyLayout({ nodes: [], links: [] }, 400, 200, 12, 8); + expect(result.nodes).toHaveLength(0); + expect(result.links).toHaveLength(0); + }); + + it('handles multi-column layout', () => { + const data = { + nodes: [ + { id: 'a', label: 'A' }, + { id: 'b', label: 'B' }, + { id: 'c', label: 'C' }, + ], + links: [ + { source: 'a', target: 'b', value: 50 }, + { source: 'b', target: 'c', value: 50 }, + ], + }; + const result = computeSankeyLayout(data, 600, 200, 12, 8); + const cols = result.nodes.map((n) => n.column); + expect(new Set(cols).size).toBe(3); + }); +}); + +describe('sankeyLinkPath', () => { + it('produces a valid SVG path with cubic bezier', () => { + const data = { + nodes: [ + { id: 'a', label: 'A' }, + { id: 'b', label: 'B' }, + ], + links: [{ source: 'a', target: 'b', value: 100 }], + }; + const result = computeSankeyLayout(data, 400, 200, 12, 8); + const path = sankeyLinkPath(result.links[0]); + expect(path).toMatch(/^M/); + expect(path).toContain('C'); + }); +}); diff --git a/src/components/Chart/ChartWrapper.tsx b/src/components/Chart/ChartWrapper.tsx index 30704b5..19f8701 100644 --- a/src/components/Chart/ChartWrapper.tsx +++ b/src/components/Chart/ChartWrapper.tsx @@ -14,7 +14,6 @@ export interface ChartWrapperProps { series?: ResolvedSeries[]; children: React.ReactNode; className?: string; - activeIndex?: number | null; ariaLiveContent?: string; } @@ -27,7 +26,6 @@ export function ChartWrapper({ series, children, className, - activeIndex: _activeIndex, ariaLiveContent, }: ChartWrapperProps) { if (loading) { diff --git a/src/components/Chart/ComposedChart.tsx b/src/components/Chart/ComposedChart.tsx index ab3b10e..5ad7628 100644 --- a/src/components/Chart/ComposedChart.tsx +++ b/src/components/Chart/ComposedChart.tsx @@ -7,6 +7,8 @@ import { niceTicks, monotonePath, linearPath, + monotonePathGroups, + linearPathGroups, monotoneInterpolator, linearInterpolator, thinIndices, @@ -19,6 +21,7 @@ import { type ResolvedSeries, type TooltipProp, type ReferenceLine, + type ReferenceBand, SERIES_COLORS, DASH_PATTERNS, PAD_TOP, @@ -58,6 +61,8 @@ export interface ComposedChartProps extends React.ComponentPropsWithoutRef<'div' curve?: 'monotone' | 'linear'; /** Reference lines on the left Y axis. */ referenceLines?: ReferenceLine[]; + /** Shaded bands spanning a value range on the left Y axis. Rendered behind bars and lines. */ + referenceBands?: ReferenceBand[]; /** Show legend below chart. */ legend?: boolean; /** Show loading skeleton. */ @@ -81,6 +86,8 @@ export interface ComposedChartProps extends React.ComponentPropsWithoutRef<'div' formatYLabel?: (value: number) => string; /** Formatter for the right Y axis labels. */ formatYLabelRight?: (value: number) => string; + /** Connect across null/NaN gaps in line series. When false, gaps break the line. */ + connectNulls?: boolean; } const EMPTY_TICKS = { min: 0, max: 1, ticks: [0, 1] } as const; @@ -96,6 +103,7 @@ export const Composed = React.forwardRef( tooltip: tooltipProp, curve = 'monotone', referenceLines, + referenceBands, legend, loading, empty, @@ -107,6 +115,7 @@ export const Composed = React.forwardRef( formatXLabel, formatYLabel, formatYLabelRight, + connectNulls = true, className, ...props }, @@ -168,9 +177,15 @@ export const Composed = React.forwardRef( if (rl.value > max) max = rl.value; } } + if (referenceBands) { + for (const rb of referenceBands) { + const hi = Math.max(rb.from, rb.to); + if (hi > max) max = hi; + } + } if (max === -Infinity) max = 1; return niceTicks(0, max, tickTarget); - }, [data, series, referenceLines, tickTarget]); + }, [data, series, referenceLines, referenceBands, tickTarget]); // Right Y domain (right-axis lines) const rightDomain = React.useMemo(() => { @@ -210,28 +225,48 @@ export const Composed = React.forwardRef( : 0; // Line points and paths - const linePoints = React.useMemo(() => { - if (plotWidth <= 0 || plotHeight <= 0 || data.length === 0) return []; - return lineSeries.map((s) => { + const { linePoints, lineGroups } = React.useMemo(() => { + if (plotWidth <= 0 || plotHeight <= 0 || data.length === 0) + return { linePoints: [] as Point[][], lineGroups: [] as Point[][][] }; + const allPoints: Point[][] = []; + const allGroups: Point[][][] = []; + for (const s of lineSeries) { const domain = s.axis === 'right' ? rightDomain : leftDomain; const points: Point[] = []; + const groups: Point[][] = []; + let currentGroup: Point[] = []; for (let i = 0; i < data.length; i++) { const v = Number(data[i][s.key]); - if (isNaN(v)) continue; + if (isNaN(v)) { + if (!connectNulls && currentGroup.length > 0) { + groups.push(currentGroup); + currentGroup = []; + } + continue; + } const x = data.length === 1 ? plotWidth / 2 : (i + 0.5) * slotWidth; const y = linearScale(v, domain.min, domain.max, plotHeight, 0); - points.push({ x, y }); + const pt = { x, y }; + points.push(pt); + currentGroup.push(pt); } - return points; - }); - }, [data, lineSeries, plotWidth, plotHeight, slotWidth, leftDomain, rightDomain]); + if (currentGroup.length > 0) groups.push(currentGroup); + allPoints.push(points); + allGroups.push(groups); + } + return { linePoints: allPoints, lineGroups: allGroups }; + }, [data, lineSeries, plotWidth, plotHeight, slotWidth, leftDomain, rightDomain, connectNulls]); const linePaths = React.useMemo(() => { - const build = curve === 'monotone' ? monotonePath : linearPath; - return linePoints.map((pts) => build(pts)); - }, [linePoints, curve]); + if (connectNulls) { + const build = curve === 'monotone' ? monotonePath : linearPath; + return linePoints.map((pts) => build(pts)); + } + const build = curve === 'monotone' ? monotonePathGroups : linearPathGroups; + return lineGroups.map((groups) => build(groups)); + }, [linePoints, lineGroups, curve, connectNulls]); // Interpolators for line dot tracking const interpolators = React.useMemo(() => { @@ -254,6 +289,7 @@ export const Composed = React.forwardRef( interpolatorsRef, data, onActiveChange, + onActivate: onClickDatum, }); // Y axis labels @@ -316,8 +352,7 @@ export const Composed = React.forwardRef( height={height} legend={legend} series={wrapperSeries} - activeIndex={scrub.activeIndex} - ariaLiveContent={ariaLiveContent} + ariaLiveContent={showTooltip ? ariaLiveContent : undefined} >
( ))} + {/* Reference bands */} + {referenceBands?.map((rb, i) => { + const bandColor = rb.color ?? 'var(--stroke-primary)'; + if (rb.axis === 'x') { + const x1 = data.length > 0 ? (rb.from + 0.5) * slotWidth : 0; + const x2 = data.length > 0 ? (rb.to + 0.5) * slotWidth : plotWidth; + const bx = Math.min(x1, x2); + const bw = Math.abs(x2 - x1); + return ( + + + {rb.label && ( + {rb.label} + )} + + ); + } + const y1 = linearScale(rb.from, leftDomain.min, leftDomain.max, plotHeight, 0); + const y2 = linearScale(rb.to, leftDomain.min, leftDomain.max, plotHeight, 0); + const by = Math.min(y1, y2); + const bh = Math.abs(y1 - y2); + return ( + + + {rb.label && ( + {rb.label} + )} + + ); + })} + {/* Reference lines */} {referenceLines?.map((rl, i) => { const ry = linearScale(rl.value, leftDomain.min, leftDomain.max, plotHeight, 0); diff --git a/src/components/Chart/FunnelChart.tsx b/src/components/Chart/FunnelChart.tsx new file mode 100644 index 0000000..ad694b5 --- /dev/null +++ b/src/components/Chart/FunnelChart.tsx @@ -0,0 +1,425 @@ +'use client'; + +import * as React from 'react'; +import clsx from 'clsx'; +import { useResizeWidth } from './hooks'; +import { SERIES_COLORS } from './types'; +import styles from './Chart.module.scss'; + +export interface FunnelStage { + key?: string; + label: string; + value: number; + color?: string; +} + +export interface FunnelChartProps extends React.ComponentPropsWithoutRef<'div'> { + data: FunnelStage[]; + formatValue?: (value: number) => string; + formatRate?: (rate: number) => string; + showRates?: boolean; + showLabels?: boolean; + showGrid?: boolean; + height?: number; + animate?: boolean; + loading?: boolean; + empty?: React.ReactNode; + ariaLabel?: string; + onClickDatum?: (index: number, stage: FunnelStage) => void; +} + +const PAD = 8; +const LABEL_ROW_HEIGHT = 18; +const LABEL_GAP = 6; + +const rd = (n: number) => Math.round(n * 100) / 100; + +export const Funnel = React.forwardRef( + function Funnel( + { + data, + formatValue, + formatRate, + showRates = true, + showLabels = true, + showGrid = true, + height = 140, + animate = true, + loading, + empty, + ariaLabel, + onClickDatum, + className, + ...props + }, + ref, + ) { + const { width, attachRef } = useResizeWidth(); + const [activeIndex, setActiveIndex] = React.useState(null); + const tooltipRef = React.useRef(null); + const rootRef = React.useRef(null); + + const mergedRef = React.useCallback( + (node: HTMLDivElement | null) => { + rootRef.current = node; + attachRef(node); + if (typeof ref === 'function') ref(node); + else if (ref) ref.current = node; + }, + [ref, attachRef], + ); + + const fmtValue = React.useCallback( + (v: number) => (formatValue ? formatValue(v) : v.toLocaleString()), + [formatValue], + ); + + const fmtRate = React.useCallback( + (r: number) => (formatRate ? formatRate(r) : `${Math.round(r * 100)}%`), + [formatRate], + ); + + const plotWidth = Math.max(0, width - PAD * 2); + const stageWidth = data.length > 0 ? plotWidth / data.length : 0; + + const dense = stageWidth < 60; + const effectiveShowLabels = showLabels && !dense; + const effectiveShowGrid = showGrid && !dense; + + const labelSpace = effectiveShowLabels ? LABEL_ROW_HEIGHT + LABEL_GAP : 0; + const plotHeight = Math.max(0, height - PAD * 2 - labelSpace); + const centerY = PAD + plotHeight / 2; + const maxValue = data.length > 0 ? data[0].value : 0; + + const flatRatio = + stageWidth >= 100 ? 0.55 : stageWidth >= 60 ? 0.7 : 0.85; + + const stageColors = React.useMemo( + () => + data.map( + (d, i) => d.color ?? SERIES_COLORS[i % SERIES_COLORS.length], + ), + [data], + ); + + const stagePaths = React.useMemo(() => { + if (data.length === 0 || plotWidth <= 0 || plotHeight <= 0) return []; + + return data.map((stage, i) => { + const lH = maxValue > 0 ? (stage.value / maxValue) * plotHeight : 0; + const isLast = i === data.length - 1; + const rH = isLast + ? lH + : maxValue > 0 + ? (data[i + 1].value / maxValue) * plotHeight + : 0; + + const x0 = rd(PAD + i * stageWidth); + const x1 = rd(x0 + stageWidth); + + const topL = rd(centerY - lH / 2); + const botL = rd(centerY + lH / 2); + const topR = rd(centerY - rH / 2); + const botR = rd(centerY + rH / 2); + + if (isLast) { + return `M${x0},${topL} L${x1},${topL} L${x1},${botL} L${x0},${botL} Z`; + } + + const flat = rd(x0 + stageWidth * flatRatio); + const tw = stageWidth * (1 - flatRatio); + const cp1x = rd(flat + tw * 0.45); + const cp2x = rd(x1 - tw * 0.15); + + return [ + `M${x0},${topL}`, + `L${flat},${topL}`, + `C${cp1x},${topL} ${cp2x},${topR} ${x1},${topR}`, + `L${x1},${botR}`, + `C${cp2x},${botR} ${cp1x},${botL} ${flat},${botL}`, + `L${x0},${botL}`, + 'Z', + ].join(' '); + }); + }, [data, maxValue, plotWidth, plotHeight, stageWidth, centerY, flatRatio]); + + const svgDesc = React.useMemo(() => { + if (data.length === 0) return undefined; + const parts = data.map((d, i) => { + const rate = + i > 0 && data[0].value > 0 + ? ` (${fmtRate(d.value / data[0].value)})` + : ''; + return `${d.label}: ${fmtValue(d.value)}${rate}`; + }); + return `Funnel chart with ${data.length} stages. ${parts.join(', ')}.`; + }, [data, fmtValue, fmtRate]); + + const tooltipContent = React.useMemo(() => { + if (activeIndex === null || activeIndex >= data.length) return null; + const stage = data[activeIndex]; + const rate = + showRates && data[0].value > 0 + ? fmtRate(stage.value / data[0].value) + : null; + return { label: stage.label, value: fmtValue(stage.value), rate }; + }, [activeIndex, data, fmtValue, fmtRate, showRates]); + + const positionTooltip = React.useCallback( + (e: React.MouseEvent) => { + const tip = tooltipRef.current; + const root = rootRef.current; + if (!tip || !root) return; + const rect = root.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + tip.style.display = ''; + const tipW = tip.offsetWidth; + const gap = 12; + const fitsRight = x + gap + tipW <= width; + const fitsLeft = x - gap - tipW >= 0; + const preferRight = x <= width / 2; + tip.style.left = `${x}px`; + tip.style.top = `${y}px`; + if ((preferRight && fitsRight) || !fitsLeft) { + tip.style.transform = `translate(${gap}px, -50%)`; + } else { + tip.style.transform = `translate(calc(-100% - ${gap}px), -50%)`; + } + }, + [width], + ); + + const handleMouseLeave = React.useCallback(() => { + setActiveIndex(null); + const tip = tooltipRef.current; + if (tip) tip.style.display = 'none'; + }, []); + + const handleKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + if (data.length === 0) return; + let next = activeIndex ?? -1; + switch (e.key) { + case 'ArrowRight': + case 'ArrowDown': + next = Math.min(data.length - 1, next + 1); + break; + case 'ArrowLeft': + case 'ArrowUp': + next = Math.max(0, next - 1); + break; + case 'Home': + next = 0; + break; + case 'End': + next = data.length - 1; + break; + case 'Enter': + case ' ': + if (onClickDatum && activeIndex !== null && activeIndex < data.length) { + e.preventDefault(); + onClickDatum(activeIndex, data[activeIndex]); + } + return; + case 'Escape': + handleMouseLeave(); + return; + default: + return; + } + e.preventDefault(); + setActiveIndex(next); + const tip = tooltipRef.current; + if (tip) { + const x = PAD + next * stageWidth + (stageWidth * flatRatio) / 2; + const gap = 12; + tip.style.left = `${x}px`; + tip.style.top = `${centerY}px`; + const tipW = tip.offsetWidth; + const fitsRight = x + gap + tipW <= width; + tip.style.transform = fitsRight + ? `translate(${gap}px, -50%)` + : `translate(calc(-100% - ${gap}px), -50%)`; + tip.style.display = ''; + } + }, + [activeIndex, data, stageWidth, flatRatio, centerY, width, onClickDatum, handleMouseLeave], + ); + + const ready = width > 0; + + if (loading) { + return ( +
+
+
+ {[100, 45, 15].map((pct, i) => ( +
+ ))} +
+
+
+ ); + } + + if (data.length === 0 && empty !== undefined) { + return ( +
+
+ {typeof empty === 'boolean' ? 'No data' : empty} +
+
+ ); + } + + return ( +
+ {ready && ( + <> + + {svgDesc && {svgDesc}} + + {effectiveShowGrid && + data.length > 1 && + data.slice(1).map((_, i) => { + const x = rd(PAD + (i + 1) * stageWidth); + return ( + + ); + })} + + {stagePaths.map((d, i) => ( + { + setActiveIndex(i); + positionTooltip(e); + }} + onMouseMove={positionTooltip} + onMouseLeave={handleMouseLeave} + onClick={ + onClickDatum + ? () => onClickDatum(i, data[i]) + : undefined + } + cursor={onClickDatum ? 'pointer' : undefined} + role="graphics-symbol" + aria-roledescription="Stage" + aria-label={`${data[i].label}: ${fmtValue(data[i].value)}`} + /> + ))} + + {effectiveShowLabels && + data.map((stage, i) => { + const x = rd( + PAD + i * stageWidth + (stageWidth * flatRatio) / 2, + ); + const y = rd( + PAD + plotHeight + LABEL_GAP + LABEL_ROW_HEIGHT / 2, + ); + return ( + + {stage.label} + + ); + })} + + +
+ {tooltipContent && ( +
+
+ + {tooltipContent.label} + + + {tooltipContent.value} + +
+ {tooltipContent.rate && ( +
+ Rate + + {tooltipContent.rate} + +
+ )} +
+ )} +
+ + )} +
+ ); + }, +); + +if (process.env.NODE_ENV !== 'production') { + Funnel.displayName = 'Chart.Funnel'; +} diff --git a/src/components/Chart/LineChart.tsx b/src/components/Chart/LineChart.tsx index 352bd41..841a8e5 100644 --- a/src/components/Chart/LineChart.tsx +++ b/src/components/Chart/LineChart.tsx @@ -9,6 +9,8 @@ import { niceTicks, monotonePath, linearPath, + monotonePathGroups, + linearPathGroups, monotoneInterpolator, linearInterpolator, thinIndices, @@ -21,6 +23,7 @@ import { type ResolvedSeries, type TooltipProp, type ReferenceLine, + type ReferenceBand, PAD_TOP, PAD_RIGHT, PAD_BOTTOM_AXIS, @@ -32,7 +35,7 @@ import { import { ChartWrapper } from './ChartWrapper'; import styles from './Chart.module.scss'; -export type { Series, TooltipProp, ReferenceLine }; +export type { Series, TooltipProp, ReferenceLine, ReferenceBand }; export interface LineChartProps extends React.ComponentPropsWithoutRef<'div'> { /** Array of data objects. Each object should contain keys matching `dataKey` or `series[].key`. */ @@ -70,8 +73,14 @@ export interface LineChartProps extends React.ComponentPropsWithoutRef<'div'> { fadeLeft?: boolean | number; /** Reference lines at specific values. Supports horizontal (y) and vertical (x) lines. */ referenceLines?: ReferenceLine[]; + /** Shaded bands spanning a value range. Rendered behind data paths. */ + referenceBands?: ReferenceBand[]; /** Fixed Y-axis domain. When omitted, auto-scales from data. */ yDomain?: [number, number]; + /** Comparison data for "this period vs last period" overlays. Rendered as dashed lines behind the main paths. */ + compareData?: Record[]; + /** Legend label for comparison series. Defaults to "Previous". */ + compareLabel?: string; /** Show a legend below the chart for multi-series. */ legend?: boolean; /** Show a loading skeleton. */ @@ -98,6 +107,8 @@ export interface LineChartProps extends React.ComponentPropsWithoutRef<'div'> { formatXLabel?: (value: unknown) => string; /** Format y-axis labels. */ formatYLabel?: (value: number) => string; + /** Connect across null/NaN gaps. When false, gaps break the line. */ + connectNulls?: boolean; } export const Line = React.forwardRef( @@ -117,7 +128,10 @@ export const Line = React.forwardRef( fill: fillProp, fadeLeft, referenceLines, + referenceBands, yDomain: yDomainProp, + compareData, + compareLabel, legend, loading, empty, @@ -128,6 +142,7 @@ export const Line = React.forwardRef( formatValue, formatXLabel, formatYLabel, + connectNulls = true, className, ...props }, @@ -191,6 +206,17 @@ export const Line = React.forwardRef( } } } + if (compareData) { + for (const s of series) { + for (const d of compareData) { + const v = Number(d[s.key]); + if (!isNaN(v)) { + if (v < min) min = v; + if (v > max) max = v; + } + } + } + } if (referenceLines) { for (const rl of referenceLines) { if (rl.axis !== 'x') { @@ -199,12 +225,22 @@ export const Line = React.forwardRef( } } } + if (referenceBands) { + for (const rb of referenceBands) { + if (rb.axis !== 'x') { + const lo = Math.min(rb.from, rb.to); + const hi = Math.max(rb.from, rb.to); + if (lo < min) min = lo; + if (hi > max) max = hi; + } + } + } if (min === Infinity) { return { yMin: 0, yMax: 1, yTicks: [0, 1] }; } const result = niceTicks(min, max, tickTarget); return { yMin: result.min, yMax: result.max, yTicks: result.ticks }; - }, [data, series, referenceLines, yDomainProp, tickTarget]); + }, [data, series, compareData, referenceLines, referenceBands, yDomainProp, tickTarget]); const padLeft = React.useMemo(() => { if (!showYAxis) return 0; @@ -221,40 +257,99 @@ export const Line = React.forwardRef( const clipActiveId = `${uid}-clip-active`; const clipInactiveId = `${uid}-clip-inactive`; - // Compute pixel points for each series - const seriesPoints = React.useMemo(() => { - if (plotWidth <= 0 || plotHeight <= 0 || data.length === 0) return []; - return series.map((s) => { + // Compute pixel points for each series (flat list for interpolators, + // grouped by contiguous runs for gap rendering when connectNulls=false). + const { seriesPoints, seriesGroups } = React.useMemo(() => { + if (plotWidth <= 0 || plotHeight <= 0 || data.length === 0) + return { seriesPoints: [] as Point[][], seriesGroups: [] as Point[][][] }; + const allPoints: Point[][] = []; + const allGroups: Point[][][] = []; + for (const s of series) { const points: Point[] = []; + const groups: Point[][] = []; + let currentGroup: Point[] = []; for (let i = 0; i < data.length; i++) { const v = Number(data[i][s.key]); - if (isNaN(v)) continue; + if (isNaN(v)) { + if (!connectNulls && currentGroup.length > 0) { + groups.push(currentGroup); + currentGroup = []; + } + continue; + } const x = data.length === 1 ? plotWidth / 2 : (i / (data.length - 1)) * plotWidth; const y = linearScale(v, yMin, yMax, plotHeight, 0); - points.push({ x, y }); + const pt = { x, y }; + points.push(pt); + currentGroup.push(pt); } - return points; - }); - }, [data, series, plotWidth, plotHeight, yMin, yMax]); + if (currentGroup.length > 0) groups.push(currentGroup); + allPoints.push(points); + allGroups.push(groups); + } + return { seriesPoints: allPoints, seriesGroups: allGroups }; + }, [data, series, plotWidth, plotHeight, yMin, yMax, connectNulls]); // SVG paths const paths = React.useMemo(() => { - const build = curve === 'monotone' ? monotonePath : linearPath; - return seriesPoints.map((pts) => build(pts)); - }, [seriesPoints, curve]); + if (connectNulls) { + const build = curve === 'monotone' ? monotonePath : linearPath; + return seriesPoints.map((pts) => build(pts)); + } + const build = curve === 'monotone' ? monotonePathGroups : linearPathGroups; + return seriesGroups.map((groups) => build(groups)); + }, [seriesPoints, seriesGroups, curve, connectNulls]); // Area paths const areaPaths = React.useMemo(() => { - return seriesPoints.map((pts, i) => { - if (pts.length === 0) return ''; - const firstX = pts[0].x; - const lastX = pts[pts.length - 1].x; - return `${paths[i]} L ${lastX},${plotHeight} L ${firstX},${plotHeight} Z`; + if (connectNulls) { + return seriesPoints.map((pts, i) => { + if (pts.length === 0) return ''; + const firstX = pts[0].x; + const lastX = pts[pts.length - 1].x; + return `${paths[i]} L ${lastX},${plotHeight} L ${firstX},${plotHeight} Z`; + }); + } + const buildPath = curve === 'monotone' ? monotonePath : linearPath; + return seriesGroups.map((groups) => + groups.map((g) => { + if (g.length === 0) return ''; + const d = buildPath(g); + const firstX = g[0].x; + const lastX = g[g.length - 1].x; + return `${d} L ${lastX},${plotHeight} L ${firstX},${plotHeight} Z`; + }).join(''), + ); + }, [seriesPoints, seriesGroups, paths, plotHeight, connectNulls, curve]); + + // Compare data: points and paths for period comparison overlay + const compareLen = compareData ? Math.min(data.length, compareData.length) : 0; + + const compareSeriesPoints = React.useMemo(() => { + if (!compareData || compareLen === 0 || plotWidth <= 0 || plotHeight <= 0) return []; + return series.map((s) => { + const points: Point[] = []; + for (let i = 0; i < compareLen; i++) { + const v = Number(compareData[i][s.key]); + if (isNaN(v)) continue; + const x = + compareLen === 1 + ? plotWidth / 2 + : (i / (data.length - 1)) * plotWidth; + const y = linearScale(v, yMin, yMax, plotHeight, 0); + points.push({ x, y }); + } + return points; }); - }, [seriesPoints, paths, plotHeight]); + }, [compareData, compareLen, series, data.length, plotWidth, plotHeight, yMin, yMax]); + + const comparePaths = React.useMemo(() => { + const build = curve === 'monotone' ? monotonePath : linearPath; + return compareSeriesPoints.map((pts) => build(pts)); + }, [compareSeriesPoints, curve]); // X axis labels const xLabels = React.useMemo(() => { @@ -303,6 +398,7 @@ export const Line = React.forwardRef( interpolatorsRef, data, onActiveChange, + onActivate: onClickDatum, }); const fmtValue = React.useCallback( @@ -346,7 +442,6 @@ export const Line = React.forwardRef( height={height} legend={legend} series={series} - activeIndex={scrub.activeIndex} ariaLiveContent={interactive ? ariaLiveContent : undefined} >
( ))} + {/* Reference bands */} + {referenceBands?.map((rb, i) => { + const bandColor = rb.color ?? 'var(--stroke-primary)'; + if (rb.axis === 'x') { + const x1 = data.length <= 1 ? 0 : (rb.from / (data.length - 1)) * plotWidth; + const x2 = data.length <= 1 ? plotWidth : (rb.to / (data.length - 1)) * plotWidth; + const bx = Math.min(x1, x2); + const bw = Math.abs(x2 - x1); + return ( + + + {rb.label && ( + {rb.label} + )} + + ); + } + const y1 = linearScale(rb.from, yMin, yMax, plotHeight, 0); + const y2 = linearScale(rb.to, yMin, yMax, plotHeight, 0); + const by = Math.min(y1, y2); + const bh = Math.abs(y1 - y2); + return ( + + + {rb.label && ( + {rb.label} + )} + + ); + })} + {/* Reference lines */} {referenceLines?.map((rl, i) => { const rlColor = rl.color ?? 'var(--text-primary)'; @@ -450,6 +576,23 @@ export const Line = React.forwardRef( ); })} + {/* Comparison paths (dashed, behind main data) */} + {comparePaths.map((d, i) => + d ? ( + + ) : null, + )} + {/* Gradient fills */} {areaPaths.map((d, i) => @@ -632,12 +775,33 @@ export const Line = React.forwardRef(
{series.map((s) => { const v = Number(data[scrub.activeIndex!][s.key]); + const cv = compareData && scrub.activeIndex! < compareData.length + ? Number(compareData[scrub.activeIndex!][s.key]) + : NaN; + const delta = !isNaN(v) && !isNaN(cv) ? v - cv : NaN; + const pct = !isNaN(delta) && cv !== 0 + ? ((delta / Math.abs(cv)) * 100).toFixed(1) + : null; return ( -
- - {s.label} - {isNaN(v) ? '--' : fmtValue(v)} -
+ +
+ + {s.label} + {isNaN(v) ? '--' : fmtValue(v)} +
+ {compareData && !isNaN(cv) && ( +
+ + {compareLabel ?? 'Previous'} + + {fmtValue(cv)} + {!isNaN(delta) && ( + <> ({delta >= 0 ? '+' : ''}{fmtValue(delta)}{pct ? `, ${delta >= 0 ? '+' : ''}${pct}%` : ''}) + )} + +
+ )} +
); })}
@@ -648,6 +812,20 @@ export const Line = React.forwardRef( )}
+ {legend && compareData && compareData.length > 0 && ( +
1 ? 0 : undefined }}> +
+ + {compareLabel ?? 'Previous'} +
+
+ )} ); }, diff --git a/src/components/Chart/LiveChart.tsx b/src/components/Chart/LiveChart.tsx index 0ea2790..5007033 100644 --- a/src/components/Chart/LiveChart.tsx +++ b/src/components/Chart/LiveChart.tsx @@ -323,7 +323,7 @@ export const Live = React.forwardRef( const toY = (v: number) => PAD.top + (1 - (v - st.displayMin) / (st.displayMax - st.displayMin)) * chartH; const clampY = (y: number) => Math.max(PAD.top, Math.min(PAD.top + chartH, y)); - // Grid + // Grid lines (drawn before fade so lines fade at the left edge) if (cfg.grid) { const valRange = st.displayMax - st.displayMin; const pxPerUnit = chartH / (valRange || 1); @@ -350,7 +350,6 @@ export const Live = React.forwardRef( if (!st.gridLabels.has(key)) st.gridLabels.set(key, 0.01); } - const fmtVal = cfg.formatValue ?? ((v: number) => v.toFixed(v % 1 === 0 ? 0 : 2)); ctx.lineWidth = 1; for (const [key, alpha] of st.gridLabels) { if (alpha < 0.01) continue; @@ -361,12 +360,6 @@ export const Live = React.forwardRef( ctx.setLineDash([1, 3]); ctx.beginPath(); ctx.moveTo(padLeft, y); ctx.lineTo(padLeft + chartW, y); ctx.stroke(); ctx.setLineDash([]); - ctx.globalAlpha = alpha * 0.4; - ctx.fillStyle = 'rgb(0,0,0)'; - ctx.font = CHART_LABEL_FONT; - ctx.textAlign = 'right'; - ctx.textBaseline = 'middle'; - ctx.fillText(fmtVal(v), padLeft - 8, y); } ctx.globalAlpha = 1; } @@ -418,6 +411,23 @@ export const Live = React.forwardRef( ctx.fillRect(0, 0, padLeft + FADE_EDGE_WIDTH, h); ctx.restore(); + // Y-axis labels (drawn after fade so they remain visible) + if (cfg.grid) { + const fmtVal = cfg.formatValue ?? ((v: number) => v.toFixed(v % 1 === 0 ? 0 : 2)); + ctx.font = CHART_LABEL_FONT; + ctx.textAlign = 'right'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = 'rgb(0,0,0)'; + for (const [key, alpha] of st.gridLabels) { + if (alpha < 0.01) continue; + const v = key / 1000; + const y = Math.round(toY(v)) + 0.5; + ctx.globalAlpha = alpha * 0.4; + ctx.fillText(fmtVal(v), padLeft - 8, y); + } + ctx.globalAlpha = 1; + } + // Time axis if (cfg.grid) { const fmtTime = cfg.formatTime ?? formatDefaultTime; diff --git a/src/components/Chart/LiveDot.tsx b/src/components/Chart/LiveDot.tsx deleted file mode 100644 index 6ea79e9..0000000 --- a/src/components/Chart/LiveDot.tsx +++ /dev/null @@ -1,50 +0,0 @@ -'use client'; - -import * as React from 'react'; -import clsx from 'clsx'; -import styles from './Chart.module.scss'; - -export interface LiveDotProps extends React.ComponentPropsWithoutRef<'span'> { - /** Status determines color: active (green), idle (neutral), error (red), processing (accent). */ - status?: 'active' | 'idle' | 'error' | 'processing'; - /** Show pulsing ring animation. Defaults to true for active and processing. */ - pulse?: boolean; - /** Dot size in px. */ - size?: number; -} - -const STATUS_COLORS: Record = { - active: 'var(--surface-green-strong)', - idle: 'var(--text-tertiary)', - error: 'var(--surface-red-strong)', - processing: 'var(--surface-blue-strong)', -}; - -export const LiveDot = React.forwardRef( - function LiveDot( - { status = 'active', pulse, size = 8, className, style, ...props }, - ref, - ) { - const shouldPulse = pulse ?? (status === 'active' || status === 'processing'); - const color = STATUS_COLORS[status] ?? STATUS_COLORS.active; - - return ( - - ); - }, -); - -if (process.env.NODE_ENV !== 'production') { - LiveDot.displayName = 'Chart.LiveDot'; -} diff --git a/src/components/Chart/LiveValue.tsx b/src/components/Chart/LiveValue.tsx deleted file mode 100644 index 327f3b8..0000000 --- a/src/components/Chart/LiveValue.tsx +++ /dev/null @@ -1,116 +0,0 @@ -'use client'; - -import * as React from 'react'; -import clsx from 'clsx'; -import { filerp } from './utils'; -import styles from './Chart.module.scss'; - -export interface LiveValueProps extends React.ComponentPropsWithoutRef<'span'> { - /** The target value to animate toward. */ - value: number; - /** Interpolation speed (0-1). Higher = snappier. */ - lerpSpeed?: number; - /** Format the displayed value. */ - formatValue?: (v: number) => string; -} - -const MAX_DELTA_MS = 50; - -export const LiveValue = React.forwardRef( - function LiveValue( - { value, lerpSpeed = 0.08, formatValue, className, ...props }, - ref, - ) { - const elRef = React.useRef(null); - const rafRef = React.useRef(0); - const lastFrameRef = React.useRef(0); - const displayRef = React.useRef(value); - const targetRef = React.useRef(value); - const formatRef = React.useRef(formatValue); - const speedRef = React.useRef(lerpSpeed); - - React.useLayoutEffect(() => { - formatRef.current = formatValue; - speedRef.current = lerpSpeed; - }); - - const tick = React.useCallback(() => { - const now = performance.now(); - const dt = lastFrameRef.current ? Math.min(now - lastFrameRef.current, MAX_DELTA_MS) : 16.67; - lastFrameRef.current = now; - - const target = targetRef.current; - let display = displayRef.current; - display = filerp(display, target, speedRef.current, dt); - - const range = Math.abs(target) || 1; - if (Math.abs(display - target) < range * 0.001) display = target; - displayRef.current = display; - - const el = elRef.current; - if (el) { - const fmt = formatRef.current; - el.textContent = fmt ? fmt(display) : display.toFixed(display % 1 === 0 && Math.abs(display - target) < 0.01 ? 0 : 2); - } - - if (display === target) { - rafRef.current = 0; - return; - } - rafRef.current = requestAnimationFrame(tick); - }, []); - - // Restart loop when target changes - React.useEffect(() => { - targetRef.current = value; - if (!rafRef.current) { - lastFrameRef.current = 0; - rafRef.current = requestAnimationFrame(tick); - } - }, [value, tick]); - - React.useEffect(() => { - rafRef.current = requestAnimationFrame(tick); - const onVisibility = () => { - if (!document.hidden && !rafRef.current && displayRef.current !== targetRef.current) { - lastFrameRef.current = 0; - rafRef.current = requestAnimationFrame(tick); - } - }; - document.addEventListener('visibilitychange', onVisibility); - return () => { - if (rafRef.current) cancelAnimationFrame(rafRef.current); - rafRef.current = 0; - document.removeEventListener('visibilitychange', onVisibility); - }; - }, [tick]); - - const mergedRef = React.useCallback( - (node: HTMLSpanElement | null) => { - (elRef as React.MutableRefObject).current = node; - if (typeof ref === 'function') ref(node); - else if (ref) ref.current = node; - }, - [ref], - ); - - const fmt = formatValue ?? ((v: number) => v.toFixed(v % 1 === 0 ? 0 : 2)); - - return ( - - {fmt(value)} - - ); - }, -); - -if (process.env.NODE_ENV !== 'production') { - LiveValue.displayName = 'Chart.LiveValue'; -} diff --git a/src/components/Chart/SankeyChart.tsx b/src/components/Chart/SankeyChart.tsx new file mode 100644 index 0000000..34925e1 --- /dev/null +++ b/src/components/Chart/SankeyChart.tsx @@ -0,0 +1,581 @@ +'use client'; + +import * as React from 'react'; +import clsx from 'clsx'; +import { useResizeWidth } from './hooks'; +import { + type SankeyData, + type LayoutNode, + type LayoutLink, + computeSankeyLayout, + sankeyLinkPath, +} from './sankeyLayout'; +import { SERIES_COLORS } from './types'; +import { measureLabelWidth } from './utils'; +import styles from './Chart.module.scss'; + +export type { SankeyData, LayoutNode, LayoutLink } from './sankeyLayout'; +export type { SankeyNode, SankeyLink } from './sankeyLayout'; + +export interface SankeyChartProps extends React.ComponentPropsWithoutRef<'div'> { + data: SankeyData; + nodeWidth?: number; + nodePadding?: number; + height?: number; + animate?: boolean; + showLabels?: boolean; + showValues?: boolean; + stages?: string[]; + loading?: boolean; + empty?: React.ReactNode; + ariaLabel?: string; + formatValue?: (value: number) => string; + onClickNode?: (node: LayoutNode) => void; + onClickLink?: (link: LayoutLink) => void; +} + +type ActiveElement = + | { type: 'node'; id: string } + | { type: 'link'; sourceId: string; targetId: string } + | null; + +const LABEL_GAP = 8; +const STAGE_HEIGHT = 16; +const STAGE_GAP = 20; +const PAD_BOTTOM = 8; +const LINK_OPACITY = 0.5; +const LINK_OPACITY_DIM = 0.06; +const NODE_OPACITY_DIM = 0.15; + +export const Sankey = React.forwardRef( + function Sankey( + { + data, + nodeWidth = 8, + nodePadding = 12, + height = 350, + animate = true, + showLabels = true, + showValues = false, + stages, + loading, + empty, + ariaLabel, + formatValue, + onClickNode, + onClickLink, + className, + ...props + }, + ref, + ) { + const { width, attachRef } = useResizeWidth(); + const [active, setActive] = React.useState(null); + const tooltipRef = React.useRef(null); + const rootRef = React.useRef(null); + + const mergedRef = React.useCallback( + (node: HTMLDivElement | null) => { + rootRef.current = node; + attachRef(node); + if (typeof ref === 'function') ref(node); + else if (ref) ref.current = node; + }, + [ref, attachRef], + ); + + const hasStages = stages !== undefined && stages.length > 0; + + const fmtValue = React.useCallback( + (v: number) => (formatValue ? formatValue(v) : v.toLocaleString()), + [formatValue], + ); + + const labelPad = React.useMemo(() => { + if (!showLabels) return { left: 0, right: 0, visible: false }; + const sourceIds = new Set(); + const targetIds = new Set(); + for (const link of data.links) { + targetIds.add(link.target); + sourceIds.add(link.source); + } + const leftNodes = data.nodes.filter((n) => !targetIds.has(n.id)); + const rightNodes = data.nodes.filter((n) => !sourceIds.has(n.id)); + + const left = leftNodes.length > 0 + ? Math.ceil(Math.max(...leftNodes.map((n) => measureLabelWidth(n.label)))) + LABEL_GAP + : 0; + + let right = 0; + if (rightNodes.length > 0) { + const nodeValues = new Map(); + for (const node of data.nodes) { + const sumIn = data.links.filter((l) => l.target === node.id).reduce((s, l) => s + l.value, 0); + const sumOut = data.links.filter((l) => l.source === node.id).reduce((s, l) => s + l.value, 0); + nodeValues.set(node.id, Math.max(sumIn, sumOut)); + } + right = Math.ceil(Math.max(...rightNodes.map((n) => { + const base = measureLabelWidth(n.label); + if (!showValues) return base; + const val = nodeValues.get(n.id) ?? 0; + return base + measureLabelWidth(` ${fmtValue(val)}`); + }))) + LABEL_GAP; + } + + if (width > 0 && left + right > width * 0.4) { + return { left: 0, right: 0, visible: false }; + } + + return { left, right, visible: true }; + }, [data.nodes, data.links, showLabels, showValues, fmtValue, width]); + + const padTop = + hasStages && labelPad.visible ? STAGE_HEIGHT + STAGE_GAP : 8; + + const layout = React.useMemo(() => { + const plotWidth = width - labelPad.left - labelPad.right; + const plotHeight = height - padTop - PAD_BOTTOM; + if (plotWidth <= 0 || plotHeight <= 0) return null; + return computeSankeyLayout(data, plotWidth, plotHeight, nodeWidth, nodePadding); + }, [data, width, height, nodeWidth, nodePadding, labelPad, padTop]); + + const maxColumn = React.useMemo( + () => (layout ? Math.max(...layout.nodes.map((n) => n.column), 0) : 0), + [layout], + ); + + const nodesByColumn = React.useMemo(() => { + if (!layout) return new Map(); + const map = new Map(); + for (const node of layout.nodes) { + if (!map.has(node.column)) map.set(node.column, []); + map.get(node.column)!.push(node); + } + for (const col of map.values()) { + col.sort((a, b) => a.y0 - b.y0); + } + return map; + }, [layout]); + + const isNodeConnected = React.useCallback( + (node: LayoutNode): boolean => { + if (!active) return true; + if (active.type === 'node') { + if (node.id === active.id) return true; + return ( + node.sourceLinks.some((l) => l.target === active.id) || + node.targetLinks.some((l) => l.source === active.id) + ); + } + return node.id === active.sourceId || node.id === active.targetId; + }, + [active], + ); + + const isLinkConnected = React.useCallback( + (link: LayoutLink): boolean => { + if (!active) return true; + if (active.type === 'link') { + return ( + link.source === active.sourceId && link.target === active.targetId + ); + } + return link.source === active.id || link.target === active.id; + }, + [active], + ); + + const handleMouseLeave = React.useCallback(() => { + setActive(null); + const tip = tooltipRef.current; + if (tip) tip.style.display = 'none'; + }, []); + + const positionTooltip = React.useCallback( + (e: React.MouseEvent) => { + const tip = tooltipRef.current; + const root = rootRef.current; + if (!tip || !root) return; + const rect = root.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + tip.style.display = ''; + const tipW = tip.offsetWidth; + const gap = 12; + const fitsRight = x + gap + tipW <= width; + const fitsLeft = x - gap - tipW >= 0; + const preferRight = x <= width / 2; + tip.style.left = `${x}px`; + tip.style.top = `${y}px`; + if ((preferRight && fitsRight) || !fitsLeft) { + tip.style.transform = `translate(${gap}px, -50%)`; + } else { + tip.style.transform = `translate(calc(-100% - ${gap}px), -50%)`; + } + }, + [width], + ); + + const tooltipContent = React.useMemo(() => { + if (!active || !layout) return null; + if (active.type === 'node') { + const node = layout.nodes.find((n) => n.id === active.id); + if (!node) return null; + return { label: node.label, value: fmtValue(node.value) }; + } + const link = layout.links.find( + (l) => l.source === active.sourceId && l.target === active.targetId, + ); + if (!link) return null; + return { + label: `${link.sourceNode.label} \u2192 ${link.targetNode.label}`, + value: fmtValue(link.value), + }; + }, [active, layout, fmtValue]); + + const handleKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + if (!layout || layout.nodes.length === 0) return; + + const activeNode = + active?.type === 'node' + ? layout.nodes.find((n) => n.id === active.id) + : null; + + let nextNode: LayoutNode | undefined; + + if (!activeNode) { + nextNode = nodesByColumn.get(0)?.[0]; + } else { + const col = nodesByColumn.get(activeNode.column) ?? []; + const idx = col.findIndex((n) => n.id === activeNode.id); + + switch (e.key) { + case 'ArrowDown': + nextNode = col[Math.min(col.length - 1, idx + 1)]; + break; + case 'ArrowUp': + nextNode = col[Math.max(0, idx - 1)]; + break; + case 'ArrowRight': { + const next = nodesByColumn.get(activeNode.column + 1); + nextNode = next?.[0]; + break; + } + case 'ArrowLeft': { + const prev = nodesByColumn.get(activeNode.column - 1); + nextNode = prev?.[0]; + break; + } + case 'Home': + nextNode = nodesByColumn.get(0)?.[0]; + break; + case 'End': { + const lastCol = nodesByColumn.get(maxColumn) ?? []; + nextNode = lastCol[lastCol.length - 1]; + break; + } + case 'Enter': + case ' ': { + if (onClickNode && activeNode) { + e.preventDefault(); + onClickNode(activeNode); + } + return; + } + case 'Escape': + handleMouseLeave(); + return; + default: + return; + } + } + + if (nextNode) { + e.preventDefault(); + setActive({ type: 'node', id: nextNode.id }); + const tip = tooltipRef.current; + if (tip) { + const x = labelPad.left + (nextNode.x0 + nextNode.x1) / 2; + const y = padTop + (nextNode.y0 + nextNode.y1) / 2; + tip.style.display = ''; + const tipW = tip.offsetWidth; + const gap = 12; + const fitsRight = x + gap + tipW <= width; + const fitsLeft = x - gap - tipW >= 0; + const preferRight = x <= width / 2; + tip.style.left = `${x}px`; + tip.style.top = `${y}px`; + if ((preferRight && fitsRight) || !fitsLeft) { + tip.style.transform = `translate(${gap}px, -50%)`; + } else { + tip.style.transform = `translate(calc(-100% - ${gap}px), -50%)`; + } + } + } + }, + [active, layout, nodesByColumn, maxColumn, labelPad, padTop, width, onClickNode, handleMouseLeave], + ); + + if (loading) { + return ( +
+
+
+
+
+ ); + } + + if (data.nodes.length === 0 && empty !== undefined) { + return ( +
+
+ {typeof empty === 'boolean' ? 'No data' : empty} +
+
+ ); + } + + const ready = width > 0 && layout; + + const svgDesc = layout + ? `Flow diagram with ${layout.nodes.length} nodes and ${layout.links.length} connections.` + : undefined; + + return ( +
+ {ready && ( + <> + + {svgDesc && {svgDesc}} + + {hasStages && labelPad.visible && ( + + {stages.map((label, i) => { + const col = nodesByColumn.get(i); + if (!col || col.length === 0) return null; + const cx = + labelPad.left + + (col[0].x0 + col[0].x1) / 2; + return ( + + {label} + + ); + })} + + )} + + + + {layout.links.map((link, i) => { + const connected = isLinkConnected(link); + const colDelay = Math.min( + link.sourceNode.column * 100, + 400, + ); + return ( + { + setActive({ + type: 'link', + sourceId: link.source, + targetId: link.target, + }); + positionTooltip(e); + }} + onMouseMove={positionTooltip} + onClick={ + onClickLink + ? () => onClickLink(link) + : undefined + } + /> + ); + })} + + + + {layout.nodes.map((node) => { + const connected = isNodeConnected(node); + const colDelay = Math.min(node.column * 100, 400); + return ( + { + setActive({ type: 'node', id: node.id }); + positionTooltip(e); + }} + onMouseMove={positionTooltip} + onClick={ + onClickNode + ? () => onClickNode(node) + : undefined + } + /> + ); + })} + + + {labelPad.visible && ( + + {layout.nodes.map((node) => { + const isFirst = node.column === 0; + const isLast = node.column === maxColumn; + const midY = (node.y0 + node.y1) / 2; + + let lx: number; + let ly: number; + let anchor: 'start' | 'middle' | 'end'; + + if (isFirst) { + lx = node.x0 - LABEL_GAP; + ly = midY; + anchor = 'end'; + } else if (isLast) { + lx = node.x1 + LABEL_GAP; + ly = midY; + anchor = 'start'; + } else { + lx = (node.x0 + node.x1) / 2; + ly = node.y0 - 6; + anchor = 'middle'; + } + + return ( + + {node.label} + {showValues && isLast && ( + + {' '} + {fmtValue(node.value)} + + )} + + ); + })} + + )} + + + +
+ {tooltipContent && ( +
+
+ + {tooltipContent.label} + + + {tooltipContent.value} + +
+
+ )} +
+ + )} +
+ ); + }, +); + +if (process.env.NODE_ENV !== 'production') { + Sankey.displayName = 'Chart.Sankey'; +} diff --git a/src/components/Chart/ScatterChart.tsx b/src/components/Chart/ScatterChart.tsx new file mode 100644 index 0000000..2cf7888 --- /dev/null +++ b/src/components/Chart/ScatterChart.tsx @@ -0,0 +1,570 @@ +'use client'; + +import * as React from 'react'; +import clsx from 'clsx'; +import { linearScale, niceTicks, thinIndices, axisPadForLabels } from './utils'; +import { useResizeWidth } from './hooks'; +import { + type TooltipProp, + type ReferenceLine, + type ReferenceBand, + PAD_TOP, + PAD_RIGHT, + PAD_BOTTOM_AXIS, + SERIES_COLORS, + TOOLTIP_GAP, + resolveTooltipMode, + axisTickTarget, +} from './types'; +import { ChartWrapper } from './ChartWrapper'; +import styles from './Chart.module.scss'; + +export interface ScatterPoint { + x: number; + y: number; + label?: string; + color?: string; + size?: number; +} + +export interface ScatterSeries { + key: string; + label?: string; + color?: string; + data: ScatterPoint[]; +} + +export interface ScatterChartProps extends React.ComponentPropsWithoutRef<'div'> { + data: ScatterSeries[]; + height?: number; + grid?: boolean; + tooltip?: TooltipProp; + dotSize?: number; + referenceLines?: ReferenceLine[]; + /** Shaded bands spanning a value range. Rendered behind dots. */ + referenceBands?: ReferenceBand[]; + ariaLabel?: string; + animate?: boolean; + legend?: boolean; + loading?: boolean; + empty?: React.ReactNode; + formatValue?: (value: number) => string; + formatXLabel?: (value: number) => string; + formatYLabel?: (value: number) => string; + xDomain?: [number, number]; + yDomain?: [number, number]; + onClickDatum?: (seriesKey: string, point: ScatterPoint, index: number) => void; +} + +interface ResolvedScatterSeries { + key: string; + label: string; + color: string; + data: ScatterPoint[]; +} + +interface ActiveDot { + seriesIndex: number; + pointIndex: number; + point: ScatterPoint; + series: ResolvedScatterSeries; +} + +export const Scatter = React.forwardRef( + function Scatter( + { + data, + height = 300, + grid = false, + tooltip: tooltipProp, + dotSize = 4, + referenceLines, + referenceBands, + ariaLabel, + animate = true, + legend, + loading, + empty, + formatValue, + formatXLabel, + formatYLabel, + xDomain: xDomainProp, + yDomain: yDomainProp, + onClickDatum, + className, + ...props + }, + ref, + ) { + const { width, attachRef } = useResizeWidth(); + const tooltipRef = React.useRef(null); + const [activeDot, setActiveDot] = React.useState(null); + + const tooltipMode = resolveTooltipMode(tooltipProp); + const showTooltip = tooltipMode !== 'off'; + const tooltipRender = + typeof tooltipProp === 'function' ? tooltipProp : undefined; + + const mergedRef = React.useCallback( + (node: HTMLDivElement | null) => { + attachRef(node); + if (typeof ref === 'function') ref(node); + else if (ref) ref.current = node; + }, + [ref, attachRef], + ); + + const series = React.useMemo( + () => + data.map((s, i) => ({ + key: s.key, + label: s.label ?? s.key, + color: s.color ?? SERIES_COLORS[i % SERIES_COLORS.length], + data: s.data, + })), + [data], + ); + + const totalPoints = React.useMemo( + () => series.reduce((sum, s) => sum + s.data.length, 0), + [series], + ); + + const showXAxis = true; + const showYAxis = grid; + const padBottom = showXAxis ? PAD_BOTTOM_AXIS : 0; + const plotHeight = Math.max(0, height - PAD_TOP - padBottom); + + const yTickTarget = axisTickTarget(plotHeight); + + const { yMin, yMax, yTicks } = React.useMemo(() => { + if (yDomainProp) { + const result = niceTicks(yDomainProp[0], yDomainProp[1], yTickTarget); + return { yMin: result.min, yMax: result.max, yTicks: result.ticks }; + } + let min = Infinity; + let max = -Infinity; + for (const s of series) { + for (const p of s.data) { + if (p.y < min) min = p.y; + if (p.y > max) max = p.y; + } + } + if (referenceLines) { + for (const rl of referenceLines) { + if (rl.axis !== 'x') { + if (rl.value < min) min = rl.value; + if (rl.value > max) max = rl.value; + } + } + } + if (referenceBands) { + for (const rb of referenceBands) { + if (rb.axis !== 'x') { + const lo = Math.min(rb.from, rb.to); + const hi = Math.max(rb.from, rb.to); + if (lo < min) min = lo; + if (hi > max) max = hi; + } + } + } + if (min === Infinity) return { yMin: 0, yMax: 1, yTicks: [0, 1] }; + const result = niceTicks(min, max, yTickTarget); + return { yMin: result.min, yMax: result.max, yTicks: result.ticks }; + }, [series, referenceLines, referenceBands, yDomainProp, yTickTarget]); + + const padLeft = React.useMemo(() => { + if (!showYAxis) return 0; + const fmt = formatYLabel ?? ((v: number) => String(v)); + return axisPadForLabels(yTicks.map(fmt)); + }, [showYAxis, yTicks, formatYLabel]); + const plotWidth = Math.max(0, width - padLeft - PAD_RIGHT); + + const xTickTarget = axisTickTarget(plotWidth, true); + + const { xMin, xMax, xTicks } = React.useMemo(() => { + if (xDomainProp) { + const result = niceTicks(xDomainProp[0], xDomainProp[1], xTickTarget); + return { xMin: result.min, xMax: result.max, xTicks: result.ticks }; + } + let min = Infinity; + let max = -Infinity; + for (const s of series) { + for (const p of s.data) { + if (p.x < min) min = p.x; + if (p.x > max) max = p.x; + } + } + if (min === Infinity) return { xMin: 0, xMax: 1, xTicks: [0, 1] }; + const result = niceTicks(min, max, xTickTarget); + return { xMin: result.min, xMax: result.max, xTicks: result.ticks }; + }, [series, xDomainProp, xTickTarget]); + + const yLabels = React.useMemo(() => { + if (!showYAxis || plotHeight <= 0) return []; + return yTicks.map((v) => ({ + y: linearScale(v, yMin, yMax, plotHeight, 0), + text: formatYLabel ? formatYLabel(v) : String(v), + })); + }, [showYAxis, yTicks, yMin, yMax, plotHeight, formatYLabel]); + + const xLabels = React.useMemo(() => { + if (plotWidth <= 0) return []; + const maxLabels = Math.max(2, Math.floor(plotWidth / 60)); + const indices = thinIndices(xTicks.length, maxLabels); + return indices.map((i) => ({ + x: linearScale(xTicks[i], xMin, xMax, 0, plotWidth), + text: formatXLabel ? formatXLabel(xTicks[i]) : String(xTicks[i]), + })); + }, [xTicks, xMin, xMax, plotWidth, formatXLabel]); + + const screenPoints = React.useMemo(() => { + if (plotWidth <= 0 || plotHeight <= 0) return []; + return series.map((s) => + s.data.map((p) => ({ + sx: linearScale(p.x, xMin, xMax, 0, plotWidth), + sy: linearScale(p.y, yMin, yMax, plotHeight, 0), + point: p, + })), + ); + }, [series, xMin, xMax, yMin, yMax, plotWidth, plotHeight]); + + const findNearest = React.useCallback( + (mouseX: number, mouseY: number): ActiveDot | null => { + let best: ActiveDot | null = null; + let bestDist = Infinity; + const threshold = 20; + for (let si = 0; si < screenPoints.length; si++) { + for (let pi = 0; pi < screenPoints[si].length; pi++) { + const { sx, sy, point } = screenPoints[si][pi]; + const dx = mouseX - sx; + const dy = mouseY - sy; + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist < bestDist && dist < threshold) { + bestDist = dist; + best = { seriesIndex: si, pointIndex: pi, point, series: series[si] }; + } + } + } + return best; + }, + [screenPoints, series], + ); + + const positionTooltip = React.useCallback( + (sx: number, sy: number) => { + const tip = tooltipRef.current; + if (!tip) return; + const absX = padLeft + sx; + const absY = PAD_TOP + sy; + const totalW = padLeft + plotWidth + PAD_RIGHT; + tip.style.display = ''; + const tipW = tip.offsetWidth; + const fitsRight = absX + TOOLTIP_GAP + tipW <= totalW; + const fitsLeft = absX - TOOLTIP_GAP - tipW >= 0; + const preferRight = sx <= plotWidth / 2; + tip.style.left = `${absX}px`; + tip.style.top = `${absY}px`; + if ((preferRight && fitsRight) || !fitsLeft) { + tip.style.transform = `translate(${TOOLTIP_GAP}px, -50%)`; + } else { + tip.style.transform = `translate(calc(-100% - ${TOOLTIP_GAP}px), -50%)`; + } + }, + [padLeft, plotWidth], + ); + + const handleMouseMove = React.useCallback( + (e: React.MouseEvent) => { + const rect = e.currentTarget.getBoundingClientRect(); + const mx = e.clientX - rect.left - padLeft; + const my = e.clientY - rect.top - PAD_TOP; + const nearest = findNearest(mx, my); + setActiveDot(nearest); + if (nearest) { + const sp = screenPoints[nearest.seriesIndex][nearest.pointIndex]; + positionTooltip(sp.sx, sp.sy); + } else { + const tip = tooltipRef.current; + if (tip) tip.style.display = 'none'; + } + }, + [padLeft, findNearest, screenPoints, positionTooltip], + ); + + const handleMouseLeave = React.useCallback(() => { + setActiveDot(null); + const tip = tooltipRef.current; + if (tip) tip.style.display = 'none'; + }, []); + + const fmtValue = React.useCallback( + (v: number) => (formatValue ? formatValue(v) : String(v)), + [formatValue], + ); + + const handleClick = React.useCallback(() => { + if (!onClickDatum || !activeDot) return; + onClickDatum(activeDot.series.key, activeDot.point, activeDot.pointIndex); + }, [onClickDatum, activeDot]); + + const ready = width > 0; + + const svgDesc = React.useMemo(() => { + if (series.length === 0 || totalPoints === 0) return undefined; + const names = series.map((s) => s.label).join(', '); + return `Scatter chart with ${totalPoints} points showing ${names}.`; + }, [series, totalPoints]); + + const ariaLiveContent = React.useMemo(() => { + if (!activeDot) return ''; + const parts = [activeDot.series.label]; + if (activeDot.point.label) parts.push(activeDot.point.label); + parts.push(`x: ${fmtValue(activeDot.point.x)}, y: ${fmtValue(activeDot.point.y)}`); + return parts.join(', '); + }, [activeDot, fmtValue]); + + const allPointsFlat = React.useMemo(() => { + const result: ActiveDot[] = []; + for (let si = 0; si < series.length; si++) { + for (let pi = 0; pi < series[si].data.length; pi++) { + result.push({ seriesIndex: si, pointIndex: pi, point: series[si].data[pi], series: series[si] }); + } + } + return result; + }, [series]); + + const handleKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + if (allPointsFlat.length === 0) return; + const currentIdx = activeDot + ? allPointsFlat.findIndex((p) => p.seriesIndex === activeDot.seriesIndex && p.pointIndex === activeDot.pointIndex) + : -1; + let next = currentIdx; + switch (e.key) { + case 'ArrowRight': case 'ArrowDown': next = Math.min(allPointsFlat.length - 1, next + 1); break; + case 'ArrowLeft': case 'ArrowUp': next = Math.max(0, next - 1); break; + case 'Home': next = 0; break; + case 'End': next = allPointsFlat.length - 1; break; + case 'Enter': case ' ': + if (onClickDatum && activeDot) { + e.preventDefault(); + onClickDatum(activeDot.series.key, activeDot.point, activeDot.pointIndex); + } + return; + case 'Escape': handleMouseLeave(); return; + default: return; + } + e.preventDefault(); + const dot = allPointsFlat[next]; + setActiveDot(dot); + const sp = screenPoints[dot.seriesIndex][dot.pointIndex]; + positionTooltip(sp.sx, sp.sy); + }, + [allPointsFlat, activeDot, screenPoints, onClickDatum, handleMouseLeave, positionTooltip], + ); + + const legendSeries = React.useMemo( + () => series.map((s) => ({ key: s.key, label: s.label, color: s.color, style: 'solid' as const })), + [series], + ); + + return ( + +
+ {ready && ( + <> + { + if (!e.touches[0]) return; + const rect = e.currentTarget.getBoundingClientRect(); + const mx = e.touches[0].clientX - rect.left - padLeft; + const my = e.touches[0].clientY - rect.top - PAD_TOP; + const nearest = findNearest(mx, my); + setActiveDot(nearest); + if (nearest) { + const sp = screenPoints[nearest.seriesIndex][nearest.pointIndex]; + positionTooltip(sp.sx, sp.sy); + } + }} + onTouchMove={(e) => { + if (!e.touches[0]) return; + const rect = e.currentTarget.getBoundingClientRect(); + const mx = e.touches[0].clientX - rect.left - padLeft; + const my = e.touches[0].clientY - rect.top - PAD_TOP; + const nearest = findNearest(mx, my); + setActiveDot(nearest); + if (nearest) { + const sp = screenPoints[nearest.seriesIndex][nearest.pointIndex]; + positionTooltip(sp.sx, sp.sy); + } else { + const tip = tooltipRef.current; + if (tip) tip.style.display = 'none'; + } + }} + onTouchEnd={handleMouseLeave} + onTouchCancel={handleMouseLeave} + onKeyDown={handleKeyDown} + > + {svgDesc && {svgDesc}} + + + {grid && + yLabels.map(({ y }, i) => ( + + ))} + + {/* Reference bands */} + {referenceBands?.map((rb, i) => { + const bandColor = rb.color ?? 'var(--stroke-primary)'; + if (rb.axis === 'x') { + const x1 = linearScale(rb.from, xMin, xMax, 0, plotWidth); + const x2 = linearScale(rb.to, xMin, xMax, 0, plotWidth); + const bx = Math.min(x1, x2); + const bw = Math.abs(x2 - x1); + return ( + + + {rb.label && ( + {rb.label} + )} + + ); + } + const y1 = linearScale(rb.from, yMin, yMax, plotHeight, 0); + const y2 = linearScale(rb.to, yMin, yMax, plotHeight, 0); + const by = Math.min(y1, y2); + const bh = Math.abs(y1 - y2); + return ( + + + {rb.label && ( + {rb.label} + )} + + ); + })} + + {referenceLines?.map((rl, i) => { + const rlColor = rl.color ?? 'var(--text-primary)'; + if (rl.axis === 'x') { + const rx = linearScale(rl.value, xMin, xMax, 0, plotWidth); + return ( + + + {rl.label && {rl.label}} + + ); + } + const ry = linearScale(rl.value, yMin, yMax, plotHeight, 0); + return ( + + + {rl.label && {rl.label}} + + ); + })} + + {screenPoints.map((pts, si) => + pts.map(({ sx, sy, point }, pi) => { + const isActive = activeDot?.seriesIndex === si && activeDot?.pointIndex === pi; + const r = point.size ?? dotSize; + return ( + + ); + }), + )} + + {yLabels.map(({ y, text }, i) => ( + {text} + ))} + + {xLabels.map(({ x, text }, i) => ( + {text} + ))} + + + + {showTooltip && ( +
+ {activeDot && + (tooltipMode === 'custom' && tooltipRender ? ( + tooltipRender( + { x: activeDot.point.x, y: activeDot.point.y, label: activeDot.point.label }, + legendSeries, + ) + ) : ( + <> + {activeDot.point.label && ( +

{activeDot.point.label}

+ )} +
+
+ + {activeDot.series.label} +
+
+ x + {fmtValue(activeDot.point.x)} +
+
+ y + {fmtValue(activeDot.point.y)} +
+
+ + ))} +
+ )} + + )} +
+
+ ); + }, +); + +if (process.env.NODE_ENV !== 'production') { + Scatter.displayName = 'Chart.Scatter'; +} diff --git a/src/components/Chart/SplitChart.tsx b/src/components/Chart/SplitChart.tsx new file mode 100644 index 0000000..ec852aa --- /dev/null +++ b/src/components/Chart/SplitChart.tsx @@ -0,0 +1,185 @@ +'use client'; + +import * as React from 'react'; +import clsx from 'clsx'; +import { SERIES_COLORS } from './types'; +import styles from './Chart.module.scss'; + +export interface SplitSegment { + key?: string; + label: string; + value: number; + color?: string; +} + +export interface SplitChartProps extends React.ComponentPropsWithoutRef<'div'> { + data: SplitSegment[]; + formatValue?: (value: number) => string; + showPercentage?: boolean; + showValues?: boolean; + height?: number; + legend?: boolean; + loading?: boolean; + empty?: React.ReactNode; + ariaLabel?: string; + onClickDatum?: (segment: SplitSegment, index: number) => void; +} + +export const Split = React.forwardRef( + function Split( + { + data, + formatValue, + showPercentage = true, + showValues = false, + height = 24, + legend = true, + loading, + empty, + ariaLabel, + onClickDatum, + className, + ...props + }, + ref, + ) { + const [activeIndex, setActiveIndex] = React.useState(null); + const barRef = React.useRef(null); + + const handleKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + if (data.length === 0) return; + let next = activeIndex ?? -1; + switch (e.key) { + case 'ArrowRight': + case 'ArrowDown': + next = Math.min(data.length - 1, next + 1); + break; + case 'ArrowLeft': + case 'ArrowUp': + next = Math.max(0, next - 1); + break; + case 'Home': + next = 0; + break; + case 'End': + next = data.length - 1; + break; + case 'Escape': + setActiveIndex(null); + return; + case 'Enter': + case ' ': + if (onClickDatum && activeIndex !== null && activeIndex < data.length) { + e.preventDefault(); + onClickDatum(data[activeIndex], activeIndex); + } + return; + default: + return; + } + e.preventDefault(); + setActiveIndex(next); + }, + [activeIndex, data, onClickDatum], + ); + + if (loading) { + return ( +
+
+
+
+
+ ); + } + + if (data.length === 0 && empty !== undefined) { + return ( +
+
+ {typeof empty === 'boolean' ? 'No data' : empty} +
+
+ ); + } + + const total = data.reduce((sum, d) => sum + d.value, 0); + const fmtValue = (v: number) => (formatValue ? formatValue(v) : String(v)); + + const segments = data.map((d, i) => ({ + ...d, + color: d.color ?? SERIES_COLORS[i % SERIES_COLORS.length], + pct: total > 0 ? (d.value / total) * 100 : 0, + })); + + const desc = ariaLabel ?? + `Distribution: ${segments.map((s) => `${s.label} ${Math.round(s.pct)}%`).join(', ')}`; + + return ( +
+
setActiveIndex(null)} + > + {segments.map((seg, i) => { + return ( +
setActiveIndex(i)} + onMouseLeave={() => setActiveIndex(null)} + onClick={onClickDatum ? () => onClickDatum(seg, i) : undefined} + /> + ); + })} +
+ + {legend && ( +
+ {activeIndex !== null && segments[activeIndex] ? ( +
+ + + {segments[activeIndex].label} + {showValues && ` ${fmtValue(segments[activeIndex].value)}`} + {showPercentage && ` (${Math.round(segments[activeIndex].pct)}%)`} + +
+ ) : ( + segments.map((seg, i) => ( +
+ + + {seg.label} + {showPercentage && ` (${Math.round(seg.pct)}%)`} + +
+ )) + )} +
+ )} +
+ ); + }, +); + +if (process.env.NODE_ENV !== 'production') { + Split.displayName = 'Chart.Split'; +} diff --git a/src/components/Chart/StackedAreaChart.tsx b/src/components/Chart/StackedAreaChart.tsx index dbe1ea6..6a701e8 100644 --- a/src/components/Chart/StackedAreaChart.tsx +++ b/src/components/Chart/StackedAreaChart.tsx @@ -20,6 +20,7 @@ import { type ResolvedSeries, type TooltipProp, type ReferenceLine, + type ReferenceBand, PAD_TOP, PAD_RIGHT, PAD_BOTTOM_AXIS, @@ -41,6 +42,8 @@ export interface StackedAreaChartProps extends React.ComponentPropsWithoutRef<'d fillOpacity?: number; /** Horizontal reference lines at specific y-values. */ referenceLines?: ReferenceLine[]; + /** Shaded bands spanning a value range. Rendered behind area bands. */ + referenceBands?: ReferenceBand[]; /** Fixed Y-axis domain. When omitted, auto-scales from stacked totals. */ yDomain?: [number, number]; /** Show a legend below the chart for multi-series. */ @@ -49,8 +52,6 @@ export interface StackedAreaChartProps extends React.ComponentPropsWithoutRef<'d loading?: boolean; /** Content to show when data is empty. `true` for default message. */ empty?: React.ReactNode; - /** Control animation. Currently a no-op — provided for API consistency with other chart types. */ - animate?: boolean; ariaLabel?: string; onActiveChange?: ( index: number | null, @@ -78,11 +79,11 @@ export const StackedArea = React.forwardRef max) max = rl.value; } } + if (referenceBands) { + for (const rb of referenceBands) { + const hi = Math.max(rb.from, rb.to); + if (hi > max) max = hi; + } + } if (max === -Infinity) max = 1; const result = niceTicks(0, max, tickTarget); return { yMin: result.min, yMax: result.max, yTicks: result.ticks }; - }, [stacked, referenceLines, yDomainProp, tickTarget]); + }, [stacked, referenceLines, referenceBands, yDomainProp, tickTarget]); const padLeft = React.useMemo(() => { if (!showYAxis) return 0; @@ -222,6 +229,7 @@ export const StackedArea = React.forwardRef
))} + {/* Reference bands */} + {referenceBands?.map((rb, i) => { + const bandColor = rb.color ?? 'var(--stroke-primary)'; + if (rb.axis === 'x') { + const x1 = data.length <= 1 ? 0 : (rb.from / (data.length - 1)) * plotWidth; + const x2 = data.length <= 1 ? plotWidth : (rb.to / (data.length - 1)) * plotWidth; + const bx = Math.min(x1, x2); + const bw = Math.abs(x2 - x1); + return ( + + + {rb.label && ( + {rb.label} + )} + + ); + } + const y1 = linearScale(rb.from, yMin, yMax, plotHeight, 0); + const y2 = linearScale(rb.to, yMin, yMax, plotHeight, 0); + const by = Math.min(y1, y2); + const bh = Math.abs(y1 - y2); + return ( + + + {rb.label && ( + {rb.label} + )} + + ); + })} + {/* Reference lines */} {referenceLines?.map((rl, i) => { const ry = linearScale(rl.value, yMin, yMax, plotHeight, 0); diff --git a/src/components/Chart/WaterfallChart.tsx b/src/components/Chart/WaterfallChart.tsx new file mode 100644 index 0000000..750918f --- /dev/null +++ b/src/components/Chart/WaterfallChart.tsx @@ -0,0 +1,459 @@ +'use client'; + +import * as React from 'react'; +import clsx from 'clsx'; +import { linearScale, niceTicks, thinIndices, axisPadForLabels } from './utils'; +import { useResizeWidth } from './hooks'; +import { PAD_TOP, PAD_RIGHT, PAD_BOTTOM_AXIS, TOOLTIP_GAP, axisTickTarget } from './types'; +import { ChartWrapper } from './ChartWrapper'; +import styles from './Chart.module.scss'; + +export interface WaterfallSegment { + key?: string; + label: string; + value: number; + type?: 'increase' | 'decrease' | 'total'; + color?: string; +} + +export interface WaterfallChartProps extends React.ComponentPropsWithoutRef<'div'> { + data: WaterfallSegment[]; + formatValue?: (value: number) => string; + formatYLabel?: (value: number) => string; + showConnectors?: boolean; + showValues?: boolean; + height?: number; + grid?: boolean; + animate?: boolean; + tooltip?: boolean; + loading?: boolean; + empty?: React.ReactNode; + ariaLabel?: string; + onClickDatum?: (index: number, segment: WaterfallSegment) => void; +} + +interface ComputedBar { + y0: number; + y1: number; + runningTotal: number; + segmentType: 'increase' | 'decrease' | 'total'; + fill: string; +} + +function resolveType(seg: WaterfallSegment): 'increase' | 'decrease' | 'total' { + if (seg.type) return seg.type; + return seg.value >= 0 ? 'increase' : 'decrease'; +} + +const DEFAULT_COLORS: Record = { + increase: 'var(--color-green-500)', + decrease: 'var(--color-red-500)', + total: 'var(--color-blue-500)', +}; + +export const Waterfall = React.forwardRef( + function Waterfall( + { + data, + formatValue, + formatYLabel, + showConnectors = true, + showValues = false, + height = 300, + grid = false, + animate = true, + tooltip: tooltipProp = false, + loading, + empty, + ariaLabel, + onClickDatum, + className, + ...props + }, + ref, + ) { + const { width, attachRef } = useResizeWidth(); + const tooltipRef = React.useRef(null); + const [activeIndex, setActiveIndex] = React.useState(null); + + const mergedRef = React.useCallback( + (node: HTMLDivElement | null) => { + attachRef(node); + if (typeof ref === 'function') ref(node); + else if (ref) ref.current = node; + }, + [ref, attachRef], + ); + + const bars = React.useMemo(() => { + let running = 0; + return data.map((seg) => { + const segType = resolveType(seg); + const fill = seg.color ?? DEFAULT_COLORS[segType]; + + if (segType === 'total') { + const bar: ComputedBar = { + y0: 0, + y1: running, + runningTotal: running, + segmentType: segType, + fill, + }; + return bar; + } + + const prevRunning = running; + running += seg.value; + return { + y0: prevRunning, + y1: running, + runningTotal: running, + segmentType: segType, + fill, + }; + }); + }, [data]); + + const padBottom = PAD_BOTTOM_AXIS; + const plotHeight = Math.max(0, height - PAD_TOP - padBottom); + + const allValues = React.useMemo(() => { + const vals: number[] = [0]; + for (const bar of bars) { + vals.push(bar.y0, bar.y1); + } + return vals; + }, [bars]); + + const tickTarget = axisTickTarget(plotHeight); + const { yMin, yMax, yTicks } = React.useMemo(() => { + const dataMin = Math.min(...allValues); + const dataMax = Math.max(...allValues); + const result = niceTicks(dataMin, dataMax, tickTarget); + return { yMin: result.min, yMax: result.max, yTicks: result.ticks }; + }, [allValues, tickTarget]); + + const padLeft = React.useMemo(() => { + if (!grid) return 0; + const fmt = formatYLabel ?? ((v: number) => String(v)); + return axisPadForLabels(yTicks.map(fmt)); + }, [grid, yTicks, formatYLabel]); + + const plotWidth = Math.max(0, width - padLeft - PAD_RIGHT); + + const slotSize = data.length > 0 ? plotWidth / data.length : 0; + const barWidth = slotSize * 0.6; + + const valueLabels = React.useMemo(() => { + if (!grid) return []; + if (plotHeight <= 0) return []; + const fmt = formatYLabel ?? ((v: number) => String(v)); + return yTicks.map((v) => ({ + pos: linearScale(v, yMin, yMax, plotHeight, 0), + text: fmt(v), + })); + }, [grid, yTicks, yMin, yMax, plotHeight, formatYLabel]); + + const fmtValue = React.useCallback( + (v: number) => (formatValue ? formatValue(v) : String(v)), + [formatValue], + ); + + const handleMouseMove = React.useCallback( + (e: React.MouseEvent) => { + if (data.length === 0 || plotWidth <= 0) return; + const rect = e.currentTarget.getBoundingClientRect(); + const raw = e.clientX - rect.left - padLeft; + const idx = Math.max(0, Math.min(data.length - 1, Math.floor(raw / slotSize))); + setActiveIndex((prev) => (prev === idx ? prev : idx)); + + const tip = tooltipRef.current; + if (tip) { + const absX = padLeft + (idx + 0.5) * slotSize; + const isLeftHalf = raw <= plotWidth / 2; + tip.style.left = `${absX}px`; + tip.style.top = `${PAD_TOP}px`; + tip.style.transform = isLeftHalf + ? `translateX(${TOOLTIP_GAP}px)` + : `translateX(calc(-100% - ${TOOLTIP_GAP}px))`; + tip.style.display = ''; + } + }, + [data.length, plotWidth, padLeft, slotSize], + ); + + const handleMouseLeave = React.useCallback(() => { + setActiveIndex(null); + const tip = tooltipRef.current; + if (tip) tip.style.display = 'none'; + }, []); + + const handleTouch = React.useCallback( + (e: React.TouchEvent) => { + if (!e.touches[0]) return; + const rect = e.currentTarget.getBoundingClientRect(); + const raw = e.touches[0].clientX - rect.left - padLeft; + const idx = Math.max(0, Math.min(data.length - 1, Math.floor(raw / slotSize))); + setActiveIndex(idx); + }, + [data.length, slotSize, padLeft], + ); + + const handleKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + if (data.length === 0) return; + let next = activeIndex ?? -1; + switch (e.key) { + case 'ArrowRight': + case 'ArrowDown': + next = Math.min(data.length - 1, next + 1); + break; + case 'ArrowLeft': + case 'ArrowUp': + next = Math.max(0, next - 1); + break; + case 'Home': + next = 0; + break; + case 'End': + next = data.length - 1; + break; + case 'Enter': + case ' ': + if (onClickDatum && activeIndex !== null && activeIndex < data.length) { + e.preventDefault(); + onClickDatum(activeIndex, data[activeIndex]); + } + return; + case 'Escape': + handleMouseLeave(); + return; + default: + return; + } + e.preventDefault(); + setActiveIndex(next); + const tip = tooltipRef.current; + if (tip) { + const absX = padLeft + (next + 0.5) * slotSize; + tip.style.left = `${absX}px`; + tip.style.top = `${PAD_TOP}px`; + tip.style.transform = next < data.length / 2 + ? `translateX(${TOOLTIP_GAP}px)` + : `translateX(calc(-100% - ${TOOLTIP_GAP}px))`; + tip.style.display = ''; + } + }, + [activeIndex, data, slotSize, padLeft, onClickDatum, handleMouseLeave], + ); + + const interactive = tooltipProp !== false || !!onClickDatum; + + const handleClick = React.useCallback(() => { + if (onClickDatum && activeIndex !== null && activeIndex < data.length) { + onClickDatum(activeIndex, data[activeIndex]); + } + }, [onClickDatum, activeIndex, data]); + + const ready = width > 0; + + const svgDesc = React.useMemo(() => { + if (data.length === 0) return undefined; + return `Waterfall chart with ${data.length} segments.`; + }, [data.length]); + + const xLabelIndices = React.useMemo(() => { + const maxLabels = Math.max(2, Math.floor(plotWidth / 60)); + return thinIndices(data.length, maxLabels); + }, [data.length, plotWidth]); + + return ( + +
+ {ready && ( + <> + + {svgDesc && {svgDesc}} + + + {grid && valueLabels.map(({ pos }, i) => ( + + ))} + + {activeIndex !== null && ( + + )} + + {showConnectors && bars.map((bar, i) => { + if (i >= bars.length - 1) return null; + const endValue = bar.y1; + const connectorY = linearScale(endValue, yMin, yMax, plotHeight, 0); + const x1 = (i + 0.5) * slotSize + barWidth / 2; + const x2 = (i + 1 + 0.5) * slotSize - barWidth / 2; + return ( + + ); + })} + + {bars.map((bar, i) => { + const topVal = Math.max(bar.y0, bar.y1); + const bottomVal = Math.min(bar.y0, bar.y1); + const yTop = linearScale(topVal, yMin, yMax, plotHeight, 0); + const yBottom = linearScale(bottomVal, yMin, yMax, plotHeight, 0); + const barH = Math.max(1, yBottom - yTop); + const barX = (i + 0.5) * slotSize - barWidth / 2; + const delay = Math.min(i * 40, 400); + + return ( + + ); + })} + + {showValues && bars.map((bar, i) => { + const seg = data[i]; + const val = bar.segmentType === 'total' ? bar.y1 : seg.value; + const topVal = Math.max(bar.y0, bar.y1); + const bottomVal = Math.min(bar.y0, bar.y1); + const isNeg = bar.segmentType === 'decrease' || seg.value < 0; + const labelY = isNeg && bar.segmentType !== 'total' + ? linearScale(bottomVal, yMin, yMax, plotHeight, 0) + 14 + : linearScale(topVal, yMin, yMax, plotHeight, 0) - 6; + + return ( + + {fmtValue(val)} + + ); + })} + + {grid && valueLabels.map(({ pos, text }, i) => ( + + {text} + + ))} + + {xLabelIndices.map((i) => ( + + {data[i].label} + + ))} + + + + {tooltipProp && ( +
+ {activeIndex !== null && activeIndex < data.length && ( + <> +

+ {data[activeIndex].label} +

+
+
+ + + {bars[activeIndex].segmentType === 'total' ? 'Total' : 'Change'} + + + {fmtValue(bars[activeIndex].segmentType === 'total' + ? bars[activeIndex].y1 + : data[activeIndex].value)} + +
+
+ Running total + + {fmtValue(bars[activeIndex].runningTotal)} + +
+
+ + )} +
+ )} + + )} +
+
+ ); + }, +); + +if (process.env.NODE_ENV !== 'production') { + Waterfall.displayName = 'Chart.Waterfall'; +} diff --git a/src/components/Chart/hooks.ts b/src/components/Chart/hooks.ts index 766b6b2..24cf125 100644 --- a/src/components/Chart/hooks.ts +++ b/src/components/Chart/hooks.ts @@ -38,6 +38,7 @@ export interface ChartScrubOptions { index: number | null, datum: Record | null, ) => void; + onActivate?: (index: number, datum: Record) => void; } export function useChartScrub(opts: ChartScrubOptions) { @@ -50,6 +51,7 @@ export function useChartScrub(opts: ChartScrubOptions) { interpolatorsRef, data, onActiveChange, + onActivate, } = opts; const cursorRef = React.useRef(null); @@ -114,6 +116,7 @@ export function useChartScrub(opts: ChartScrubOptions) { const tip = tooltipRef.current; if (tip) { const absX = padLeft + clampedX; + const totalW = padLeft + plotWidth + PAD_RIGHT; if (tooltipMode === 'compact') { tip.style.display = ''; const tipW = tip.offsetWidth; @@ -122,12 +125,17 @@ export function useChartScrub(opts: ChartScrubOptions) { tip.style.left = `${left}px`; tip.style.transform = 'none'; } else { - const isLeftHalf = clampedX <= plotWidth / 2; - tip.style.left = `${absX}px`; - tip.style.transform = isLeftHalf - ? `translateX(${TOOLTIP_GAP}px)` - : `translateX(calc(-100% - ${TOOLTIP_GAP}px))`; tip.style.display = ''; + const tipW = tip.offsetWidth; + const fitsRight = absX + TOOLTIP_GAP + tipW <= totalW; + const fitsLeft = absX - TOOLTIP_GAP - tipW >= 0; + const preferRight = clampedX <= plotWidth / 2; + tip.style.left = `${absX}px`; + if ((preferRight && fitsRight) || !fitsLeft) { + tip.style.transform = `translateX(${TOOLTIP_GAP}px)`; + } else { + tip.style.transform = `translateX(calc(-100% - ${TOOLTIP_GAP}px))`; + } } } @@ -209,6 +217,7 @@ export function useChartScrub(opts: ChartScrubOptions) { const tip = tooltipRef.current; if (tip) { const absX = padLeft + x; + const totalW = padLeft + plotWidth + PAD_RIGHT; if (tooltipMode === 'compact') { tip.style.display = ''; const tipW = tip.offsetWidth; @@ -217,12 +226,17 @@ export function useChartScrub(opts: ChartScrubOptions) { tip.style.left = `${left}px`; tip.style.transform = 'none'; } else { - const isLeftHalf = x <= plotWidth / 2; - tip.style.left = `${absX}px`; - tip.style.transform = isLeftHalf - ? `translateX(${TOOLTIP_GAP}px)` - : `translateX(calc(-100% - ${TOOLTIP_GAP}px))`; tip.style.display = ''; + const tipW = tip.offsetWidth; + const fitsRight = absX + TOOLTIP_GAP + tipW <= totalW; + const fitsLeft = absX - TOOLTIP_GAP - tipW >= 0; + const preferRight = x <= plotWidth / 2; + tip.style.left = `${absX}px`; + if ((preferRight && fitsRight) || !fitsLeft) { + tip.style.transform = `translateX(${TOOLTIP_GAP}px)`; + } else { + tip.style.transform = `translateX(calc(-100% - ${TOOLTIP_GAP}px))`; + } } } }, @@ -238,6 +252,12 @@ export function useChartScrub(opts: ChartScrubOptions) { case 'ArrowLeft': case 'ArrowUp': next = Math.max(0, next - 1); break; case 'Home': next = 0; break; case 'End': next = dataLength - 1; break; + case 'Enter': case ' ': + if (onActivate && activeIndex !== null && activeIndex < dataLength) { + e.preventDefault(); + onActivate(activeIndex, data[activeIndex]); + } + return; case 'Escape': hideHover(); return; default: return; } @@ -245,7 +265,7 @@ export function useChartScrub(opts: ChartScrubOptions) { setActiveIndex(next); positionAtIndex(next); }, - [dataLength, activeIndex, hideHover, positionAtIndex], + [dataLength, activeIndex, hideHover, positionAtIndex, onActivate], ); return { diff --git a/src/components/Chart/index.ts b/src/components/Chart/index.ts index 806ef07..52a05e4 100644 --- a/src/components/Chart/index.ts +++ b/src/components/Chart/index.ts @@ -1,5 +1,5 @@ export { Line } from './LineChart'; -export type { LineChartProps, Series, TooltipProp, ReferenceLine } from './LineChart'; +export type { LineChartProps, Series, TooltipProp, ReferenceLine, ReferenceBand } from './LineChart'; export { Sparkline } from './Sparkline'; export type { SparklineProps } from './Sparkline'; @@ -25,14 +25,21 @@ export type { BarListProps, BarListItem } from './BarList'; export { Uptime } from './UptimeChart'; export type { UptimeChartProps, UptimePoint } from './UptimeChart'; -export { ActivityGrid } from './ActivityGrid'; -export type { ActivityGridProps, ActivityCell } from './ActivityGrid'; - export { Live } from './LiveChart'; export type { LiveChartProps, LivePoint } from './LiveChart'; -export { LiveValue } from './LiveValue'; -export type { LiveValueProps } from './LiveValue'; +export { Scatter } from './ScatterChart'; +export type { ScatterChartProps, ScatterSeries, ScatterPoint } from './ScatterChart'; + +export { Split } from './SplitChart'; +export type { SplitChartProps, SplitSegment } from './SplitChart'; + + +export { Sankey } from './SankeyChart'; +export type { SankeyChartProps, SankeyData, SankeyNode, SankeyLink, LayoutNode, LayoutLink } from './SankeyChart'; + +export { Funnel } from './FunnelChart'; +export type { FunnelChartProps, FunnelStage } from './FunnelChart'; -export { LiveDot } from './LiveDot'; -export type { LiveDotProps } from './LiveDot'; +export { Waterfall } from './WaterfallChart'; +export type { WaterfallChartProps, WaterfallSegment } from './WaterfallChart'; diff --git a/src/components/Chart/sankeyLayout.ts b/src/components/Chart/sankeyLayout.ts new file mode 100644 index 0000000..1931f0e --- /dev/null +++ b/src/components/Chart/sankeyLayout.ts @@ -0,0 +1,285 @@ +export interface SankeyNode { + id: string; + label: string; + color?: string; +} + +export interface SankeyLink { + source: string; + target: string; + value: number; + color?: string; +} + +export interface SankeyData { + nodes: SankeyNode[]; + links: SankeyLink[]; +} + +export interface LayoutNode extends SankeyNode { + x0: number; + x1: number; + y0: number; + y1: number; + value: number; + sourceLinks: LayoutLink[]; + targetLinks: LayoutLink[]; + column: number; +} + +export interface LayoutLink extends SankeyLink { + sourceNode: LayoutNode; + targetNode: LayoutNode; + width: number; + sy: number; + ty: number; +} + +export interface SankeyLayoutResult { + nodes: LayoutNode[]; + links: LayoutLink[]; +} + +const RELAXATION_ITERATIONS = 32; + +export function computeSankeyLayout( + data: SankeyData, + width: number, + height: number, + nodeWidth: number, + nodePadding: number, +): SankeyLayoutResult { + if (data.nodes.length === 0) return { nodes: [], links: [] }; + + const nodeMap = new Map(); + for (const n of data.nodes) { + nodeMap.set(n.id, { + ...n, + x0: 0, x1: 0, y0: 0, y1: 0, + value: 0, + sourceLinks: [], + targetLinks: [], + column: 0, + }); + } + + const layoutLinks: LayoutLink[] = []; + for (const l of data.links) { + const sourceNode = nodeMap.get(l.source); + const targetNode = nodeMap.get(l.target); + if (!sourceNode || !targetNode) continue; + layoutLinks.push({ ...l, sourceNode, targetNode, width: 0, sy: 0, ty: 0 }); + } + + for (const link of layoutLinks) { + link.sourceNode.sourceLinks.push(link); + link.targetNode.targetLinks.push(link); + } + + const nodes = Array.from(nodeMap.values()); + computeNodeValues(nodes); + computeNodeColumns(nodes); + computeNodePositions(nodes, width, height, nodeWidth, nodePadding); + computeLinkWidthsAndOffsets(nodes); + + return { nodes, links: layoutLinks }; +} + +function computeNodeValues(nodes: LayoutNode[]) { + for (const node of nodes) { + const sumSource = node.sourceLinks.reduce((s, l) => s + l.value, 0); + const sumTarget = node.targetLinks.reduce((s, l) => s + l.value, 0); + node.value = Math.max(sumSource, sumTarget); + } +} + +function computeNodeColumns(nodes: LayoutNode[]) { + const remaining = new Set(nodes); + let column = 0; + + while (remaining.size > 0) { + const current: LayoutNode[] = []; + for (const node of remaining) { + if (node.targetLinks.every((l) => !remaining.has(l.sourceNode))) { + current.push(node); + } + } + if (current.length === 0) { + for (const node of remaining) { + node.column = column; + } + break; + } + for (const node of current) { + node.column = column; + remaining.delete(node); + } + column++; + } +} + +function computeNodePositions( + nodes: LayoutNode[], + width: number, + height: number, + nodeWidth: number, + nodePadding: number, +) { + const maxColumn = Math.max(...nodes.map((n) => n.column), 0); + const columns = new Map(); + for (const node of nodes) { + if (!columns.has(node.column)) columns.set(node.column, []); + columns.get(node.column)!.push(node); + } + + const colWidth = maxColumn > 0 ? (width - nodeWidth) / maxColumn : 0; + for (const node of nodes) { + node.x0 = node.column * colWidth; + node.x1 = node.x0 + nodeWidth; + } + + const maxValue = Math.max(...Array.from(columns.values()).map( + (col) => col.reduce((s, n) => s + n.value, 0) + Math.max(0, col.length - 1) * nodePadding, + ), 1); + + const ky = height / maxValue; + + for (const [, col] of columns) { + let y = 0; + col.sort((a, b) => b.value - a.value); + for (const node of col) { + node.y0 = y; + node.y1 = y + node.value * ky; + y = node.y1 + nodePadding; + } + resolveCollisions(col, nodePadding, height); + } + + for (let iter = 0; iter < RELAXATION_ITERATIONS; iter++) { + const alpha = Math.pow(0.99, iter + 1); + if (iter % 2 === 0) { + relaxRight(columns, alpha, nodePadding, height); + } else { + relaxLeft(columns, alpha, nodePadding, maxColumn, height); + } + } +} + +function relaxRight( + columns: Map, + alpha: number, + nodePadding: number, + height: number, +) { + const maxCol = Math.max(...columns.keys(), 0); + for (let c = 1; c <= maxCol; c++) { + const col = columns.get(c); + if (!col) continue; + for (const node of col) { + if (node.targetLinks.length === 0) continue; + let weightedY = 0; + let totalWeight = 0; + for (const link of node.targetLinks) { + const sourceCenter = (link.sourceNode.y0 + link.sourceNode.y1) / 2; + weightedY += sourceCenter * link.value; + totalWeight += link.value; + } + if (totalWeight === 0) continue; + const targetCenter = weightedY / totalWeight; + const nodeHeight = node.y1 - node.y0; + const dy = (targetCenter - (node.y0 + nodeHeight / 2)) * alpha; + node.y0 += dy; + node.y1 += dy; + } + resolveCollisions(col, nodePadding, height); + } +} + +function relaxLeft( + columns: Map, + alpha: number, + nodePadding: number, + maxColumn: number, + height: number, +) { + for (let c = maxColumn - 1; c >= 0; c--) { + const col = columns.get(c); + if (!col) continue; + for (const node of col) { + if (node.sourceLinks.length === 0) continue; + let weightedY = 0; + let totalWeight = 0; + for (const link of node.sourceLinks) { + const targetCenter = (link.targetNode.y0 + link.targetNode.y1) / 2; + weightedY += targetCenter * link.value; + totalWeight += link.value; + } + if (totalWeight === 0) continue; + const targetCenter = weightedY / totalWeight; + const nodeHeight = node.y1 - node.y0; + const dy = (targetCenter - (node.y0 + nodeHeight / 2)) * alpha; + node.y0 += dy; + node.y1 += dy; + } + resolveCollisions(col, nodePadding, height); + } +} + +function resolveCollisions(col: LayoutNode[], nodePadding: number, height: number) { + col.sort((a, b) => a.y0 - b.y0); + + let y = 0; + for (const node of col) { + const dy = Math.max(0, y - node.y0); + if (dy > 0) { + node.y0 += dy; + node.y1 += dy; + } + y = node.y1 + nodePadding; + } + + let overflow = col[col.length - 1].y1 - height; + if (overflow > 0) { + for (let i = col.length - 1; i >= 0; i--) { + const node = col[i]; + const shift = Math.min(overflow, node.y0 - (i === 0 ? 0 : col[i - 1].y1 + nodePadding)); + node.y0 -= shift; + node.y1 -= shift; + overflow -= shift; + if (overflow <= 0) break; + } + } +} + +function computeLinkWidthsAndOffsets(nodes: LayoutNode[]) { + for (const node of nodes) { + const nodeHeight = node.y1 - node.y0; + if (node.value === 0) continue; + const scale = nodeHeight / node.value; + + node.sourceLinks.sort((a, b) => a.targetNode.y0 - b.targetNode.y0); + let sy = 0; + for (const link of node.sourceLinks) { + link.width = link.value * scale; + link.sy = sy; + sy += link.width; + } + + node.targetLinks.sort((a, b) => a.sourceNode.y0 - b.sourceNode.y0); + let ty = 0; + for (const link of node.targetLinks) { + link.width = link.value * scale; + link.ty = ty; + ty += link.width; + } + } +} + +export function sankeyLinkPath(link: LayoutLink): string { + const x0 = link.sourceNode.x1; + const x1 = link.targetNode.x0; + const y0 = link.sourceNode.y0 + link.sy + link.width / 2; + const y1 = link.targetNode.y0 + link.ty + link.width / 2; + const mx = (x0 + x1) / 2; + return `M${x0},${y0} C${mx},${y0} ${mx},${y1} ${x1},${y1}`; +} diff --git a/src/components/Chart/types.ts b/src/components/Chart/types.ts index c26395c..5e7a5f1 100644 --- a/src/components/Chart/types.ts +++ b/src/components/Chart/types.ts @@ -29,6 +29,19 @@ export interface ReferenceLine { axis?: 'x' | 'y'; } +export interface ReferenceBand { + /** Start value (Y-axis value for horizontal bands, x-axis index for vertical). */ + from: number; + /** End value. */ + to: number; + /** Optional label text rendered inside the band. */ + label?: string; + /** Fill color. Defaults to stroke-primary at 6% opacity. */ + color?: string; + /** Band direction. Defaults to 'y' (horizontal band spanning a Y range). */ + axis?: 'x' | 'y'; +} + export type TooltipProp = | boolean | 'simple' diff --git a/src/components/Chart/utils.ts b/src/components/Chart/utils.ts index 29c2e27..f96262f 100644 --- a/src/components/Chart/utils.ts +++ b/src/components/Chart/utils.ts @@ -216,6 +216,14 @@ export function linearPath(points: Point[]): string { return segments.join(''); } +export function monotonePathGroups(groups: Point[][]): string { + return groups.map((g) => monotonePath(g)).join(''); +} + +export function linearPathGroups(groups: Point[][]): string { + return groups.map((g) => linearPath(g)).join(''); +} + // Interpolators: given screen-space points, return a function that // evaluates the curve's y at any screen x. These assume data points // are evenly spaced on x (index-based), which makes the Bezier x-component diff --git a/src/index.ts b/src/index.ts index 3e05fe4..75c4ad7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -112,6 +112,7 @@ export type { LineChartProps as ChartLineProps, Series as ChartSeries, ReferenceLine as ChartReferenceLine, + ReferenceBand as ChartReferenceBand, SparklineProps as ChartSparklineProps, StackedAreaChartProps as ChartStackedAreaProps, BarChartProps as ChartBarProps, @@ -121,12 +122,14 @@ export type { ComposedSeries as ChartComposedSeries, LiveChartProps as ChartLiveProps, LivePoint as ChartLivePoint, - LiveValueProps as ChartLiveValueProps, - LiveDotProps as ChartLiveDotProps, GaugeChartProps as ChartGaugeProps, GaugeThreshold as ChartGaugeThreshold, BarListProps as ChartBarListProps, BarListItem as ChartBarListItem, + WaterfallChartProps as ChartWaterfallProps, + WaterfallSegment as ChartWaterfallSegment, + FunnelChartProps as ChartFunnelProps, + FunnelStage as ChartFunnelStage, } from './components/Chart'; // Simple components (direct exports) From 7e832125dd1e64e0801029622acbf86d773bc72c Mon Sep 17 00:00:00 2001 From: jaymantri Date: Mon, 2 Mar 2026 22:44:36 -0800 Subject: [PATCH 04/22] Fix demo page ordering, section gaps, and Sankey layout overflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reorder demo page h2 sections alphabetically (Calendar before Card) - Reorder chart h3 sub-sections alphabetically (Bar through Waterfall) - Add consistent 128px spacing between all h2 sections (9 missing spacers, 3 wrong margin values) - Fix Sankey layout scale factor to subtract padding before dividing by value sum, preventing initial placement overflow - Rewrite resolveCollisions to cascade shifts from bottom up (d3-sankey approach) — the previous implementation only shifted the topmost node, leaving bottom nodes past the plot height after relaxation - Decouple stage headers from label visibility so stages always render when the stages prop is provided - Position middle-column Sankey labels at node center instead of node.y0 - 6 to prevent overlap with neighboring link strokes - Shorten demo "Infrastructure" label to "Infra" to stay within the 40% label-width threshold at typical container widths Made-with: Cursor --- src/app/page.tsx | 631 ++++++++++++++------------- src/components/Chart/SankeyChart.tsx | 15 +- src/components/Chart/sankeyLayout.ts | 38 +- 3 files changed, 355 insertions(+), 329 deletions(-) diff --git a/src/app/page.tsx b/src/app/page.tsx index 384c791..67c5e7e 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1714,9 +1714,6 @@ export default function Home() {

Origin

Design system rebuild — Base UI + Figma-first approach.

-

Calendar

- -

Accordion Component

@@ -1838,6 +1835,8 @@ export default function Home() {

Autocomplete Component

+
+

Badge Component

@@ -2034,6 +2033,10 @@ export default function Home() {
+

Calendar

+ +
+

Card Component

@@ -2063,195 +2066,8 @@ export default function Home() {
-

Charts

- -

Line

-
-
-

Multi-series with grid

- -
-
-

Area fill + fadeLeft

- -
-
-

Dashed + dotted series

- -
-
-

Reference lines

- -
-
- -

Tooltip Modes

-
-
-

simple

- -
-
-

compact

- -
-
-

detailed

- -
-
- -

Live (Real-Time)

-
-
-

Streaming data (random walk)

- -
-
- -

Sparkline

-
-
-

Line

- -
-
-

Line

- -
-
-

Bar

- -
-
-

Stacked Area

-
-
- -
-
+

Charts

Bar

@@ -2320,6 +2136,40 @@ export default function Home() {
+

BarList

+
+
+ +
+
+ +

BarList (ranked)

+
+
+

With rank, change indicators, and secondary values

+ `$${v.toLocaleString()}`} + formatSecondaryValue={(v) => `${v}%`} + showRank + /> +
+
+

Composed

@@ -2343,15 +2193,45 @@ export default function Home() {
-

Uptime

+

Donut

-
- ({ - status: (i === 12 ? 'down' : i === 34 ? 'degraded' : i === 67 ? 'down' : i === 45 ? 'degraded' : 'up') as 'up' | 'down' | 'degraded', - label: `Day ${i + 1}`, - }))} - label="90 days — 97.8% uptime" +
+ +
+
+ +
+
+ +

Funnel

+
+
+

Conversion pipeline

+ v.toLocaleString()} />
@@ -2385,48 +2265,144 @@ export default function Home() { { upTo: 0.8, color: 'var(--color-yellow-500)', label: 'Needs work' }, { upTo: 1, color: 'var(--color-red-500)', label: 'Poor' }, ]} - formatValue={(v) => `${v.toFixed(2)}s`} + formatValue={(v) => `${v.toFixed(2)}s`} + /> +
+
+ +

Line

+
+
+

Multi-series with grid

+ +
+
+

Area fill + fadeLeft

+ +
+
+

Dashed + dotted series

+ +
+
+

Reference lines

+
-

BarList

+

Live (Real-Time)

-
- +
+

Streaming data (random walk)

+
-

Donut

-
-
- -
-
- Sankey +
+
+

Budget allocation

+ `$${v}k`} />
@@ -2473,6 +2449,38 @@ export default function Home() {
+

Sparkline

+
+
+

Line

+ +
+
+

Line

+ +
+
+

Bar

+ +
+
+

Split (Distribution)

@@ -2490,27 +2498,81 @@ export default function Home() {
-

BarList (ranked)

+

Stacked Area

-
-

With rank, change indicators, and secondary values

- + `$${v.toLocaleString()}`} - formatSecondaryValue={(v) => `${v}%`} - showRank + series={[ + { key: 'payments', label: 'Payments', color: 'var(--color-blue-700)' }, + { key: 'transfers', label: 'Transfers', color: 'var(--color-blue-400)' }, + { key: 'fees', label: 'Fees', color: 'var(--color-blue-200)' }, + ]} + xKey="month" + height={250} + grid + tooltip />
-

Waterfall

+

Tooltip Modes

+
+
+

simple

+ +
+
+

compact

+ +
+
+

detailed

+ +
+
+ +

Uptime

+
+ ({ + status: (i === 12 ? 'down' : i === 34 ? 'degraded' : i === 67 ? 'down' : i === 45 ? 'degraded' : 'up') as 'up' | 'down' | 'degraded', + label: `Day ${i + 1}`, + }))} + label="90 days — 97.8% uptime" + /> +
+
+ +

Waterfall

+

Revenue breakdown

- -

Funnel

-
-
-

Conversion pipeline

- v.toLocaleString()} - /> -
-
- -

Sankey

-
-
-

Budget allocation

- `$${v}k`} - /> -
-

Checkbox Component

@@ -2679,12 +2683,18 @@ export default function Home() {

Combobox Component

+
+

Command Component

+
+

Context Menu Component

+
+

Dialog Component

@@ -2752,6 +2762,7 @@ export default function Home() {

Drawer

+

Field Component

@@ -3011,9 +3022,13 @@ export default function Home() {

Menu Component

+
+

Menubar Component

+
+

Meter Component

@@ -3947,6 +3962,8 @@ export default function Home() {

Table Component

+
+

Tabs Component

@@ -4018,7 +4035,7 @@ export default function Home() {

Textarea

-
+
Default @@ -4058,7 +4075,7 @@ export default function Home() {

Textarea Group

-
+
Default @@ -4147,7 +4164,7 @@ export default function Home() {

Toggle

-
+
Standalone
diff --git a/src/components/Chart/SankeyChart.tsx b/src/components/Chart/SankeyChart.tsx index 34925e1..07c9c19 100644 --- a/src/components/Chart/SankeyChart.tsx +++ b/src/components/Chart/SankeyChart.tsx @@ -129,8 +129,7 @@ export const Sankey = React.forwardRef( return { left, right, visible: true }; }, [data.nodes, data.links, showLabels, showValues, fmtValue, width]); - const padTop = - hasStages && labelPad.visible ? STAGE_HEIGHT + STAGE_GAP : 8; + const padTop = hasStages ? STAGE_HEIGHT + STAGE_GAP : 8; const layout = React.useMemo(() => { const plotWidth = width - labelPad.left - labelPad.right; @@ -377,7 +376,7 @@ export const Sankey = React.forwardRef( > {svgDesc && {svgDesc}} - {hasStages && labelPad.visible && ( + {hasStages && ( {stages.map((label, i) => { const col = nodesByColumn.get(i); @@ -514,9 +513,9 @@ export const Sankey = React.forwardRef( ly = midY; anchor = 'start'; } else { - lx = (node.x0 + node.x1) / 2; - ly = node.y0 - 6; - anchor = 'middle'; + lx = node.x1 + LABEL_GAP; + ly = midY; + anchor = 'start'; } return ( @@ -525,9 +524,7 @@ export const Sankey = React.forwardRef( x={lx} y={ly} textAnchor={anchor} - dominantBaseline={ - isFirst || isLast ? 'central' : 'auto' - } + dominantBaseline="central" className={styles.sankeyLabel} > {node.label} diff --git a/src/components/Chart/sankeyLayout.ts b/src/components/Chart/sankeyLayout.ts index 1931f0e..47f7ce3 100644 --- a/src/components/Chart/sankeyLayout.ts +++ b/src/components/Chart/sankeyLayout.ts @@ -138,11 +138,13 @@ function computeNodePositions( node.x1 = node.x0 + nodeWidth; } - const maxValue = Math.max(...Array.from(columns.values()).map( - (col) => col.reduce((s, n) => s + n.value, 0) + Math.max(0, col.length - 1) * nodePadding, - ), 1); - - const ky = height / maxValue; + const ky = Math.min(...Array.from(columns.values()).map( + (col) => { + const totalValue = col.reduce((s, n) => s + n.value, 0); + const totalPadding = Math.max(0, col.length - 1) * nodePadding; + return totalValue > 0 ? (height - totalPadding) / totalValue : height; + }, + )); for (const [, col] of columns) { let y = 0; @@ -238,15 +240,25 @@ function resolveCollisions(col: LayoutNode[], nodePadding: number, height: numbe y = node.y1 + nodePadding; } - let overflow = col[col.length - 1].y1 - height; + const last = col[col.length - 1]; + const overflow = last.y1 - height; if (overflow > 0) { - for (let i = col.length - 1; i >= 0; i--) { - const node = col[i]; - const shift = Math.min(overflow, node.y0 - (i === 0 ? 0 : col[i - 1].y1 + nodePadding)); - node.y0 -= shift; - node.y1 -= shift; - overflow -= shift; - if (overflow <= 0) break; + last.y0 -= overflow; + last.y1 -= overflow; + for (let i = col.length - 2; i >= 0; i--) { + const overlap = col[i].y1 + nodePadding - col[i + 1].y0; + if (overlap > 0) { + col[i].y0 -= overlap; + col[i].y1 -= overlap; + } + } + } + + if (col[0].y0 < 0) { + const shift = -col[0].y0; + for (const node of col) { + node.y0 += shift; + node.y1 += shift; } } } From d0a34c728cca58a849819afc75f6c469d50e8a85 Mon Sep 17 00:00:00 2001 From: jaymantri Date: Mon, 2 Mar 2026 23:13:16 -0800 Subject: [PATCH 05/22] Rename Calendar component to DatePicker The component functions as a date picker (inputs, controls, footer) rather than a bare calendar grid. Rename aligns with its actual role and clarifies that products compose it inside their own popover or dialog. Reorders demo page to maintain alphabetical section order. Made-with: Cursor --- src/app/page.tsx | 78 +++--- src/components/Calendar/index.ts | 19 -- .../DatePicker.module.scss} | 0 .../DatePicker.stories.tsx} | 116 ++++----- .../DatePicker.test-stories.tsx} | 232 +++++++++--------- .../DatePicker.test.tsx} | 4 +- src/components/DatePicker/index.ts | 19 ++ .../{Calendar => DatePicker}/parts.tsx | 96 ++++---- src/index.ts | 18 +- 9 files changed, 292 insertions(+), 290 deletions(-) delete mode 100644 src/components/Calendar/index.ts rename src/components/{Calendar/Calendar.module.scss => DatePicker/DatePicker.module.scss} (100%) rename src/components/{Calendar/Calendar.stories.tsx => DatePicker/DatePicker.stories.tsx} (73%) rename src/components/{Calendar/Calendar.test-stories.tsx => DatePicker/DatePicker.test-stories.tsx} (80%) rename src/components/{Calendar/Calendar.test.tsx => DatePicker/DatePicker.test.tsx} (99%) create mode 100644 src/components/DatePicker/index.ts rename src/components/{Calendar => DatePicker}/parts.tsx (93%) diff --git a/src/app/page.tsx b/src/app/page.tsx index 67c5e7e..bb64c04 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -61,8 +61,8 @@ import { Popover } from '@/components/Popover'; import { PreviewCard } from '@/components/PreviewCard'; import { Logo } from '@/components/Logo'; import { Toggle, ToggleGroup } from '@/components/Toggle'; -import * as Calendar from '@/components/Calendar'; -import type { DateRange } from '@/components/Calendar'; +import * as DatePicker from '@/components/DatePicker'; +import type { DateRange } from '@/components/DatePicker'; // Data for combobox examples const fruits = ['Apple', 'Banana', 'Cherry', 'Date', 'Elderberry', 'Fig', 'Grape']; @@ -1610,7 +1610,7 @@ function DrawerDemo() { ); } -function CalendarDemo() { +function DatePickerDemo() { const [singleDate, setSingleDate] = React.useState(null); const [rangeValue, setRangeValue] = React.useState(null); const [mode, setMode] = React.useState<'single' | 'range'>('range'); @@ -1620,28 +1620,28 @@ function CalendarDemo() {

Single date

- setSingleDate(v as Date)}> - - - - + setSingleDate(v as Date)}> + + + + - - + +

Date range

- - - - - - + + + + + - - + + - - - + + + - - + +

French (locale)

- - - - - - + + + + + - - + + - - - + + + - - + +
); @@ -2033,8 +2033,6 @@ export default function Home() {
-

Calendar

-

Card Component

@@ -2695,6 +2693,10 @@ export default function Home() {
+

DatePicker

+ +
+

Dialog Component

diff --git a/src/components/Calendar/index.ts b/src/components/Calendar/index.ts deleted file mode 100644 index d1b158f..0000000 --- a/src/components/Calendar/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -export { - Root, - Header, - Navigation, - Grid, - Controls, - ControlItem, - Footer, - type CalendarRootProps, - type CalendarHeaderProps, - type CalendarNavigationProps, - type CalendarGridProps, - type CalendarControlsProps, - type CalendarControlItemProps, - type CalendarFooterProps, - type CalendarLabels, - type DayCellState, - type DateRange, -} from './parts'; diff --git a/src/components/Calendar/Calendar.module.scss b/src/components/DatePicker/DatePicker.module.scss similarity index 100% rename from src/components/Calendar/Calendar.module.scss rename to src/components/DatePicker/DatePicker.module.scss diff --git a/src/components/Calendar/Calendar.stories.tsx b/src/components/DatePicker/DatePicker.stories.tsx similarity index 73% rename from src/components/Calendar/Calendar.stories.tsx rename to src/components/DatePicker/DatePicker.stories.tsx index f55633f..1482e3c 100644 --- a/src/components/Calendar/Calendar.stories.tsx +++ b/src/components/DatePicker/DatePicker.stories.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react'; -import * as Calendar from './index'; +import * as DatePicker from './index'; import type { DateRange, DayCellState } from './index'; import { Switch } from '../Switch'; import { Button } from '../Button'; @@ -8,16 +8,16 @@ import { Button } from '../Button'; function SingleCalendar() { const [value, setValue] = useState(null); return ( - setValue(v as Date)}> - - - - + setValue(v as Date)}> + + + + - - + + ); } @@ -27,17 +27,17 @@ function RangeCalendar() { const [value, setValue] = useState(null); return ( - - - - - - + + + + + - - + + - - - + + + - - + + ); } function WithTimeCalendar() { const [value, setValue] = useState(null); return ( - setValue(v as Date)} includeTime > - - - - + + + + ); } function RangeWithTimeCalendar() { const [value, setValue] = useState(null); return ( - setValue(v as DateRange)} > - - - - + + + + ); } @@ -102,48 +102,48 @@ function ConstrainedCalendar() { max.setMonth(max.getMonth() + 3); return ( - setValue(v as Date)} min={today} max={max} > - - - + + + ); } function WeekdaysOnlyCalendar() { const [value, setValue] = useState(null); return ( - setValue(v as Date)} disabled={(date) => date.getDay() === 0 || date.getDay() === 6} > - - - + + + ); } function MondayStartCalendar() { const [value, setValue] = useState(null); return ( - setValue(v as Date)} weekStartsOn={1} > - - - + + + ); } const meta: Meta = { - title: 'Components/Calendar', + title: 'Components/DatePicker', parameters: { layout: 'centered' }, }; @@ -182,7 +182,7 @@ export const MondayStart: Story = { function GermanCalendar() { const [value, setValue] = useState(null); return ( - setValue(v as Date)} locale="de-DE" @@ -193,10 +193,10 @@ function GermanCalendar() { date: 'Datum', }} > - - - - + + + + ); } @@ -207,15 +207,15 @@ export const LocaleGerman: Story = { function JapaneseCalendar() { const [value, setValue] = useState(null); return ( - setValue(v as Date)} locale="ja-JP" > - - - - + + + + ); } @@ -228,9 +228,9 @@ function EventDotsCalendar() { const eventDays = new Set([3, 7, 14, 21, 28]); return ( - setValue(v as Date)}> - - setValue(v as Date)}> + + ( {date.getDate()} @@ -252,7 +252,7 @@ function EventDotsCalendar() { )} /> - + ); } diff --git a/src/components/Calendar/Calendar.test-stories.tsx b/src/components/DatePicker/DatePicker.test-stories.tsx similarity index 80% rename from src/components/Calendar/Calendar.test-stories.tsx rename to src/components/DatePicker/DatePicker.test-stories.tsx index e3fc5f1..b8f1752 100644 --- a/src/components/Calendar/Calendar.test-stories.tsx +++ b/src/components/DatePicker/DatePicker.test-stories.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import * as Calendar from './index'; +import * as DatePicker from './index'; import type { DateRange, DayCellState } from './index'; import { Switch } from '../Switch'; import { Button } from '../Button'; @@ -7,55 +7,55 @@ import { Button } from '../Button'; export function TestDefault() { const [value, setValue] = useState(null); return ( - setValue(v as Date)} defaultMonth={new Date(2026, 1, 1)} > - - + +
{value ? value.toISOString().split('T')[0] : 'none'}
-
+ ); } export function TestWithValue() { const [value, setValue] = useState(new Date(2026, 1, 15)); return ( - setValue(v as Date)} defaultMonth={new Date(2026, 1, 1)} > - - + +
{value ? value.toISOString().split('T')[0] : 'none'}
-
+ ); } export function TestRange() { const [value, setValue] = useState(null); return ( - setValue(v as DateRange)} defaultMonth={new Date(2026, 1, 1)} > - - + +
{value ? value.start.toISOString().split('T')[0] : 'none'}
{value ? value.end.toISOString().split('T')[0] : 'none'}
-
+ ); } @@ -65,52 +65,52 @@ export function TestRangeWithValue() { end: new Date(2026, 1, 15), }); return ( - setValue(v as DateRange)} defaultMonth={new Date(2026, 1, 1)} > - - - + + + ); } export function TestDisabled() { const [value, setValue] = useState(null); return ( - setValue(v as Date)} defaultMonth={new Date(2026, 1, 1)} disabled={(date) => date.getDay() === 0 || date.getDay() === 6} > - - + +
{value ? value.toISOString().split('T')[0] : 'none'}
-
+ ); } export function TestMinMax() { const [value, setValue] = useState(null); return ( - setValue(v as Date)} defaultMonth={new Date(2026, 1, 1)} min={new Date(2026, 1, 5)} max={new Date(2026, 1, 25)} > - - + +
{value ? value.toISOString().split('T')[0] : 'none'}
-
+ ); } @@ -123,18 +123,18 @@ export function TestFullFeatured() { const rangeValue = value && !(value instanceof Date) ? value : null; return ( - - - - - - + + + + + - - + + - - - + + + - +
{applied ? 'yes' : 'no'}
{rangeValue ? rangeValue.start.toISOString().split('T')[0] : 'none'} @@ -172,7 +172,7 @@ export function TestFullFeatured() {
{rangeValue ? rangeValue.end.toISOString().split('T')[0] : 'none'}
- + ); } @@ -181,15 +181,15 @@ export function TestWithTime() { new Date(2026, 1, 11, 14, 30), ); return ( - setValue(v as Date)} defaultMonth={new Date(2026, 1, 1)} includeTime > - - - + + +
{value ? value.toISOString() : 'none'}
@@ -197,7 +197,7 @@ export function TestWithTime() {
{value ? value.getMinutes() : ''}
-
+ ); } @@ -205,14 +205,14 @@ export function TestModeSwitch() { const [mode, setMode] = useState<'single' | 'range'>('range'); const [value, setValue] = useState(null); return ( - - - + +
-
+ ); } export function TestReverseRange() { const [value, setValue] = useState(null); return ( - setValue(v as DateRange)} defaultMonth={new Date(2026, 1, 1)} > - - + +
{value ? value.start.toISOString().split('T')[0] : 'none'}
{value ? value.end.toISOString().split('T')[0] : 'none'}
-
+ ); } export function TestSameDayRange() { const [value, setValue] = useState(null); return ( - setValue(v as DateRange)} defaultMonth={new Date(2026, 1, 1)} > - - + +
{value ? value.start.toISOString().split('T')[0] : 'none'}
{value ? value.end.toISOString().split('T')[0] : 'none'}
-
+ ); } export function TestDateInput() { const [value, setValue] = useState(new Date(2026, 1, 11)); return ( - setValue(v as Date)} defaultMonth={new Date(2026, 1, 1)} > - - - + + +
{value ? value.toISOString().split('T')[0] : 'none'}
-
+ ); } @@ -300,16 +300,16 @@ export function TestRangeWithTime() { end: new Date(2026, 1, 15, 17, 30), }); return ( - setValue(v as DateRange)} defaultMonth={new Date(2026, 1, 1)} > - - - + + +
{value ? value.start.getHours() : ''}
{value ? value.start.getMinutes() : ''}
{value ? value.end.getHours() : ''}
@@ -320,110 +320,110 @@ export function TestRangeWithTime() {
{value ? value.end.toISOString().split('T')[0] : 'none'}
-
+ ); } export function TestYearBoundary() { const [value, setValue] = useState(null); return ( - setValue(v as Date)} defaultMonth={new Date(2026, 11, 1)} > - - + +
{value ? value.toISOString().split('T')[0] : 'none'}
-
+ ); } export function TestLeapYear() { const [value, setValue] = useState(null); return ( - setValue(v as Date)} defaultMonth={new Date(2028, 1, 1)} > - - + +
{value ? value.toISOString().split('T')[0] : 'none'}
-
+ ); } export function TestMondayStart() { const [value, setValue] = useState(null); return ( - setValue(v as Date)} defaultMonth={new Date(2026, 1, 1)} weekStartsOn={1} > - - - + + + ); } export function TestMinEqualsMax() { const [value, setValue] = useState(null); return ( - setValue(v as Date)} defaultMonth={new Date(2026, 1, 1)} min={new Date(2026, 1, 15)} max={new Date(2026, 1, 15)} > - - + +
{value ? value.toISOString().split('T')[0] : 'none'}
-
+ ); } export function TestLocaleDE() { const [value, setValue] = useState(null); return ( - setValue(v as Date)} defaultMonth={new Date(2026, 1, 1)} locale="de-DE" labels={{ date: 'Datum', startDate: 'Startdatum', endDate: 'Enddatum' }} > - - - + + +
{value ? value.toISOString().split('T')[0] : 'none'}
-
+ ); } export function TestLocaleJA() { const [value, setValue] = useState(null); return ( - setValue(v as Date)} defaultMonth={new Date(2026, 1, 1)} locale="ja-JP" > - - - + + + ); } @@ -431,14 +431,14 @@ export function TestControlledMonth() { const [value, setValue] = useState(null); const [month, setMonth] = useState(new Date(2026, 1, 1)); return ( - setValue(v as Date)} month={month} onMonthChange={setMonth} > - - + +
- {[ - ['Request ID', 'ck8qs-177'], - ['Path', '/customers'], - ['Host', 'api.example.com'], - ['Duration', '314ms'], - ['Cache', 'HIT'], - ].map(([label, value]) => ( + {details.map(([label, value]) => (
{label} {value} @@ -85,3 +89,423 @@ export const SidePanel: StoryObj = { ); }, }; + +export const LeftPanel: StoryObj = { + render: function Render() { + const [open, setOpen] = React.useState(false); + + const navItems = [ + { icon: 'IconHome' as const, label: 'Dashboard' }, + { icon: 'IconPerson' as const, label: 'Customers' }, + { icon: 'IconReceipt' as const, label: 'Transactions' }, + { icon: 'IconGear' as const, label: 'Settings' }, + ]; + + return ( +
+ + + + + + +
+ Menu + }> + + +
+ + + +
+
+
+
+
+ ); + }, +}; + +export const SnapPoints: StoryObj = { + render: function Render() { + const [snap, setSnap] = React.useState(0.4); + + return ( + + }> + Open with snap points + + + + + + Activity + +

+ Drag to resize between 40%, 70%, and full height. +

+
+ + + +
+
+ {Array.from({ length: 20 }, (_, i) => ( +
+ + Activity item {i + 1} + +
+ ))} +
+
+
+
+
+
+ ); + }, +}; + +export const NonModal: StoryObj = { + render: function Render() { + const [count, setCount] = React.useState(0); + + return ( +
+

+ The drawer opens without blocking interaction with the page behind it. + Counter: {count} +

+
+ + }> + Open non-modal drawer + + + + + Non-modal drawer + + + You can still interact with the content behind this drawer. Try clicking the increment button. + +
+ }> + Close + +
+
+
+
+
+
+ +
+
+ ); + }, +}; + +export const NestedDrawers: StoryObj = { + render: () => ( + + + +
+ + }> + Open parent drawer + + + + + + Parent drawer + + + Opening the child drawer will indent this one. The page behind scales down to reinforce depth. + +
+ + }> + Open child drawer + + + + + + Child drawer + + + This is a nested drawer. Swipe down or press Escape to go back to the parent. + +
+ }> + Back to parent + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ), +}; + +export const CustomWidth: StoryObj = { + render: function Render() { + const [open, setOpen] = React.useState(false); + + return ( +
+ + + + + + +
+ + Event log + + }> + + +
+ +

+ This panel uses --drawer-width: 600px instead of the default 420px. +

+
+ {[ + { time: '14:32:01', event: 'payment.created', id: 'evt_01' }, + { time: '14:32:04', event: 'payment.processing', id: 'evt_02' }, + { time: '14:32:09', event: 'payment.completed', id: 'evt_03' }, + { time: '14:33:15', event: 'webhook.sent', id: 'evt_04' }, + { time: '14:33:16', event: 'webhook.delivered', id: 'evt_05' }, + ].map((row) => ( +
+ {row.time} + {row.event} + {row.id} +
+ ))} +
+
+
+
+
+
+
+ ); + }, +}; + +export const Controlled: StoryObj = { + render: function Render() { + const [open, setOpen] = React.useState(false); + const actionsRef = React.useRef<{ unmount: () => void; close: () => void }>(null); + + return ( +
+

+ State: {open ? 'Open' : 'Closed'} +

+
+ + +
+ + + + + + Controlled drawer + + + This drawer is controlled via React state and exposes an imperative close action. Try the "Close imperatively" button outside. + +
+ }> + Close from inside + +
+
+
+
+
+
+
+ ); + }, +}; + +export const DefaultOpen: StoryObj = { + render: () => ( + + }> + Reopen drawer + + + + + + Welcome + + + This drawer was open when the page loaded. Close it and use the button to reopen. + +
+ }> + Got it + +
+
+
+
+
+
+ ), +}; + +export const InitialFocus: StoryObj = { + render: function Render() { + const inputRef = React.useRef(null); + + return ( + + }> + Add a note + + + + + + New note + +
+ + +
+
+ }> + Cancel + + +
+
+
+
+
+
+ ); + }, +}; + +export const OpenChangeComplete: StoryObj = { + render: function Render() { + const [log, setLog] = React.useState([]); + + const addEntry = (open: boolean) => { + const time = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + setLog((prev) => [`${time} — ${open ? 'Open' : 'Close'} animation finished`, ...prev].slice(0, 8)); + }; + + return ( +
+ + }> + Open drawer + + + + + + Transition callback + + + Close this drawer to see the callback fire after the animation completes. + +
+ }> + Close + +
+
+
+
+
+
+ {log.length > 0 && ( +
+ Callback log + {log.map((entry, i) => ( + + {entry} + + ))} +
+ )} +
+ ); + }, +}; From 54132f9b279c53a299289bee746fc97d4cff5471 Mon Sep 17 00:00:00 2001 From: jaymantri Date: Wed, 4 Mar 2026 13:30:41 -0800 Subject: [PATCH 12/22] Harden chart library: analytics, prop renames, a11y, and structural cleanup Add analyticsName tracking to all 15 chart components. Rename props for consistency (showGrid->grid, onHover->onActiveChange, scrub->interactive, onClickItem->onClickDatum). Add interactive, animate, onActiveChange, yDomain, and loading/empty props where missing. Standardize ChartWrapper usage with ref forwarding, extract useMergedRef utility, add keyboard navigation to Pie and Uptime, replace class toggles with data-* attributes, and move inline styles to SCSS. Re-create LiveDot and LiveValue components. Add 24+ Storybook stories covering all new features. Made-with: Cursor --- .cursor/rules/charts.mdc | 2 +- src/app/page.tsx | 2 +- src/components/Chart/BarChart.tsx | 37 +- src/components/Chart/BarList.tsx | 57 +- src/components/Chart/Chart.module.scss | 74 ++- src/components/Chart/Chart.stories.tsx | 645 ++++++++++++++++++---- src/components/Chart/ChartWrapper.tsx | 12 +- src/components/Chart/ComposedChart.tsx | 107 ++-- src/components/Chart/FunnelChart.tsx | 157 +++--- src/components/Chart/GaugeChart.tsx | 14 + src/components/Chart/LineChart.tsx | 32 +- src/components/Chart/LiveChart.tsx | 86 ++- src/components/Chart/LiveDot.tsx | 46 ++ src/components/Chart/LiveValue.tsx | 94 ++++ src/components/Chart/PieChart.tsx | 98 +++- src/components/Chart/SankeyChart.tsx | 130 +++-- src/components/Chart/ScatterChart.tsx | 76 ++- src/components/Chart/Sparkline.tsx | 13 +- src/components/Chart/SplitChart.tsx | 64 ++- src/components/Chart/StackedAreaChart.tsx | 99 ++-- src/components/Chart/UptimeChart.tsx | 112 +++- src/components/Chart/WaterfallChart.tsx | 108 ++-- src/components/Chart/hooks.ts | 9 +- src/components/Chart/index.ts | 7 +- src/components/Chart/useMergedRef.ts | 17 + src/index.ts | 8 + 26 files changed, 1519 insertions(+), 587 deletions(-) create mode 100644 src/components/Chart/LiveDot.tsx create mode 100644 src/components/Chart/LiveValue.tsx create mode 100644 src/components/Chart/useMergedRef.ts diff --git a/.cursor/rules/charts.mdc b/.cursor/rules/charts.mdc index d634f96..45fec13 100644 --- a/.cursor/rules/charts.mdc +++ b/.cursor/rules/charts.mdc @@ -130,7 +130,7 @@ Each chart type exposes a click handler matching its data model: | `Chart.Line`, `Chart.Bar`, `Chart.Composed`, `Chart.StackedArea`, `Chart.Pie` | `onClickDatum(index: number, datum: Record) => void` | | `Chart.Split` | `onClickDatum(segment: SplitSegment, index: number) => void` | | `Chart.Scatter` | `onClickDatum(seriesKey: string, point: ScatterPoint, index: number) => void` | -| `Chart.BarList` | `onClickItem(item: BarListItem, index: number) => void` | +| `Chart.BarList` | `onClickDatum(item: BarListItem, index: number) => void` | | `Chart.Funnel` | `onClickDatum(index: number, stage: FunnelStage) => void` | | `Chart.Waterfall` | `onClickDatum(index: number, segment: WaterfallSegment) => void` | | `Chart.Sankey` | `onClickNode(node: LayoutNode) => void` / `onClickLink(link: LayoutLink) => void` | diff --git a/src/app/page.tsx b/src/app/page.tsx index 6f43978..7a6a925 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1518,7 +1518,7 @@ function LiveDemo() { height={200} grid fill - scrub + interactive formatValue={(v) => v.toFixed(1)} /> ); diff --git a/src/components/Chart/BarChart.tsx b/src/components/Chart/BarChart.tsx index 05f3067..872e35e 100644 --- a/src/components/Chart/BarChart.tsx +++ b/src/components/Chart/BarChart.tsx @@ -4,6 +4,7 @@ import * as React from 'react'; import clsx from 'clsx'; import { linearScale, niceTicks, thinIndices, dynamicTickTarget, measureLabelWidth, axisPadForLabels } from './utils'; import { useResizeWidth } from './hooks'; +import { useMergedRef } from './useMergedRef'; import { type Series, type ResolvedSeries, @@ -21,10 +22,13 @@ import { axisTickTarget, } from './types'; import { ChartWrapper } from './ChartWrapper'; +import { useTrackedCallback } from '../Analytics/useTrackedCallback'; import styles from './Chart.module.scss'; const EMPTY_TICKS = { min: 0, max: 1, ticks: [0, 1] } as const; +const clickIndexMeta = (index: number) => ({ index }); + export interface BarChartProps extends React.ComponentPropsWithoutRef<'div'> { data: Record[]; dataKey?: string; @@ -59,6 +63,9 @@ export interface BarChartProps extends React.ComponentPropsWithoutRef<'div'> { empty?: React.ReactNode; /** Click handler called with the active data index and datum. */ onClickDatum?: (index: number, datum: Record) => void; + analyticsName?: string; + /** Disables interaction, cursor, dots, and tooltip. */ + interactive?: boolean; /** Control bar mount animation. Defaults to `true`. */ animate?: boolean; /** Per-data-point color override. Return a CSS color string to override `series.color`, or `undefined` to keep the default. */ @@ -92,6 +99,8 @@ export const Bar = React.forwardRef( loading, empty, onClickDatum, + analyticsName, + interactive: interactiveProp = true, animate = true, getBarColor, orientation = 'vertical', @@ -105,17 +114,15 @@ export const Bar = React.forwardRef( const [activeIndex, setActiveIndex] = React.useState(null); const tooltipMode = resolveTooltipMode(tooltipProp); - const showTooltip = tooltipMode !== 'off'; + const showTooltip = interactiveProp && tooltipMode !== 'off'; const tooltipRender = typeof tooltipProp === 'function' ? tooltipProp : undefined; - const mergedRef = React.useCallback( - (node: HTMLDivElement | null) => { - attachRef(node); - if (typeof ref === 'function') ref(node); - else if (ref) ref.current = node; - }, - [ref, attachRef], + const mergedRef = useMergedRef(ref, attachRef); + + const trackedClick = useTrackedCallback( + analyticsName, 'Chart.Bar', 'click', onClickDatum, + onClickDatum ? clickIndexMeta : undefined, ); const series = React.useMemo( @@ -344,7 +351,7 @@ export const Bar = React.forwardRef( case ' ': if (onClickDatum && activeIndex !== null && activeIndex < data.length) { e.preventDefault(); - onClickDatum(activeIndex, data[activeIndex]); + trackedClick(activeIndex, data[activeIndex]); } return; case 'Escape': @@ -380,26 +387,28 @@ export const Bar = React.forwardRef( tip.style.display = ''; } }, - [activeIndex, data, slotSize, padLeft, padRight, plotWidth, isHorizontal, onClickDatum, handleMouseLeave], + [activeIndex, data, slotSize, padLeft, padRight, plotWidth, isHorizontal, onClickDatum, trackedClick, handleMouseLeave], ); - const interactive = showTooltip || !!onClickDatum; + const interactive = interactiveProp; const handleClick = React.useCallback(() => { if (onClickDatum && activeIndex !== null && activeIndex < data.length) { - onClickDatum(activeIndex, data[activeIndex]); + trackedClick(activeIndex, data[activeIndex]); } - }, [onClickDatum, activeIndex, data]); + }, [onClickDatum, activeIndex, data, trackedClick]); return (
{ /** Format the secondary value for display. */ formatSecondaryValue?: (value: number) => string; /** Called when a row is clicked. */ - onClickItem?: (item: BarListItem, index: number) => void; + onClickDatum?: (item: BarListItem, index: number) => void; + analyticsName?: string; /** Show numbered rank in front of each row. */ showRank?: boolean; /** Maximum number of items to display. */ @@ -46,12 +49,16 @@ export interface BarListProps extends React.ComponentPropsWithoutRef<'div'> { ariaLabel?: string; } +const SKELETON_HEIGHT = 120; + const CHANGE_ARROWS: Record = { up: '\u2191', down: '\u2193', neutral: '\u2013', }; +const barListClickMeta = (item: BarListItem) => ({ name: item.name }); + export const BarList = React.forwardRef( function BarList( { @@ -59,7 +66,8 @@ export const BarList = React.forwardRef( color = 'var(--surface-secondary)', formatValue, formatSecondaryValue, - onClickItem, + onClickDatum, + analyticsName, showRank, max, loading, @@ -70,35 +78,26 @@ export const BarList = React.forwardRef( }, ref, ) { - if (loading) { - return ( -
- {[1, 2, 3].map((i) => ( -
-
-
- ))} -
- ); - } + const trackedClickItem = useTrackedCallback( + analyticsName, 'Chart.BarList', 'click', onClickDatum, + onClickDatum ? barListClickMeta : undefined, + ); const items = max ? data.slice(0, max) : data; - if (items.length === 0 && empty !== undefined) { - return ( -
-
- {typeof empty === 'boolean' ? 'No data' : empty} -
-
- ); - } - const maxValue = Math.max(...items.map((d) => d.value), 1); const fmtValue = (v: number) => (formatValue ? formatValue(v) : String(v)); const fmtSecondary = (v: number) => (formatSecondaryValue ? formatSecondaryValue(v) : String(v)); return ( +
( {items.map((item, i) => { const pct = (item.value / maxValue) * 100; const barColor = item.color ?? color; - const clickable = Boolean(onClickItem || item.href); + const clickable = Boolean(onClickDatum || item.href); const display = item.displayValue ?? fmtValue(item.value); return (
onClickItem(item, i) : undefined} - onKeyDown={onClickItem ? (e: React.KeyboardEvent) => { - if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClickItem(item, i); } + onClick={onClickDatum ? () => trackedClickItem(item, i) : undefined} + onKeyDown={onClickDatum ? (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); trackedClickItem(item, i); } } : undefined} >
@@ -155,6 +155,7 @@ export const BarList = React.forwardRef( ); })}
+ ); }, ); diff --git a/src/components/Chart/Chart.module.scss b/src/components/Chart/Chart.module.scss index e35ebb8..2ebe7d6 100644 --- a/src/components/Chart/Chart.module.scss +++ b/src/components/Chart/Chart.module.scss @@ -307,6 +307,8 @@ } .pieSegment { + cursor: pointer; + transition: transform 200ms cubic-bezier(0.33, 1, 0.68, 1); opacity: 0; animation: chart-fade-in 400ms ease both; @@ -512,7 +514,7 @@ .gaugeTrack { display: flex; - gap: 4px; + gap: var(--spacing-4xs); height: 8px; overflow: hidden; position: relative; @@ -595,22 +597,22 @@ min-height: 32px; display: flex; align-items: center; -} -.barListRowClickable { - cursor: pointer; + &[data-clickable] { + cursor: pointer; - &:hover .barListBar { - opacity: 0.8; - } + &:hover .barListBar { + opacity: 0.8; + } - &:focus { - outline: none; - } + &:focus { + outline: none; + } - &:focus-visible { - outline: 2px solid var(--border-primary); - outline-offset: -2px; + &:focus-visible { + outline: 2px solid var(--border-primary); + outline-offset: -2px; + } } } @@ -743,7 +745,7 @@ .uptimeBars { display: flex; align-items: flex-end; - gap: 4px; + gap: var(--spacing-4xs); width: 100%; } @@ -756,10 +758,10 @@ @media (prefers-reduced-motion: reduce) { transition: none; } -} -.uptimeBarActive { - height: calc(100% + 4px); + &[data-active] { + height: calc(100% + 4px); + } } .uptimeTooltip { @@ -832,10 +834,10 @@ @media (prefers-reduced-motion: reduce) { transition: none; } -} -.splitSegmentClickable { - cursor: pointer; + &[data-clickable] { + cursor: pointer; + } } .splitSkeleton { @@ -912,3 +914,35 @@ font-variant-numeric: tabular-nums; } +// LiveDot + +.liveDot { + display: inline-block; + border-radius: 50%; + flex-shrink: 0; +} + +.liveDotPulse { + animation: live-dot-pulse 1.5s ease-in-out infinite; + + @media (prefers-reduced-motion: reduce) { + animation: none; + } +} + +@keyframes live-dot-pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.7; + } +} + +// LiveValue + +.liveValue { + font-variant-numeric: tabular-nums; +} + diff --git a/src/components/Chart/Chart.stories.tsx b/src/components/Chart/Chart.stories.tsx index ac5fdf2..4692d59 100644 --- a/src/components/Chart/Chart.stories.tsx +++ b/src/components/Chart/Chart.stories.tsx @@ -1,6 +1,10 @@ import * as React from 'react'; import type { Meta, StoryObj } from '@storybook/react'; import * as Chart from './'; +import type { WaterfallSegment } from './'; +import { AnalyticsProvider } from '../Analytics'; +import type { InteractionInfo } from '../Analytics'; +import { Button } from '../Button'; const meta = { title: 'Components/Chart', @@ -13,10 +17,6 @@ const meta = { export default meta; type Story = StoryObj; -/* -------------------------------------------------------------------------- */ -/* 1. Line */ -/* -------------------------------------------------------------------------- */ - export const Line: Story = { render: () => (
@@ -41,10 +41,6 @@ export const Line: Story = { ), }; -/* -------------------------------------------------------------------------- */ -/* 2. LineAreaFill */ -/* -------------------------------------------------------------------------- */ - export const LineAreaFill: Story = { render: () => (
@@ -69,10 +65,6 @@ export const LineAreaFill: Story = { ), }; -/* -------------------------------------------------------------------------- */ -/* 3. LineDashed */ -/* -------------------------------------------------------------------------- */ - export const LineDashed: Story = { render: () => (
@@ -98,10 +90,6 @@ export const LineDashed: Story = { ), }; -/* -------------------------------------------------------------------------- */ -/* 4. LineReferenceLines */ -/* -------------------------------------------------------------------------- */ - export const LineReferenceLines: Story = { render: () => (
@@ -127,10 +115,6 @@ export const LineReferenceLines: Story = { ), }; -/* -------------------------------------------------------------------------- */ -/* 5. SparklineLine */ -/* -------------------------------------------------------------------------- */ - export const SparklineLine: Story = { render: () => (
@@ -142,10 +126,6 @@ export const SparklineLine: Story = { ), }; -/* -------------------------------------------------------------------------- */ -/* 6. SparklineBar */ -/* -------------------------------------------------------------------------- */ - export const SparklineBar: Story = { render: () => (
@@ -163,10 +143,6 @@ export const SparklineBar: Story = { ), }; -/* -------------------------------------------------------------------------- */ -/* 7. StackedArea */ -/* -------------------------------------------------------------------------- */ - export const StackedArea: Story = { render: () => (
@@ -193,10 +169,6 @@ export const StackedArea: Story = { ), }; -/* -------------------------------------------------------------------------- */ -/* 8. BarGrouped */ -/* -------------------------------------------------------------------------- */ - export const BarGrouped: Story = { render: () => (
@@ -221,10 +193,6 @@ export const BarGrouped: Story = { ), }; -/* -------------------------------------------------------------------------- */ -/* 9. BarStacked */ -/* -------------------------------------------------------------------------- */ - export const BarStacked: Story = { render: () => (
@@ -250,10 +218,6 @@ export const BarStacked: Story = { ), }; -/* -------------------------------------------------------------------------- */ -/* 10. BarHorizontal */ -/* -------------------------------------------------------------------------- */ - export const BarHorizontal: Story = { render: () => (
@@ -276,10 +240,6 @@ export const BarHorizontal: Story = { ), }; -/* -------------------------------------------------------------------------- */ -/* 11. Composed */ -/* -------------------------------------------------------------------------- */ - export const Composed: Story = { render: () => (
@@ -306,10 +266,6 @@ export const Composed: Story = { ), }; -/* -------------------------------------------------------------------------- */ -/* 12. Donut */ -/* -------------------------------------------------------------------------- */ - export const Donut: Story = { render: () => (
@@ -326,10 +282,6 @@ export const Donut: Story = { ), }; -/* -------------------------------------------------------------------------- */ -/* 13. Live */ -/* -------------------------------------------------------------------------- */ - function LiveChartWrapper() { const [data, setData] = React.useState<{ time: number; value: number }[]>([]); const [value, setValue] = React.useState(100); @@ -371,7 +323,7 @@ function LiveChartWrapper() { height={200} grid fill - scrub + interactive formatValue={(v) => v.toFixed(1)} /> ); @@ -385,10 +337,6 @@ export const Live: Story = { ), }; -/* -------------------------------------------------------------------------- */ -/* 16. Gauge */ -/* -------------------------------------------------------------------------- */ - export const Gauge: Story = { render: () => (
@@ -408,10 +356,6 @@ export const Gauge: Story = { ), }; -/* -------------------------------------------------------------------------- */ -/* 17. GaugeMinimal */ -/* -------------------------------------------------------------------------- */ - export const GaugeMinimal: Story = { render: () => (
@@ -431,10 +375,6 @@ export const GaugeMinimal: Story = { ), }; -/* -------------------------------------------------------------------------- */ -/* 18. BarList */ -/* -------------------------------------------------------------------------- */ - export const BarList: Story = { render: () => (
@@ -451,10 +391,6 @@ export const BarList: Story = { ), }; -/* -------------------------------------------------------------------------- */ -/* 19. Uptime */ -/* -------------------------------------------------------------------------- */ - const uptimeData = Array.from({ length: 90 }, (_, i) => ({ status: (i % 17 === 0 ? 'down' : i % 11 === 0 ? 'degraded' : 'up') as 'up' | 'down' | 'degraded', label: `Day ${i + 1}`, @@ -468,10 +404,6 @@ export const Uptime: Story = { ), }; -/* -------------------------------------------------------------------------- */ -/* 21. Scatter */ -/* -------------------------------------------------------------------------- */ - export const Scatter: Story = { render: () => (
@@ -514,10 +446,6 @@ export const Scatter: Story = { ), }; -/* -------------------------------------------------------------------------- */ -/* 22. Split (Distribution) */ -/* -------------------------------------------------------------------------- */ - export const Split: Story = { render: () => (
@@ -535,10 +463,6 @@ export const Split: Story = { ), }; -/* -------------------------------------------------------------------------- */ -/* 23. BarListRanked */ -/* -------------------------------------------------------------------------- */ - export const BarListRanked: Story = { render: () => (
@@ -559,10 +483,6 @@ export const BarListRanked: Story = { }; -/* -------------------------------------------------------------------------- */ -/* 27. Waterfall */ -/* -------------------------------------------------------------------------- */ - export const Waterfall: Story = { render: () => (
@@ -586,10 +506,6 @@ export const Waterfall: Story = { ), }; -/* -------------------------------------------------------------------------- */ -/* 28. Sankey */ -/* -------------------------------------------------------------------------- */ - export const Funnel: Story = { render: () => (
@@ -607,10 +523,6 @@ export const Funnel: Story = { ), }; -/* -------------------------------------------------------------------------- */ -/* 26. Sankey */ -/* -------------------------------------------------------------------------- */ - export const Sankey: Story = { render: () => (
@@ -651,3 +563,550 @@ export const Sankey: Story = {
), }; + +export const LiveDotStates: Story = { + render: () => ( +
+ {(['active', 'degraded', 'down', 'unknown'] as const).map((status) => ( +
+ + + {status} + +
+ ))} +
+ ), +}; + +export const LiveValueAnimated: Story = { + render: function Render() { + const [value, setValue] = React.useState(1234); + + return ( +
+ v.toLocaleString(undefined, { maximumFractionDigits: 0 })} + className="headline-lg" + style={{ color: 'var(--text-primary)' }} + /> +
+ + + +
+

+ Target: {value.toLocaleString()} — the displayed value lerps smoothly toward the target. +

+
+ ); + }, +}; + +export const BarWithAnalytics: Story = { + render: function Render() { + const [events, setEvents] = React.useState([]); + + const handler = React.useMemo( + () => ({ + onInteraction: (info: InteractionInfo) => { + const entry = `${info.component} · ${info.interaction} · "${info.name}" ${info.metadata ? JSON.stringify(info.metadata) : ''}`; + setEvents((prev) => [entry, ...prev].slice(0, 10)); + }, + }), + [], + ); + + return ( + +
+
+ {}} + /> +
+
+

+ Click a bar to fire an analytics event +

+
+              {events.length === 0 ? '(no events yet)' : events.join('\n')}
+            
+
+
+
+ ); + }, +}; + +export const StackedAreaNonInteractive: Story = { + render: () => ( +
+ +
+ ), +}; + +export const BarNonInteractive: Story = { + render: () => ( +
+ +
+ ), +}; + +export const ComposedNonInteractive: Story = { + render: () => ( +
+ +
+ ), +}; + +export const ScatterNonInteractive: Story = { + render: () => ( +
+ +
+ ), +}; + +export const StackedAreaNoAnimation: Story = { + render: () => ( +
+ +
+ ), +}; + +export const PieNoAnimation: Story = { + render: () => ( +
+ +
+ ), +}; + +export const FunnelActiveChange: Story = { + render: function Render() { + const [active, setActive] = React.useState(null); + return ( +
+
+ +
+

+ Active index: {active ?? 'none'} +

+
+ ); + }, +}; + +export const WaterfallActiveChange: Story = { + render: function Render() { + const [active, setActive] = React.useState(null); + return ( +
+
+ +
+

+ Active index: {active ?? 'none'} +

+
+ ); + }, +}; + +export const SplitActiveChange: Story = { + render: function Render() { + const [active, setActive] = React.useState(null); + return ( +
+
+ +
+

+ Active index: {active ?? 'none'} +

+
+ ); + }, +}; + +export const ScatterActiveChange: Story = { + render: function Render() { + const [active, setActive] = React.useState<{ seriesIndex: number; pointIndex: number } | null>(null); + return ( +
+
+ +
+

+ Active: {active ? `series ${active.seriesIndex}, point ${active.pointIndex}` : 'none'} +

+
+ ); + }, +}; + +export const ComposedFixedDomain: Story = { + render: () => ( +
+ +
+ ), +}; + +export const WaterfallCustomTooltip: Story = { + render: () => ( +
+ { + const datum = d as WaterfallSegment; + return ( +
+ {datum.label} +
+ {`$${Math.abs(datum.value).toLocaleString()}`} +
+ ); + }} + /> +
+ ), +}; + +export const SankeyNoTooltip: Story = { + render: () => ( +
+ +
+ ), +}; + +export const FunnelNoTooltip: Story = { + render: () => ( +
+ +
+ ), +}; + +export const UptimeLoading: Story = { + render: () => ( +
+ +
+ ), +}; + +export const UptimeEmpty: Story = { + render: () => ( +
+ +
+ ), +}; + +export const GaugeLoading: Story = { + render: () => ( +
+ +
+ ), +}; + +export const LiveLoading: Story = { + render: () => ( +
+ +
+ ), +}; + +export const LiveEmpty: Story = { + render: () => ( +
+ +
+ ), +}; + +export const PieKeyboardNav: Story = { + render: function Render() { + const [active, setActive] = React.useState(null); + return ( +
+
+ +
+

+ Focus the chart and use arrow keys to cycle segments. Active: {active ?? 'none'} +

+
+ ); + }, +}; + +export const UptimeKeyboardNav: Story = { + render: function Render() { + const [active, setActive] = React.useState(null); + const data = Array.from({ length: 30 }, (_, i) => ({ + status: i === 12 || i === 13 ? ('down' as const) : ('up' as const), + label: new Date(Date.now() - (30 - i) * 60_000).toLocaleTimeString(), + })); + return ( +
+
+ setActive(index)} + /> +
+

+ Focus the bars and use arrow keys to navigate. Active: {active ?? 'none'} +

+
+ ); + }, +}; diff --git a/src/components/Chart/ChartWrapper.tsx b/src/components/Chart/ChartWrapper.tsx index 19f8701..5127e3e 100644 --- a/src/components/Chart/ChartWrapper.tsx +++ b/src/components/Chart/ChartWrapper.tsx @@ -6,9 +6,11 @@ import type { ResolvedSeries } from './types'; import styles from './Chart.module.scss'; export interface ChartWrapperProps { + ref?: React.Ref; loading?: boolean; empty?: React.ReactNode; dataLength: number; + isEmpty?: boolean; height: number; legend?: boolean; series?: ResolvedSeries[]; @@ -18,9 +20,11 @@ export interface ChartWrapperProps { } export function ChartWrapper({ + ref, loading, empty, dataLength, + isEmpty, height, legend, series, @@ -28,9 +32,11 @@ export function ChartWrapper({ className, ariaLiveContent, }: ChartWrapperProps) { + const showEmpty = isEmpty ?? (dataLength === 0); + if (loading) { return ( -
+
@@ -38,9 +44,9 @@ export function ChartWrapper({ ); } - if (dataLength === 0 && empty !== undefined) { + if (showEmpty && empty !== undefined) { return ( -
+
{typeof empty === 'boolean' ? 'No data' : empty}
diff --git a/src/components/Chart/ComposedChart.tsx b/src/components/Chart/ComposedChart.tsx index 5ad7628..f743304 100644 --- a/src/components/Chart/ComposedChart.tsx +++ b/src/components/Chart/ComposedChart.tsx @@ -15,7 +15,9 @@ import { axisPadForLabels, type Point, } from './utils'; -import { useResizeWidth, useChartScrub } from './hooks'; +import { useTrackedCallback } from '../Analytics/useTrackedCallback'; +import { useResizeWidth, useChartInteraction } from './hooks'; +import { useMergedRef } from './useMergedRef'; import { type Series, type ResolvedSeries, @@ -72,6 +74,8 @@ export interface ComposedChartProps extends React.ComponentPropsWithoutRef<'div' /** Control animation. */ animate?: boolean; ariaLabel?: string; + /** Disables interaction, cursor, dots, and tooltip. */ + interactive?: boolean; onActiveChange?: ( index: number | null, datum: Record | null, @@ -81,6 +85,8 @@ export interface ComposedChartProps extends React.ComponentPropsWithoutRef<'div' index: number, datum: Record, ) => void; + /** Analytics name for event tracking. */ + analyticsName?: string; formatValue?: (value: number) => string; formatXLabel?: (value: unknown) => string; formatYLabel?: (value: number) => string; @@ -88,10 +94,16 @@ export interface ComposedChartProps extends React.ComponentPropsWithoutRef<'div' formatYLabelRight?: (value: number) => string; /** Connect across null/NaN gaps in line series. When false, gaps break the line. */ connectNulls?: boolean; + /** Lock the left Y-axis domain instead of auto-scaling from data. */ + yDomain?: [number, number]; + /** Lock the right Y-axis domain instead of auto-scaling from data. */ + yDomainRight?: [number, number]; } const EMPTY_TICKS = { min: 0, max: 1, ticks: [0, 1] } as const; +const clickIndexMeta = (index: number) => ({ index }); + export const Composed = React.forwardRef( function Composed( { @@ -109,32 +121,33 @@ export const Composed = React.forwardRef( empty, animate = true, ariaLabel, + interactive = true, onActiveChange, onClickDatum, + analyticsName, formatValue, formatXLabel, formatYLabel, formatYLabelRight, connectNulls = true, + yDomain: yDomainProp, + yDomainRight: yDomainRightProp, className, ...props }, ref, ) { const { width, attachRef } = useResizeWidth(); + const trackedClick = useTrackedCallback( + analyticsName, 'Chart.Composed', 'click', onClickDatum, + onClickDatum ? clickIndexMeta : undefined, + ); const tooltipMode = resolveTooltipMode(tooltipProp); - const showTooltip = tooltipMode !== 'off'; + const showTooltip = interactive && tooltipMode !== 'off'; const tooltipRender = typeof tooltipProp === 'function' ? tooltipProp : undefined; - const mergedRef = React.useCallback( - (node: HTMLDivElement | null) => { - attachRef(node); - if (typeof ref === 'function') ref(node); - else if (ref) ref.current = node; - }, - [ref, attachRef], - ); + const mergedRef = useMergedRef(ref, attachRef); // Resolve series const series = React.useMemo( @@ -165,6 +178,7 @@ export const Composed = React.forwardRef( // Left Y domain (bar series + left-axis lines) const leftDomain = React.useMemo(() => { + if (yDomainProp) return niceTicks(yDomainProp[0], yDomainProp[1], tickTarget); let max = -Infinity; for (const s of series.filter((s) => s.axis === 'left')) { for (const d of data) { @@ -185,11 +199,12 @@ export const Composed = React.forwardRef( } if (max === -Infinity) max = 1; return niceTicks(0, max, tickTarget); - }, [data, series, referenceLines, referenceBands, tickTarget]); + }, [data, series, referenceLines, referenceBands, tickTarget, yDomainProp]); // Right Y domain (right-axis lines) const rightDomain = React.useMemo(() => { if (!hasRightAxis) return EMPTY_TICKS; + if (yDomainRightProp) return niceTicks(yDomainRightProp[0], yDomainRightProp[1], tickTarget); let min = Infinity; let max = -Infinity; for (const s of series.filter((s) => s.axis === 'right')) { @@ -203,7 +218,7 @@ export const Composed = React.forwardRef( } if (min === Infinity) return EMPTY_TICKS; return niceTicks(min, max, tickTarget); - }, [data, series, hasRightAxis, tickTarget]); + }, [data, series, hasRightAxis, tickTarget, yDomainRightProp]); const padLeft = React.useMemo(() => { if (!showYAxis) return 0; @@ -280,12 +295,12 @@ export const Composed = React.forwardRef( }, [interpolators]); // Scrub - const scrub = useChartScrub({ + const scrub = useChartInteraction({ dataLength: data.length, seriesCount: lineSeries.length, plotWidth, padLeft, - tooltipMode, + tooltipMode: interactive ? tooltipMode : 'off', interpolatorsRef, data, onActiveChange, @@ -318,8 +333,8 @@ export const Composed = React.forwardRef( const handleClick = React.useCallback(() => { if (!onClickDatum || scrub.activeIndex === null || scrub.activeIndex >= data.length) return; - onClickDatum(scrub.activeIndex, data[scrub.activeIndex]); - }, [onClickDatum, scrub.activeIndex, data]); + trackedClick(scrub.activeIndex, data[scrub.activeIndex]); + }, [onClickDatum, trackedClick, scrub.activeIndex, data]); const svgDesc = React.useMemo(() => { if (series.length === 0 || data.length === 0) return undefined; @@ -346,13 +361,15 @@ export const Composed = React.forwardRef( return (
( {svgDesc && {svgDesc}} @@ -491,25 +508,27 @@ export const Composed = React.forwardRef( ); })} - {/* Cursor line */} - - - {/* Line dots */} - {lineSeries.map((s, i) => ( - { scrub.dotRefs.current[i] = el; }} - cx={0} cy={0} r={3} - fill={s.color} - className={styles.activeDot} - style={{ display: 'none' }} - /> - ))} + {interactive && ( + <> + + + {lineSeries.map((s, i) => ( + { scrub.dotRefs.current[i] = el; }} + cx={0} cy={0} r={3} + fill={s.color} + className={styles.activeDot} + style={{ display: 'none' }} + /> + ))} + + )} {/* Left Y axis labels */} {yLabelsLeft.map(({ y, text }, i) => ( diff --git a/src/components/Chart/FunnelChart.tsx b/src/components/Chart/FunnelChart.tsx index ad694b5..3705a65 100644 --- a/src/components/Chart/FunnelChart.tsx +++ b/src/components/Chart/FunnelChart.tsx @@ -2,8 +2,11 @@ import * as React from 'react'; import clsx from 'clsx'; +import { useTrackedCallback } from '../Analytics/useTrackedCallback'; import { useResizeWidth } from './hooks'; +import { useMergedRef } from './useMergedRef'; import { SERIES_COLORS } from './types'; +import { ChartWrapper } from './ChartWrapper'; import styles from './Chart.module.scss'; export interface FunnelStage { @@ -19,13 +22,16 @@ export interface FunnelChartProps extends React.ComponentPropsWithoutRef<'div'> formatRate?: (rate: number) => string; showRates?: boolean; showLabels?: boolean; - showGrid?: boolean; + grid?: boolean; height?: number; animate?: boolean; loading?: boolean; empty?: React.ReactNode; ariaLabel?: string; + tooltip?: boolean; onClickDatum?: (index: number, stage: FunnelStage) => void; + onActiveChange?: (index: number | null) => void; + analyticsName?: string; } const PAD = 8; @@ -34,6 +40,8 @@ const LABEL_GAP = 6; const rd = (n: number) => Math.round(n * 100) / 100; +const clickIndexMeta = (index: number) => ({ index }); + export const Funnel = React.forwardRef( function Funnel( { @@ -42,13 +50,16 @@ export const Funnel = React.forwardRef( formatRate, showRates = true, showLabels = true, - showGrid = true, + grid = true, height = 140, animate = true, + tooltip = true, loading, empty, ariaLabel, onClickDatum, + onActiveChange, + analyticsName, className, ...props }, @@ -56,21 +67,29 @@ export const Funnel = React.forwardRef( ) { const { width, attachRef } = useResizeWidth(); const [activeIndex, setActiveIndex] = React.useState(null); + + const onActiveChangeRef = React.useRef(onActiveChange); + React.useLayoutEffect(() => { + onActiveChangeRef.current = onActiveChange; + }, [onActiveChange]); + + React.useEffect(() => { + onActiveChangeRef.current?.(activeIndex); + }, [activeIndex]); + const tooltipRef = React.useRef(null); const rootRef = React.useRef(null); - const mergedRef = React.useCallback( - (node: HTMLDivElement | null) => { - rootRef.current = node; - attachRef(node); - if (typeof ref === 'function') ref(node); - else if (ref) ref.current = node; - }, - [ref, attachRef], + const trackedClick = useTrackedCallback( + analyticsName, 'Chart.Funnel', 'click', onClickDatum, + onClickDatum ? clickIndexMeta : undefined, ); + const resizeRef = useMergedRef(ref, attachRef); + const mergedRef = useMergedRef(resizeRef, rootRef); + const fmtValue = React.useCallback( - (v: number) => (formatValue ? formatValue(v) : v.toLocaleString()), + (v: number) => (formatValue ? formatValue(v) : String(v)), [formatValue], ); @@ -84,7 +103,7 @@ export const Funnel = React.forwardRef( const dense = stageWidth < 60; const effectiveShowLabels = showLabels && !dense; - const effectiveShowGrid = showGrid && !dense; + const effectiveShowGrid = grid && !dense; const labelSpace = effectiveShowLabels ? LABEL_ROW_HEIGHT + LABEL_GAP : 0; const plotHeight = Math.max(0, height - PAD * 2 - labelSpace); @@ -219,7 +238,7 @@ export const Funnel = React.forwardRef( case ' ': if (onClickDatum && activeIndex !== null && activeIndex < data.length) { e.preventDefault(); - onClickDatum(activeIndex, data[activeIndex]); + trackedClick(activeIndex, data[activeIndex]); } return; case 'Escape': @@ -244,53 +263,20 @@ export const Funnel = React.forwardRef( tip.style.display = ''; } }, - [activeIndex, data, stageWidth, flatRatio, centerY, width, onClickDatum, handleMouseLeave], + [activeIndex, data, stageWidth, flatRatio, centerY, width, onClickDatum, trackedClick, handleMouseLeave], ); const ready = width > 0; - if (loading) { - return ( -
-
-
- {[100, 45, 15].map((pct, i) => ( -
- ))} -
-
-
- ); - } - - if (data.length === 0 && empty !== undefined) { - return ( -
-
- {typeof empty === 'boolean' ? 'No data' : empty} -
-
- ); - } - return ( +
( onMouseLeave={handleMouseLeave} onClick={ onClickDatum - ? () => onClickDatum(i, data[i]) + ? () => trackedClick(i, data[i]) : undefined } cursor={onClickDatum ? 'pointer' : undefined} @@ -381,41 +367,44 @@ export const Funnel = React.forwardRef( })} -
- {tooltipContent && ( -
-
- - {tooltipContent.label} - - - {tooltipContent.value} - -
- {tooltipContent.rate && ( + {tooltip !== false && ( +
+ {tooltipContent && ( +
- Rate + + {tooltipContent.label} + - {tooltipContent.rate} + {tooltipContent.value}
- )} -
- )} -
+ {tooltipContent.rate && ( +
+ Rate + + {tooltipContent.rate} + +
+ )} +
+ )} +
+ )} )}
+
); }, ); diff --git a/src/components/Chart/GaugeChart.tsx b/src/components/Chart/GaugeChart.tsx index ff195f9..b53f568 100644 --- a/src/components/Chart/GaugeChart.tsx +++ b/src/components/Chart/GaugeChart.tsx @@ -28,6 +28,8 @@ export interface GaugeChartProps extends React.ComponentPropsWithoutRef<'div'> { formatValue?: (value: number) => string; /** Visual density. */ variant?: 'default' | 'minimal'; + loading?: boolean; + analyticsName?: string; /** Accessible label. */ ariaLabel?: string; } @@ -42,6 +44,8 @@ export const Gauge = React.forwardRef( markerLabel, formatValue, variant = 'default', + loading, + analyticsName: _analyticsName, ariaLabel, className, ...props @@ -57,6 +61,16 @@ export const Gauge = React.forwardRef( const fmtValue = formatValue ? formatValue(value) : String(value); + if (loading) { + return ( +
+
+
+
+
+ ); + } + return (
({ index }); + export interface LineChartProps extends React.ComponentPropsWithoutRef<'div'> { /** Array of data objects. Each object should contain keys matching `dataKey` or `series[].key`. */ data: Record[]; @@ -87,7 +91,7 @@ export interface LineChartProps extends React.ComponentPropsWithoutRef<'div'> { empty?: React.ReactNode; /** Accessible label for the chart SVG. */ ariaLabel?: string; - /** Disables scrub interaction, cursor, dots, and tooltip. */ + /** Disables interaction, cursor, dots, and tooltip. */ interactive?: boolean; /** Called when the hovered data point changes. Receives `null` on leave. */ onActiveChange?: ( @@ -99,6 +103,8 @@ export interface LineChartProps extends React.ComponentPropsWithoutRef<'div'> { index: number, datum: Record, ) => void; + /** Analytics name for event tracking. */ + analyticsName?: string; /** Format values in tooltips. */ formatValue?: (value: number) => string; /** Format x-axis labels. */ @@ -137,6 +143,7 @@ export const Line = React.forwardRef( interactive = true, onActiveChange, onClickDatum, + analyticsName, formatValue, formatXLabel, formatYLabel, @@ -147,6 +154,10 @@ export const Line = React.forwardRef( ref, ) { const { width, attachRef } = useResizeWidth(); + const trackedClick = useTrackedCallback( + analyticsName, 'Chart.Line', 'click', onClickDatum, + onClickDatum ? clickIndexMeta : undefined, + ); const uid = React.useId().replace(/:/g, ''); const tooltipMode = resolveTooltipMode(tooltipProp); @@ -154,14 +165,7 @@ export const Line = React.forwardRef( const tooltipRender = typeof tooltipProp === 'function' ? tooltipProp : undefined; - const mergedRef = React.useCallback( - (node: HTMLDivElement | null) => { - attachRef(node); - if (typeof ref === 'function') ref(node); - else if (ref) ref.current = node; - }, - [ref, attachRef], - ); + const mergedRef = useMergedRef(ref, attachRef); const series = React.useMemo( () => resolveSeries(seriesProp, dataKey, color), @@ -387,7 +391,7 @@ export const Line = React.forwardRef( }, [interpolators]); // Scrub interaction - const scrub = useChartScrub({ + const scrub = useChartInteraction({ dataLength: data.length, seriesCount: series.length, plotWidth, @@ -408,8 +412,8 @@ export const Line = React.forwardRef( const handleClick = React.useCallback(() => { if (!onClickDatum || scrub.activeIndex === null || scrub.activeIndex >= data.length) return; - onClickDatum(scrub.activeIndex, data[scrub.activeIndex]); - }, [onClickDatum, scrub.activeIndex, data]); + trackedClick(scrub.activeIndex, data[scrub.activeIndex]); + }, [onClickDatum, trackedClick, scrub.activeIndex, data]); const svgDesc = React.useMemo(() => { if (series.length === 0 || data.length === 0) return undefined; @@ -434,12 +438,14 @@ export const Line = React.forwardRef( return (
{ /** Show pulsing live dot. */ pulse?: boolean; /** Show crosshair on hover. */ - scrub?: boolean; + interactive?: boolean; height?: number; /** Interpolation speed (0-1). Higher = snappier. */ lerpSpeed?: number; formatValue?: (v: number) => string; - formatTime?: (t: number) => string; + formatXLabel?: (t: number) => string; ariaLabel?: string; - onHover?: (point: { time: number; value: number; x: number; y: number } | null) => void; + onActiveChange?: (point: { time: number; value: number; x: number; y: number } | null) => void; + loading?: boolean; + empty?: React.ReactNode; + /** Analytics name for event tracking. */ + analyticsName?: string; } // Layout constants @@ -142,13 +147,16 @@ export const Live = React.forwardRef( grid = true, fill = true, pulse = true, - scrub = true, + interactive = true, height = 200, lerpSpeed = 0.08, formatValue, - formatTime, + formatXLabel, ariaLabel, - onHover, + onActiveChange, + loading, + empty, + analyticsName: _analyticsName, className, ...props }, @@ -173,13 +181,13 @@ export const Live = React.forwardRef( // Config ref so rAF callback doesn't need recreation const configRef = React.useRef({ - data, value, color, windowSecs, grid, fill, pulse, scrub, - lerpSpeed, formatValue, formatTime, onHover, height, + data, value, color, windowSecs, grid, fill, pulse, interactive, + lerpSpeed, formatValue, formatXLabel, onActiveChange, height, loading, }); React.useLayoutEffect(() => { configRef.current = { - data, value, color, windowSecs, grid, fill, pulse, scrub, - lerpSpeed, formatValue, formatTime, onHover, height, + data, value, color, windowSecs, grid, fill, pulse, interactive, + lerpSpeed, formatValue, formatXLabel, onActiveChange, height, loading, }; }); @@ -218,16 +226,16 @@ export const Live = React.forwardRef( const el = containerRef.current; if (!el) return; const onMove = (e: MouseEvent) => { - if (!configRef.current.scrub) return; + if (!configRef.current.interactive) return; const rect = el.getBoundingClientRect(); hoverXRef.current = e.clientX - rect.left; }; const onLeave = () => { hoverXRef.current = null; - configRef.current.onHover?.(null); + configRef.current.onActiveChange?.(null); }; const onTouchMove = (e: TouchEvent) => { - if (!configRef.current.scrub || !e.touches[0]) return; + if (!configRef.current.interactive || !e.touches[0]) return; const rect = el.getBoundingClientRect(); hoverXRef.current = e.touches[0].clientX - rect.left; }; @@ -249,8 +257,8 @@ export const Live = React.forwardRef( const canvas = canvasRef.current; const { w, h } = sizeRef.current; - if (!canvas || w === 0 || h === 0) { - rafRef.current = requestAnimationFrame(draw); + if (!canvas || w === 0 || h === 0 || configRef.current.loading) { + rafRef.current = 0; return; } @@ -430,7 +438,7 @@ export const Live = React.forwardRef( // Time axis if (cfg.grid) { - const fmtTime = cfg.formatTime ?? formatDefaultTime; + const fmtTime = cfg.formatXLabel ?? formatDefaultTime; const timeStep = Math.max(1, Math.ceil(cfg.windowSecs / 5)); const firstT = Math.ceil(leftEdge / timeStep) * timeStep; ctx.font = CHART_LABEL_FONT; @@ -486,9 +494,9 @@ export const Live = React.forwardRef( ctx.fill(); } - // Crosshair / scrub + // Crosshair / interaction overlay const hoverX = hoverXRef.current; - const scrubTarget = hoverX !== null && cfg.scrub ? 1 : 0; + const scrubTarget = hoverX !== null && cfg.interactive ? 1 : 0; st.scrubAmount += (scrubTarget - st.scrubAmount) * 0.12; if (st.scrubAmount < 0.01) st.scrubAmount = 0; if (st.scrubAmount > 0.99) st.scrubAmount = 1; @@ -525,7 +533,7 @@ export const Live = React.forwardRef( // Tooltip text — tracks horizontally with crosshair const fmtVal = cfg.formatValue ?? ((v: number) => v.toFixed(2)); - const fmtTime = cfg.formatTime ?? formatDefaultTime; + const fmtTime = cfg.formatXLabel ?? formatDefaultTime; const label = `${fmtVal(hoverVal)} · ${fmtTime(hoverTime)}`; ctx.globalAlpha = opacity; ctx.font = CHART_LABEL_FONT.replace('11px', '12px'); @@ -539,7 +547,7 @@ export const Live = React.forwardRef( ctx.fillStyle = 'rgb(26,26,26)'; ctx.fillText(label, labelX, labelY); - cfg.onHover?.({ time: hoverTime, value: hoverVal, x: clampedX, y: hoverY }); + cfg.onActiveChange?.({ time: hoverTime, value: hoverVal, x: clampedX, y: hoverY }); } } @@ -563,20 +571,40 @@ export const Live = React.forwardRef( }; }, [draw]); - const mergedRef = React.useCallback( - (node: HTMLDivElement | null) => { - (containerRef as React.MutableRefObject).current = node; - if (typeof ref === 'function') ref(node); - else if (ref) ref.current = node; - }, - [ref], - ); + React.useEffect(() => { + if (!loading && !rafRef.current) { + lastFrameRef.current = 0; + rafRef.current = requestAnimationFrame(draw); + } + }, [loading, draw]); + + const mergedRef = useMergedRef(ref, containerRef); + + if (loading) { + return ( +
+
+
+
+
+ ); + } + + if (data.length === 0 && empty !== undefined) { + return ( +
+
+ {typeof empty === 'boolean' ? 'No data' : empty} +
+
+ ); + } return (
{ + status?: LiveDotStatus; + size?: number; +} + +const STATUS_COLORS: Record = { + active: 'var(--color-blue-700)', + degraded: 'var(--color-yellow-500)', + down: 'var(--color-red-500)', + unknown: 'var(--color-gray-300)', +}; + +export const LiveDot = React.forwardRef( + function LiveDot({ status = 'active', size = 8, className, style, ...props }, ref) { + const color = STATUS_COLORS[status]; + const shouldPulse = status === 'active'; + + return ( + + ); + }, +); + +if (process.env.NODE_ENV !== 'production') { + LiveDot.displayName = 'Chart.LiveDot'; +} diff --git a/src/components/Chart/LiveValue.tsx b/src/components/Chart/LiveValue.tsx new file mode 100644 index 0000000..18b07e6 --- /dev/null +++ b/src/components/Chart/LiveValue.tsx @@ -0,0 +1,94 @@ +'use client'; + +import * as React from 'react'; +import clsx from 'clsx'; +import { filerp } from './utils'; +import { useMergedRef } from './useMergedRef'; +import styles from './Chart.module.scss'; + +export interface LiveValueProps extends React.ComponentPropsWithoutRef<'span'> { + value: number; + formatValue?: (v: number) => string; +} + +const DEFAULT_FORMAT = (v: number) => String(Math.round(v)); +const MAX_DELTA_MS = 50; +const LERP_SPEED = 0.08; + +export const LiveValue = React.forwardRef( + function LiveValue({ value, formatValue, className, ...props }, ref) { + const spanRef = React.useRef(null); + const displayRef = React.useRef(value); + const rafRef = React.useRef(0); + const lastFrameRef = React.useRef(0); + const valueRef = React.useRef(value); + const formatRef = React.useRef(formatValue); + + React.useLayoutEffect(() => { + valueRef.current = value; + formatRef.current = formatValue; + }, [value, formatValue]); + + const tick = React.useCallback(() => { + const now = performance.now(); + const dt = lastFrameRef.current + ? Math.min(now - lastFrameRef.current, MAX_DELTA_MS) + : 16.67; + lastFrameRef.current = now; + + displayRef.current = filerp(displayRef.current, valueRef.current, LERP_SPEED, dt); + if (Math.abs(displayRef.current - valueRef.current) < 0.01) { + displayRef.current = valueRef.current; + } + + const el = spanRef.current; + if (el) { + const fmt = formatRef.current ?? DEFAULT_FORMAT; + el.textContent = fmt(displayRef.current); + } + + if (displayRef.current !== valueRef.current) { + rafRef.current = requestAnimationFrame(tick); + } else { + rafRef.current = 0; + } + }, []); + + React.useEffect(() => { + if (!rafRef.current) { + rafRef.current = requestAnimationFrame(tick); + } + return () => { + if (rafRef.current) { + cancelAnimationFrame(rafRef.current); + rafRef.current = 0; + } + }; + }, [tick]); + + React.useEffect(() => { + if (!rafRef.current && displayRef.current !== value) { + lastFrameRef.current = 0; + rafRef.current = requestAnimationFrame(tick); + } + }, [value, tick]); + + const mergedRef = useMergedRef(ref, spanRef); + + const fmt = formatValue ?? DEFAULT_FORMAT; + + return ( + + {fmt(value)} + + ); + }, +); + +if (process.env.NODE_ENV !== 'production') { + LiveValue.displayName = 'Chart.LiveValue'; +} diff --git a/src/components/Chart/PieChart.tsx b/src/components/Chart/PieChart.tsx index ead1169..1450386 100644 --- a/src/components/Chart/PieChart.tsx +++ b/src/components/Chart/PieChart.tsx @@ -2,7 +2,9 @@ import * as React from 'react'; import clsx from 'clsx'; +import { useTrackedCallback } from '../Analytics/useTrackedCallback'; import { useResizeWidth } from './hooks'; +import { useMergedRef } from './useMergedRef'; import { SERIES_COLORS } from './types'; import { ChartWrapper } from './ChartWrapper'; import styles from './Chart.module.scss'; @@ -33,6 +35,8 @@ export interface PieChartProps extends React.ComponentPropsWithoutRef<'div'> { onActiveChange?: (index: number | null, segment: PieSegment | null) => void; /** Called when a segment is clicked. */ onClickDatum?: (index: number, segment: PieSegment) => void; + animate?: boolean; + analyticsName?: string; ariaLabel?: string; formatValue?: (value: number) => string; } @@ -79,6 +83,8 @@ function arcPath( ].join(' '); } +const clickIndexMeta = (index: number) => ({ index }); + const SEGMENT_GAP = 0.02; export const Pie = React.forwardRef( @@ -92,7 +98,9 @@ export const Pie = React.forwardRef( loading, empty, onActiveChange, + animate = true, onClickDatum, + analyticsName, ariaLabel, formatValue, className, @@ -103,17 +111,15 @@ export const Pie = React.forwardRef( const tooltipEnabled = !!tooltipProp; const customTooltip = typeof tooltipProp === 'function' ? tooltipProp : null; + const trackedClickDatum = useTrackedCallback( + analyticsName, 'Chart.Pie', 'click', onClickDatum, + onClickDatum ? clickIndexMeta : undefined, + ); + const { width, attachRef } = useResizeWidth(); const [activeIndex, setActiveIndex] = React.useState(null); - const mergedRef = React.useCallback( - (node: HTMLDivElement | null) => { - attachRef(node); - if (typeof ref === 'function') ref(node); - else if (ref) ref.current = node; - }, - [ref, attachRef], - ); + const mergedRef = useMergedRef(ref, attachRef); const onActiveChangeRef = React.useRef(onActiveChange); React.useLayoutEffect(() => { @@ -183,24 +189,63 @@ export const Pie = React.forwardRef( return null; }, [tooltipEnabled, activeSeg, customTooltip]); + const handleKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + if (segments.length === 0) return; + switch (e.key) { + case 'ArrowRight': + case 'ArrowDown': + e.preventDefault(); + setActiveIndex((prev) => + prev === null ? 0 : (prev + 1) % segments.length, + ); + break; + case 'ArrowLeft': + case 'ArrowUp': + e.preventDefault(); + setActiveIndex((prev) => + prev === null + ? segments.length - 1 + : (prev - 1 + segments.length) % segments.length, + ); + break; + case 'Enter': + case ' ': + if (onClickDatum && activeIndex !== null) { + e.preventDefault(); + trackedClickDatum(activeIndex, data[activeIndex]); + } + return; + case 'Escape': + e.preventDefault(); + setActiveIndex(null); + break; + default: + return; + } + }, + [segments.length, onClickDatum, activeIndex, trackedClickDatum, data], + ); + const ariaLiveText = activeSeg && tooltipEnabled ? `${activeSeg.name}: ${fmtValue(activeSeg.value)} (${activeSeg.percentage.toFixed(0)}%)` : undefined; return ( -
- {ready && ( <> @@ -210,6 +255,8 @@ export const Pie = React.forwardRef( width={svgSize} height={svgSize} className={styles.svg} + tabIndex={0} + onKeyDown={handleKeyDown} > {svgDesc && {svgDesc}} @@ -226,14 +273,17 @@ export const Pie = React.forwardRef( stroke="var(--surface-primary)" strokeWidth={1.5} transform={`translate(${tx}, ${ty})`} + role="graphics-symbol" + aria-roledescription="Segment" + aria-label={`${data[i].name}: ${data[i].value}`} onMouseEnter={() => setActiveIndex(i)} onMouseLeave={() => setActiveIndex(null)} - onClick={() => onClickDatum?.(i, data[i])} + onClick={() => trackedClickDatum(i, data[i])} className={styles.pieSegment} style={{ - cursor: 'pointer', - transition: 'transform 200ms cubic-bezier(0.33, 1, 0.68, 1)', - animationDelay: `${i * 60}ms`, + animationDelay: animate ? `${i * 60}ms` : undefined, + animation: animate ? undefined : 'none', + opacity: animate ? undefined : 1, }} /> ); @@ -304,8 +354,8 @@ export const Pie = React.forwardRef(
)} - -
+
+ ); }, ); diff --git a/src/components/Chart/SankeyChart.tsx b/src/components/Chart/SankeyChart.tsx index 07c9c19..7c9626e 100644 --- a/src/components/Chart/SankeyChart.tsx +++ b/src/components/Chart/SankeyChart.tsx @@ -2,7 +2,9 @@ import * as React from 'react'; import clsx from 'clsx'; +import { useTrackedCallback } from '../Analytics/useTrackedCallback'; import { useResizeWidth } from './hooks'; +import { useMergedRef } from './useMergedRef'; import { type SankeyData, type LayoutNode, @@ -11,6 +13,7 @@ import { sankeyLinkPath, } from './sankeyLayout'; import { SERIES_COLORS } from './types'; +import { ChartWrapper } from './ChartWrapper'; import { measureLabelWidth } from './utils'; import styles from './Chart.module.scss'; @@ -30,8 +33,10 @@ export interface SankeyChartProps extends React.ComponentPropsWithoutRef<'div'> empty?: React.ReactNode; ariaLabel?: string; formatValue?: (value: number) => string; + tooltip?: boolean; onClickNode?: (node: LayoutNode) => void; onClickLink?: (link: LayoutLink) => void; + analyticsName?: string; } type ActiveElement = @@ -47,6 +52,9 @@ const LINK_OPACITY = 0.5; const LINK_OPACITY_DIM = 0.06; const NODE_OPACITY_DIM = 0.15; +const sankeyNodeClickMeta = (node: LayoutNode) => ({ id: node.id }); +const sankeyLinkClickMeta = (link: LayoutLink) => ({ source: link.source, target: link.target }); + export const Sankey = React.forwardRef( function Sankey( { @@ -58,36 +66,40 @@ export const Sankey = React.forwardRef( showLabels = true, showValues = false, stages, + tooltip = true, loading, empty, ariaLabel, formatValue, onClickNode, onClickLink, + analyticsName, className, ...props }, ref, ) { + const trackedClickNode = useTrackedCallback( + analyticsName, 'Chart.Sankey', 'click', onClickNode, + onClickNode ? sankeyNodeClickMeta : undefined, + ); + const trackedClickLink = useTrackedCallback( + analyticsName, 'Chart.Sankey', 'click', onClickLink, + onClickLink ? sankeyLinkClickMeta : undefined, + ); + const { width, attachRef } = useResizeWidth(); const [active, setActive] = React.useState(null); const tooltipRef = React.useRef(null); const rootRef = React.useRef(null); - const mergedRef = React.useCallback( - (node: HTMLDivElement | null) => { - rootRef.current = node; - attachRef(node); - if (typeof ref === 'function') ref(node); - else if (ref) ref.current = node; - }, - [ref, attachRef], - ); + const resizeRef = useMergedRef(ref, attachRef); + const mergedRef = useMergedRef(resizeRef, rootRef); const hasStages = stages !== undefined && stages.length > 0; const fmtValue = React.useCallback( - (v: number) => (formatValue ? formatValue(v) : v.toLocaleString()), + (v: number) => (formatValue ? formatValue(v) : String(v)), [formatValue], ); @@ -278,7 +290,7 @@ export const Sankey = React.forwardRef( case ' ': { if (onClickNode && activeNode) { e.preventDefault(); - onClickNode(activeNode); + trackedClickNode(activeNode); } return; } @@ -313,39 +325,9 @@ export const Sankey = React.forwardRef( } } }, - [active, layout, nodesByColumn, maxColumn, labelPad, padTop, width, onClickNode, handleMouseLeave], + [active, layout, nodesByColumn, maxColumn, labelPad, padTop, width, onClickNode, trackedClickNode, handleMouseLeave], ); - if (loading) { - return ( -
-
-
-
-
- ); - } - - if (data.nodes.length === 0 && empty !== undefined) { - return ( -
-
- {typeof empty === 'boolean' ? 'No data' : empty} -
-
- ); - } - const ready = width > 0 && layout; const svgDesc = layout @@ -353,6 +335,15 @@ export const Sankey = React.forwardRef( : undefined; return ( +
( {ready && ( <> ( onMouseMove={positionTooltip} onClick={ onClickLink - ? () => onClickLink(link) + ? () => trackedClickLink(link) : undefined } /> @@ -485,7 +476,7 @@ export const Sankey = React.forwardRef( onMouseMove={positionTooltip} onClick={ onClickNode - ? () => onClickNode(node) + ? () => trackedClickNode(node) : undefined } /> @@ -542,33 +533,36 @@ export const Sankey = React.forwardRef( -
- {tooltipContent && ( -
-
- - {tooltipContent.label} - - - {tooltipContent.value} - + {tooltip !== false && ( +
+ {tooltipContent && ( +
+
+ + {tooltipContent.label} + + + {tooltipContent.value} + +
-
- )} -
+ )} +
+ )} )}
+ ); }, ); diff --git a/src/components/Chart/ScatterChart.tsx b/src/components/Chart/ScatterChart.tsx index 2cf7888..56ea912 100644 --- a/src/components/Chart/ScatterChart.tsx +++ b/src/components/Chart/ScatterChart.tsx @@ -3,7 +3,9 @@ import * as React from 'react'; import clsx from 'clsx'; import { linearScale, niceTicks, thinIndices, axisPadForLabels } from './utils'; +import { useTrackedCallback } from '../Analytics/useTrackedCallback'; import { useResizeWidth } from './hooks'; +import { useMergedRef } from './useMergedRef'; import { type TooltipProp, type ReferenceLine, @@ -49,13 +51,19 @@ export interface ScatterChartProps extends React.ComponentPropsWithoutRef<'div'> loading?: boolean; empty?: React.ReactNode; formatValue?: (value: number) => string; - formatXLabel?: (value: number) => string; + formatXLabel?: (value: unknown) => string; formatYLabel?: (value: number) => string; xDomain?: [number, number]; yDomain?: [number, number]; onClickDatum?: (seriesKey: string, point: ScatterPoint, index: number) => void; + onActiveChange?: (activeDot: { seriesIndex: number; pointIndex: number } | null) => void; + analyticsName?: string; + /** Disables interaction, cursor, dots, and tooltip. */ + interactive?: boolean; } +const scatterClickMeta = (seriesKey: string, _point: ScatterPoint, index: number) => ({ seriesKey, index }); + interface ResolvedScatterSeries { key: string; label: string; @@ -91,28 +99,29 @@ export const Scatter = React.forwardRef( xDomain: xDomainProp, yDomain: yDomainProp, onClickDatum, + onActiveChange, + analyticsName, + interactive = true, className, ...props }, ref, ) { + const trackedClickDatum = useTrackedCallback( + analyticsName, 'Chart.Scatter', 'click', onClickDatum, + onClickDatum ? scatterClickMeta : undefined, + ); + const { width, attachRef } = useResizeWidth(); const tooltipRef = React.useRef(null); const [activeDot, setActiveDot] = React.useState(null); const tooltipMode = resolveTooltipMode(tooltipProp); - const showTooltip = tooltipMode !== 'off'; + const showTooltip = interactive && tooltipMode !== 'off'; const tooltipRender = typeof tooltipProp === 'function' ? tooltipProp : undefined; - const mergedRef = React.useCallback( - (node: HTMLDivElement | null) => { - attachRef(node); - if (typeof ref === 'function') ref(node); - else if (ref) ref.current = node; - }, - [ref, attachRef], - ); + const mergedRef = useMergedRef(ref, attachRef); const series = React.useMemo( () => @@ -305,8 +314,8 @@ export const Scatter = React.forwardRef( const handleClick = React.useCallback(() => { if (!onClickDatum || !activeDot) return; - onClickDatum(activeDot.series.key, activeDot.point, activeDot.pointIndex); - }, [onClickDatum, activeDot]); + trackedClickDatum(activeDot.series.key, activeDot.point, activeDot.pointIndex); + }, [onClickDatum, activeDot, trackedClickDatum]); const ready = width > 0; @@ -334,6 +343,19 @@ export const Scatter = React.forwardRef( return result; }, [series]); + const onActiveChangeRef = React.useRef(onActiveChange); + React.useLayoutEffect(() => { + onActiveChangeRef.current = onActiveChange; + }, [onActiveChange]); + + React.useEffect(() => { + onActiveChangeRef.current?.( + activeDot + ? { seriesIndex: activeDot.seriesIndex, pointIndex: activeDot.pointIndex } + : null, + ); + }, [activeDot]); + const handleKeyDown = React.useCallback( (e: React.KeyboardEvent) => { if (allPointsFlat.length === 0) return; @@ -349,7 +371,7 @@ export const Scatter = React.forwardRef( case 'Enter': case ' ': if (onClickDatum && activeDot) { e.preventDefault(); - onClickDatum(activeDot.series.key, activeDot.point, activeDot.pointIndex); + trackedClickDatum(activeDot.series.key, activeDot.point, activeDot.pointIndex); } return; case 'Escape': handleMouseLeave(); return; @@ -361,7 +383,7 @@ export const Scatter = React.forwardRef( const sp = screenPoints[dot.seriesIndex][dot.pointIndex]; positionTooltip(sp.sx, sp.sy); }, - [allPointsFlat, activeDot, screenPoints, onClickDatum, handleMouseLeave, positionTooltip], + [allPointsFlat, activeDot, screenPoints, onClickDatum, trackedClickDatum, handleMouseLeave, positionTooltip], ); const legendSeries = React.useMemo( @@ -371,12 +393,14 @@ export const Scatter = React.forwardRef( return (
( { + onTouchStart={interactive ? (e) => { if (!e.touches[0]) return; const rect = e.currentTarget.getBoundingClientRect(); const mx = e.touches[0].clientX - rect.left - padLeft; @@ -408,8 +432,8 @@ export const Scatter = React.forwardRef( const sp = screenPoints[nearest.seriesIndex][nearest.pointIndex]; positionTooltip(sp.sx, sp.sy); } - }} - onTouchMove={(e) => { + } : undefined} + onTouchMove={interactive ? (e) => { if (!e.touches[0]) return; const rect = e.currentTarget.getBoundingClientRect(); const mx = e.touches[0].clientX - rect.left - padLeft; @@ -423,10 +447,10 @@ export const Scatter = React.forwardRef( const tip = tooltipRef.current; if (tip) tip.style.display = 'none'; } - }} - onTouchEnd={handleMouseLeave} - onTouchCancel={handleMouseLeave} - onKeyDown={handleKeyDown} + } : undefined} + onTouchEnd={interactive ? handleMouseLeave : undefined} + onTouchCancel={interactive ? handleMouseLeave : undefined} + onKeyDown={interactive ? handleKeyDown : undefined} > {svgDesc && {svgDesc}} @@ -489,7 +513,7 @@ export const Scatter = React.forwardRef( {screenPoints.map((pts, si) => pts.map(({ sx, sy, point }, pi) => { - const isActive = activeDot?.seriesIndex === si && activeDot?.pointIndex === pi; + const isActive = interactive && activeDot?.seriesIndex === si && activeDot?.pointIndex === pi; const r = point.size ?? dotSize; return ( ( - {showTooltip && ( + {interactive && showTooltip && (
( function SparklineBar( - { data, dataKey, color, height = 40, className, ...props }, + { data, dataKey, color, height = 40, className, analyticsName: _analyticsName, ...props }, ref, ) { const { width, attachRef } = useResizeWidth(); - - const mergedRef = React.useCallback( - (node: HTMLDivElement | null) => { - attachRef(node); - if (typeof ref === 'function') ref(node); - else if (ref) ref.current = node; - }, - [ref, attachRef], - ); + const mergedRef = useMergedRef(ref, attachRef); const key = dataKey ?? 'value'; const barColor = color ?? SERIES_COLORS[0]; diff --git a/src/components/Chart/SplitChart.tsx b/src/components/Chart/SplitChart.tsx index ec852aa..94e436d 100644 --- a/src/components/Chart/SplitChart.tsx +++ b/src/components/Chart/SplitChart.tsx @@ -2,7 +2,9 @@ import * as React from 'react'; import clsx from 'clsx'; +import { useTrackedCallback } from '../Analytics/useTrackedCallback'; import { SERIES_COLORS } from './types'; +import { ChartWrapper } from './ChartWrapper'; import styles from './Chart.module.scss'; export interface SplitSegment { @@ -23,8 +25,12 @@ export interface SplitChartProps extends React.ComponentPropsWithoutRef<'div'> { empty?: React.ReactNode; ariaLabel?: string; onClickDatum?: (segment: SplitSegment, index: number) => void; + onActiveChange?: (index: number | null) => void; + analyticsName?: string; } +const splitClickMeta = (_segment: SplitSegment, index: number) => ({ index }); + export const Split = React.forwardRef( function Split( { @@ -38,12 +44,29 @@ export const Split = React.forwardRef( empty, ariaLabel, onClickDatum, + onActiveChange, + analyticsName, className, ...props }, ref, ) { + const trackedClickDatum = useTrackedCallback( + analyticsName, 'Chart.Split', 'click', onClickDatum, + onClickDatum ? splitClickMeta : undefined, + ); + const [activeIndex, setActiveIndex] = React.useState(null); + + const onActiveChangeRef = React.useRef(onActiveChange); + React.useLayoutEffect(() => { + onActiveChangeRef.current = onActiveChange; + }, [onActiveChange]); + + React.useEffect(() => { + onActiveChangeRef.current?.(activeIndex); + }, [activeIndex]); + const barRef = React.useRef(null); const handleKeyDown = React.useCallback( @@ -72,7 +95,7 @@ export const Split = React.forwardRef( case ' ': if (onClickDatum && activeIndex !== null && activeIndex < data.length) { e.preventDefault(); - onClickDatum(data[activeIndex], activeIndex); + trackedClickDatum(data[activeIndex], activeIndex); } return; default: @@ -81,29 +104,9 @@ export const Split = React.forwardRef( e.preventDefault(); setActiveIndex(next); }, - [activeIndex, data, onClickDatum], + [activeIndex, data, onClickDatum, trackedClickDatum], ); - if (loading) { - return ( -
-
-
-
-
- ); - } - - if (data.length === 0 && empty !== undefined) { - return ( -
-
- {typeof empty === 'boolean' ? 'No data' : empty} -
-
- ); - } - const total = data.reduce((sum, d) => sum + d.value, 0); const fmtValue = (v: number) => (formatValue ? formatValue(v) : String(v)); @@ -117,6 +120,14 @@ export const Split = React.forwardRef( `Distribution: ${segments.map((s) => `${s.label} ${Math.round(s.pct)}%`).join(', ')}`; return ( +
( return (
setActiveIndex(i)} onMouseLeave={() => setActiveIndex(null)} - onClick={onClickDatum ? () => onClickDatum(seg, i) : undefined} + onClick={onClickDatum ? () => trackedClickDatum(seg, i) : undefined} /> ); })} @@ -176,6 +185,7 @@ export const Split = React.forwardRef(
)}
+ ); }, ); diff --git a/src/components/Chart/StackedAreaChart.tsx b/src/components/Chart/StackedAreaChart.tsx index 6a701e8..c6b56bb 100644 --- a/src/components/Chart/StackedAreaChart.tsx +++ b/src/components/Chart/StackedAreaChart.tsx @@ -14,7 +14,9 @@ import { axisPadForLabels, type Point, } from './utils'; -import { useResizeWidth, useChartScrub } from './hooks'; +import { useTrackedCallback } from '../Analytics/useTrackedCallback'; +import { useResizeWidth, useChartInteraction } from './hooks'; +import { useMergedRef } from './useMergedRef'; import { type Series, type ResolvedSeries, @@ -31,6 +33,8 @@ import { import { ChartWrapper } from './ChartWrapper'; import styles from './Chart.module.scss'; +const clickIndexMeta = (index: number) => ({ index }); + export interface StackedAreaChartProps extends React.ComponentPropsWithoutRef<'div'> { data: Record[]; series: [Series, Series, ...Series[]]; @@ -52,7 +56,10 @@ export interface StackedAreaChartProps extends React.ComponentPropsWithoutRef<'d loading?: boolean; /** Content to show when data is empty. `true` for default message. */ empty?: React.ReactNode; + animate?: boolean; ariaLabel?: string; + /** Disables interaction, cursor, dots, and tooltip. */ + interactive?: boolean; onActiveChange?: ( index: number | null, datum: Record | null, @@ -62,6 +69,8 @@ export interface StackedAreaChartProps extends React.ComponentPropsWithoutRef<'d index: number, datum: Record, ) => void; + /** Analytics name for event tracking. */ + analyticsName?: string; formatValue?: (value: number) => string; formatXLabel?: (value: unknown) => string; formatYLabel?: (value: number) => string; @@ -84,9 +93,12 @@ export const StackedArea = React.forwardRef { - attachRef(node); - if (typeof ref === 'function') ref(node); - else if (ref) ref.current = node; - }, - [ref, attachRef], - ); + const mergedRef = useMergedRef(ref, attachRef); const series = React.useMemo( () => resolveSeries(seriesProp, undefined, undefined), @@ -220,12 +229,12 @@ export const StackedArea = React.forwardRef { if (!onClickDatum || scrub.activeIndex === null || scrub.activeIndex >= data.length) return; - onClickDatum(scrub.activeIndex, data[scrub.activeIndex]); - }, [onClickDatum, scrub.activeIndex, data]); + trackedClick(scrub.activeIndex, data[scrub.activeIndex]); + }, [onClickDatum, trackedClick, scrub.activeIndex, data]); const svgDesc = React.useMemo(() => { if (series.length === 0 || data.length === 0) return undefined; @@ -283,13 +292,15 @@ export const StackedArea = React.forwardRef
{svgDesc && {svgDesc}} @@ -382,32 +393,36 @@ export const StackedArea = React.forwardRef d ? ( - + ) : null, )} {topPaths.map((d, i) => d ? ( - + ) : null, )} - - - {series.map((s, i) => ( - { scrub.dotRefs.current[i] = el; }} - cx={0} cy={0} r={3} - fill={s.color} - className={styles.activeDot} - style={{ display: 'none' }} - /> - ))} + {interactive && ( + <> + + + {series.map((s, i) => ( + { scrub.dotRefs.current[i] = el; }} + cx={0} cy={0} r={3} + fill={s.color} + className={styles.activeDot} + style={{ display: 'none' }} + /> + ))} + + )} {yLabels.map(({ y, text }, i) => ( diff --git a/src/components/Chart/UptimeChart.tsx b/src/components/Chart/UptimeChart.tsx index 431878a..14ad1f0 100644 --- a/src/components/Chart/UptimeChart.tsx +++ b/src/components/Chart/UptimeChart.tsx @@ -15,7 +15,7 @@ export interface UptimeChartProps extends React.ComponentPropsWithoutRef<'div'> /** Array of status points, ordered chronologically. */ data: UptimePoint[]; /** Height of the status bars in px. */ - barHeight?: number; + height?: number; /** Color map for statuses. Defaults to green/red/yellow/gray. */ colors?: Partial>; /** Accessible label. */ @@ -32,7 +32,10 @@ export interface UptimeChartProps extends React.ComponentPropsWithoutRef<'div'> */ labelStatus?: UptimePoint['status']; /** Called when a bar is hovered. */ - onHover?: (point: UptimePoint | null, index: number | null) => void; + onActiveChange?: (point: UptimePoint | null, index: number | null) => void; + loading?: boolean; + empty?: React.ReactNode; + analyticsName?: string; } const DEFAULT_COLORS: Record = { @@ -46,12 +49,15 @@ export const Uptime = React.forwardRef( function Uptime( { data, - barHeight = 32, + height = 32, colors: colorsProp, ariaLabel, label: labelProp, labelStatus = 'up', - onHover, + onActiveChange, + loading, + empty, + analyticsName: _analyticsName, className, ...props }, @@ -61,23 +67,90 @@ export const Uptime = React.forwardRef( const colors = { ...DEFAULT_COLORS, ...colorsProp }; const showLabel = labelProp !== false; - const handleEnter = React.useCallback( - (i: number) => { - setActiveIndex(i); - onHover?.(data[i], i); - }, - [data, onHover], - ); + const onActiveChangeRef = React.useRef(onActiveChange); + React.useLayoutEffect(() => { + onActiveChangeRef.current = onActiveChange; + }, [onActiveChange]); + + React.useEffect(() => { + onActiveChangeRef.current?.( + activeIndex !== null ? data[activeIndex] : null, + activeIndex, + ); + }, [activeIndex, data]); + + const handleEnter = React.useCallback((i: number) => { + setActiveIndex(i); + }, []); const handleLeave = React.useCallback(() => { setActiveIndex(null); - onHover?.(null, null); - }, [onHover]); + }, []); + + const handleKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + if (data.length === 0) return; + switch (e.key) { + case 'ArrowRight': + e.preventDefault(); + setActiveIndex((prev) => { + const next = prev === null ? 0 : (prev + 1) % data.length; + return next; + }); + break; + case 'ArrowLeft': + e.preventDefault(); + setActiveIndex((prev) => { + const next = + prev === null + ? data.length - 1 + : (prev - 1 + data.length) % data.length; + return next; + }); + break; + case 'Home': + e.preventDefault(); + setActiveIndex(0); + break; + case 'End': + e.preventDefault(); + setActiveIndex(data.length - 1); + break; + case 'Escape': + e.preventDefault(); + setActiveIndex(null); + break; + default: + return; + } + }, + [data], + ); const activePoint = activeIndex !== null ? data[activeIndex] : null; const displayLabel = activePoint?.label ?? labelProp ?? null; const displayStatus = activePoint?.status ?? labelStatus; + if (loading) { + return ( +
+
+
+
+
+ ); + } + + if (data.length === 0 && empty !== undefined) { + return ( +
+
+ {typeof empty === 'boolean' ? 'No data' : empty} +
+
+ ); + } + return (
( aria-label={ariaLabel ?? `Uptime chart with ${data.length} periods`} {...props} > -
+
{data.map((point, i) => (
handleEnter(i)} onMouseLeave={handleLeave} diff --git a/src/components/Chart/WaterfallChart.tsx b/src/components/Chart/WaterfallChart.tsx index 750918f..3771465 100644 --- a/src/components/Chart/WaterfallChart.tsx +++ b/src/components/Chart/WaterfallChart.tsx @@ -4,8 +4,10 @@ import * as React from 'react'; import clsx from 'clsx'; import { linearScale, niceTicks, thinIndices, axisPadForLabels } from './utils'; import { useResizeWidth } from './hooks'; -import { PAD_TOP, PAD_RIGHT, PAD_BOTTOM_AXIS, TOOLTIP_GAP, axisTickTarget } from './types'; +import { useMergedRef } from './useMergedRef'; +import { type TooltipProp, PAD_TOP, PAD_RIGHT, PAD_BOTTOM_AXIS, TOOLTIP_GAP, axisTickTarget } from './types'; import { ChartWrapper } from './ChartWrapper'; +import { useTrackedCallback } from '../Analytics/useTrackedCallback'; import styles from './Chart.module.scss'; export interface WaterfallSegment { @@ -25,11 +27,15 @@ export interface WaterfallChartProps extends React.ComponentPropsWithoutRef<'div height?: number; grid?: boolean; animate?: boolean; - tooltip?: boolean; + tooltip?: TooltipProp; loading?: boolean; empty?: React.ReactNode; ariaLabel?: string; onClickDatum?: (index: number, segment: WaterfallSegment) => void; + onActiveChange?: (index: number | null) => void; + analyticsName?: string; + /** Disables interaction, cursor, dots, and tooltip. */ + interactive?: boolean; } interface ComputedBar { @@ -51,6 +57,8 @@ const DEFAULT_COLORS: Record = { total: 'var(--color-blue-500)', }; +const clickIndexMeta = (index: number) => ({ index }); + export const Waterfall = React.forwardRef( function Waterfall( { @@ -67,6 +75,9 @@ export const Waterfall = React.forwardRef( empty, ariaLabel, onClickDatum, + onActiveChange, + analyticsName, + interactive: interactiveProp = true, className, ...props }, @@ -76,13 +87,20 @@ export const Waterfall = React.forwardRef( const tooltipRef = React.useRef(null); const [activeIndex, setActiveIndex] = React.useState(null); - const mergedRef = React.useCallback( - (node: HTMLDivElement | null) => { - attachRef(node); - if (typeof ref === 'function') ref(node); - else if (ref) ref.current = node; - }, - [ref, attachRef], + const onActiveChangeRef = React.useRef(onActiveChange); + React.useLayoutEffect(() => { + onActiveChangeRef.current = onActiveChange; + }, [onActiveChange]); + + React.useEffect(() => { + onActiveChangeRef.current?.(activeIndex); + }, [activeIndex]); + + const mergedRef = useMergedRef(ref, attachRef); + + const trackedClick = useTrackedCallback( + analyticsName, 'Chart.Waterfall', 'click', onClickDatum, + onClickDatum ? clickIndexMeta : undefined, ); const bars = React.useMemo(() => { @@ -222,7 +240,7 @@ export const Waterfall = React.forwardRef( case ' ': if (onClickDatum && activeIndex !== null && activeIndex < data.length) { e.preventDefault(); - onClickDatum(activeIndex, data[activeIndex]); + trackedClick(activeIndex, data[activeIndex]); } return; case 'Escape': @@ -244,16 +262,16 @@ export const Waterfall = React.forwardRef( tip.style.display = ''; } }, - [activeIndex, data, slotSize, padLeft, onClickDatum, handleMouseLeave], + [activeIndex, data, slotSize, padLeft, onClickDatum, trackedClick, handleMouseLeave], ); - const interactive = tooltipProp !== false || !!onClickDatum; + const interactive = interactiveProp; const handleClick = React.useCallback(() => { if (onClickDatum && activeIndex !== null && activeIndex < data.length) { - onClickDatum(activeIndex, data[activeIndex]); + trackedClick(activeIndex, data[activeIndex]); } - }, [onClickDatum, activeIndex, data]); + }, [onClickDatum, activeIndex, data, trackedClick]); const ready = width > 0; @@ -269,10 +287,12 @@ export const Waterfall = React.forwardRef( return (
( }} > {activeIndex !== null && activeIndex < data.length && ( - <> -

- {data[activeIndex].label} -

-
-
- - - {bars[activeIndex].segmentType === 'total' ? 'Total' : 'Change'} - - - {fmtValue(bars[activeIndex].segmentType === 'total' - ? bars[activeIndex].y1 - : data[activeIndex].value)} - -
-
- Running total - - {fmtValue(bars[activeIndex].runningTotal)} - -
-
- + typeof tooltipProp === 'function' + ? tooltipProp( + { + label: data[activeIndex].label, + value: data[activeIndex].value, + type: bars[activeIndex].segmentType, + runningTotal: bars[activeIndex].runningTotal, + }, + [], + ) + : ( + <> +

+ {data[activeIndex].label} +

+
+
+ + + {bars[activeIndex].segmentType === 'total' ? 'Total' : 'Change'} + + + {fmtValue(bars[activeIndex].segmentType === 'total' + ? bars[activeIndex].y1 + : data[activeIndex].value)} + +
+
+ Running total + + {fmtValue(bars[activeIndex].runningTotal)} + +
+
+ + ) )}
)} diff --git a/src/components/Chart/hooks.ts b/src/components/Chart/hooks.ts index 24cf125..a41e8ce 100644 --- a/src/components/Chart/hooks.ts +++ b/src/components/Chart/hooks.ts @@ -26,7 +26,7 @@ export function useResizeWidth() { return { width, attachRef }; } -export interface ChartScrubOptions { +export interface ChartInteractionOptions { dataLength: number; seriesCount: number; plotWidth: number; @@ -41,7 +41,7 @@ export interface ChartScrubOptions { onActivate?: (index: number, datum: Record) => void; } -export function useChartScrub(opts: ChartScrubOptions) { +export function useChartInteraction(opts: ChartInteractionOptions) { const { dataLength, seriesCount, @@ -69,6 +69,9 @@ export function useChartScrub(opts: ChartScrubOptions) { setActiveIndex(null); }, [data.length]); + const dataRef = React.useRef(data); + React.useLayoutEffect(() => { dataRef.current = data; }, [data]); + const onActiveChangeRef = React.useRef(onActiveChange); React.useLayoutEffect(() => { onActiveChangeRef.current = onActiveChange; @@ -255,7 +258,7 @@ export function useChartScrub(opts: ChartScrubOptions) { case 'Enter': case ' ': if (onActivate && activeIndex !== null && activeIndex < dataLength) { e.preventDefault(); - onActivate(activeIndex, data[activeIndex]); + onActivate(activeIndex, dataRef.current[activeIndex]); } return; case 'Escape': hideHover(); return; diff --git a/src/components/Chart/index.ts b/src/components/Chart/index.ts index 52a05e4..c22517f 100644 --- a/src/components/Chart/index.ts +++ b/src/components/Chart/index.ts @@ -28,13 +28,18 @@ export type { UptimeChartProps, UptimePoint } from './UptimeChart'; export { Live } from './LiveChart'; export type { LiveChartProps, LivePoint } from './LiveChart'; +export { LiveDot } from './LiveDot'; +export type { LiveDotProps, LiveDotStatus } from './LiveDot'; + +export { LiveValue } from './LiveValue'; +export type { LiveValueProps } from './LiveValue'; + export { Scatter } from './ScatterChart'; export type { ScatterChartProps, ScatterSeries, ScatterPoint } from './ScatterChart'; export { Split } from './SplitChart'; export type { SplitChartProps, SplitSegment } from './SplitChart'; - export { Sankey } from './SankeyChart'; export type { SankeyChartProps, SankeyData, SankeyNode, SankeyLink, LayoutNode, LayoutLink } from './SankeyChart'; diff --git a/src/components/Chart/useMergedRef.ts b/src/components/Chart/useMergedRef.ts new file mode 100644 index 0000000..e295779 --- /dev/null +++ b/src/components/Chart/useMergedRef.ts @@ -0,0 +1,17 @@ +import * as React from 'react'; + +export function useMergedRef( + forwardedRef: React.ForwardedRef, + localRef: React.MutableRefObject | ((node: T | null) => void), +): (node: T | null) => void { + return React.useCallback( + (node: T | null) => { + if (typeof localRef === 'function') localRef(node); + else localRef.current = node; + + if (typeof forwardedRef === 'function') forwardedRef(node); + else if (forwardedRef) forwardedRef.current = node; + }, + [forwardedRef, localRef], + ); +} diff --git a/src/index.ts b/src/index.ts index 6512bbd..e28e146 100644 --- a/src/index.ts +++ b/src/index.ts @@ -130,6 +130,14 @@ export type { WaterfallSegment as ChartWaterfallSegment, FunnelChartProps as ChartFunnelProps, FunnelStage as ChartFunnelStage, + ScatterChartProps as ChartScatterProps, + ScatterSeries as ChartScatterSeries, + ScatterPoint as ChartScatterPoint, + UptimeChartProps as ChartUptimeProps, + UptimePoint as ChartUptimePoint, + LiveDotProps as ChartLiveDotProps, + LiveDotStatus as ChartLiveDotStatus, + LiveValueProps as ChartLiveValueProps, } from './components/Chart'; // Simple components (direct exports) From 6f49fa641253dc501a684125dc9d9b14a4a7ca28 Mon Sep 17 00:00:00 2001 From: jaymantri Date: Wed, 4 Mar 2026 16:54:42 -0800 Subject: [PATCH 13/22] Add Storybook controls and remove redundant variant stories Replace single-prop-flip variant stories with args-driven controls across all components. Primary stories now expose boolean, enum, and number props in the Controls panel. Removes ~80 redundant stories that duplicated what a control toggle already demonstrates. Made-with: Cursor --- .../Accordion/Accordion.stories.tsx | 35 +- src/components/Alert/Alert.stories.tsx | 25 - .../AlertDialog/AlertDialog.stories.tsx | 11 +- .../Autocomplete/Autocomplete.stories.tsx | 53 +- src/components/Avatar/Avatar.stories.tsx | 132 +-- src/components/Badge/Badge.stories.tsx | 59 +- src/components/Button/Button.stories.tsx | 62 +- .../ButtonGroup/ButtonGroup.stories.tsx | 78 +- src/components/Card/Card.stories.tsx | 57 +- src/components/Chart/Chart.stories.tsx | 791 +++++++++--------- src/components/Checkbox/Checkbox.stories.tsx | 56 +- src/components/Chip/Chip.stories.tsx | 26 +- .../Collapsible/Collapsible.stories.tsx | 78 +- src/components/Combobox/Combobox.stories.tsx | 52 +- src/components/Dialog/Dialog.stories.tsx | 11 +- src/components/Field/Field.stories.tsx | 12 - src/components/Input/Input.stories.tsx | 23 +- .../InputGroup/InputGroup.stories.tsx | 74 +- src/components/Item/Item.stories.tsx | 16 - src/components/Loader/Loader.stories.tsx | 11 +- src/components/Logo/Logo.stories.tsx | 69 +- src/components/Meter/Meter.stories.tsx | 56 +- .../Pagination/Pagination.stories.tsx | 92 +- .../PhoneInput/PhoneInput.stories.tsx | 32 +- src/components/Popover/Popover.stories.tsx | 57 +- src/components/Progress/Progress.stories.tsx | 28 +- src/components/Radio/Radio.stories.tsx | 54 +- src/components/Select/Select.stories.tsx | 149 +--- .../Separator/Separator.stories.tsx | 50 +- src/components/Sidebar/Sidebar.stories.tsx | 39 +- src/components/Switch/Switch.stories.tsx | 28 +- src/components/Table/Table.stories.tsx | 27 +- src/components/Table/Table.test-stories.tsx | 4 +- src/components/Tabs/Tabs.stories.tsx | 35 +- src/components/Textarea/Textarea.stories.tsx | 31 +- .../TextareaGroup/TextareaGroup.stories.tsx | 69 +- src/components/Toggle/Toggle.stories.tsx | 12 +- src/components/Tooltip/Tooltip.stories.tsx | 28 +- 38 files changed, 913 insertions(+), 1609 deletions(-) diff --git a/src/components/Accordion/Accordion.stories.tsx b/src/components/Accordion/Accordion.stories.tsx index ab0232e..a580cff 100644 --- a/src/components/Accordion/Accordion.stories.tsx +++ b/src/components/Accordion/Accordion.stories.tsx @@ -5,13 +5,19 @@ import { Accordion } from './index'; const meta: Meta = { title: 'Components/Accordion', component: Accordion.Root, + argTypes: { + multiple: { control: 'boolean' }, + }, }; export default meta; export const Default: StoryObj = { - render: () => ( - + args: { + multiple: false, + }, + render: (args) => ( + @@ -49,31 +55,6 @@ export const Default: StoryObj = { ), }; -export const Multiple: StoryObj = { - render: () => ( - - - - - First - - - - Multiple items can be open. - - - - - Second - - - - This stays open when others open. - - - ), -}; - export const Controlled: StoryObj = { render: function Render() { const [value, setValue] = useState(['item-1']); diff --git a/src/components/Alert/Alert.stories.tsx b/src/components/Alert/Alert.stories.tsx index 4f876c8..0e7b7ac 100644 --- a/src/components/Alert/Alert.stories.tsx +++ b/src/components/Alert/Alert.stories.tsx @@ -30,14 +30,6 @@ export const Default: Story = { }, }; -export const Critical: Story = { - args: { - variant: 'critical', - title: 'Title', - description: 'Description here.', - }, -}; - export const TitleOnly: Story = { args: { variant: 'default', @@ -45,23 +37,6 @@ export const TitleOnly: Story = { }, }; -export const NoIcon: Story = { - args: { - variant: 'default', - title: 'No icon alert', - description: 'This alert has no icon.', - icon: false, - }, -}; - -export const Warning: Story = { - args: { - variant: 'warning', - title: 'Title', - description: 'Description here.', - }, -}; - export const AllVariants: Story = { args: { title: 'Title', diff --git a/src/components/AlertDialog/AlertDialog.stories.tsx b/src/components/AlertDialog/AlertDialog.stories.tsx index 4d348ca..e8e3c8a 100644 --- a/src/components/AlertDialog/AlertDialog.stories.tsx +++ b/src/components/AlertDialog/AlertDialog.stories.tsx @@ -5,16 +5,23 @@ import { Button } from '../Button'; const meta: Meta = { title: 'Components/AlertDialog', + component: AlertDialog.Root, parameters: { layout: 'centered', }, + argTypes: { + defaultOpen: { control: 'boolean' }, + }, }; export default meta; export const Default: StoryObj = { - render: () => ( - + args: { + defaultOpen: false, + }, + render: (args) => ( + }> Delete Item diff --git a/src/components/Autocomplete/Autocomplete.stories.tsx b/src/components/Autocomplete/Autocomplete.stories.tsx index 470624d..aa053a8 100644 --- a/src/components/Autocomplete/Autocomplete.stories.tsx +++ b/src/components/Autocomplete/Autocomplete.stories.tsx @@ -21,19 +21,27 @@ const fruits: Fruit[] = [ { value: 'honeydew', label: 'Honeydew' }, ]; -const meta: Meta = { +const meta: Meta = { title: 'Components/Autocomplete', + component: Autocomplete.Root, parameters: { layout: 'centered', }, + argTypes: { + disabled: { control: 'boolean' }, + }, }; export default meta; +type Story = StoryObj; -export const Basic: StoryObj = { - render: () => ( +export const Basic: Story = { + args: { + disabled: false, + }, + render: (args) => (
- + @@ -54,7 +62,7 @@ export const Basic: StoryObj = { ), }; -export const WithLeadingIcons: StoryObj = { +export const WithLeadingIcons: Story = { render: () => (
@@ -82,7 +90,7 @@ export const WithLeadingIcons: StoryObj = { ), }; -export const Grouped: StoryObj = { +export const Grouped: Story = { render: () => { const groupedItems = [ { @@ -134,7 +142,7 @@ export const Grouped: StoryObj = { }, }; -export const AsyncLoading: StoryObj = { +export const AsyncLoading: Story = { render: function AsyncAutocomplete() { const [items, setItems] = React.useState([]); const [loading, setLoading] = React.useState(false); @@ -184,30 +192,7 @@ export const AsyncLoading: StoryObj = { }, }; -export const Disabled: StoryObj = { - render: () => ( -
- - - - - - - {(item: Fruit) => ( - - {item.label} - - )} - - - - - -
- ), -}; - -export const DisabledItems: StoryObj = { +export const DisabledItems: Story = { render: () => (
@@ -235,7 +220,7 @@ export const DisabledItems: StoryObj = { ), }; -export const Controlled: StoryObj = { +export const Controlled: Story = { render: function ControlledAutocomplete() { const [value, setValue] = React.useState(''); @@ -344,11 +329,11 @@ function FuzzyMatchingDemo() { ); } -export const FuzzyMatching: StoryObj = { +export const FuzzyMatching: Story = { render: () => , }; -export const WithField: StoryObj = { +export const WithField: Story = { render: function WithField() { const [value, setValue] = React.useState(''); const [touched, setTouched] = React.useState(false); diff --git a/src/components/Avatar/Avatar.stories.tsx b/src/components/Avatar/Avatar.stories.tsx index 182ea8f..3bf444c 100644 --- a/src/components/Avatar/Avatar.stories.tsx +++ b/src/components/Avatar/Avatar.stories.tsx @@ -10,15 +10,15 @@ const meta = { tags: ['autodocs'], argTypes: { size: { - control: { type: 'select' }, + control: 'radio', options: ['16', '20', '24', '32', '40', '48'], }, variant: { - control: { type: 'select' }, + control: 'radio', options: ['squircle', 'circle'], }, color: { - control: { type: 'select' }, + control: 'radio', options: ['blue', 'purple', 'sky', 'pink', 'green', 'yellow', 'red', 'gray'], }, }, @@ -56,32 +56,6 @@ export const WithImage: Story = { ), }; -export const Squircle: Story = { - args: { - size: '48', - variant: 'squircle', - color: 'blue', - }, - render: (args) => ( - - CS - - ), -}; - -export const Circle: Story = { - args: { - size: '48', - variant: 'circle', - color: 'blue', - }, - render: (args) => ( - - CS - - ), -}; - export const AllSizes: Story = { render: () => (
@@ -107,84 +81,7 @@ export const AllSizes: Story = { ), }; -export const AllSizesCircle: Story = { - render: () => ( -
- - C - - - C - - - C - - - CS - - - CS - - - CS - -
- ), -}; - -export const AllColors: Story = { - render: () => ( -
- - BL - - - PU - - - SK - - - PK - - - GR - - - YE - - - RE - - - GY - -
- ), -}; - -export const ImageWithFallback: Story = { - render: () => ( -
- - - LT - - - - FB - -
- ), -}; - -export const VariantComparison: Story = { +export const AllVariants: Story = { render: () => (
@@ -236,3 +133,24 @@ export const VariantComparison: Story = {
), }; + +export const WithFallback: Story = { + render: () => ( +
+ + + LT + + + + FB + +
+ ), +}; diff --git a/src/components/Badge/Badge.stories.tsx b/src/components/Badge/Badge.stories.tsx index 6a729f5..5d59e83 100644 --- a/src/components/Badge/Badge.stories.tsx +++ b/src/components/Badge/Badge.stories.tsx @@ -10,7 +10,7 @@ const meta = { tags: ['autodocs'], argTypes: { variant: { - control: { type: 'select' }, + control: 'radio', options: ['gray', 'purple', 'blue', 'sky', 'pink', 'green', 'yellow', 'red'], }, vibrant: { control: 'boolean' }, @@ -22,58 +22,17 @@ type Story = StoryObj; export const Default: Story = { args: { - children: 'Label', + children: 'Badge', variant: 'gray', + vibrant: false, }, -}; - -export const Purple: Story = { - args: { - children: 'Label', - variant: 'purple', - }, -}; - -export const Blue: Story = { - args: { - children: 'Label', - variant: 'blue', - }, -}; - -export const Sky: Story = { - args: { - children: 'Label', - variant: 'sky', - }, -}; - -export const Pink: Story = { - args: { - children: 'Label', - variant: 'pink', - }, -}; - -export const Green: Story = { - args: { - children: 'Label', - variant: 'green', - }, -}; - -export const Yellow: Story = { - args: { - children: 'Label', - variant: 'yellow', - }, -}; - -export const Red: Story = { - args: { - children: 'Label', - variant: 'red', + argTypes: { + variant: { + control: 'radio', + options: ['gray', 'purple', 'blue', 'sky', 'pink', 'green', 'yellow', 'red'], + }, }, + render: (args) => , }; export const Vibrant: Story = { diff --git a/src/components/Button/Button.stories.tsx b/src/components/Button/Button.stories.tsx index 4e2dc65..aa2112c 100644 --- a/src/components/Button/Button.stories.tsx +++ b/src/components/Button/Button.stories.tsx @@ -28,61 +28,29 @@ const meta: Meta = { }, argTypes: { variant: { - control: 'select', + control: 'radio', options: ['filled', 'secondary', 'outline', 'ghost', 'critical', 'link'], }, size: { - control: 'select', + control: 'radio', options: ['default', 'compact', 'dense'], }, loading: { control: 'boolean' }, disabled: { control: 'boolean' }, - iconOnly: { control: 'boolean' }, + children: { control: 'text' }, }, }; export default meta; type Story = StoryObj; -export const Filled: Story = { +export const Default: Story = { args: { variant: 'filled', - children: 'Filled Button', - }, -}; - -export const Secondary: Story = { - args: { - variant: 'secondary', - children: 'Secondary Button', - }, -}; - -export const Outline: Story = { - args: { - variant: 'outline', - children: 'Outline Button', - }, -}; - -export const Ghost: Story = { - args: { - variant: 'ghost', - children: 'Ghost Button', - }, -}; - -export const Critical: Story = { - args: { - variant: 'critical', - children: 'Delete', - }, -}; - -export const Link: Story = { - args: { - variant: 'link', - children: 'Learn more', + size: 'default', + loading: false, + disabled: false, + children: 'Button', }, }; @@ -96,20 +64,6 @@ export const Sizes: Story = { ), }; -export const Loading: Story = { - args: { - loading: true, - children: 'Loading', - }, -}; - -export const Disabled: Story = { - args: { - disabled: true, - children: 'Disabled', - }, -}; - export const WithLeadingIcon: Story = { args: { leadingIcon: , diff --git a/src/components/ButtonGroup/ButtonGroup.stories.tsx b/src/components/ButtonGroup/ButtonGroup.stories.tsx index f590717..20166d0 100644 --- a/src/components/ButtonGroup/ButtonGroup.stories.tsx +++ b/src/components/ButtonGroup/ButtonGroup.stories.tsx @@ -34,80 +34,23 @@ const meta: Meta = { export default meta; type Story = StoryObj; -export const FilledHorizontal: Story = { - name: 'Filled (Horizontal)', - render: () => ( - - - - - - ), -}; - -export const OutlineHorizontal: Story = { - name: 'Outline (Horizontal)', - render: () => ( - - - - - - ), -}; - -export const SecondaryHorizontal: Story = { - name: 'Secondary (Horizontal)', - render: () => ( - - - - + + ), }; -export const FilledVertical: Story = { - name: 'Filled (Vertical)', - render: () => ( - - - - - - ), -}; - -export const OutlineVertical: Story = { - name: 'Outline (Vertical)', - render: () => ( - - - - - - ), -}; - -export const SecondaryVertical: Story = { - name: 'Secondary (Vertical)', - render: () => ( - - - - - - ), -}; - export const TwoButtons: Story = { - name: 'Two Buttons', render: () => ( @@ -117,7 +60,6 @@ export const TwoButtons: Story = { }; export const WithAriaLabel: Story = { - name: 'With aria-label', render: () => ( diff --git a/src/components/Card/Card.stories.tsx b/src/components/Card/Card.stories.tsx index 4389374..21bdc38 100644 --- a/src/components/Card/Card.stories.tsx +++ b/src/components/Card/Card.stories.tsx @@ -2,16 +2,61 @@ import type { Meta, StoryObj } from '@storybook/react'; import { Card } from './index'; import { Button } from '../Button'; -const meta: Meta = { +const meta: Meta = { title: 'Components/Card', + component: Card.Root, parameters: { layout: 'centered', }, + argTypes: { + variant: { + control: 'radio', + options: ['structured', 'simple'], + }, + }, }; export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + variant: 'structured', + }, + render: (args) => ( + + {args.variant === 'structured' ? ( + <> + + + Card title + Subtitle goes here. + + + +

Slot components in to the body here to extend the functionality of the card.

+
+ + + + + ) : ( + <> + + Card title + Subtitle goes here. + + +

Slot components in to the body here to extend the functionality of the card.

+
+ + + )} +
+ ), +}; -export const Structured: StoryObj = { +export const Structured: Story = { render: () => ( @@ -30,7 +75,7 @@ export const Structured: StoryObj = { ), }; -export const StructuredWithBackButton: StoryObj = { +export const StructuredWithBackButton: Story = { render: () => ( @@ -50,7 +95,7 @@ export const StructuredWithBackButton: StoryObj = { ), }; -export const Simple: StoryObj = { +export const Simple: Story = { render: () => ( @@ -65,7 +110,7 @@ export const Simple: StoryObj = { ), }; -export const FullwidthBody: StoryObj = { +export const FullwidthBody: Story = { render: () => ( @@ -86,7 +131,7 @@ export const FullwidthBody: StoryObj = { ), }; -export const AllVariants: StoryObj = { +export const AllVariants: Story = { render: () => (
diff --git a/src/components/Chart/Chart.stories.tsx b/src/components/Chart/Chart.stories.tsx index 4692d59..8f28015 100644 --- a/src/components/Chart/Chart.stories.tsx +++ b/src/components/Chart/Chart.stories.tsx @@ -17,26 +17,41 @@ const meta = { export default meta; type Story = StoryObj; +const LINE_DATA = [ + { date: 'Mon', incoming: 120, outgoing: 80 }, + { date: 'Tue', incoming: 150, outgoing: 95 }, + { date: 'Wed', incoming: 140, outgoing: 110 }, + { date: 'Thu', incoming: 180, outgoing: 100 }, + { date: 'Fri', incoming: 160, outgoing: 130 }, +]; + +const LINE_SERIES = [ + { key: 'incoming', label: 'Incoming' }, + { key: 'outgoing', label: 'Outgoing' }, +]; + export const Line: Story = { - render: () => ( + args: { + height: 200, + grid: true, + tooltip: true, + animate: true, + interactive: true, + legend: false, + loading: false, + connectNulls: true, + strokeWidth: 2, + fill: false, + fadeLeft: false, + compareLabel: '', + }, + argTypes: { + curve: { control: 'radio', options: ['monotone', 'linear'] }, + tooltip: { control: 'radio', options: [true, false, 'simple', 'compact'] }, + }, + render: (args) => (
- +
), }; @@ -115,80 +130,94 @@ export const LineReferenceLines: Story = { ), }; +const SPARKLINE_DATA = [{ v: 10 }, { v: 15 }, { v: 12 }, { v: 18 }, { v: 14 }, { v: 22 }, { v: 19 }, { v: 25 }]; + export const SparklineLine: Story = { - render: () => ( + args: { + height: 40, + strokeWidth: 2, + loading: false, + color: 'var(--color-blue-500)', + }, + argTypes: { + variant: { control: 'radio', options: ['line', 'bar'] }, + }, + render: (args) => (
- +
), }; -export const SparklineBar: Story = { - render: () => ( -
- -
- ), -}; + +const STACKED_AREA_DATA = [ + { month: 'Jan', payments: 400, transfers: 200, fees: 50 }, + { month: 'Feb', payments: 450, transfers: 250, fees: 60 }, + { month: 'Mar', payments: 420, transfers: 280, fees: 55 }, + { month: 'Apr', payments: 500, transfers: 300, fees: 70 }, + { month: 'May', payments: 480, transfers: 320, fees: 65 }, + { month: 'Jun', payments: 550, transfers: 350, fees: 80 }, +]; + +const STACKED_AREA_SERIES = [ + { key: 'payments', label: 'Payments', color: 'var(--color-blue-700)' }, + { key: 'transfers', label: 'Transfers', color: 'var(--color-blue-400)' }, + { key: 'fees', label: 'Fees', color: 'var(--color-blue-200)' }, +]; export const StackedArea: Story = { - render: () => ( + args: { + height: 250, + grid: true, + tooltip: true, + animate: true, + interactive: true, + legend: false, + loading: false, + fillOpacity: 0.6, + }, + argTypes: { + curve: { control: 'radio', options: ['monotone', 'linear'] }, + tooltip: { control: 'radio', options: [true, false, 'simple', 'compact'] }, + }, + render: (args) => (
- +
), }; +const BAR_DATA = [ + { month: 'Jan', incoming: 400, outgoing: 240 }, + { month: 'Feb', incoming: 500, outgoing: 300 }, + { month: 'Mar', incoming: 450, outgoing: 280 }, + { month: 'Apr', incoming: 600, outgoing: 350 }, + { month: 'May', incoming: 550, outgoing: 320 }, +]; + +const BAR_SERIES = [ + { key: 'incoming', label: 'Incoming' }, + { key: 'outgoing', label: 'Outgoing' }, +]; + export const BarGrouped: Story = { - render: () => ( + args: { + height: 220, + grid: true, + tooltip: true, + animate: true, + interactive: true, + legend: false, + loading: false, + stacked: false, + }, + argTypes: { + orientation: { control: 'radio', options: ['vertical', 'horizontal'] }, + tooltip: { control: 'radio', options: [true, false, 'simple', 'compact'] }, + }, + render: (args) => (
- +
), }; @@ -218,71 +247,73 @@ export const BarStacked: Story = { ), }; -export const BarHorizontal: Story = { - render: () => ( -
- -
- ), -}; + +const COMPOSED_DATA = [ + { month: 'Jan', revenue: 4200, rate: 3.2 }, + { month: 'Feb', revenue: 5100, rate: 3.8 }, + { month: 'Mar', revenue: 4800, rate: 3.5 }, + { month: 'Apr', revenue: 6200, rate: 4.1 }, + { month: 'May', revenue: 5800, rate: 3.9 }, + { month: 'Jun', revenue: 7100, rate: 4.5 }, +]; + +const COMPOSED_SERIES = [ + { key: 'revenue', label: 'Revenue', type: 'bar' as const, color: 'var(--color-blue-300)' }, + { key: 'rate', label: 'Conversion %', type: 'line' as const, axis: 'right' as const, color: 'var(--text-primary)' }, +]; export const Composed: Story = { - render: () => ( + args: { + height: 250, + grid: true, + tooltip: true, + animate: true, + interactive: true, + legend: false, + loading: false, + connectNulls: true, + }, + argTypes: { + curve: { control: 'radio', options: ['monotone', 'linear'] }, + tooltip: { control: 'radio', options: [true, false, 'simple', 'compact'] }, + }, + render: (args) => (
`${v}%`} + {...args} />
), }; +const PIE_DATA = [ + { name: 'Payments', value: 4200, color: 'var(--color-blue-700)' }, + { name: 'Transfers', value: 2800, color: 'var(--color-blue-500)' }, + { name: 'Fees', value: 650, color: 'var(--color-blue-300)' }, + { name: 'Refunds', value: 320, color: 'var(--color-blue-100)' }, +]; + export const Donut: Story = { - render: () => ( + args: { + height: 200, + innerRadius: 0.65, + legend: false, + tooltip: true, + animate: true, + loading: false, + }, + render: (args) => (
- +
), }; -function LiveChartWrapper() { +function LiveChartWrapper(props: Record) { const [data, setData] = React.useState<{ time: number; value: number }[]>([]); const [value, setValue] = React.useState(100); const valueRef = React.useRef(100); @@ -318,75 +349,77 @@ function LiveChartWrapper() { v.toFixed(1)} + {...props} /> ); } export const Live: Story = { - render: () => ( + args: { + height: 200, + grid: true, + fill: true, + pulse: true, + interactive: true, + loading: false, + window: 30, + lerpSpeed: 0.15, + color: 'var(--color-blue-500)', + }, + render: (args) => (
- +
), }; +const GAUGE_THRESHOLDS = [ + { upTo: 0.5, color: 'var(--color-green-500)', label: 'Great' }, + { upTo: 0.8, color: 'var(--color-yellow-500)', label: 'Needs work' }, + { upTo: 1, color: 'var(--color-red-500)', label: 'Poor' }, +]; + export const Gauge: Story = { - render: () => ( + args: { + value: 0.32, + min: 0, + max: 1, + markerLabel: 'P75', + loading: false, + }, + argTypes: { + variant: { control: 'radio', options: ['default', 'minimal'] }, + }, + render: (args) => (
`${v.toFixed(2)}s`} + thresholds={GAUGE_THRESHOLDS} + formatValue={(v: number) => `${v.toFixed(2)}s`} + {...args} />
), }; -export const GaugeMinimal: Story = { - render: () => ( -
- `${v.toFixed(2)}s`} - /> -
- ), -}; + +const BAR_LIST_DATA = [ + { name: '/', value: 2340, displayValue: '0.28s' }, + { name: '/pricing', value: 326, displayValue: '0.34s' }, + { name: '/blog', value: 148, displayValue: '0.31s' }, + { name: '/docs', value: 89, displayValue: '0.42s' }, + { name: '/about', value: 45, displayValue: '0.25s' }, +]; export const BarList: Story = { - render: () => ( + args: { + showRank: false, + loading: false, + max: 10, + }, + render: (args) => (
- +
), }; @@ -397,68 +430,93 @@ const uptimeData = Array.from({ length: 90 }, (_, i) => ({ })); export const Uptime: Story = { - render: () => ( + args: { + height: 32, + loading: false, + label: '90 days — 97.8% uptime', + }, + argTypes: { + labelStatus: { control: 'radio', options: ['up', 'down', 'degraded', 'unknown'] }, + }, + render: (args) => (
- +
), }; +const SCATTER_DATA = [ + { + key: 'product-a', + label: 'Product A', + color: 'var(--color-blue-600)', + data: [ + { x: 10, y: 30, label: 'Jan' }, + { x: 25, y: 55, label: 'Feb' }, + { x: 40, y: 70, label: 'Mar' }, + { x: 55, y: 45, label: 'Apr' }, + { x: 70, y: 85, label: 'May' }, + { x: 85, y: 60, label: 'Jun' }, + ], + }, + { + key: 'product-b', + label: 'Product B', + color: 'var(--color-purple-500)', + data: [ + { x: 15, y: 60 }, + { x: 30, y: 40 }, + { x: 50, y: 80 }, + { x: 65, y: 35 }, + { x: 80, y: 90 }, + ], + }, +]; + export const Scatter: Story = { - render: () => ( + args: { + height: 300, + grid: true, + tooltip: true, + legend: true, + animate: true, + interactive: true, + loading: false, + dotSize: 6, + }, + argTypes: { + tooltip: { control: 'radio', options: [true, false, 'simple', 'compact'] }, + }, + render: (args) => (
`${v}%`} - formatYLabel={(v) => `$${v}`} + data={SCATTER_DATA} + formatXLabel={(v: unknown) => `${v}%`} + formatYLabel={(v: number) => `$${v}`} + {...args} />
), }; +const SPLIT_DATA = [ + { label: 'Payments', value: 4200, color: 'var(--color-blue-700)' }, + { label: 'Transfers', value: 2800, color: 'var(--color-blue-400)' }, + { label: 'Fees', value: 650, color: 'var(--color-blue-200)' }, + { label: 'Refunds', value: 320, color: 'var(--color-blue-100)' }, +]; + export const Split: Story = { - render: () => ( + args: { + height: 24, + showValues: true, + showPercentage: true, + legend: true, + loading: false, + }, + render: (args) => (
- `$${v.toLocaleString()}`} - showValues - /> + `$${v.toLocaleString()}`} {...args} />
), }; @@ -483,93 +541,126 @@ export const BarListRanked: Story = { }; +const WATERFALL_DATA = [ + { label: 'Revenue', value: 420, type: 'total' as const }, + { label: 'Product', value: 280 }, + { label: 'Services', value: 140 }, + { label: 'Refunds', value: -85 }, + { label: 'Fees', value: -45 }, + { label: 'Tax', value: -62 }, + { label: 'Net', value: 648, type: 'total' as const }, +]; + export const Waterfall: Story = { - render: () => ( + args: { + height: 300, + grid: true, + tooltip: true, + showValues: true, + showConnectors: true, + animate: true, + interactive: true, + loading: false, + }, + argTypes: { + tooltip: { control: 'radio', options: [true, false, 'simple', 'compact'] }, + }, + render: (args) => (
- `$${v}`} - /> + `$${v}`} {...args} />
), }; +const FUNNEL_DATA = [ + { label: 'Visitors', value: 10000, color: 'var(--color-blue-700)' }, + { label: 'Sign ups', value: 4200, color: 'var(--color-blue-500)' }, + { label: 'Activated', value: 2800, color: 'var(--color-blue-400)' }, + { label: 'Subscribed', value: 1200, color: 'var(--color-blue-300)' }, + { label: 'Retained', value: 900, color: 'var(--color-blue-200)' }, +]; + export const Funnel: Story = { - render: () => ( + args: { + height: 220, + showRates: true, + showLabels: true, + tooltip: true, + animate: true, + grid: false, + loading: false, + }, + render: (args) => (
- v.toLocaleString()} - /> + v.toLocaleString()} {...args} />
), }; +const SANKEY_DATA = { + nodes: [ + { id: 'revenue', label: 'Revenue', color: 'var(--color-blue-700)' }, + { id: 'grants', label: 'Grants', color: 'var(--color-blue-400)' }, + { id: 'investments', label: 'Investments', color: 'var(--color-blue-200)' }, + { id: 'engineering', label: 'Engineering', color: 'var(--color-purple-600)' }, + { id: 'marketing', label: 'Marketing', color: 'var(--color-purple-400)' }, + { id: 'operations', label: 'Operations', color: 'var(--color-purple-200)' }, + { id: 'product', label: 'Product', color: 'var(--color-green-600)' }, + { id: 'growth', label: 'Growth', color: 'var(--color-green-400)' }, + { id: 'infra', label: 'Infrastructure', color: 'var(--color-green-200)' }, + ], + links: [ + { source: 'revenue', target: 'engineering', value: 400 }, + { source: 'revenue', target: 'marketing', value: 200 }, + { source: 'revenue', target: 'operations', value: 150 }, + { source: 'grants', target: 'engineering', value: 80 }, + { source: 'grants', target: 'operations', value: 40 }, + { source: 'investments', target: 'marketing', value: 60 }, + { source: 'investments', target: 'engineering', value: 30 }, + { source: 'engineering', target: 'product', value: 350 }, + { source: 'engineering', target: 'infra', value: 160 }, + { source: 'marketing', target: 'growth', value: 220 }, + { source: 'marketing', target: 'product', value: 40 }, + { source: 'operations', target: 'infra', value: 120 }, + { source: 'operations', target: 'growth', value: 70 }, + ], +}; + export const Sankey: Story = { - render: () => ( + args: { + height: 380, + showValues: true, + showLabels: true, + tooltip: true, + animate: true, + loading: false, + nodeWidth: 12, + nodePadding: 16, + }, + render: (args) => (
`$${v}k`} + formatValue={(v: number) => `$${v}k`} + {...args} />
), }; export const LiveDotStates: Story = { - render: () => ( + args: { + size: 8, + }, + argTypes: { + status: { control: 'radio', options: ['active', 'degraded', 'down', 'unknown'] }, + }, + render: (args) => (
{(['active', 'degraded', 'down', 'unknown'] as const).map((status) => (
- + {status} @@ -580,8 +671,15 @@ export const LiveDotStates: Story = { }; export const LiveValueAnimated: Story = { - render: function Render() { - const [value, setValue] = React.useState(1234); + args: { + value: 1234, + }, + render: function Render(args) { + const [value, setValue] = React.useState(args.value as number); + + React.useEffect(() => { + setValue(args.value as number); + }, [args.value]); return (
@@ -670,138 +768,11 @@ export const BarWithAnalytics: Story = { }, }; -export const StackedAreaNonInteractive: Story = { - render: () => ( -
- -
- ), -}; -export const BarNonInteractive: Story = { - render: () => ( -
- -
- ), -}; -export const ComposedNonInteractive: Story = { - render: () => ( -
- -
- ), -}; -export const ScatterNonInteractive: Story = { - render: () => ( -
- -
- ), -}; -export const StackedAreaNoAnimation: Story = { - render: () => ( -
- -
- ), -}; -export const PieNoAnimation: Story = { - render: () => ( -
- -
- ), -}; export const FunnelActiveChange: Story = { render: function Render() { diff --git a/src/components/Checkbox/Checkbox.stories.tsx b/src/components/Checkbox/Checkbox.stories.tsx index f22871e..e9ab8e5 100644 --- a/src/components/Checkbox/Checkbox.stories.tsx +++ b/src/components/Checkbox/Checkbox.stories.tsx @@ -2,20 +2,33 @@ import type { Meta, StoryObj } from '@storybook/react'; import { useState } from 'react'; import { Checkbox } from './Checkbox'; -const meta: Meta = { +const meta: Meta = { title: 'Components/Checkbox', + component: Checkbox.Group, parameters: { layout: 'centered', }, + argTypes: { + variant: { + control: 'radio', + options: ['default', 'card'], + }, + disabled: { control: 'boolean' }, + }, }; export default meta; +type Story = StoryObj; -export const Default: StoryObj = { - render: () => ( +export const Default: Story = { + args: { + variant: 'default', + disabled: false, + }, + render: (args) => ( Legend - + @@ -26,20 +39,7 @@ export const Default: StoryObj = { ), }; -export const CardVariant: StoryObj = { - render: () => ( - - Legend - - - - - Help text goes here. - - ), -}; - -export const WithError: StoryObj = { +export const WithError: Story = { render: () => ( Legend @@ -54,19 +54,7 @@ export const WithError: StoryObj = { ), }; -export const Disabled: StoryObj = { - render: () => ( - - Legend - - - - - - ), -}; - -export const DisabledCard: StoryObj = { +export const DisabledCard: Story = { render: () => ( Legend @@ -78,7 +66,7 @@ export const DisabledCard: StoryObj = { ), }; -export const Indeterminate: StoryObj = { +export const Indeterminate: Story = { render: function IndeterminateStory() { const [value, setValue] = useState(['child1']); const allValues = ['child1', 'child2', 'child3']; @@ -99,7 +87,7 @@ export const Indeterminate: StoryObj = { }, }; -export const Controlled: StoryObj = { +export const Controlled: Story = { render: function ControlledStory() { const [value, setValue] = useState(['option2']); @@ -119,7 +107,7 @@ export const Controlled: StoryObj = { }, }; -export const AllStates: StoryObj = { +export const AllStates: Story = { render: () => (
diff --git a/src/components/Chip/Chip.stories.tsx b/src/components/Chip/Chip.stories.tsx index 3059814..b29da44 100644 --- a/src/components/Chip/Chip.stories.tsx +++ b/src/components/Chip/Chip.stories.tsx @@ -14,6 +14,7 @@ const meta = { options: ['sm', 'md'], }, disabled: { control: 'boolean' }, + children: { control: 'text' }, onDismiss: { action: 'dismissed' }, }, } satisfies Meta; @@ -25,13 +26,7 @@ export const Default: Story = { args: { children: 'label', size: 'md', - }, -}; - -export const Small: Story = { - args: { - children: 'label', - size: 'sm', + disabled: false, }, }; @@ -39,22 +34,7 @@ export const WithDismiss: Story = { args: { children: 'label', size: 'md', - onDismiss: () => {}, - }, -}; - -export const SmallWithDismiss: Story = { - args: { - children: 'label', - size: 'sm', - onDismiss: () => {}, - }, -}; - -export const Disabled: Story = { - args: { - children: 'label', - disabled: true, + disabled: false, onDismiss: () => {}, }, }; diff --git a/src/components/Collapsible/Collapsible.stories.tsx b/src/components/Collapsible/Collapsible.stories.tsx index 12a5932..de73da1 100644 --- a/src/components/Collapsible/Collapsible.stories.tsx +++ b/src/components/Collapsible/Collapsible.stories.tsx @@ -3,16 +3,32 @@ import { useState } from 'react'; import { Collapsible } from './index'; import { CentralIcon } from '../Icon'; -const meta: Meta = { +const meta: Meta = { title: 'Components/Collapsible', component: Collapsible.Root, + parameters: { + layout: 'centered', + }, + argTypes: { + defaultOpen: { + control: 'boolean', + }, + disabled: { + control: 'boolean', + }, + }, }; export default meta; +type Story = StoryObj; -export const Default: StoryObj = { - render: () => ( - +export const Default: Story = { + args: { + defaultOpen: false, + disabled: false, + }, + render: (args) => ( + Advanced settings These settings are for experienced users. Adjust with caution. @@ -21,40 +37,7 @@ export const Default: StoryObj = { ), }; -export const DefaultOpen: StoryObj = { - render: () => ( - - Details - - This panel starts open by default. - - - ), -}; - -export const Disabled: StoryObj = { - render: () => ( - - Cannot toggle - - This content is locked. - - - ), -}; - -export const HideIcon: StoryObj = { - render: () => ( - - Show more - - The trigger has no chevron icon. - - - ), -}; - -export const CustomIcon: StoryObj = { +export const CustomIcon: Story = { render: () => ( }> @@ -67,7 +50,7 @@ export const CustomIcon: StoryObj = { ), }; -export const Controlled: StoryObj = { +export const Controlled: Story = { render: function Render() { const [open, setOpen] = useState(false); @@ -86,3 +69,20 @@ export const Controlled: StoryObj = { ); }, }; + +export const Nested: Story = { + render: () => ( + + Parent section + +

Parent content.

+ + Child section + + Nested collapsible content. + + +
+
+ ), +}; diff --git a/src/components/Combobox/Combobox.stories.tsx b/src/components/Combobox/Combobox.stories.tsx index 6ff90bc..a060aa8 100644 --- a/src/components/Combobox/Combobox.stories.tsx +++ b/src/components/Combobox/Combobox.stories.tsx @@ -3,12 +3,16 @@ import { useState } from 'react'; import { Combobox } from './index'; import { Field } from '@/components/Field'; -const meta: Meta = { +const meta: Meta = { title: 'Components/Combobox', component: Combobox.Root, + argTypes: { + disabled: { control: 'boolean' }, + }, }; export default meta; +type Story = StoryObj; const fruits = [ 'Apple', @@ -23,9 +27,12 @@ const fruits = [ 'Lemon', ]; -export const Default: StoryObj = { - render: () => ( - +export const Default: Story = { + args: { + disabled: false, + }, + render: (args) => ( + @@ -51,7 +58,7 @@ export const Default: StoryObj = { ), }; -export const WithClear: StoryObj = { +export const WithClear: Story = { render: () => ( @@ -80,7 +87,7 @@ export const WithClear: StoryObj = { ), }; -export const Multiple: StoryObj = { +export const Multiple: Story = { render: () => ( @@ -113,7 +120,7 @@ const groupedFruits = { exotic: ['Dragon Fruit', 'Mangosteen', 'Rambutan'], }; -export const WithGroups: StoryObj = { +export const WithGroups: Story = { render: () => ( @@ -153,34 +160,7 @@ export const WithGroups: StoryObj = { ), }; -export const Disabled: StoryObj = { - render: () => ( - - - - - - - - - - - - {(item: string) => ( - - - {item} - - )} - - - - - - ), -}; - -export const Controlled: StoryObj = { +export const Controlled: Story = { render: function Render() { const [value, setValue] = useState(null); @@ -215,7 +195,7 @@ export const Controlled: StoryObj = { }, }; -export const WithField: StoryObj = { +export const WithField: Story = { render: function WithField() { const [value, setValue] = useState(null); const [touched, setTouched] = useState(false); diff --git a/src/components/Dialog/Dialog.stories.tsx b/src/components/Dialog/Dialog.stories.tsx index 884cdbe..ce1fb09 100644 --- a/src/components/Dialog/Dialog.stories.tsx +++ b/src/components/Dialog/Dialog.stories.tsx @@ -5,16 +5,23 @@ import { Button } from '../Button'; const meta: Meta = { title: 'Components/Dialog', + component: Dialog.Root, parameters: { layout: 'centered', }, + argTypes: { + modal: { control: 'boolean' }, + }, }; export default meta; export const Default: StoryObj = { - render: () => ( - + args: { + modal: true, + }, + render: (args) => ( + }> Open Dialog diff --git a/src/components/Field/Field.stories.tsx b/src/components/Field/Field.stories.tsx index 8995b90..5d9b9b2 100644 --- a/src/components/Field/Field.stories.tsx +++ b/src/components/Field/Field.stories.tsx @@ -43,18 +43,6 @@ export const WithError: Story = { ), }; -export const Disabled: Story = { - render: () => ( -
- - Email - - This field is disabled. - -
- ), -}; - export const WithoutLabel: Story = { render: () => (
diff --git a/src/components/Input/Input.stories.tsx b/src/components/Input/Input.stories.tsx index 683e6b9..fe2d352 100644 --- a/src/components/Input/Input.stories.tsx +++ b/src/components/Input/Input.stories.tsx @@ -23,6 +23,8 @@ type Story = StoryObj; export const Default: Story = { args: { placeholder: 'Placeholder', + disabled: false, + readOnly: false, }, }; @@ -32,27 +34,6 @@ export const WithValue: Story = { }, }; -export const Disabled: Story = { - args: { - placeholder: 'Placeholder', - disabled: true, - }, -}; - -export const DisabledWithValue: Story = { - args: { - defaultValue: 'Content', - disabled: true, - }, -}; - -export const ReadOnly: Story = { - args: { - defaultValue: 'Read only content', - readOnly: true, - }, -}; - export const Invalid: Story = { render: () => ( diff --git a/src/components/InputGroup/InputGroup.stories.tsx b/src/components/InputGroup/InputGroup.stories.tsx index efd5e4f..edc7e20 100644 --- a/src/components/InputGroup/InputGroup.stories.tsx +++ b/src/components/InputGroup/InputGroup.stories.tsx @@ -5,15 +5,20 @@ import type { Meta, StoryObj } from '@storybook/react'; import { InputGroup } from './'; import { Field } from '@/components/Field'; -const meta: Meta = { +const meta: Meta = { title: 'Components/InputGroup', + component: InputGroup.Root, parameters: { layout: 'centered', }, tags: ['autodocs'], + argTypes: { + disabled: { control: 'boolean' }, + }, }; export default meta; +type Story = StoryObj; function SearchIcon() { return ( @@ -41,10 +46,13 @@ function LinkIcon() { ); } -export const Default: StoryObj = { - render: () => ( +export const Default: Story = { + args: { + disabled: false, + }, + render: (args) => (
- + @@ -54,7 +62,7 @@ export const Default: StoryObj = { ), }; -export const TrailingAddon: StoryObj = { +export const TrailingAddon: Story = { render: () => (
@@ -67,7 +75,7 @@ export const TrailingAddon: StoryObj = { ), }; -export const LeadingAndTrailing: StoryObj = { +export const LeadingAndTrailing: Story = { render: () => (
@@ -83,7 +91,7 @@ export const LeadingAndTrailing: StoryObj = { ), }; -export const WithGhostButton: StoryObj = { +export const WithGhostButton: Story = { name: 'Button (Ghost)', render: () => (
@@ -98,7 +106,7 @@ export const WithGhostButton: StoryObj = { ), }; -export const WithOutlineButton: StoryObj = { +export const WithOutlineButton: Story = { name: 'Button (Outline)', render: () => (
@@ -113,7 +121,7 @@ export const WithOutlineButton: StoryObj = { ), }; -export const WithGhostSelectTrigger: StoryObj = { +export const WithGhostSelectTrigger: Story = { name: 'Select Trigger (Ghost)', render: () => (
@@ -125,7 +133,7 @@ export const WithGhostSelectTrigger: StoryObj = { ), }; -export const WithOutlineSelectTrigger: StoryObj = { +export const WithOutlineSelectTrigger: Story = { name: 'Select Trigger (Outline)', render: () => (
@@ -137,7 +145,7 @@ export const WithOutlineSelectTrigger: StoryObj = { ), }; -export const Cap: StoryObj = { +export const Cap: Story = { render: () => (
@@ -148,7 +156,7 @@ export const Cap: StoryObj = { ), }; -export const TrailingCap: StoryObj = { +export const TrailingCap: Story = { render: () => (
@@ -159,7 +167,7 @@ export const TrailingCap: StoryObj = { ), }; -export const CapWithButton: StoryObj = { +export const CapWithButton: Story = { name: 'Cap with Button', render: () => (
@@ -173,7 +181,7 @@ export const CapWithButton: StoryObj = { ), }; -export const CapWithIconButton: StoryObj = { +export const CapWithIconButton: Story = { name: 'Cap with Icon Button', render: () => (
@@ -189,7 +197,7 @@ export const CapWithIconButton: StoryObj = { ), }; -export const WithText: StoryObj = { +export const WithText: Story = { render: () => (
@@ -201,33 +209,7 @@ export const WithText: StoryObj = { ), }; -export const Disabled: StoryObj = { - render: () => ( -
- - - - - - -
- ), -}; - -export const Invalid: StoryObj = { - render: () => ( -
- - - - - - -
- ), -}; - -export const Controlled: StoryObj = { +export const Controlled: Story = { render: function ControlledExample() { const [value, setValue] = React.useState(''); return ( @@ -250,7 +232,7 @@ export const Controlled: StoryObj = { }, }; -export const WithField: StoryObj = { +export const WithField: Story = { render: function WithFieldExample() { const [value, setValue] = React.useState(''); const [touched, setTouched] = React.useState(false); @@ -282,7 +264,7 @@ export const WithField: StoryObj = { }, }; -export const URLInput: StoryObj = { +export const URLInput: Story = { name: 'URL Input', render: () => (
@@ -295,7 +277,7 @@ export const URLInput: StoryObj = { ), }; -export const CurrencyInput: StoryObj = { +export const CurrencyInput: Story = { name: 'Currency Input', render: () => (
@@ -308,7 +290,7 @@ export const CurrencyInput: StoryObj = { ), }; -export const AllVariants: StoryObj = { +export const AllVariants: Story = { render: () => (
diff --git a/src/components/Item/Item.stories.tsx b/src/components/Item/Item.stories.tsx index ea61901..8e5969f 100644 --- a/src/components/Item/Item.stories.tsx +++ b/src/components/Item/Item.stories.tsx @@ -62,22 +62,6 @@ export const WithSwitch: Story = { render: () => , }; -export const Selected: Story = { - args: { - title: 'Selected item', - selected: true, - trailing: , - }, -}; - -export const Disabled: Story = { - args: { - title: 'Disabled item', - description: 'This item is disabled', - disabled: true, - }, -}; - function SelectableListComponent() { const [selected, setSelected] = React.useState('item-1'); const items = [ diff --git a/src/components/Loader/Loader.stories.tsx b/src/components/Loader/Loader.stories.tsx index 40bb306..4c501b1 100644 --- a/src/components/Loader/Loader.stories.tsx +++ b/src/components/Loader/Loader.stories.tsx @@ -8,18 +8,19 @@ const meta = { layout: 'centered', }, tags: ['autodocs'], + argTypes: { + label: { + control: 'text', + }, + }, } satisfies Meta; export default meta; type Story = StoryObj; export const Default: Story = { - args: {}, -}; - -export const CustomLabel: Story = { args: { - label: 'Fetching data', + label: 'Loading', }, }; diff --git a/src/components/Logo/Logo.stories.tsx b/src/components/Logo/Logo.stories.tsx index 32c31a6..4775b44 100644 --- a/src/components/Logo/Logo.stories.tsx +++ b/src/components/Logo/Logo.stories.tsx @@ -9,15 +9,15 @@ const meta: Meta = { }, argTypes: { brand: { - control: 'select', + control: 'radio', options: ['lightspark', 'grid'], }, variant: { - control: 'select', + control: 'radio', options: ['logo', 'logomark', 'wordmark'], }, weight: { - control: 'select', + control: 'radio', options: ['regular', 'light'], }, height: { @@ -31,70 +31,13 @@ type Story = StoryObj; export const Default: Story = { args: { - 'aria-label': 'Lightspark', - }, -}; - -export const LogoVariant: Story = { - args: { + brand: 'lightspark', variant: 'logo', weight: 'regular', + height: 24, 'aria-label': 'Lightspark', }, -}; - -export const LogoLight: Story = { - args: { - variant: 'logo', - weight: 'light', - 'aria-label': 'Lightspark', - }, -}; - -export const Logomark: Story = { - args: { - variant: 'logomark', - weight: 'regular', - 'aria-label': 'Lightspark', - }, -}; - -export const LogomarkLight: Story = { - args: { - variant: 'logomark', - weight: 'light', - 'aria-label': 'Lightspark', - }, -}; - -export const Wordmark: Story = { - args: { - variant: 'wordmark', - 'aria-label': 'Lightspark', - }, -}; - -export const CustomHeight: Story = { - args: { - height: 40, - 'aria-label': 'Lightspark', - }, -}; - -export const GridLogo: Story = { - args: { - brand: 'grid', - variant: 'logo', - 'aria-label': 'Grid', - }, -}; - -export const GridLogomark: Story = { - args: { - brand: 'grid', - variant: 'logomark', - 'aria-label': 'Grid', - }, + render: (args) => , }; export const AllVariants: Story = { diff --git a/src/components/Meter/Meter.stories.tsx b/src/components/Meter/Meter.stories.tsx index 7f7e0e2..7c39788 100644 --- a/src/components/Meter/Meter.stories.tsx +++ b/src/components/Meter/Meter.stories.tsx @@ -6,14 +6,24 @@ const meta: Meta = { parameters: { layout: 'centered', }, + argTypes: { + value: { control: { type: 'range', min: 0, max: 100 } }, + min: { control: { type: 'number' } }, + max: { control: { type: 'number' } }, + }, }; export default meta; export const Default: StoryObj = { - render: () => ( + args: { + value: 50, + min: 0, + max: 100, + }, + render: (args) => (
- + Storage used @@ -24,48 +34,6 @@ export const Default: StoryObj = { ), }; -export const Low: StoryObj = { - render: () => ( -
- - Battery level - - - - - -
- ), -}; - -export const High: StoryObj = { - render: () => ( -
- - Disk space used - - - - - -
- ), -}; - -export const Full: StoryObj = { - render: () => ( -
- - Storage full - - - - - -
- ), -}; - export const TrackOnly: StoryObj = { render: () => (
diff --git a/src/components/Pagination/Pagination.stories.tsx b/src/components/Pagination/Pagination.stories.tsx index 5dcbc5d..b1be2ff 100644 --- a/src/components/Pagination/Pagination.stories.tsx +++ b/src/components/Pagination/Pagination.stories.tsx @@ -3,25 +3,15 @@ import * as React from 'react'; import { Pagination } from './Pagination'; import { Select } from '../Select'; -const meta: Meta = { - title: 'Components/Pagination', - parameters: { - layout: 'centered', - }, -}; - -export default meta; - -// Interactive wrapper for controlled pagination function PaginationDemo({ initialPage = 1, - totalItems = 2500, - initialPageSize = 100, + totalItems, + initialPageSize, showSelect = true, }: { initialPage?: number; - totalItems?: number; - initialPageSize?: number; + totalItems: number; + initialPageSize: number; showSelect?: boolean; }) { const [page, setPage] = React.useState(initialPage); @@ -58,10 +48,10 @@ function PaginationDemo({ 100 - - - - + + + + )} @@ -72,34 +62,50 @@ function PaginationDemo({ ); } -export const Default: StoryObj = { - render: () => , -}; - -export const FirstPage: StoryObj = { - render: () => , -}; - -export const LastPage: StoryObj = { - render: () => , -}; - -export const MiddlePage: StoryObj = { - render: () => , -}; - -export const SinglePage: StoryObj = { - render: () => , +const meta: Meta = { + title: 'Components/Pagination', + component: Pagination.Root, + parameters: { + layout: 'centered', + }, + argTypes: { + totalItems: { + control: { type: 'number' }, + }, + pageSize: { + control: { type: 'number' }, + }, + }, }; -export const EmptyState: StoryObj = { - render: () => , -}; +export default meta; +type Story = StoryObj; -export const WithoutSelect: StoryObj = { - render: () => , +export const Controlled: Story = { + args: { + totalItems: 2500, + pageSize: 100, + }, + render: (args) => ( + + ), }; -export const SmallPageSize: StoryObj = { - render: () => , +export const WithoutSelect: Story = { + args: { + totalItems: 2500, + pageSize: 100, + }, + render: (args) => ( + + ), }; diff --git a/src/components/PhoneInput/PhoneInput.stories.tsx b/src/components/PhoneInput/PhoneInput.stories.tsx index 7bb140f..cd28ddd 100644 --- a/src/components/PhoneInput/PhoneInput.stories.tsx +++ b/src/components/PhoneInput/PhoneInput.stories.tsx @@ -5,7 +5,6 @@ import type { Meta, StoryObj } from '@storybook/react'; import { PhoneInput } from './'; import { Field } from '@/components/Field'; -// Example country data const exampleCountries = [ { code: 'US', name: 'United States', dialCode: '+1' }, { code: 'GB', name: 'United Kingdom', dialCode: '+44' }, @@ -21,28 +20,30 @@ const exampleCountries = [ type Country = typeof exampleCountries[number]; -// Circle-flags CDN URL helper function getFlagUrl(code: string) { return `https://hatscripts.github.io/circle-flags/flags/${code.toLowerCase()}.svg`; } const meta: Meta = { title: 'Components/PhoneInput', + component: PhoneInput.Root, parameters: { layout: 'centered', }, + tags: ['autodocs'], + argTypes: { + disabled: { control: 'boolean' }, + }, }; export default meta; function PhoneInputExample({ disabled = false, - invalid = false, placeholder = 'Enter phone', defaultCountry = exampleCountries[0], }: { disabled?: boolean; - invalid?: boolean; placeholder?: string; defaultCountry?: Country; }) { @@ -51,7 +52,7 @@ function PhoneInputExample({ return (
- + , -}; - -export const Disabled: StoryObj = { - render: () => , -}; - -export const Invalid: StoryObj = { - render: () => , +export const Default: StoryObj<{ disabled?: boolean }> = { + args: { + disabled: false, + }, + render: (args) => , }; -export const WithDifferentCountry: StoryObj = { +export const WithDefaultCountry: StoryObj = { render: () => , }; -export const CustomPlaceholder: StoryObj = { - render: () => , -}; - // Controlled example with form function ControlledExample() { const [selectedCountry, setSelectedCountry] = React.useState(exampleCountries[0]); diff --git a/src/components/Popover/Popover.stories.tsx b/src/components/Popover/Popover.stories.tsx index 8ad0ce6..c999f8d 100644 --- a/src/components/Popover/Popover.stories.tsx +++ b/src/components/Popover/Popover.stories.tsx @@ -1,3 +1,4 @@ +import * as React from 'react'; import type { Meta, StoryObj } from '@storybook/react'; import { Popover } from './Popover'; import { Button } from '../Button'; @@ -17,8 +18,14 @@ type Story = StoryObj; const contentStyle = { padding: 'var(--spacing-md)', maxWidth: 280 }; export const Default: Story = { - render: () => ( - + args: { + defaultOpen: false, + }, + argTypes: { + defaultOpen: { control: 'boolean' }, + }, + render: (args) => ( + }> Open Popover @@ -88,24 +95,30 @@ export const WithClose: Story = { ), }; -export const OpenByDefault: Story = { - render: () => ( - - }> - Already Open - - - - -
- Open by Default - - This popover renders in its open state. - -
-
-
-
-
- ), +export const Controlled: Story = { + render: function Controlled() { + const [open, setOpen] = React.useState(false); + return ( +
+ + + Controlled popover} /> + + + +
+ Controlled + + Open state is controlled via React state. + +
+
+
+
+
+
+ ); + }, }; diff --git a/src/components/Progress/Progress.stories.tsx b/src/components/Progress/Progress.stories.tsx index 18e8d5d..6991803 100644 --- a/src/components/Progress/Progress.stories.tsx +++ b/src/components/Progress/Progress.stories.tsx @@ -7,14 +7,24 @@ const meta: Meta = { parameters: { layout: 'centered', }, + argTypes: { + value: { control: { type: 'range', min: 0, max: 100 } }, + min: { control: { type: 'number' } }, + max: { control: { type: 'number' } }, + }, }; export default meta; export const Default: StoryObj = { - render: () => ( + args: { + value: 50, + min: 0, + max: 100, + }, + render: (args) => (
- + Export data @@ -25,20 +35,6 @@ export const Default: StoryObj = { ), }; -export const Complete: StoryObj = { - render: () => ( -
- - Upload complete - - - - - -
- ), -}; - export const Indeterminate: StoryObj = { render: () => (
diff --git a/src/components/Radio/Radio.stories.tsx b/src/components/Radio/Radio.stories.tsx index 0808368..671daae 100644 --- a/src/components/Radio/Radio.stories.tsx +++ b/src/components/Radio/Radio.stories.tsx @@ -9,16 +9,27 @@ const meta = { layout: 'centered', }, tags: ['autodocs'], + argTypes: { + variant: { + control: 'radio', + options: ['default', 'card'], + }, + disabled: { control: 'boolean' }, + }, } satisfies Meta; export default meta; type Story = StoryObj; export const Default: Story = { - render: () => ( + args: { + variant: 'default', + disabled: false, + }, + render: (args) => ( Legend - + @@ -27,19 +38,6 @@ export const Default: Story = { ), }; -export const CardVariant: Story = { - render: () => ( - - Legend - - - - - Help text goes here. - - ), -}; - export const CriticalState: Story = { render: () => ( @@ -66,32 +64,6 @@ export const WithoutDescriptions: Story = { ), }; -export const Disabled: Story = { - render: () => ( - - Legend - - - - - This field is disabled. - - ), -}; - -export const DisabledItem: Story = { - render: () => ( - - Legend - - - - - - - ), -}; - export const Controlled: Story = { render: function ControlledRadio() { const [value, setValue] = useState('card2'); diff --git a/src/components/Select/Select.stories.tsx b/src/components/Select/Select.stories.tsx index 787a425..7f7d759 100644 --- a/src/components/Select/Select.stories.tsx +++ b/src/components/Select/Select.stories.tsx @@ -6,9 +6,15 @@ import { CentralIcon } from '../Icon'; const meta: Meta = { title: 'Components/Select', + component: Select.Root, parameters: { layout: 'centered', }, + tags: ['autodocs'], + argTypes: { + disabled: { control: 'boolean' }, + variant: { control: 'radio', options: ['default', 'ghost'] }, + }, }; export default meta; @@ -21,62 +27,16 @@ const fruits = [ { value: 'mango', label: 'Mango' }, ]; -export const Default: StoryObj = { - render: () => ( - - - - - - - - - - {fruits.map((fruit) => ( - - - {fruit.label} - - ))} - - - - - - ), -}; - -export const WithDefaultValue: StoryObj = { - render: () => ( - - - - - - - - - - {fruits.map((fruit) => ( - - - {fruit.label} - - ))} - - - - - - ), -}; - -export const Disabled: StoryObj = { - render: () => ( - - +export const Default: StoryObj<{ disabled?: boolean; variant?: 'default' | 'ghost' }> = { + args: { + disabled: false, + variant: 'default', + }, + render: (args) => ( + + - + {args.variant === 'ghost' ? : } @@ -137,7 +97,7 @@ export const WithGroups: StoryObj = { ), }; -export const WithTrailingIcons: StoryObj = { +export const WithIcons: StoryObj = { render: () => ( @@ -216,7 +176,7 @@ export const MultiSelect: StoryObj = { }, }; -export const WithFieldValidation: StoryObj = { +export const WithField: StoryObj = { render: function WithFieldValidation() { const [value, setValue] = React.useState(null); const [submitted, setSubmitted] = React.useState(false); @@ -300,56 +260,33 @@ export const Ghost: StoryObj = { }, }; -export const GhostWithPlaceholder: StoryObj = { - render: () => ( - - - - - - - - - - {environments.map((env) => ( - - {env.label} - - - ))} - - - - - - ), -}; +export const Controlled: StoryObj = { + render: function Controlled() { + const [value, setValue] = React.useState(null); -export const GhostDisabled: StoryObj = { - render: () => ( - - - - {(selected) => environments.find(e => e.value === selected)?.label ?? selected} - - - - - - - - {environments.map((env) => ( - - {env.label} - - - ))} - - - - - - ), + return ( + + + + + + + + + + {fruits.map((fruit) => ( + + + {fruit.label} + + ))} + + + + + + ); + }, }; const countries = [ diff --git a/src/components/Separator/Separator.stories.tsx b/src/components/Separator/Separator.stories.tsx index bb95ccd..569364b 100644 --- a/src/components/Separator/Separator.stories.tsx +++ b/src/components/Separator/Separator.stories.tsx @@ -10,11 +10,11 @@ const meta = { tags: ['autodocs'], argTypes: { variant: { - control: { type: 'select' }, + control: { type: 'radio' }, options: ['default', 'hairline'], }, orientation: { - control: { type: 'select' }, + control: { type: 'radio' }, options: ['horizontal', 'vertical'], }, }, @@ -37,52 +37,6 @@ export const Default: Story = { ], }; -export const Hairline: Story = { - args: { - variant: 'hairline', - orientation: 'horizontal', - }, - decorators: [ - (Story) => ( -
- -
- ), - ], -}; - -export const Vertical: Story = { - args: { - variant: 'default', - orientation: 'vertical', - }, - decorators: [ - (Story) => ( -
- Left - - Right -
- ), - ], -}; - -export const VerticalHairline: Story = { - args: { - variant: 'hairline', - orientation: 'vertical', - }, - decorators: [ - (Story) => ( -
- Left - - Right -
- ), - ], -}; - export const AllVariants: Story = { args: {}, render: () => ( diff --git a/src/components/Sidebar/Sidebar.stories.tsx b/src/components/Sidebar/Sidebar.stories.tsx index 5f1e1c2..5a8c9fd 100644 --- a/src/components/Sidebar/Sidebar.stories.tsx +++ b/src/components/Sidebar/Sidebar.stories.tsx @@ -35,8 +35,9 @@ type Story = StoryObj; * Default expanded sidebar with groups and items. */ export const Default: Story = { - render: () => ( - + args: { collapsed: false }, + render: (args) => ( +
Header Slot @@ -78,40 +79,6 @@ export const Default: Story = { ), }; -/** - * Collapsed sidebar showing only icons. - */ -export const Collapsed: Story = { - render: () => ( - - -
- - - - - } active> - Dashboard - - }> - Profile - - }> - Settings - - }> - Projects - - - - - -
- - - ), -}; - /** * Uses Provider for external state management with the built-in Trigger component. * The Trigger can be placed anywhere within the Provider. diff --git a/src/components/Switch/Switch.stories.tsx b/src/components/Switch/Switch.stories.tsx index b09bd03..9624f88 100644 --- a/src/components/Switch/Switch.stories.tsx +++ b/src/components/Switch/Switch.stories.tsx @@ -12,13 +12,12 @@ const meta: Meta = { tags: ['autodocs'], argTypes: { size: { - control: 'select', + control: 'radio', options: ['sm', 'md'], }, - checked: { control: 'boolean' }, + defaultChecked: { control: 'boolean' }, disabled: { control: 'boolean' }, readOnly: { control: 'boolean' }, - required: { control: 'boolean' }, }, }; @@ -28,19 +27,9 @@ type Story = StoryObj; export const Default: Story = { args: { size: 'md', - }, -}; - -export const Checked: Story = { - args: { - defaultChecked: true, - size: 'md', - }, -}; - -export const Small: Story = { - args: { - size: 'sm', + defaultChecked: false, + disabled: false, + readOnly: false, }, }; @@ -51,13 +40,6 @@ export const SmallChecked: Story = { }, }; -export const Disabled: Story = { - args: { - disabled: true, - size: 'md', - }, -}; - export const DisabledChecked: Story = { args: { disabled: true, diff --git a/src/components/Table/Table.stories.tsx b/src/components/Table/Table.stories.tsx index 18e4854..5792948 100644 --- a/src/components/Table/Table.stories.tsx +++ b/src/components/Table/Table.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react'; +import { Table } from './index'; import { BasicTable, SortableTable, @@ -6,26 +7,33 @@ import { AlignedTable, LoadingTable, ResizableTable, - CompactTable, SlotsTable, DescriptionTable, FooterTable, CompactFooterTable, } from './Table.test-stories'; -const meta: Meta = { +const meta: Meta = { title: 'Components/Table', + component: Table.Root, parameters: { layout: 'padded', }, + argTypes: { + size: { + control: 'radio', + options: ['default', 'compact'], + }, + }, }; export default meta; -type Story = StoryObj; +type Story = StoryObj; export const Basic: Story = { - render: () => , + args: { size: 'default' }, + render: (args) => , }; export const Sortable: Story = { @@ -83,17 +91,6 @@ export const Resizable: Story = { }, }; -export const Compact: Story = { - render: () => , - parameters: { - docs: { - description: { - story: 'Compact density with shorter row heights (header 32px, cell 36px).', - }, - }, - }, -}; - export const WithSlots: Story = { render: () => , parameters: { diff --git a/src/components/Table/Table.test-stories.tsx b/src/components/Table/Table.test-stories.tsx index d899101..aeafa2d 100644 --- a/src/components/Table/Table.test-stories.tsx +++ b/src/components/Table/Table.test-stories.tsx @@ -80,7 +80,7 @@ const sortableColumns = [ /** * Basic table with default styling */ -export function BasicTable() { +export function BasicTable({ size }: { size?: 'default' | 'compact' } = {}) { const table = useReactTable({ data: sampleData, columns: basicColumns, @@ -88,7 +88,7 @@ export function BasicTable() { }); return ( - + {table.getHeaderGroups().map((headerGroup) => ( diff --git a/src/components/Tabs/Tabs.stories.tsx b/src/components/Tabs/Tabs.stories.tsx index 81d6980..4bbd1ee 100644 --- a/src/components/Tabs/Tabs.stories.tsx +++ b/src/components/Tabs/Tabs.stories.tsx @@ -5,14 +5,24 @@ import { Tabs } from './index'; const meta: Meta = { title: 'Components/Tabs', component: Tabs.Root, + argTypes: { + variant: { + control: 'radio', + options: ['default', 'minimal'], + description: 'Tabs.List variant', + }, + }, }; export default meta; export const Default: StoryObj = { - render: () => ( + args: { + variant: 'default', + }, + render: (args) => ( - + Account Password Settings @@ -30,27 +40,6 @@ export const Default: StoryObj = { ), }; -export const Minimal: StoryObj = { - render: () => ( - - - Overview - Details - History - - - Overview content without container background. - - - Details content. - - - History content. - - - ), -}; - export const WithDisabled: StoryObj = { render: () => ( diff --git a/src/components/Textarea/Textarea.stories.tsx b/src/components/Textarea/Textarea.stories.tsx index 8d9bff3..44e4679 100644 --- a/src/components/Textarea/Textarea.stories.tsx +++ b/src/components/Textarea/Textarea.stories.tsx @@ -24,6 +24,9 @@ type Story = StoryObj; export const Default: Story = { args: { placeholder: 'Placeholder', + disabled: false, + readOnly: false, + rows: 3, }, }; @@ -33,40 +36,12 @@ export const WithValue: Story = { }, }; -export const Disabled: Story = { - args: { - placeholder: 'Placeholder', - disabled: true, - }, -}; - -export const DisabledWithValue: Story = { - args: { - defaultValue: 'Content', - disabled: true, - }, -}; - -export const ReadOnly: Story = { - args: { - defaultValue: 'Read only content', - readOnly: true, - }, -}; - export const Invalid: Story = { render: () => (