Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions e2e/invariants/gallery-mobile.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { expect, test } from '@playwright/test';

for (const width of [320, 390, 430, 640, 1280]) {
for (const mode of ['light', 'dark'] as const) {
test(`welcome uses available space at ${width}px in ${mode} mode`, async ({ page }) => {
await page.setViewportSize({ width, height: 844 });
await page.emulateMedia({ colorScheme: mode, reducedMotion: 'reduce' });
await page.goto('/?story=welcome--welcome');
const chart = page.locator('.ocw-hero-chart .story-chart');
await expect(chart.locator('svg.oc-chart')).toBeVisible();
await expect(page.locator('.oc-gallery')).toHaveAttribute('data-oc-mode', mode);
await page.evaluate(() => document.fonts.ready);

const box = await chart.boundingBox();
expect(box).not.toBeNull();
if (width <= 640) {
expect(box!.width).toBeGreaterThanOrEqual(width - 28);
const toolbar = await page.locator('.oc-gallery-toolbar').boundingBox();
const title = await page.locator('.oc-gallery-title').boundingBox();
expect(toolbar!.y + toolbar!.height).toBeLessThanOrEqual(title!.y);
}
if (box!.width <= 700) {
await expect(chart.locator('.oc-legend-entry')).toHaveText([
'Amazon', 'Apple', 'Alphabet', 'Microsoft', 'Meta',
]);
await expect(chart.locator('.oc-endpoint-label-entry')).toHaveCount(0);
const colors = await chart.locator('.oc-legend-swatch-line').evaluateAll(
(elements) => elements.map((el) => el.getAttribute('stroke')),
);
expect(new Set(colors).size).toBe(5);
const legend = await chart.locator('.oc-legend').boundingBox();
const yAxis = await chart.locator('.oc-axis-y').boundingBox();
expect(legend!.y + legend!.height).toBeLessThan(yAxis!.y);
if (box!.width < 400) {
const annotation = await chart.locator('.oc-annotation-bg').boundingBox();
expect(annotation!.y).toBeGreaterThan(legend!.y + legend!.height);
}
} else {
await expect(chart.locator('.oc-endpoint-label-entry')).toHaveCount(5);
}
const overflow = await page.evaluate(
() => document.documentElement.scrollWidth - window.innerWidth,
);
expect(overflow).toBeLessThanOrEqual(1);
});
}
}

for (const demo of [
{ story: 'charts--line-and-area', id: 'five-series-legend' },
{ story: 'showcase--showcase', id: 'multi-series-line' },
]) {
test(`${demo.story} keeps every revenue series identifiable on phones`, async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(`/?story=${demo.story}#${demo.id}`);
const card = page.locator(`#${demo.id}`);
await card.scrollIntoViewIfNeeded();
await expect(card.locator('.oc-legend-entry')).toHaveCount(5);
await expect(card.locator('.oc-endpoint-label-entry')).toHaveCount(0);
const box = await card.locator('.story-chart').boundingBox();
expect(box!.width).toBeGreaterThanOrEqual(362);
});
}

for (const mode of ['light', 'dark'] as const) {
test(`phone width preset keeps the gallery toolbar clear in ${mode} mode`, async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.emulateMedia({ colorScheme: mode, reducedMotion: 'reduce' });
await page.goto('/?story=welcome--welcome&width=390');
const frame = page.frameLocator('iframe');
const chart = frame.locator('.ocw-hero-chart .story-chart');
await expect(chart.locator('.oc-legend-entry')).toHaveCount(5);
const box = await chart.boundingBox();
expect(box!.width).toBeGreaterThanOrEqual(362);
const toolbar = frame.locator('.oc-gallery-toolbar');
await expect(toolbar).toHaveAttribute('data-oc-mode', mode);
await expect(toolbar).toHaveCSS('background-color', mode === 'dark' ? 'rgb(15, 16, 17)' : 'rgb(250, 250, 250)');
const toolbarBox = await toolbar.boundingBox();
const titleBox = await frame.locator('.oc-gallery-title').boundingBox();
expect(toolbarBox!.y + toolbarBox!.height).toBeLessThanOrEqual(titleBox!.y);

const trigger = frame.getByRole('button', { name: 'Theme Default', exact: true });
await trigger.click();
await expect(trigger).toHaveAttribute('aria-expanded', 'true');
const option = frame.getByRole('button', { name: 'Midnight', exact: true });
expect((await option.boundingBox())!.height).toBeGreaterThanOrEqual(44);
await frame.locator('.oc-gallery-title').click({ position: { x: 300, y: 10 } });
await expect(trigger).toHaveAttribute('aria-expanded', 'false');
await trigger.click();
await option.focus();
await page.keyboard.press('Escape');
await expect(trigger).toHaveAttribute('aria-expanded', 'false');
await expect(trigger).toBeFocused();
});
}

test('theme controls fit a narrow phone and remain accessible in a short viewport', async ({ page }) => {
await page.setViewportSize({ width: 320, height: 360 });
await page.goto('/?story=welcome--welcome');
await page.getByRole('button', { name: 'Theme Default', exact: true }).click();
await page.getByRole('button', { name: 'Midnight', exact: true }).click();
const trigger = page.getByRole('button', { name: 'Theme Midnight', exact: true });
const triggerBox = await trigger.boundingBox();
const githubBox = await page.locator('.oc-github-link').boundingBox();
expect(triggerBox!.x + triggerBox!.width).toBeLessThan(githubBox!.x);
await trigger.click();
await page.getByRole('button', { name: 'Signal', exact: true }).scrollIntoViewIfNeeded();
const optionsBox = await page.locator('.oc-theme-options').boundingBox();
expect(optionsBox!.x + optionsBox!.width).toBeLessThanOrEqual(320);
expect(optionsBox!.y + optionsBox!.height).toBeLessThanOrEqual(360);
});
28 changes: 23 additions & 5 deletions examples/.ladle/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
* capture.ts hides them (C1).
* 5. Shell chrome styling via `.ladle/shell.css` (imported here).
*/
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useId, useMemo, useRef, useState } from 'react';
import '@opendata-ai/openchart-core/styles.css';
import '../src/stories.css';
import './shell.css';
Expand Down Expand Up @@ -236,14 +236,26 @@ function ThemePicker({
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const popupId = useId();

useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
const ownerDocument = ref.current?.ownerDocument ?? document;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setOpen(false);
ref.current?.querySelector('button')?.focus();
}
};
ownerDocument.addEventListener('mousedown', handler);
ownerDocument.addEventListener('keydown', onKeyDown);
return () => {
ownerDocument.removeEventListener('mousedown', handler);
ownerDocument.removeEventListener('keydown', onKeyDown);
};
}, [open]);

const p = PALETTE[mode];
Expand All @@ -262,6 +274,8 @@ function ThemePicker({
>
<button
type="button"
aria-expanded={open}
aria-controls={popupId}
onClick={() => setOpen(!open)}
style={{
display: 'flex',
Expand Down Expand Up @@ -318,6 +332,8 @@ function ThemePicker({

{open ? (
<div
id={popupId}
className="oc-theme-options"
style={{
position: 'absolute',
top: 'calc(100% + 4px)',
Expand Down Expand Up @@ -445,11 +461,13 @@ export const Provider: GlobalProvider = ({ children, globalState }) => {
return (
<OcModeContext.Provider value={modeCtx}>
<VizThemeProvider theme={theme} darkMode={darkMode}>
<ThemePicker selected={selected} onChange={setSelected} mode={mode} />
<div className="oc-gallery-toolbar" data-oc-mode={mode}>
<ThemePicker selected={selected} onChange={setSelected} mode={mode} />
<GitHubLink mode={mode} />
</div>
<div className="ladle-story-root" style={{ background: bg, color: fg }}>
{children}
</div>
<GitHubLink mode={mode} />
</VizThemeProvider>
</OcModeContext.Provider>
);
Expand Down
38 changes: 38 additions & 0 deletions examples/.ladle/shell.css
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,44 @@ body {
/* biome-ignore lint/complexity/noImportantStyles: must beat the component's inline right: 280px */
right: 12px !important;
}

/* Gallery pages own their gutters. Keep fixture story padding unchanged. */
.ladle-main:has(.oc-gallery) {
padding: 0;
}

.oc-gallery-toolbar:has(+ .ladle-story-root .oc-gallery) {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 12px;
background: var(--gx-surface);
border-bottom: 1px solid var(--gx-border);
}

.oc-gallery-toolbar:has(+ .ladle-story-root .oc-gallery) > * {
/* biome-ignore lint/complexity/noImportantStyles: mobile layout replaces inline fixed positioning */
position: relative !important;
/* biome-ignore lint/complexity/noImportantStyles: reset inline desktop offsets */
inset: auto !important;
}

.oc-gallery-toolbar:has(+ .ladle-story-root .oc-gallery) .ladle-theme-picker button,
.oc-gallery-toolbar:has(+ .ladle-story-root .oc-gallery) .oc-github-link {
min-height: 44px;
}

.oc-gallery-toolbar:has(+ .ladle-story-root .oc-gallery) .ladle-theme-picker > button {
max-width: calc(100vw - 124px);
}

.oc-gallery-toolbar:has(+ .ladle-story-root .oc-gallery) .oc-theme-options {
max-width: calc(100vw - 24px);
max-height: calc(100dvh - 76px);
/* biome-ignore lint/complexity/noImportantStyles: override the dropdown's inline overflow */
overflow-y: auto !important;
}
}

/* ---------------------------------------------------------------------------
Expand Down
29 changes: 20 additions & 9 deletions examples/src/__tests__/v9-editorial-examples.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,15 @@ describe('v9 authored editorial choices', () => {

it.each(
amazonExamples,
)('%s explicitly focuses Amazon and labels every comparison series', (file, name) => {
)('%s focuses Amazon and identifies every comparison series at each width', (file, name) => {
const spec = specFor(file, name);
expect(spec.encoding.color).toMatchObject({
field: 'company',
scale: { domain: ['Amazon', 'Apple', 'Alphabet', 'Microsoft', 'Meta'] },
});
expect(spec.seriesStyles).toMatchObject({ Amazon: { strokeWidth: 3 } });
for (const width of [390, 1280]) {
const series = ['Alphabet', 'Amazon', 'Apple', 'Meta', 'Microsoft'];
for (const width of [320, 390, 700, 701, 1280]) {
const layout = compileChart(spec, { width, height: 460, onWarn: () => {} });
const lines = layout.marks.filter((mark): mark is LineMark => mark.type === 'line');
const amazon = lines.find((line) => line.seriesKey === 'Amazon')!;
Expand All @@ -69,13 +70,23 @@ describe('v9 authored editorial choices', () => {
.filter((line) => line.seriesKey !== 'Amazon')
.every((line) => line.stroke !== amazon.stroke && line.strokeWidth < amazon.strokeWidth),
).toBe(true);
expect(layout.endpointLabels?.entries.map((entry) => entry.seriesKey).sort()).toEqual([
'Alphabet',
'Amazon',
'Apple',
'Meta',
'Microsoft',
]);
if (!('entries' in layout.legend)) throw new Error('Expected a categorical revenue legend');
if (width <= 700) {
expect(layout.endpointLabels).toBeUndefined();
expect(layout.legend.position).toBe('top');
expect(layout.legend.entries.map((entry) => entry.label).sort()).toEqual(series);
expect(layout.legend.bounds.y + layout.legend.bounds.height).toBeLessThanOrEqual(
layout.area.y,
);
for (const entry of layout.legend.entries) {
expect(entry.color).toBe(lines.find((line) => line.seriesKey === entry.label)?.stroke);
}
} else {
expect(layout.endpointLabels?.entries.map((entry) => entry.seriesKey).sort()).toEqual(
series,
);
expect(layout.legend.entries).toHaveLength(0);
}
}
});

Expand Down
67 changes: 67 additions & 0 deletions examples/src/components/gallery.css
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,12 @@
container-name: gallery-root;
}

@media (max-width: 640px) {
.oc-gallery {
padding: var(--gx-space-5) var(--gx-space-3) var(--gx-space-8);
}
}

.oc-gallery *,
.oc-gallery *::before,
.oc-gallery *::after {
Expand Down Expand Up @@ -538,6 +544,67 @@
border-radius: var(--gx-radius-control);
}

/* Narrow content columns, including Ladle's phone-width iframe. */
@container gallery-root (max-width: 640px) {
.oc-gallery-title {
font-size: 1.75rem;
}

.oc-gallery-lede {
font-size: var(--gx-type-body);
}

.oc-gallery-header {
margin-bottom: var(--gx-space-5);
}

.oc-section {
margin-top: var(--gx-space-8);
}

.oc-section:first-of-type {
margin-top: var(--gx-space-5);
}

.oc-section-body {
margin-top: var(--gx-space-4);
gap: var(--gx-space-4);
}

.oc-demo-header {
padding: var(--gx-space-4) var(--gx-space-3) var(--gx-space-2);
}

.oc-demo-viz,
.oc-demo--headless .oc-demo-viz {
padding: var(--gx-space-2) 0 var(--gx-space-3);
}

.oc-spec-summary {
padding: var(--gx-space-1) var(--gx-space-3);
}

.oc-spec-toggle,
.oc-spec-copy,
.oc-anchor-btn {
min-height: 44px;
}

.oc-anchor-btn {
opacity: 1;
min-width: 32px;
}

.oc-spec-code {
padding: var(--gx-space-3);
}

.story-grid-2 {
grid-template-columns: minmax(0, 1fr);
gap: var(--gx-space-4);
}
}

/* ---------------------------------------------------------------------------
* Motion preference
* ------------------------------------------------------------------------- */
Expand Down
2 changes: 2 additions & 0 deletions examples/src/gallery/charts-line-area.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
monthlyTemperature,
usInflation,
} from '../data';
import { narrowRevenueLegend } from './helpers';

const ACCENT = '#0e7490';

Expand Down Expand Up @@ -182,6 +183,7 @@ const fiveSeriesSpec: ChartSpec = {
// Explicit focus mutes comparison strokes; direct labels keep every company identifiable.
legend: { show: false },
endpointLabels: { content: 'label', showMarker: true },
overrides: { compact: narrowRevenueLegend, medium: narrowRevenueLegend },
seriesStyles: { Amazon: { strokeWidth: 3 } },
labels: { density: 'none' },
chrome: {
Expand Down
13 changes: 12 additions & 1 deletion examples/src/gallery/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,18 @@
* Small spec helpers shared across gallery pages.
*/

import type { GradientDef } from '@opendata-ai/openchart-core';
import type { ChartSpecOverride, GradientDef } from '@opendata-ai/openchart-core';

/** Replace the Big Tech endpoint column with a full-width plot and a keyed legend on phones. */
export const narrowRevenueLegend: ChartSpecOverride = {
endpointLabels: false,
legend: { show: true, position: 'top', maxRows: 3 },
encoding: {
color: {
scale: { range: ['#0e7490', '#c2410c', '#7c3aed', '#15803d', '#be185d'] },
},
},
};

/** Horizontal-bar fill: a left-to-right linear gradient fading in from the base. */
export const hBarGradient = (color: string): GradientDef => ({
Expand Down
Loading
Loading