diff --git a/.cursor/commands/build-component.md b/.cursor/commands/build-component.md index 5a5db5a..b1d176e 100644 --- a/.cursor/commands/build-component.md +++ b/.cursor/commands/build-component.md @@ -113,7 +113,7 @@ - `npm run build` — catch export/import errors - `npm test` — full test suite -14. **Publish (if ready for Grid)**: +14. **Publish (if ready for consumers)**: > "Component is exported and tested. Ready to publish? > - Yes → Follow `publishing.mdc` to release a new version > - Not yet → Skip, publish later with other changes" diff --git a/.cursor/environment.json b/.cursor/environment.json deleted file mode 100644 index 3b21c2d..0000000 --- a/.cursor/environment.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "agentCanUpdateSnapshot": true -} \ No newline at end of file diff --git a/.cursor/rules/boundary.mdc b/.cursor/rules/boundary.mdc index ff058ff..ff91061 100644 --- a/.cursor/rules/boundary.mdc +++ b/.cursor/rules/boundary.mdc @@ -5,7 +5,7 @@ alwaysApply: true # Origin Boundary -Origin is the design system. Products like Grid consume it. +Origin is the design system. Products consume it as a package dependency. ## This Repo @@ -15,11 +15,11 @@ Origin is the design system. Products like Grid consume it. - Design tokens and typography - Before adding logic to a component, pause and ask: should this logic live in the primitive or in the product? Check with the user before proceeding. -## Grid Reference +## Product Reference -Grid (`/Users/ajaymantri/dev/grid`) is a product built on Origin. +If a consuming product repo exists in the workspace, use it as read-only reference: -- **Read-only** — never modify Grid files from this workspace +- **Never modify** product files from this workspace - Use as reference for how Origin components are consumed in practice - Learn from real usage patterns when designing component APIs @@ -28,8 +28,8 @@ Grid (`/Users/ajaymantri/dev/grid`) is a product built on Origin. | If you're adding... | It belongs in... | |---------------------|------------------| | Reusable UI pattern | Origin | -| Product-specific layout | Grid (or other product) | +| Product-specific layout | Consuming product | | Generic interaction (button, input) | Origin | -| Business logic | Grid (or other product) | +| Business logic | Consuming product | | Design tokens | Origin | -| App-specific tokens | Grid (or other product) | +| App-specific tokens | Consuming product | diff --git a/.cursor/rules/charts.mdc b/.cursor/rules/charts.mdc index 5e1dcee..45fec13 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` | `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/.cursor/rules/motion.mdc b/.cursor/rules/motion.mdc deleted file mode 100644 index b2296c1..0000000 --- a/.cursor/rules/motion.mdc +++ /dev/null @@ -1,126 +0,0 @@ ---- -description: Guidelines for building performant, natural-feeling animations with CSS, JavaScript, and Motion/Framer Motion -globs: "**/*.{tsx,jsx,css,scss}" -alwaysApply: false ---- - -# Animation Best Practices - -## 1. Easing - -Use custom easing functions over built-in CSS easings for more natural motion. - -### ease-out (Elements entering or exiting / user interactions) - -```css ---ease-out-quad: cubic-bezier(0.25, 0.46, 0.45, 0.94); ---ease-out-cubic: cubic-bezier(0.215, 0.61, 0.355, 1); ---ease-out-quart: cubic-bezier(0.165, 0.84, 0.44, 1); ---ease-out-quint: cubic-bezier(0.23, 1, 0.32, 1); ---ease-out-expo: cubic-bezier(0.19, 1, 0.22, 1); ---ease-out-circ: cubic-bezier(0.075, 0.82, 0.165, 1); -``` - -### ease-in-out (Elements moving within the screen) - -```css ---ease-in-out-quad: cubic-bezier(0.455, 0.03, 0.515, 0.955); ---ease-in-out-cubic: cubic-bezier(0.645, 0.045, 0.355, 1); ---ease-in-out-quart: cubic-bezier(0.77, 0, 0.175, 1); ---ease-in-out-quint: cubic-bezier(0.86, 0, 0.07, 1); ---ease-in-out-expo: cubic-bezier(1, 0, 0, 1); ---ease-in-out-circ: cubic-bezier(0.785, 0.135, 0.15, 0.86); -``` - -### ease-in (Should generally be avoided as it makes the UI feel slow.) - -```css ---ease-in-quad: cubic-bezier(0.55, 0.085, 0.68, 0.53); ---ease-in-cubic: cubic-bezier(0.55, 0.055, 0.675, 0.19); ---ease-in-quart: cubic-bezier(0.895, 0.03, 0.685, 0.22); ---ease-in-quint: cubic-bezier(0.755, 0.05, 0.855, 0.06); ---ease-in-expo: cubic-bezier(0.95, 0.05, 0.795, 0.035); ---ease-in-circ: cubic-bezier(0.6, 0.04, 0.98, 0.335); -``` - -## 2. Duration & Timing - -- **Hover transitions**: Use `transition: property 200ms ease` for `color`, `background-color`, `opacity` -- **Spring animations**: When using Motion/Framer Motion using Spring animations usually results in a better animation. Avoid using bouncy spring animations unless you are working with drag gestures. -- **Make your UI feel fast**: Keep in mind that most animations should be fast. We want the user to feel that the UI is responsive and listens to him. No animation should be longer than 1s, unless it's illustrative. -- **Origin-aware**: Animate from the trigger (e.g., dropdown animates from button). Set `transform-origin` accordingly - -## 3. Motion/Framer Motion - -- Prefer **spring animations** for natural feel (avoid bouncy springs unless using drag gestures) -- Use `transform` instead of `x`/`y` for hardware acceleration: - -```tsx -// Prefer this (hardware accelerated) - - -// Over this - -``` - -## 4. Performance - -- **Animate mostly**: `opacity`, `transform` -- **Avoid**: Animating `top`, `left`, `width`, `height` — use `transform` instead -- **Blur**: Keep blur values ≤ 20px -- **will-change**: Use sparingly, only for `transform`, `opacity`, `clipPath`, `filter` -- **Never** animate drag gestures with CSS variables - -## 5. Base UI Integration - -### Adding animations - -Use `render` prop with a `motion` component: - -```tsx - ( - - )} -> - {children} - -``` - -### Exit & layout animations - -Hoist state and use `AnimatePresence` with `keepMounted`: - -```tsx -const [open, setOpen] = useState(false); - -return ( - - Open - - {open && ( - - - ( - - )} - > - {children} - - - )} - - -); -``` diff --git a/.cursor/rules/publishing.mdc b/.cursor/rules/publishing.mdc index 1d46f73..f7d7e38 100644 --- a/.cursor/rules/publishing.mdc +++ b/.cursor/rules/publishing.mdc @@ -4,7 +4,7 @@ Origin publishes to GitHub Packages on release. ## When to Publish -- After component additions/changes ready for Grid consumption +- After component additions/changes ready for consumption - After token updates - After bug fixes affecting consumers @@ -20,7 +20,7 @@ gh release create vX.Y.Z --title "vX.Y.Z" --generate-notes - Verify build passes: `npm run build` - Verify tests pass: `npm run test:unit` -- Update Grid's dependency version after publishing +- Update the consuming product's dependency version after publishing ## Version Guidelines @@ -30,7 +30,7 @@ gh release create vX.Y.Z --title "vX.Y.Z" --generate-notes ## After Publishing -In Grid, update to the new version: +In the consuming product, update to the new version: ```bash npm install @lightsparkdev/origin@latest diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..eb7149d --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +# Required for npm install (Central Icons license) +CENTRAL_LICENSE_KEY= + +# Required for npm run figma:styles and npm run figma:node +FIGMA_TOKEN= + +# Required for npm run figma:styles (Figma design file key) +FIGMA_FILE_KEY= diff --git a/.gitignore b/.gitignore index 98fdbd4..d8ab960 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,10 @@ origin.code-workspace # Cursor workspace handoff handoff/ + +# Cursor auto-generated +.cursor/environment.json + +# Ephemeral plan files +.cursor/plans/ +docs/plans/ diff --git a/CONTEXT.md b/CONTEXT.md index 0a008f3..a56a8a4 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -289,7 +289,7 @@ The `tools/` directory is excluded from the main tsconfig since it has Figma-spe ## Related Files -- **Figma Design System**: https://www.figma.com/design/3JvbUyTqbbPL8cCpwSX0j4/Origin-design-system +- **Figma Design System**: Set `FIGMA_FILE_KEY` in `.env.local` — see `.env.example` - **Base UI Docs**: https://base-ui.com/react/components --- diff --git a/README.md b/README.md index 101fe3a..eb931a1 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,8 @@ src/ └── app/ # Next.js app tools/ -└── base-ui-lint/ # Figma structure validation plugin +├── base-ui-lint/ # Figma structure validation plugin +└── figma-styles/ # Internal Figma style sync (requires credentials) tokens/ └── figma/ # Raw Figma token exports @@ -61,6 +62,10 @@ import { CentralIcon } from '@/components/Icon'; 213 vendored icons from Central Icons. Edit `scripts/extract-icons.mjs` to add icons, then run `npm run icons:extract`. +## Tokens + +Color and spacing tokens are built from exported Figma variables (`npm run tokens:build`). Typography mixins (`_text-styles.scss`) and shadow variables (`_effects.scss`) are generated from an internal Figma file and committed to the repo — external contributors don't need to regenerate them. Don't edit these generated files by hand. + ## Scripts | Command | Description | @@ -75,6 +80,8 @@ import { CentralIcon } from '@/components/Icon'; | `npm run test:all` | Run both test suites | | `npm run lint` | Run ESLint | +Internal maintainers with Figma credentials also have `figma:styles` and `figma:node` for syncing styles from the design file. + ## Using as a Package ### Installation @@ -149,7 +156,7 @@ For full setup details, see [Using Origin in Your App](docs/using-origin-in-your ## Typography -Suisse Intl requires font metric overrides to prevent an oversized text caret in inputs: +Suisse Intl uses font metric overrides for precise line-height control: ```scss @font-face { @@ -160,7 +167,7 @@ Suisse Intl requires font metric overrides to prevent an oversized text caret in } ``` -These values are applied to all weights (Regular, Book, Medium) in `_fonts.scss`. Consuming apps **must** import Origin's fonts to get correct input rendering. +These values are applied to all weights (Regular, Book, Medium) in `_fonts.scss`. Consuming apps should import Origin's fonts for correct input rendering. Without the font, the system falls back to `system-ui`. ## Documentation diff --git a/docs/component-reference.md b/docs/component-reference.md index 01c97b4..01c7dc2 100644 --- a/docs/component-reference.md +++ b/docs/component-reference.md @@ -32,7 +32,6 @@ Fallback for CSS: `npm run figma:node ""` | Base UI exists | `components.mdc` | | Composed from Base UI | `components.mdc` | | No Base UI | `custom-components.mdc` | -| Animations | `motion.mdc` | ## Code Style diff --git a/package-lock.json b/package-lock.json index f1779ab..95bac13 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,8 +13,7 @@ "@base-ui/utils": "^0.2.3", "@tanstack/react-table": "^8.21.3", "ajv": "^8.18.0", - "clsx": "^2.1.1", - "motion": "^12.23.26" + "clsx": "^2.1.1" }, "devDependencies": { "@arethetypeswrong/cli": "^0.18.2", @@ -9972,33 +9971,6 @@ "url": "https://opencollective.com/webpack" } }, - "node_modules/framer-motion": { - "version": "12.34.2", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.34.2.tgz", - "integrity": "sha512-CcnYTzbRybm1/OE8QLXfXI8gR1cx5T4dF3D2kn5IyqsGNeLAKl2iFHb2BzFyXBGqESntDt6rPYl4Jhrb7tdB8g==", - "license": "MIT", - "dependencies": { - "motion-dom": "^12.34.2", - "motion-utils": "^12.29.2", - "tslib": "^2.4.0" - }, - "peerDependencies": { - "@emotion/is-prop-valid": "*", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@emotion/is-prop-valid": { - "optional": true - }, - "react": { - "optional": true - }, - "react-dom": { - "optional": true - } - } - }, "node_modules/fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", @@ -12136,47 +12108,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/motion": { - "version": "12.34.2", - "resolved": "https://registry.npmjs.org/motion/-/motion-12.34.2.tgz", - "integrity": "sha512-QAthwCtW6N0TpZ+bBmBMzdwuftoay2yFV2DT44jRcUQhPbFPdAX+pjzmIUNM3sMYDD5OAraJagRGAKE8q5OsmA==", - "license": "MIT", - "dependencies": { - "framer-motion": "^12.34.2", - "tslib": "^2.4.0" - }, - "peerDependencies": { - "@emotion/is-prop-valid": "*", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@emotion/is-prop-valid": { - "optional": true - }, - "react": { - "optional": true - }, - "react-dom": { - "optional": true - } - } - }, - "node_modules/motion-dom": { - "version": "12.34.2", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.34.2.tgz", - "integrity": "sha512-n7gknp7gHcW7DUcmet0JVPLVHmE3j9uWwDp5VbE3IkCNnW5qdu0mOhjNYzXMkrQjrgr+h6Db3EDM2QBhW2qNxQ==", - "license": "MIT", - "dependencies": { - "motion-utils": "^12.29.2" - } - }, - "node_modules/motion-utils": { - "version": "12.29.2", - "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.29.2.tgz", - "integrity": "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==", - "license": "MIT" - }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -15844,6 +15775,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, "license": "0BSD" }, "node_modules/tty-browserify": { diff --git a/package.json b/package.json index 5e012f6..dfae2bc 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,10 @@ "type": "git", "url": "git+https://github.com/lightsparkdev/origin.git" }, + "homepage": "https://github.com/lightsparkdev/origin#readme", + "bugs": { + "url": "https://github.com/lightsparkdev/origin/issues" + }, "type": "module", "main": "./src/index.ts", "exports": { @@ -58,8 +62,7 @@ "@base-ui/utils": "^0.2.3", "@tanstack/react-table": "^8.21.3", "ajv": "^8.18.0", - "clsx": "^2.1.1", - "motion": "^12.23.26" + "clsx": "^2.1.1" }, "peerDependencies": { "next": ">=14", diff --git a/src/app/page.tsx b/src/app/page.tsx index 5be092b..65794ff 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -39,6 +39,7 @@ import { Radio } from '@/components/Radio'; import { Select } from '@/components/Select'; import { Separator } from '@/components/Separator'; import { Sidebar } from '@/components/Sidebar'; +import { Skeleton } from '@/components/Skeleton'; import { Shortcut } from '@/components/Shortcut'; import { Switch } from '@/components/Switch'; import { Textarea } from '@/components/Textarea'; @@ -61,6 +62,8 @@ import { Popover } from '@/components/Popover'; import { PreviewCard } from '@/components/PreviewCard'; import { Logo } from '@/components/Logo'; import { Toggle, ToggleGroup } from '@/components/Toggle'; +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']; @@ -1475,23 +1478,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); @@ -1533,16 +1519,16 @@ function LiveDemo() { height={200} grid fill - scrub + interactive formatValue={(v) => v.toFixed(1)} /> ); } const drawerRequests = [ - { id: 'ck8qs-177', method: 'GET', path: '/customers', status: 200, duration: '314ms', host: 'grid-k507nwxq0.vercel.app', cache: 'HIT' }, - { id: 'ck8qs-178', method: 'POST', path: '/transactions', status: 201, duration: '892ms', host: 'grid-k507nwxq0.vercel.app', cache: 'MISS' }, - { id: 'ck8qs-179', method: 'GET', path: '/fees', status: 200, duration: '156ms', host: 'grid-k507nwxq0.vercel.app', cache: 'HIT' }, + { id: 'ck8qs-177', method: 'GET', path: '/customers', status: 200, duration: '314ms', host: 'api.example.com', cache: 'HIT' }, + { id: 'ck8qs-178', method: 'POST', path: '/transactions', status: 201, duration: '892ms', host: 'api.example.com', cache: 'MISS' }, + { id: 'ck8qs-179', method: 'GET', path: '/fees', status: 200, duration: '156ms', host: 'api.example.com', cache: 'HIT' }, ]; function DrawerDemo() { @@ -1625,6 +1611,103 @@ function DrawerDemo() { ); } +function DatePickerDemo() { + 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 (
@@ -1753,6 +1836,8 @@ export default function Home() {

Autocomplete Component

+
+

Badge Component

@@ -1949,6 +2034,8 @@ export default function Home() {
+
+

Card Component

@@ -1978,8 +2065,210 @@ export default function Home() {
+

Charts

+

Bar

+
+
+

Grouped

+ +
+
+

Stacked

+ +
+
+

Horizontal

+ +
+
+

Single series + reference

+ +
+
+ +

BarList

+
+
+ +
+
+ +

BarList (ranked)

+
+
+

With rank, change indicators, and secondary values

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

Composed

+
+
+

Bar + line, dual Y-axes

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

Donut

+
+
+ +
+
+ +
+
+ +

Funnel

+
+
+

Conversion pipeline

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

Gauge

+
+
+

Default

+ `${v.toFixed(2)}s`} + /> +
+
+

Minimal

+ `${v.toFixed(2)}s`} + /> +
+
+

Line

@@ -2068,41 +2357,6 @@ export default function Home() {
-

Tooltip Modes

-
-
-

simple

- -
-
-

compact

- -
-
-

detailed

- -
-
-

Live (Real-Time)

@@ -2111,29 +2365,86 @@ export default function Home() {
-

Live Primitives

-
-
-

LiveValue

- +

Sankey

+
+
+

Budget allocation

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

active

- -
-
-

processing

- -
-
-

idle

- -
-
-

error

- -
+
+ +

Scatter

+
+
+

Multi-series with grid

+ `${v}%`} + formatYLabel={(v) => `$${v}`} + />
@@ -2169,6 +2480,23 @@ export default function Home() {
+

Split (Distribution)

+
+
+

Shade ramp

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

Stacked Area

@@ -2194,92 +2522,37 @@ export default function Home() {
-

Bar

+

Tooltip Modes

-
-

Grouped

- -
-
-

Stacked

- -
-
-

Horizontal

- +

simple

+
-
-

Single series + reference

- +

compact

+
-
- -

Composed

-
-
-

Bar + line, dual Y-axes

- +

detailed

+ `${v}%`} + series={[{ key: 'a', label: 'Incoming' }, { key: 'b', label: 'Outgoing' }]} + xKey="d" height={160} grid tooltip="detailed" />
@@ -2297,114 +2570,25 @@ 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

-
-
-

Default

- `${v.toFixed(2)}s`} - /> -
-
-

Minimal

- `${v.toFixed(2)}s`} - /> -
-
- -

BarList

-
-
- -
-
- -

Donut

+

Waterfall

-
- -
-
- +

Revenue breakdown

+ `$${v}`} />
@@ -2498,12 +2682,22 @@ export default function Home() {

Combobox Component

+
+

Command Component

+
+

Context Menu Component

+
+ +

DatePicker

+ +
+

Dialog Component

@@ -2571,6 +2765,7 @@ export default function Home() {

Drawer

+

Field Component

@@ -2830,9 +3025,13 @@ export default function Home() {

Menu Component

+
+

Menubar Component

+
+

Meter Component

@@ -3731,6 +3930,127 @@ export default function Home() {
+

Skeleton Component

+ +
+
+

Standalone

+ +
+ +
+

Text lines (grouped)

+ +
+ + + +
+
+
+ +
+

Avatar + name (grouped)

+ +
+ +
+ + +
+
+
+
+ +
+

Card (grouped)

+ +
+ + + +
+
+
+ +
+

Table rows (grouped)

+ +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ + + +
+ ))} +
+
+
+ +
+

Form (grouped)

+ +
+
+ + +
+
+ + +
+ +
+
+
+ +
+

On surface-secondary

+
+ +
+ +
+ + +
+
+
+
+
+ +
+

On surface-tertiary

+
+ +
+ +
+ + +
+
+
+
+
+ +
+

On dark surface

+
+ +
+ +
+ + +
+
+
+
+
+
+

Switch Component

@@ -3766,6 +4086,8 @@ export default function Home() {

Table Component

+
+

Tabs Component

@@ -3837,7 +4159,7 @@ export default function Home() {

Textarea

-
+
Default @@ -3877,7 +4199,7 @@ export default function Home() {

Textarea Group

-
+
Default @@ -3966,7 +4288,7 @@ export default function Home() {

Toggle

-
+
Standalone
diff --git a/src/components/Accordion/Accordion.module.scss b/src/components/Accordion/Accordion.module.scss index 8204203..0c9b1e0 100644 --- a/src/components/Accordion/Accordion.module.scss +++ b/src/components/Accordion/Accordion.module.scss @@ -1,4 +1,3 @@ -// Figma: https://figma.com/design/3JvbUyTqbbPL8cCpwSX0j4?node-id=5471-98 @use '../../tokens/text-styles' as *; .root { 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/ActionBar/ActionBar.module.scss b/src/components/ActionBar/ActionBar.module.scss index 639e25a..616f0fa 100644 --- a/src/components/ActionBar/ActionBar.module.scss +++ b/src/components/ActionBar/ActionBar.module.scss @@ -1,5 +1,3 @@ -// Figma: https://www.figma.com/design/3JvbUyTqbbPL8cCpwSX0j4/Origin-design-system?node-id=3029-317 - @use '../../tokens/text-styles' as *; @use '../../tokens/mixins' as *; diff --git a/src/components/Alert/Alert.module.scss b/src/components/Alert/Alert.module.scss index cdfb368..8457d6a 100644 --- a/src/components/Alert/Alert.module.scss +++ b/src/components/Alert/Alert.module.scss @@ -1,5 +1,3 @@ -// Figma: https://www.figma.com/design/3JvbUyTqbbPL8cCpwSX0j4/Origin-design-system?node-id=5484-446 - @use '../../tokens/text-styles' as *; @use '../../tokens/mixins' as *; 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.module.scss b/src/components/AlertDialog/AlertDialog.module.scss index 18a6b97..2718a6f 100644 --- a/src/components/AlertDialog/AlertDialog.module.scss +++ b/src/components/AlertDialog/AlertDialog.module.scss @@ -1,4 +1,3 @@ -// Figma: https://figma.com/design/3JvbUyTqbbPL8cCpwSX0j4?node-id=6239-931 @use '../../tokens/text-styles' as *; @use '../../tokens/mixins' as *; 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.module.scss b/src/components/Autocomplete/Autocomplete.module.scss index 1a1554a..5991565 100644 --- a/src/components/Autocomplete/Autocomplete.module.scss +++ b/src/components/Autocomplete/Autocomplete.module.scss @@ -1,5 +1,3 @@ -// Figma: https://www.figma.com/design/3JvbUyTqbbPL8cCpwSX0j4/Origin-design-system?node-id=5903-6003 - @use '../../tokens/text-styles' as *; @use '../../tokens/mixins' as *; 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.module.scss b/src/components/Avatar/Avatar.module.scss index 96003db..8a1a171 100644 --- a/src/components/Avatar/Avatar.module.scss +++ b/src/components/Avatar/Avatar.module.scss @@ -1,4 +1,3 @@ -// Figma: https://figma.com/design/3JvbUyTqbbPL8cCpwSX0j4?node-id=2061-1327 @use '../../tokens/text-styles' as *; @use '../../tokens/mixins' as *; 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.module.scss b/src/components/Badge/Badge.module.scss index 3e2c14b..4eae259 100644 --- a/src/components/Badge/Badge.module.scss +++ b/src/components/Badge/Badge.module.scss @@ -1,5 +1,3 @@ -// Figma: https://www.figma.com/design/3JvbUyTqbbPL8cCpwSX0j4/Origin-design-system?node-id=2355-1102 - @use '../../tokens/text-styles' as *; @use '../../tokens/mixins' as *; 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.module.scss b/src/components/Button/Button.module.scss index 1b9f5e0..8f739e5 100644 --- a/src/components/Button/Button.module.scss +++ b/src/components/Button/Button.module.scss @@ -1,5 +1,3 @@ -// Figma: https://figma.com/design/3JvbUyTqbbPL8cCpwSX0j4?node-id=2053-951 - @use '../../tokens/mixins' as *; .button { @@ -86,14 +84,14 @@ } .secondary { - background-color: var(--color-alpha-black-04); + background-color: var(--surface-alpha-secondary); color: var(--text-primary); // Reserve border space so focus doesn't shift layout border: var(--stroke-xs) solid transparent; @media (hover: hover) { &:hover:not([data-disabled]) { - background-color: var(--color-alpha-black-04); + background-color: var(--surface-alpha-secondary); background-image: linear-gradient(var(--surface-hover), var(--surface-hover)); } } 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/Button/Button.test.tsx b/src/components/Button/Button.test.tsx index 985574f..72bc5eb 100644 --- a/src/components/Button/Button.test.tsx +++ b/src/components/Button/Button.test.tsx @@ -65,8 +65,8 @@ test.describe('Button', () => { const bgColor = await button.evaluate((el) => getComputedStyle(el).backgroundColor ); - // --color-alpha-black-04 = rgba(0, 0, 0, 0.04) - expect(bgColor).toMatch(/rgba\(0,\s*0,\s*0,\s*0\.0[34]\d*\)/); + // --surface-alpha-secondary resolves to a low-opacity tint + expect(bgColor).not.toBe('rgba(0, 0, 0, 0)'); }); test('secondary variant has no border by default', async ({ mount, page }) => { diff --git a/src/components/ButtonGroup/ButtonGroup.module.scss b/src/components/ButtonGroup/ButtonGroup.module.scss index addc010..ddc968a 100644 --- a/src/components/ButtonGroup/ButtonGroup.module.scss +++ b/src/components/ButtonGroup/ButtonGroup.module.scss @@ -1,5 +1,3 @@ -// Figma: https://figma.com/design/3JvbUyTqbbPL8cCpwSX0j4?node-id=5592-4906 - @use '../../tokens/mixins' as *; .root { 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.module.scss b/src/components/Card/Card.module.scss index 7419896..8d2f59f 100644 --- a/src/components/Card/Card.module.scss +++ b/src/components/Card/Card.module.scss @@ -1,5 +1,3 @@ -// Figma: https://www.figma.com/design/3JvbUyTqbbPL8cCpwSX0j4/Origin-design-system?node-id=6113-1667 - @use '../../tokens/text-styles' as *; @use '../../tokens/mixins' as *; 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/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..42f24d8 100644 --- a/src/components/Chart/BarChart.tsx +++ b/src/components/Chart/BarChart.tsx @@ -4,25 +4,31 @@ 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, type TooltipProp, type ReferenceLine, + type ReferenceBand, PAD_TOP, PAD_RIGHT, PAD_BOTTOM_AXIS, BAR_GROUP_GAP, BAR_ITEM_GAP, + TOOLTIP_GAP, resolveSeries, resolveTooltipMode, 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; @@ -37,6 +43,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, @@ -55,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. */ @@ -77,6 +88,7 @@ export const Bar = React.forwardRef( stacked = false, color, referenceLines, + referenceBands, ariaLabel, onActiveChange, formatValue, @@ -87,6 +99,8 @@ export const Bar = React.forwardRef( loading, empty, onClickDatum, + analyticsName, + interactive: interactiveProp = true, animate = true, getBarColor, orientation = 'vertical', @@ -100,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( @@ -121,8 +133,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 +164,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 +264,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,22 +315,100 @@ 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(); + trackedClick(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, trackedClick, handleMouseLeave], + ); + + 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 (
( {ready && ( <> { 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 +448,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 +559,16 @@ export const Bar = React.forwardRef( const barX = linearScale(cum - v, yMin, yMax, 0, plotWidth); return ( + role="graphics-symbol img" aria-roledescription="Bar" aria-label={`${s.label}: ${fmtValue(v)}`} + 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 ( + role="graphics-symbol img" aria-roledescription="Bar" aria-label={`${s.label}: ${fmtValue(v)}`} + className={animate ? barAnimClass : undefined} style={animate ? { animationDelay: `${delay}ms` } : undefined} /> ); })} @@ -462,14 +585,16 @@ export const Bar = React.forwardRef( const barW = ((v - yMin) / (yMax - yMin)) * plotWidth; return ( + role="graphics-symbol img" aria-roledescription="Bar" aria-label={`${s.label}: ${fmtValue(v)}`} + className={animate ? barAnimClass : undefined} style={animate ? { animationDelay: `${delay}ms` } : undefined} /> ); } const barH = ((v - yMin) / (yMax - yMin)) * plotHeight; const barY = plotHeight - barH; return ( + role="graphics-symbol img" aria-roledescription="Bar" aria-label={`${s.label}: ${fmtValue(v)}`} + className={animate ? barAnimClass : undefined} style={animate ? { animationDelay: `${delay}ms` } : undefined} /> ); })} @@ -509,7 +634,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..1406496 100644 --- a/src/components/Chart/BarList.tsx +++ b/src/components/Chart/BarList.tsx @@ -2,19 +2,27 @@ import * as React from 'react'; import clsx from 'clsx'; +import { useTrackedCallback } from '../Analytics/useTrackedCallback'; +import { ChartWrapper } from './ChartWrapper'; 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,78 +32,102 @@ 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; + onClickDatum?: (item: BarListItem, index: number) => void; + analyticsName?: string; + /** 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 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( { data, color = 'var(--surface-secondary)', formatValue, - onClickItem, + formatSecondaryValue, + onClickDatum, + analyticsName, + showRank, + max, loading, empty, + ariaLabel, className, ...props }, ref, ) { - if (loading) { - return ( -
- {[1, 2, 3].map((i) => ( -
-
-
- ))} -
- ); - } + const trackedClickItem = useTrackedCallback( + analyticsName, 'Chart.BarList', 'click', onClickDatum, + onClickDatum ? barListClickMeta : undefined, + ); - if (data.length === 0 && empty !== undefined) { - return ( -
-
- {typeof empty === 'boolean' ? 'No data' : empty} -
-
- ); - } + const items = max ? data.slice(0, max) : data; - 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 clickable = Boolean(onClickDatum || item.href); + const display = item.displayValue ?? fmtValue(item.value); - const row = ( + 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} >
+ {showRank && ( + {i + 1} + )} {item.href ? ( {item.name} @@ -103,14 +135,27 @@ 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..66e7331 100644 --- a/src/components/Chart/Chart.module.scss +++ b/src/components/Chart/Chart.module.scss @@ -1,5 +1,3 @@ -// Figma: https://www.figma.com/design/3JvbUyTqbbPL8cCpwSX0j4/Origin-design-system?node-id=6338-2213 - @use '../../tokens/text-styles' as *; @use '../../tokens/mixins' as *; @@ -11,6 +9,15 @@ .svg { display: block; overflow: visible; + + &:focus { + outline: none; + } + + &:focus-visible { + outline: 2px solid var(--border-primary); + outline-offset: 2px; + } } // Grid @@ -165,6 +172,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 +191,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 { @@ -213,8 +307,10 @@ } .pieSegment { + cursor: pointer; + transition: transform 200ms cubic-bezier(0.33, 1, 0.68, 1); 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 { @@ -338,35 +389,27 @@ height: 100%; } -.loadingSkeleton { + +.empty { + display: flex; + align-items: center; + justify-content: center; 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; - @include smooth-corners(var(--corner-radius-xs)); + color: var(--text-tertiary); - @media (prefers-reduced-motion: reduce) { - animation: none; - } -} + @include label-chart; -@keyframes skeleton-shimmer { - 0% { background-position: 200% 0; } - 100% { background-position: -200% 0; } + font-size: 12px; } -.empty { +// Shared empty for HTML-based charts + +.chartEmpty { display: flex; align-items: center; justify-content: center; - width: 100%; - height: 100%; + min-height: 80px; color: var(--text-tertiary); @include label-chart; @@ -374,6 +417,7 @@ font-size: 12px; } + // Screen reader only .srOnly { @@ -433,7 +477,7 @@ .gaugeTrack { display: flex; - gap: 4px; + gap: var(--spacing-4xs); height: 8px; overflow: hidden; position: relative; @@ -516,20 +560,28 @@ min-height: 32px; display: flex; align-items: center; -} -.barListRowClickable { - cursor: pointer; + &[data-clickable] { + cursor: pointer; + + &:hover .barListBar { + opacity: 0.8; + } + + &:focus { + outline: none; + } - &:hover .barListBar { - opacity: 0.8; + &: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 +620,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; @@ -590,16 +681,6 @@ font-size: 12px; } -.barListSkeleton { - height: 32px; - background-color: var(--surface-secondary); - border-radius: var(--corner-radius-xs); - animation: skeleton-shimmer 1.5s ease-in-out infinite; - - @media (prefers-reduced-motion: reduce) { - animation: none; - } -} // Uptime @@ -612,7 +693,7 @@ .uptimeBars { display: flex; align-items: flex-end; - gap: 4px; + gap: var(--spacing-4xs); width: 100%; } @@ -625,10 +706,10 @@ @media (prefers-reduced-motion: reduce) { transition: none; } -} -.uptimeBarActive { - height: calc(100% + 4px); + &[data-active] { + height: calc(100% + 4px); + } } .uptimeTooltip { @@ -651,82 +732,149 @@ color: var(--text-secondary); } -// Activity Grid +// Inline tooltip variants + +%tooltipInlineBase { + @include tooltip-chart; -.activityGrid { - display: flex; - flex-direction: column; - gap: var(--spacing-3xs); + 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 { +// Split (Distribution) + +.splitRoot { display: flex; - gap: var(--spacing-2xs); + flex-direction: column; + gap: var(--spacing-xs); } -.activityRowLabels { +.splitBarWrap { display: flex; - flex-direction: column; + overflow: hidden; + + &:focus { + outline: none; + } + + &:focus-visible { + outline: 2px solid var(--border-primary); + outline-offset: 2px; + } } -.activityRowLabel { - @include label-chart; +.splitSegment { + min-width: 2px; + transition: opacity 150ms ease; - font-size: 9px; - color: var(--text-secondary); + @media (prefers-reduced-motion: reduce) { + transition: none; + } + + &[data-clickable] { + cursor: pointer; + } +} + + +.splitTooltipInline { display: flex; align-items: center; - justify-content: flex-end; - white-space: nowrap; + gap: var(--spacing-2xs); } -.activityCells { - display: grid; +.splitTooltipLabel { + @include body-sm; + + color: var(--text-secondary); +} + +.splitTooltipValue { + @include tooltip-chart; + + color: var(--text-primary); + font-variant-numeric: tabular-nums; } -.activityCell { - border-radius: 2px; - transition: opacity 100ms ease; +// Funnel + +.funnelFlowAnimate { + animation: funnel-flow-reveal 600ms cubic-bezier(0.33, 1, 0.68, 1) both; @media (prefers-reduced-motion: reduce) { - transition: none; + animation: none; } } -.activityCellClickable { - cursor: pointer; +@keyframes funnel-flow-reveal { + from { + clip-path: inset(0 100% 0 0); + } + to { + clip-path: inset(0 0 0 0); + } } -// Inline tooltip variants +.funnelSkeletonWrap { + display: flex; + align-items: center; + width: 100%; + height: 100%; + gap: 0; +} -%tooltipInlineBase { - @include tooltip-chart; +// Waterfall - line-height: 1; +.waterfallConnector { + pointer-events: none; } -.tooltipInlineValue { - @extend %tooltipInlineBase; +.waterfallValue { + fill-opacity: 0.7; + font-variant-numeric: tabular-nums; +} - color: var(--text-primary); +// LiveDot + +.liveDot { + display: inline-block; + border-radius: 50%; + flex-shrink: 0; } -.tooltipInlineSep, -.tooltipInlineTime { - @extend %tooltipInlineBase; +.liveDotPulse { + animation: live-dot-pulse 1.5s ease-in-out infinite; - color: var(--text-secondary); + @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 aa8ef6f..8f28015 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,38 +17,45 @@ const meta = { export default meta; type Story = StoryObj; -/* -------------------------------------------------------------------------- */ -/* 1. Line */ -/* -------------------------------------------------------------------------- */ +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) => (
- +
), }; -/* -------------------------------------------------------------------------- */ -/* 2. LineAreaFill */ -/* -------------------------------------------------------------------------- */ - export const LineAreaFill: Story = { render: () => (
@@ -69,10 +80,6 @@ export const LineAreaFill: Story = { ), }; -/* -------------------------------------------------------------------------- */ -/* 3. LineDashed */ -/* -------------------------------------------------------------------------- */ - export const LineDashed: Story = { render: () => (
@@ -98,10 +105,6 @@ export const LineDashed: Story = { ), }; -/* -------------------------------------------------------------------------- */ -/* 4. LineReferenceLines */ -/* -------------------------------------------------------------------------- */ - export const LineReferenceLines: Story = { render: () => (
@@ -127,104 +130,98 @@ export const LineReferenceLines: Story = { ), }; -/* -------------------------------------------------------------------------- */ -/* 5. SparklineLine */ -/* -------------------------------------------------------------------------- */ +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) => (
- +
), }; -/* -------------------------------------------------------------------------- */ -/* 6. SparklineBar */ -/* -------------------------------------------------------------------------- */ -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 }, +]; -/* -------------------------------------------------------------------------- */ -/* 7. StackedArea */ -/* -------------------------------------------------------------------------- */ +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) => (
- +
), }; -/* -------------------------------------------------------------------------- */ -/* 8. BarGrouped */ -/* -------------------------------------------------------------------------- */ +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) => (
- +
), }; -/* -------------------------------------------------------------------------- */ -/* 9. BarStacked */ -/* -------------------------------------------------------------------------- */ - export const BarStacked: Story = { render: () => (
@@ -250,87 +247,73 @@ export const BarStacked: Story = { ), }; -/* -------------------------------------------------------------------------- */ -/* 10. BarHorizontal */ -/* -------------------------------------------------------------------------- */ -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 }, +]; -/* -------------------------------------------------------------------------- */ -/* 11. Composed */ -/* -------------------------------------------------------------------------- */ +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} />
), }; -/* -------------------------------------------------------------------------- */ -/* 12. Donut */ -/* -------------------------------------------------------------------------- */ +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) => (
- +
), }; -/* -------------------------------------------------------------------------- */ -/* 13. Live */ -/* -------------------------------------------------------------------------- */ - -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); @@ -366,173 +349,735 @@ function LiveChartWrapper() { v.toFixed(1)} + {...props} /> ); } export const Live: Story = { + 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 = { + 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`} + {...args} + /> +
+ ), +}; + + +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 = { + args: { + showRank: false, + loading: false, + max: 10, + }, + render: (args) => ( +
+ +
+ ), +}; + +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}`, +})); + +export const Uptime: Story = { + 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 = { + 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: 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 = { + args: { + height: 24, + showValues: true, + showPercentage: true, + legend: true, + loading: false, + }, + render: (args) => ( +
+ `$${v.toLocaleString()}`} {...args} /> +
+ ), +}; + +export const BarListRanked: Story = { render: () => ( +
+ `$${v.toLocaleString()}`} + formatSecondaryValue={(v) => `${v}%`} + showRank + /> +
+ ), +}; + + +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 = { + 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}`} {...args} />
), }; -/* -------------------------------------------------------------------------- */ -/* 14. LiveValueDemo */ -/* -------------------------------------------------------------------------- */ +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)' }, +]; -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 Funnel: Story = { + args: { + height: 220, + showRates: true, + showLabels: true, + tooltip: true, + animate: true, + grid: false, + loading: false, + }, + render: (args) => ( +
+ v.toLocaleString()} {...args} /> +
+ ), +}; -export const LiveValueDemo: Story = { - render: () => , +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 }, + ], }; -/* -------------------------------------------------------------------------- */ -/* 15. LiveDotStates */ -/* -------------------------------------------------------------------------- */ +export const Sankey: Story = { + args: { + height: 380, + showValues: true, + showLabels: true, + tooltip: true, + animate: true, + loading: false, + nodeWidth: 12, + nodePadding: 16, + }, + render: (args) => ( +
+ `$${v}k`} + {...args} + /> +
+ ), +}; export const LiveDotStates: Story = { - render: () => ( -
- {(['active', 'processing', 'idle', 'error'] as const).map((status) => ( -
-

{status}

- + args: { + size: 8, + }, + argTypes: { + status: { control: 'radio', options: ['active', 'degraded', 'down', 'unknown'] }, + }, + render: (args) => ( +
+ {(['active', 'degraded', 'down', 'unknown'] as const).map((status) => ( +
+ + + {status} +
))}
), }; -/* -------------------------------------------------------------------------- */ -/* 16. Gauge */ -/* -------------------------------------------------------------------------- */ +export const LiveValueAnimated: Story = { + args: { + value: 1234, + }, + render: function Render(args) { + const [value, setValue] = React.useState(args.value as number); -export const Gauge: Story = { + React.useEffect(() => { + setValue(args.value as number); + }, [args.value]); + + 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 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: () => ( -
- + `${v.toFixed(2)}s`} + xKey="month" + height={240} + grid + tooltip + yDomain={[0, 1000]} + yDomainRight={[0, 5]} />
), }; -/* -------------------------------------------------------------------------- */ -/* 17. GaugeMinimal */ -/* -------------------------------------------------------------------------- */ - -export const GaugeMinimal: Story = { +export const WaterfallCustomTooltip: Story = { render: () => ( -
- + `${v.toFixed(2)}s`} + height={200} + grid + tooltip={(d) => { + const datum = d as WaterfallSegment; + return ( +
+ {datum.label} +
+ {`$${Math.abs(datum.value).toLocaleString()}`} +
+ ); + }} />
), }; -/* -------------------------------------------------------------------------- */ -/* 18. BarList */ -/* -------------------------------------------------------------------------- */ +export const SankeyNoTooltip: Story = { + render: () => ( +
+ +
+ ), +}; -export const BarList: Story = { +export const FunnelNoTooltip: Story = { render: () => ( -
- +
), }; -/* -------------------------------------------------------------------------- */ -/* 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}`, -})); - -export const Uptime: Story = { +export const UptimeLoading: Story = { render: () => ( -
- +
+
), }; -/* -------------------------------------------------------------------------- */ -/* 20. ActivityGrid */ -/* -------------------------------------------------------------------------- */ +export const UptimeEmpty: Story = { + render: () => ( +
+ +
+ ), +}; -const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; -const weeks = Array.from({ length: 20 }, (_, i) => `W${i + 1}`); +export const GaugeLoading: Story = { + render: () => ( +
+ +
+ ), +}; -const activityData = weeks.flatMap((week, ci) => - days.map((day) => ({ - row: day, - col: week, - value: ((ci * 7 + days.indexOf(day)) * 37) % 10, - })), -); +export const LiveLoading: Story = { + render: () => ( +
+ +
+ ), +}; -export const ActivityGrid: Story = { +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/Chart.test-stories.tsx b/src/components/Chart/Chart.test-stories.tsx index 69a72d7..667f101 100644 --- a/src/components/Chart/Chart.test-stories.tsx +++ b/src/components/Chart/Chart.test-stories.tsx @@ -27,6 +27,7 @@ export function Sparkline() { data={SAMPLE_DATA} dataKey="value" height={170} + interactive={false} data-testid="chart" /> ); @@ -38,6 +39,7 @@ export function SparklineWithColor() { data={SAMPLE_DATA} dataKey="value" height={170} + interactive={false} color="rgb(0, 0, 255)" data-testid="chart" /> @@ -222,3 +224,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..d9d194f 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,333 @@ 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="graphics-document" and aria-roledescription', async ({ mount, page }) => { + await mount(); + const svg = page.locator('[data-testid="scatter-chart"] svg'); + await expect(svg).toHaveAttribute('role', 'graphics-document document'); + await expect(svg).toHaveAttribute('aria-roledescription', 'Scatter chart'); + 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="graphics-document document"]'); + 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="graphics-document" and aria-roledescription', async ({ mount, page }) => { + await mount(); + const svg = page.locator('[data-testid="waterfall-chart"] svg'); + await expect(svg).toHaveAttribute('role', 'graphics-document document'); + await expect(svg).toHaveAttribute('aria-roledescription', 'Waterfall chart'); + 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 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="graphics-document" and aria-roledescription', async ({ mount, page }) => { + await mount(); + const svg = page.locator('[data-testid="funnel-chart"] svg'); + await expect(svg).toHaveAttribute('role', 'graphics-document document'); + await expect(svg).toHaveAttribute('aria-roledescription', 'Funnel chart'); + 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..7eaee30 100644 --- a/src/components/Chart/ChartWrapper.tsx +++ b/src/components/Chart/ChartWrapper.tsx @@ -3,46 +3,51 @@ import * as React from 'react'; import clsx from 'clsx'; import type { ResolvedSeries } from './types'; +import { Skeleton } from '../Skeleton'; 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[]; children: React.ReactNode; className?: string; - activeIndex?: number | null; ariaLiveContent?: string; } export function ChartWrapper({ + ref, loading, empty, dataLength, + isEmpty, height, legend, series, children, className, - activeIndex: _activeIndex, ariaLiveContent, }: ChartWrapperProps) { + const showEmpty = isEmpty ?? (dataLength === 0); + if (loading) { return ( -
+
-
+
); } - 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 ab3b10e..944c535 100644 --- a/src/components/Chart/ComposedChart.tsx +++ b/src/components/Chart/ComposedChart.tsx @@ -7,18 +7,23 @@ import { niceTicks, monotonePath, linearPath, + monotonePathGroups, + linearPathGroups, monotoneInterpolator, linearInterpolator, thinIndices, 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, type TooltipProp, type ReferenceLine, + type ReferenceBand, SERIES_COLORS, DASH_PATTERNS, PAD_TOP, @@ -58,6 +63,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. */ @@ -67,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, @@ -76,15 +85,25 @@ 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; /** 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; + /** 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( { @@ -96,36 +115,39 @@ export const Composed = React.forwardRef( tooltip: tooltipProp, curve = 'monotone', referenceLines, + referenceBands, legend, loading, 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( @@ -156,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) { @@ -168,13 +191,20 @@ 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, 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')) { @@ -188,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; @@ -210,28 +240,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(() => { @@ -245,15 +295,16 @@ 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, + onActivate: onClickDatum, }); // Y axis labels @@ -282,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; @@ -310,14 +361,15 @@ export const Composed = React.forwardRef( return (
( {ready && ( <> {svgDesc && {svgDesc}} @@ -352,6 +405,37 @@ export const Composed = React.forwardRef( ))} + {/* 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); @@ -425,25 +509,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 new file mode 100644 index 0000000..0e08695 --- /dev/null +++ b/src/components/Chart/FunnelChart.tsx @@ -0,0 +1,418 @@ +'use client'; + +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 { + 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; + 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; +const LABEL_ROW_HEIGHT = 18; +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( + { + data, + formatValue, + formatRate, + showRates = true, + showLabels = true, + grid = true, + height = 140, + animate = true, + tooltip = true, + loading, + empty, + ariaLabel, + onClickDatum, + onActiveChange, + analyticsName, + className, + ...props + }, + ref, + ) { + 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 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) : String(v)), + [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 = grid && !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(); + trackedClick(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, trackedClick, handleMouseLeave], + ); + + const ready = width > 0; + + 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 + ? () => trackedClick(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} + + ); + })} + + + {tooltip !== false && ( +
+ {tooltipContent && ( +
+
+ + {tooltipContent.label} + + + {tooltipContent.value} + +
+ {tooltipContent.rate && ( +
+ Rate + + {tooltipContent.rate} + +
+ )} +
+ )} +
+ )} +
+ {tooltipContent ? `${tooltipContent.label}: ${tooltipContent.value}${tooltipContent.rate ? ` (${tooltipContent.rate})` : ''}` : ''} +
+ + )} +
+ + ); + }, +); + +if (process.env.NODE_ENV !== 'production') { + Funnel.displayName = 'Chart.Funnel'; +} diff --git a/src/components/Chart/GaugeChart.tsx b/src/components/Chart/GaugeChart.tsx index ff195f9..a9db3b9 100644 --- a/src/components/Chart/GaugeChart.tsx +++ b/src/components/Chart/GaugeChart.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import clsx from 'clsx'; +import { Skeleton } from '../Skeleton'; import styles from './Chart.module.scss'; export interface GaugeThreshold { @@ -28,6 +29,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 +45,8 @@ export const Gauge = React.forwardRef( markerLabel, formatValue, variant = 'default', + loading, + analyticsName: _analyticsName, ariaLabel, className, ...props @@ -57,6 +62,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`. */ @@ -70,8 +75,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. */ @@ -80,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?: ( @@ -92,12 +103,16 @@ 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. */ 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 +132,10 @@ export const Line = React.forwardRef( fill: fillProp, fadeLeft, referenceLines, + referenceBands, yDomain: yDomainProp, + compareData, + compareLabel, legend, loading, empty, @@ -125,15 +143,21 @@ export const Line = React.forwardRef( interactive = true, onActiveChange, onClickDatum, + analyticsName, formatValue, formatXLabel, formatYLabel, + connectNulls = true, className, ...props }, 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); @@ -141,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), @@ -191,6 +208,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 +227,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 +259,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(() => { @@ -294,7 +391,7 @@ export const Line = React.forwardRef( }, [interpolators]); // Scrub interaction - const scrub = useChartScrub({ + const scrub = useChartInteraction({ dataLength: data.length, seriesCount: series.length, plotWidth, @@ -303,6 +400,7 @@ export const Line = React.forwardRef( interpolatorsRef, data, onActiveChange, + onActivate: onClickDatum, }); const fmtValue = React.useCallback( @@ -314,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; @@ -340,13 +438,14 @@ export const Line = React.forwardRef( return (
( {ready && ( <> ( ))} + {/* 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 +581,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 +780,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 +817,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..33da491 100644 --- a/src/components/Chart/LiveChart.tsx +++ b/src/components/Chart/LiveChart.tsx @@ -3,6 +3,8 @@ import * as React from 'react'; import clsx from 'clsx'; import { filerp, CHART_LABEL_FONT } from './utils'; +import { useMergedRef } from './useMergedRef'; +import { Skeleton } from '../Skeleton'; import styles from './Chart.module.scss'; export interface LivePoint { @@ -24,14 +26,18 @@ export interface LiveChartProps extends React.ComponentPropsWithoutRef<'div'> { /** 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 +148,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 +182,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 +227,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 +258,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; } @@ -323,7 +332,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 +359,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 +369,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,9 +420,26 @@ 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; + 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; @@ -476,9 +495,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; @@ -515,7 +534,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'); @@ -529,7 +548,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 }); } } @@ -553,20 +572,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 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. */ + status?: LiveDotStatus; 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)', +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', pulse, size = 8, className, style, ...props }, - ref, - ) { - const shouldPulse = pulse ?? (status === 'active' || status === 'processing'); - const color = STATUS_COLORS[status] ?? STATUS_COLORS.active; + function LiveDot({ status = 'active', size = 8, className, style, ...props }, ref) { + const color = STATUS_COLORS[status]; + const shouldPulse = status === 'active'; return ( ); diff --git a/src/components/Chart/LiveValue.tsx b/src/components/Chart/LiveValue.tsx index 327f3b8..18b07e6 100644 --- a/src/components/Chart/LiveValue.tsx +++ b/src/components/Chart/LiveValue.tsx @@ -3,106 +3,84 @@ 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'> { - /** 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 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, lerpSpeed = 0.08, formatValue, className, ...props }, - ref, - ) { - const elRef = React.useRef(null); + 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 displayRef = React.useRef(value); - const targetRef = React.useRef(value); + const valueRef = React.useRef(value); const formatRef = React.useRef(formatValue); - const speedRef = React.useRef(lerpSpeed); React.useLayoutEffect(() => { + valueRef.current = value; formatRef.current = formatValue; - speedRef.current = lerpSpeed; - }); + }, [value, formatValue]); const tick = React.useCallback(() => { const now = performance.now(); - const dt = lastFrameRef.current ? Math.min(now - lastFrameRef.current, MAX_DELTA_MS) : 16.67; + 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; + 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 = elRef.current; + const el = spanRef.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); + const fmt = formatRef.current ?? DEFAULT_FORMAT; + el.textContent = fmt(displayRef.current); } - if (display === target) { + if (displayRef.current !== valueRef.current) { + rafRef.current = requestAnimationFrame(tick); + } else { 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); + if (rafRef.current) { + cancelAnimationFrame(rafRef.current); + rafRef.current = 0; + } }; }, [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], - ); + 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 ?? ((v: number) => v.toFixed(v % 1 === 0 ? 0 : 2)); + const fmt = formatValue ?? DEFAULT_FORMAT; return ( {fmt(value)} diff --git a/src/components/Chart/PieChart.tsx b/src/components/Chart/PieChart.tsx index ead1169..479d00f 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,33 +189,75 @@ 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 && ( <> {svgDesc && {svgDesc}} @@ -226,14 +274,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 +355,8 @@ export const Pie = React.forwardRef(
)} - -
+
+ ); }, ); diff --git a/src/components/Chart/SankeyChart.tsx b/src/components/Chart/SankeyChart.tsx new file mode 100644 index 0000000..8f861b2 --- /dev/null +++ b/src/components/Chart/SankeyChart.tsx @@ -0,0 +1,575 @@ +'use client'; + +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, + type LayoutLink, + computeSankeyLayout, + sankeyLinkPath, +} from './sankeyLayout'; +import { SERIES_COLORS } from './types'; +import { ChartWrapper } from './ChartWrapper'; +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; + tooltip?: boolean; + onClickNode?: (node: LayoutNode) => void; + onClickLink?: (link: LayoutLink) => void; + analyticsName?: string; +} + +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; + +const sankeyNodeClickMeta = (node: LayoutNode) => ({ id: node.id }); +const sankeyLinkClickMeta = (link: LayoutLink) => ({ source: link.source, target: link.target }); + +export const Sankey = React.forwardRef( + function Sankey( + { + data, + nodeWidth = 8, + nodePadding = 12, + height = 350, + animate = true, + 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 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) : String(v)), + [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 ? 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(); + trackedClickNode(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, trackedClickNode, handleMouseLeave], + ); + + 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 && ( + + {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 + ? () => trackedClickLink(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 + ? () => trackedClickNode(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.x1 + LABEL_GAP; + ly = midY; + anchor = 'start'; + } + + return ( + + {node.label} + {showValues && isLast && ( + + {' '} + {fmtValue(node.value)} + + )} + + ); + })} + + )} + + + + {tooltip !== false && ( +
+ {tooltipContent && ( +
+
+ + {tooltipContent.label} + + + {tooltipContent.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..cc5743c --- /dev/null +++ b/src/components/Chart/ScatterChart.tsx @@ -0,0 +1,598 @@ +'use client'; + +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, + 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: 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; + 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, + 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 = interactive && tooltipMode !== 'off'; + const tooltipRender = + typeof tooltipProp === 'function' ? tooltipProp : undefined; + + const mergedRef = useMergedRef(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; + trackedClickDatum(activeDot.series.key, activeDot.point, activeDot.pointIndex); + }, [onClickDatum, activeDot, trackedClickDatum]); + + 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 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; + 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(); + trackedClickDatum(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, trackedClickDatum, 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); + } + } : undefined} + onTouchMove={interactive ? (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'; + } + } : undefined} + onTouchEnd={interactive ? handleMouseLeave : undefined} + onTouchCancel={interactive ? handleMouseLeave : undefined} + onKeyDown={interactive ? handleKeyDown : undefined} + > + {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 = interactive && activeDot?.seriesIndex === si && activeDot?.pointIndex === pi; + const r = point.size ?? dotSize; + return ( + + ); + }), + )} + + {yLabels.map(({ y, text }, i) => ( + {text} + ))} + + {xLabels.map(({ x, text }, i) => ( + {text} + ))} + + + + {interactive && 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/Sparkline.tsx b/src/components/Chart/Sparkline.tsx index 26adcb4..1cca2b8 100644 --- a/src/components/Chart/Sparkline.tsx +++ b/src/components/Chart/Sparkline.tsx @@ -4,6 +4,7 @@ import * as React from 'react'; import clsx from 'clsx'; import { linearScale, niceTicks } from './utils'; import { useResizeWidth } from './hooks'; +import { useMergedRef } from './useMergedRef'; import { SERIES_COLORS } from './types'; import { Line, type LineChartProps } from './LineChart'; import styles from './Chart.module.scss'; @@ -16,19 +17,11 @@ export interface SparklineProps const SparklineBar = React.forwardRef( 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 new file mode 100644 index 0000000..7f57e93 --- /dev/null +++ b/src/components/Chart/SplitChart.tsx @@ -0,0 +1,204 @@ +'use client'; + +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 { + 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; + onActiveChange?: (index: number | null) => void; + analyticsName?: string; +} + +const splitClickMeta = (_segment: SplitSegment, index: number) => ({ index }); + +export const Split = React.forwardRef( + function Split( + { + data, + formatValue, + showPercentage = true, + showValues = false, + height = 24, + legend = true, + loading, + 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( + (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(); + trackedClickDatum(data[activeIndex], activeIndex); + } + return; + default: + return; + } + e.preventDefault(); + setActiveIndex(next); + }, + [activeIndex, data, onClickDatum, trackedClickDatum], + ); + + 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 ? () => trackedClickDatum(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)}%)`} + +
+ )) + )} +
+ )} +
+ {activeIndex !== null && segments[activeIndex] + ? `${segments[activeIndex].label}: ${fmtValue(segments[activeIndex].value)} (${Math.round(segments[activeIndex].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..726816a 100644 --- a/src/components/Chart/StackedAreaChart.tsx +++ b/src/components/Chart/StackedAreaChart.tsx @@ -14,12 +14,15 @@ 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, type TooltipProp, type ReferenceLine, + type ReferenceBand, PAD_TOP, PAD_RIGHT, PAD_BOTTOM_AXIS, @@ -30,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[]]; @@ -41,6 +46,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,9 +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; - /** Control animation. Currently a no-op — provided for API consistency with other chart types. */ animate?: boolean; ariaLabel?: string; + /** Disables interaction, cursor, dots, and tooltip. */ + interactive?: boolean; onActiveChange?: ( index: number | null, datum: Record | null, @@ -61,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; @@ -78,14 +88,17 @@ 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), @@ -143,10 +153,16 @@ 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; @@ -213,15 +229,16 @@ 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; @@ -275,14 +292,15 @@ export const StackedArea = React.forwardRef
{svgDesc && {svgDesc}} @@ -317,6 +336,37 @@ 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); @@ -344,32 +394,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..38a4589 100644 --- a/src/components/Chart/UptimeChart.tsx +++ b/src/components/Chart/UptimeChart.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import clsx from 'clsx'; +import { Skeleton } from '../Skeleton'; import styles from './Chart.module.scss'; export interface UptimePoint { @@ -15,7 +16,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 +33,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 +50,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,39 +68,113 @@ 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 (
-
+
{data.map((point, i) => (
handleEnter(i)} onMouseLeave={handleLeave} @@ -115,6 +196,11 @@ export const Uptime = React.forwardRef( )}
)} +
+ {activeIndex !== null && data[activeIndex] + ? `${data[activeIndex].status}${data[activeIndex].label ? `: ${data[activeIndex].label}` : ''}` + : ''} +
); }, diff --git a/src/components/Chart/WaterfallChart.tsx b/src/components/Chart/WaterfallChart.tsx new file mode 100644 index 0000000..b9278ee --- /dev/null +++ b/src/components/Chart/WaterfallChart.tsx @@ -0,0 +1,500 @@ +'use client'; + +import * as React from 'react'; +import clsx from 'clsx'; +import { linearScale, niceTicks, thinIndices, axisPadForLabels } from './utils'; +import { useResizeWidth } from './hooks'; +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 { + 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?: 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 { + 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)', +}; + +const clickIndexMeta = (index: number) => ({ index }); + +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, + onActiveChange, + analyticsName, + interactive: interactiveProp = true, + className, + ...props + }, + ref, + ) { + const { width, attachRef } = useResizeWidth(); + const tooltipRef = React.useRef(null); + const [activeIndex, setActiveIndex] = React.useState(null); + + 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(() => { + 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(); + trackedClick(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, trackedClick, handleMouseLeave], + ); + + const interactive = interactiveProp; + + const handleClick = React.useCallback(() => { + if (onClickDatum && activeIndex !== null && activeIndex < data.length) { + trackedClick(activeIndex, data[activeIndex]); + } + }, [onClickDatum, activeIndex, data, trackedClick]); + + 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 && ( + 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)} + +
+
+ + ) + )} +
+ )} + + )} +
+ {activeIndex !== null && activeIndex < data.length + ? `${data[activeIndex].label}: ${fmtValue(data[activeIndex].value)}` + : ''} +
+
+
+ ); + }, +); + +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..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; @@ -38,9 +38,10 @@ export interface ChartScrubOptions { index: number | null, datum: Record | null, ) => void; + onActivate?: (index: number, datum: Record) => void; } -export function useChartScrub(opts: ChartScrubOptions) { +export function useChartInteraction(opts: ChartInteractionOptions) { const { dataLength, seriesCount, @@ -50,6 +51,7 @@ export function useChartScrub(opts: ChartScrubOptions) { interpolatorsRef, data, onActiveChange, + onActivate, } = opts; const cursorRef = React.useRef(null); @@ -67,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; @@ -114,6 +119,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 +128,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 +220,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 +229,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 +255,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, dataRef.current[activeIndex]); + } + return; case 'Escape': hideHover(); return; default: return; } @@ -245,7 +268,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..c22517f 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,26 @@ 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 { LiveDot } from './LiveDot'; +export type { LiveDotProps, LiveDotStatus } from './LiveDot'; + export { LiveValue } from './LiveValue'; export type { LiveValueProps } from './LiveValue'; -export { LiveDot } from './LiveDot'; -export type { LiveDotProps } from './LiveDot'; +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 { 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..47f7ce3 --- /dev/null +++ b/src/components/Chart/sankeyLayout.ts @@ -0,0 +1,297 @@ +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 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; + 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; + } + + const last = col[col.length - 1]; + const overflow = last.y1 - height; + if (overflow > 0) { + 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; + } + } +} + +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/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/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/components/Checkbox/Checkbox.module.scss b/src/components/Checkbox/Checkbox.module.scss index 429c713..f5faeb4 100644 --- a/src/components/Checkbox/Checkbox.module.scss +++ b/src/components/Checkbox/Checkbox.module.scss @@ -1,4 +1,3 @@ -// Figma: https://www.figma.com/design/3JvbUyTqbbPL8cCpwSX0j4/Origin-design-system?node-id=2003-338 @use '../../tokens/mixins' as *; .field { 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.module.scss b/src/components/Chip/Chip.module.scss index 9a90b92..07c8935 100644 --- a/src/components/Chip/Chip.module.scss +++ b/src/components/Chip/Chip.module.scss @@ -1,5 +1,3 @@ -// Figma: https://www.figma.com/design/3JvbUyTqbbPL8cCpwSX0j4/Origin-design-system?node-id=2355-1167 - @use '../../tokens/text-styles' as *; @use '../../tokens/mixins' as *; 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.module.scss b/src/components/Combobox/Combobox.module.scss index 7b0c76f..32d9414 100644 --- a/src/components/Combobox/Combobox.module.scss +++ b/src/components/Combobox/Combobox.module.scss @@ -1,4 +1,3 @@ -// Figma: https://figma.com/design/3JvbUyTqbbPL8cCpwSX0j4?node-id=5835-2405 @use '../../tokens/text-styles' as *; @use '../../tokens/mixins' as *; 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/Command/Command.module.scss b/src/components/Command/Command.module.scss index 1f20c31..a0f3c96 100644 --- a/src/components/Command/Command.module.scss +++ b/src/components/Command/Command.module.scss @@ -1,5 +1,3 @@ -// https://www.figma.com/design/3JvbUyTqbbPL8cCpwSX0j4/Origin-design-system?node-id=2348-283 - @use '../../tokens/text-styles' as *; @use '../../tokens/mixins' as *; diff --git a/src/components/Command/parts.tsx b/src/components/Command/parts.tsx index a0552ae..cb0b80a 100644 --- a/src/components/Command/parts.tsx +++ b/src/components/Command/parts.tsx @@ -1,5 +1,3 @@ -// https://www.figma.com/design/3JvbUyTqbbPL8cCpwSX0j4/Origin-design-system?node-id=2348-283 - 'use client'; import * as React from 'react'; diff --git a/src/components/DatePicker/DatePicker.module.scss b/src/components/DatePicker/DatePicker.module.scss new file mode 100644 index 0000000..ef224f4 --- /dev/null +++ b/src/components/DatePicker/DatePicker.module.scss @@ -0,0 +1,191 @@ +@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; + } + + &:disabled { + opacity: calc(var(--opacity-50) / 100); + cursor: default; + pointer-events: none; + } +} + +// 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-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); + } + + &[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/DatePicker/DatePicker.stories.tsx b/src/components/DatePicker/DatePicker.stories.tsx new file mode 100644 index 0000000..1482e3c --- /dev/null +++ b/src/components/DatePicker/DatePicker.stories.tsx @@ -0,0 +1,261 @@ +import { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import * as DatePicker 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/DatePicker', + 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/DatePicker/DatePicker.test-stories.tsx b/src/components/DatePicker/DatePicker.test-stories.tsx new file mode 100644 index 0000000..b8f1752 --- /dev/null +++ b/src/components/DatePicker/DatePicker.test-stories.tsx @@ -0,0 +1,565 @@ +import { useState } from 'react'; +import * as DatePicker 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 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), + ); + 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/DatePicker/DatePicker.test.tsx b/src/components/DatePicker/DatePicker.test.tsx new file mode 100644 index 0000000..30ba56a --- /dev/null +++ b/src/components/DatePicker/DatePicker.test.tsx @@ -0,0 +1,873 @@ +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, + TestDateInputMinMax, +} from './DatePicker.test-stories'; + +test.describe('DatePicker', () => { + 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('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, + }) => { + 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'); + }); + + 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/DatePicker/index.ts b/src/components/DatePicker/index.ts new file mode 100644 index 0000000..17aecf4 --- /dev/null +++ b/src/components/DatePicker/index.ts @@ -0,0 +1,19 @@ +export { + Root, + Header, + Navigation, + Grid, + Controls, + ControlItem, + Footer, + type DatePickerRootProps, + type DatePickerHeaderProps, + type DatePickerNavigationProps, + type DatePickerGridProps, + type DatePickerControlsProps, + type DatePickerControlItemProps, + type DatePickerFooterProps, + type DatePickerLabels, + type DayCellState, + type DateRange, +} from './parts'; diff --git a/src/components/DatePicker/parts.tsx b/src/components/DatePicker/parts.tsx new file mode 100644 index 0000000..e25b34f --- /dev/null +++ b/src/components/DatePicker/parts.tsx @@ -0,0 +1,1237 @@ +'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 './DatePicker.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 DatePickerLabels { + 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: DatePickerLabels = { + 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 DatePickerRootProps 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 DatePickerContextValue { + 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; + min?: Date; + max?: Date; + + locale: string; + weekStartsOn: 0 | 1; + labels: DatePickerLabels; +} + +const DatePickerContext = React.createContext( + undefined, +); + +function useDatePickerContext() { + const context = React.useContext(DatePickerContext); + if (context === undefined) { + throw new Error('DatePicker parts must be placed within .'); + } + return context; +} + +export const Root = React.forwardRef( + function DatePickerRoot(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) { + console.warn( + 'DatePicker: `month` prop provided without `onMonthChange`. ' + + 'The date picker 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) => 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), + ); + }); + }, []); + + 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, + 'DatePicker', + '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(startOfDay(date)); + } else { + 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); + } + }, + [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; + 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); + } + }, + [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, + min, + max, + locale, + weekStartsOn, + labels, + }), + [ + viewYear, + viewMonth, + goToPreviousMonth, + goToNextMonth, + mode, + includeTime, + singleValue, + rangeValue, + pendingStart, + hoveredDate, + focusedDate, + setFocusedDate, + selectDate, + setHoveredDate, + setDate, + setTime, + isDateDisabled, + min, + max, + 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 = useDatePickerContext(); + 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') { + (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); + + function commit() { + if (draft === formatted) return; + const parsed = parseTimeString(draft); + if (parsed) { + onTimeChange(parsed.hours, parsed.minutes); + } else { + setDraft(formatted); + } + } + + return ( + ) => { + setDraft(e.target.value); + }} + onFocus={() => setHasFocus(true)} + onBlur={() => { + setHasFocus(false); + commit(); + }} + onKeyDown={(e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + (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 = useDatePickerContext(); + 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 DatePickerHeaderProps + extends React.ComponentPropsWithoutRef<'div'> {} + +export const Header = React.forwardRef( + function DatePickerHeader({ className, children, ...props }, forwardedRef) { + return ( +
+ {children ?? } +
+ ); + }, +); + +export interface DatePickerNavigationProps + extends React.ComponentPropsWithoutRef<'div'> {} + +export const Navigation = React.forwardRef< + HTMLDivElement, + DatePickerNavigationProps +>(function DatePickerNavigation(props, forwardedRef) { + const { className, ...elementProps } = props; + const ctx = useDatePickerContext(); + 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 ( +
+
+ {monthLabel} +
+
+ + +
+
+ ); +}); + +export interface DatePickerControlsProps + extends React.ComponentPropsWithoutRef<'div'> {} + +export const Controls = React.forwardRef( + function DatePickerControls({ className, children, ...props }, forwardedRef) { + return ( +
+ {children} +
+ ); + }, +); + +export interface DatePickerControlItemProps + extends React.ComponentPropsWithoutRef<'div'> { + /** Text label for the control. */ + label: string; +} + +export const ControlItem = React.forwardRef< + HTMLDivElement, + DatePickerControlItemProps +>(function DatePickerControlItem( + { className, label, children, ...props }, + forwardedRef, +) { + return ( +
+ {label} + {children} +
+ ); +}); + +export interface DatePickerFooterProps + extends React.ComponentPropsWithoutRef<'div'> {} + +export const Footer = React.forwardRef( + function DatePickerFooter({ className, children, ...props }, forwardedRef) { + return ( +
+ {children} +
+ ); + }, +); + +export interface DatePickerGridProps + extends React.ComponentPropsWithoutRef<'table'> { + /** Custom render function for day cell content. */ + renderDay?: (date: Date, state: DayCellState) => React.ReactNode; +} + +export const Grid = React.forwardRef( + function DatePickerGrid(props, forwardedRef) { + const { className, renderDay, ...elementProps } = props; + const ctx = useDatePickerContext(); + + 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; + + 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 = 'DatePicker.Root'; + Header.displayName = 'DatePicker.Header'; + Navigation.displayName = 'DatePicker.Navigation'; + Grid.displayName = 'DatePicker.Grid'; + Controls.displayName = 'DatePicker.Controls'; + ControlItem.displayName = 'DatePicker.ControlItem'; + Footer.displayName = 'DatePicker.Footer'; +} diff --git a/src/components/Dialog/Dialog.module.scss b/src/components/Dialog/Dialog.module.scss index c5ddbe3..234247f 100644 --- a/src/components/Dialog/Dialog.module.scss +++ b/src/components/Dialog/Dialog.module.scss @@ -1,4 +1,3 @@ -// Figma: https://figma.com/design/3JvbUyTqbbPL8cCpwSX0j4?node-id=6240-957 @use '../../tokens/text-styles' as *; @use '../../tokens/mixins' as *; 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/Drawer/Drawer.module.scss b/src/components/Drawer/Drawer.module.scss index 34ea339..9307123 100644 --- a/src/components/Drawer/Drawer.module.scss +++ b/src/components/Drawer/Drawer.module.scss @@ -1,11 +1,12 @@ @use '../../tokens/text-styles' as *; -@use '../../tokens/mixins' as *; .backdrop { position: fixed; inset: 0; z-index: 50; background-color: var(--surface-backdrop); + backdrop-filter: var(--drawer-backdrop-blur, none); + -webkit-backdrop-filter: var(--drawer-backdrop-blur, none); opacity: calc(1 - var(--drawer-swipe-progress, 0)); transition: opacity 450ms cubic-bezier(0.32, 0.72, 0, 1); @@ -34,29 +35,110 @@ display: flex; align-items: flex-end; justify-content: center; + pointer-events: none; touch-action: none; + + &:has(.popup[data-swipe-direction='right']) { + align-items: stretch; + justify-content: flex-end; + } + + &:has(.popup[data-swipe-direction='left']) { + align-items: stretch; + justify-content: flex-start; + } + + &:has(.popup[data-swipe-direction='up']) { + align-items: flex-start; + justify-content: center; + } } .popup { + --bleed: 3rem; + --peek: 1rem; + --stack-progress: clamp(0, var(--drawer-swipe-progress, 0), 1); + --stack-step: 0.05; + --stack-peek-offset: max( + 0px, + calc((var(--nested-drawers, 0) - var(--stack-progress)) * var(--peek)) + ); + --stack-scale-base: max(0, calc(1 - (var(--nested-drawers, 0) * var(--stack-step)))); + --stack-scale: calc( + var(--stack-scale-base) + (var(--stack-step) * var(--stack-progress)) + ); + --stack-shrink: calc(1 - var(--stack-scale)); + --stack-height: max( + 0px, + calc(var(--drawer-frontmost-height, var(--drawer-height)) - var(--bleed)) + ); + --translate-y: calc(var(--drawer-snap-point-offset, 0px) + var(--drawer-swipe-movement-y, 0px) - var(--stack-peek-offset) - (var(--stack-shrink) * var(--stack-height))); + position: relative; display: flex; flex-direction: column; + box-sizing: border-box; + pointer-events: auto; width: 100%; max-height: calc(100dvh - var(--spacing-2xl)); min-height: 0; + height: var(--drawer-height, auto); + margin-bottom: calc(-1 * var(--bleed)); + padding-bottom: calc(var(--bleed) + env(safe-area-inset-bottom, 0px)); overflow: visible; background-color: var(--surface-panel); - border: var(--stroke-xs) solid var(--border-primary); - border-bottom: none; border-radius: var(--corner-radius-lg) var(--corner-radius-lg) 0 0; + outline: var(--stroke-xs) solid var(--border-secondary); box-shadow: var(--shadow-lg); touch-action: none; - transform: translateY(calc(var(--drawer-snap-point-offset, 0px) + var(--drawer-swipe-movement-y, 0px))); - transition: transform 450ms cubic-bezier(0.32, 0.72, 0, 1); + transform-origin: 50% calc(100% - var(--bleed)); + transform: translateY(var(--translate-y)) scale(var(--stack-scale)); + transition: + transform 450ms cubic-bezier(0.32, 0.72, 0, 1), + height 450ms cubic-bezier(0.32, 0.72, 0, 1), + box-shadow 450ms cubic-bezier(0.32, 0.72, 0, 1); + will-change: transform; + + &::before { + content: ''; + position: absolute; + left: 0; + right: 0; + top: 100%; + height: 200vh; + background-color: inherit; + pointer-events: none; + z-index: -1; + } + + &::after { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + background-color: transparent; + pointer-events: none; + transition: background-color 450ms cubic-bezier(0.32, 0.72, 0, 1); + } + + &[data-nested-drawer-open] { + height: calc(var(--stack-height) + var(--bleed)); + overflow: hidden; + + &::after { + background-color: rgb(0 0 0 / 0.05); + } + } &[data-starting-style], &[data-ending-style] { - transform: translateY(100%); + transform: translateY(calc(100% - var(--bleed))); + } + + &[data-expanded] { + border-radius: 0; + outline: none; + box-shadow: none; } &[data-swiping] { @@ -69,19 +151,22 @@ } &[data-ending-style] { + box-shadow: 0 0 0 rgb(0 0 0 / 0); transition-duration: calc(var(--drawer-swipe-strength, 1) * 400ms); } - // Right panel + // Right panel — stacking not applicable, reset to simple slide &[data-swipe-direction='right'] { + --bleed: 0px; + width: var(--drawer-width, 420px); max-width: calc(100vw - var(--spacing-2xl)); max-height: none; height: 100%; + margin-bottom: 0; + padding-bottom: 0; border-radius: 0; - border-bottom: none; - border-right: none; - border-left: var(--stroke-xs) solid var(--border-primary); + transform-origin: center center; transform: translateX(var(--drawer-swipe-movement-x, 0px)); &[data-starting-style], @@ -90,16 +175,36 @@ } } - // Left panel + // Top sheet — mirrors bottom sheet but anchored to top + &[data-swipe-direction='up'] { + --bleed: 0px; + + max-height: calc(100dvh - var(--spacing-2xl)); + margin-bottom: 0; + padding-bottom: 0; + border-radius: 0 0 var(--corner-radius-lg) var(--corner-radius-lg); + box-shadow: var(--shadow-lg); + transform-origin: center center; + transform: translateY(var(--drawer-swipe-movement-y, 0px)); + + &[data-starting-style], + &[data-ending-style] { + transform: translateY(-100%); + } + } + + // Left panel — stacking not applicable, reset to simple slide &[data-swipe-direction='left'] { + --bleed: 0px; + width: var(--drawer-width, 420px); max-width: calc(100vw - var(--spacing-2xl)); max-height: none; height: 100%; + margin-bottom: 0; + padding-bottom: 0; border-radius: 0; - border-bottom: none; - border-left: none; - border-right: var(--stroke-xs) solid var(--border-primary); + transform-origin: center center; transform: translateX(var(--drawer-swipe-movement-x, 0px)); &[data-starting-style], @@ -113,6 +218,27 @@ } } +.handle { + display: flex; + justify-content: center; + padding: var(--spacing-xs) 0; + cursor: grab; + touch-action: none; + + &::before { + content: ''; + width: 2rem; + height: 3px; + border-radius: var(--corner-radius-full); + background-color: var(--border-secondary); + opacity: 0.6; + } + + &:active { + cursor: grabbing; + } +} + .content { flex: 1; min-height: 0; @@ -120,6 +246,15 @@ overscroll-behavior: contain; touch-action: auto; padding: var(--spacing-md) var(--spacing-xl) var(--spacing-xl); + transition: opacity 300ms cubic-bezier(0.32, 0.72, 0, 1); + + .popup[data-nested-drawer-open] & { + opacity: 0; + } + + .popup[data-nested-drawer-open][data-nested-drawer-swiping] & { + opacity: 1; + } } .title { @@ -129,6 +264,15 @@ color: var(--text-primary); cursor: default; touch-action: none; + transition: opacity 300ms cubic-bezier(0.32, 0.72, 0, 1); + + .popup[data-nested-drawer-open] & { + opacity: 0; + } + + .popup[data-nested-drawer-open][data-nested-drawer-swiping] & { + opacity: 1; + } } .description { @@ -138,6 +282,7 @@ } .indent { + contain: layout; transition: transform 450ms cubic-bezier(0.32, 0.72, 0, 1), border-radius 450ms cubic-bezier(0.32, 0.72, 0, 1); &[data-active] { diff --git a/src/components/Drawer/Drawer.stories.tsx b/src/components/Drawer/Drawer.stories.tsx index 7a2ff1e..a050c7a 100644 --- a/src/components/Drawer/Drawer.stories.tsx +++ b/src/components/Drawer/Drawer.stories.tsx @@ -1,19 +1,27 @@ import * as React from 'react'; import type { Meta, StoryObj } from '@storybook/react'; -import { Drawer } from './index'; +import { Drawer, createHandle } from './index'; import { Button } from '../Button'; import { CentralIcon } from '../Icon'; const meta: Meta = { title: 'Components/Drawer', component: Drawer.Root, + argTypes: { + modal: { control: 'boolean' }, + defaultOpen: { control: 'boolean' }, + }, }; export default meta; export const BottomSheet: StoryObj = { - render: () => ( - + args: { + modal: true, + defaultOpen: false, + }, + render: (args) => ( + }> Open bottom sheet @@ -24,11 +32,11 @@ export const BottomSheet: StoryObj = { Notifications - You are all caught up. Good job! + You have no new notifications. Check back later for updates on your account activity. -
+
}> - Close + Dismiss
@@ -43,6 +51,16 @@ export const SidePanel: StoryObj = { render: function Render() { const [open, setOpen] = React.useState(false); + const details = [ + ['Request ID', 'ck8qs-177'], + ['Method', 'GET'], + ['Path', '/customers'], + ['Host', 'api.example.com'], + ['Status', '200 OK'], + ['Duration', '314ms'], + ['Cache', 'HIT'], + ]; + 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 NestedDrawers: StoryObj = { + render: () => ( + + + +
+ + }> + Open drawer stack + + + + + + Account + + + Manage your account settings and connected devices. + +
+ {['Personal laptop', 'Work desktop', 'Phone'].map((device) => ( +
+ {device} + Connected +
+ ))} +
+
+ + }> + Advanced + + + + + + Advanced + + + This drawer is taller to demonstrate variable-height stacking. + +
+
+ + +
+
+ +