From e4ccb841a9d601b9571326d3554852470d033ae3 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Tue, 12 May 2026 22:29:40 +0000 Subject: [PATCH 1/2] feat(app): refactor ColorSwatchInput to palette tokens [HDX-1360] PR-1 of the issue #1360 series. The existing ColorSwatchInput (orphan in OSS since #440) used a free-form ColorInput plus a hand-picked 9-value hex array. Refactor it to a palette-only picker driven by the unified 13-token palette landed in #1627: ten categorical tokens (chart-1..chart-10) and three semantic tokens (chart-success, chart-warning, chart-error). Storing tokens (not hex) lets user color choices reflow across themes and color modes without losing contrast or breaking the WCAG ratios baked into the palette. The component remains internal in this PR. PRs 2b through 6b in the issue #1360 series wire it into ChartSeriesEditor, ChartDisplaySettingsDrawer, and the number-tile thresholds / reference-line editors. PR 7 mirrors the schema fields on the external dashboards API. PR 8 documents the feature. Highlights: - New ChartPaletteToken type + CHART_PALETTE_TOKENS, CATEGORICAL_PALETTE_TOKENS, SEMANTIC_PALETTE_TOKENS arrays, isChartPaletteToken type guard in utils.ts. - New getColorFromCSSToken(token) reads the matching --color-chart-* CSS variable, with SSR-safe fallbacks. - New resolveChartColor(token, fallbackIndex, level) for PR 2b to plug into setLineColors / formatResponseForPieChart. - ColorSwatchInput popover split into categorical / semantic sections; trigger uses --color-outline-focus for focus-visible; swatch buttons use aria-pressed; legacy non-token values are guarded to treat as unset. - Storybook stories matrix: six isolated stories (picker mechanics) and five in-context mocks (InSeriesRow, InLineChart, InStackedBar, InNumberTile, InReferenceLineEditor). The in-context stories are storybook-only mocks; they do not import the real editor components. - 13 RTL tests cover the rejection rules and keyboard nav. --- .../components/ColorSwatchInput.module.scss | 39 ++ .../components/ColorSwatchInput.stories.tsx | 436 +++++++++++++++++- .../src/components/ColorSwatchInput.test.tsx | 190 ++++++++ .../app/src/components/ColorSwatchInput.tsx | 245 +++++++--- packages/app/src/utils.ts | 120 +++++ 5 files changed, 964 insertions(+), 66 deletions(-) create mode 100644 packages/app/src/components/ColorSwatchInput.module.scss create mode 100644 packages/app/src/components/ColorSwatchInput.test.tsx diff --git a/packages/app/src/components/ColorSwatchInput.module.scss b/packages/app/src/components/ColorSwatchInput.module.scss new file mode 100644 index 0000000000..90ab70122c --- /dev/null +++ b/packages/app/src/components/ColorSwatchInput.module.scss @@ -0,0 +1,39 @@ +.trigger { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 2px 6px; + border-radius: 4px; + background-color: var(--color-bg-field); + border: 1px solid var(--color-border); + color: var(--color-text); + font-size: 11px; + line-height: 1; + cursor: pointer; + user-select: none; + transition: + background-color 100ms ease, + border-color 100ms ease; + + &:hover { + background-color: var(--color-bg-field-highlighted); + border-color: var(--color-border); + } + + &:focus-visible { + outline: 2px solid var(--color-outline-focus); + outline-offset: 1px; + } + + &[data-disabled] { + cursor: not-allowed; + opacity: 0.6; + } +} + +.swatchButton { + &[data-selected] { + background-color: var(--color-bg-field-highlighted); + box-shadow: inset 0 0 0 1.5px var(--color-text); + } +} diff --git a/packages/app/src/components/ColorSwatchInput.stories.tsx b/packages/app/src/components/ColorSwatchInput.stories.tsx index 50722f9560..2ec8f17bd0 100644 --- a/packages/app/src/components/ColorSwatchInput.stories.tsx +++ b/packages/app/src/components/ColorSwatchInput.stories.tsx @@ -1,16 +1,448 @@ import React from 'react'; +import { + Bar, + BarChart, + CartesianGrid, + Line, + LineChart, + ReferenceLine, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; +import { + Box, + Card, + Divider, + Group, + Select, + Stack, + Text, + TextInput, +} from '@mantine/core'; import type { Meta } from '@storybook/nextjs'; +import { IconChartLine } from '@tabler/icons-react'; + +import { + CATEGORICAL_PALETTE_TOKENS, + CHART_PALETTE_TOKENS, + ChartPaletteToken, + getColorFromCSSToken, + SEMANTIC_PALETTE_TOKENS, +} from '@/utils'; import { ColorSwatchInput } from './ColorSwatchInput'; const meta = { + title: 'ColorSwatchInput', component: ColorSwatchInput, } satisfies Meta; export default meta; +// --------------------------------------------------------------------------- +// Tier 1: isolated stories (picker mechanics) +// --------------------------------------------------------------------------- + export const Default = () => { - const [color, setColor] = React.useState('#6610f2'); + const [value, setValue] = React.useState( + undefined, + ); + return ; +}; + +export const Selected = () => { + const [value, setValue] = React.useState( + 'chart-1', + ); + return ; +}; + +export const Disabled = () => ( + + + + +); + +export const WithCustomLabel = () => { + const [value, setValue] = React.useState( + undefined, + ); + return ( + + ); +}; + +/** + * One trigger per token, all pre-selected. Renders the full matrix so the + * design review can compare swatch sizes, hover states, and per-token + * contrast across themes without opening the popover thirteen times. + */ +export const AllTokensSelected = () => ( + + + Categorical + + + {CATEGORICAL_PALETTE_TOKENS.map(token => ( + + ))} + + + Semantic + + + {SEMANTIC_PALETTE_TOKENS.map(token => ( + + ))} + + +); + +/** + * Picker mounted alongside other form controls so reviewers can verify + * the focus order during keyboard nav (Tab into the picker, activate + * with Enter or Space, Tab between swatches, Esc closes). + */ +export const KeyboardNav = () => { + const [value, setValue] = React.useState(); + return ( + + + + + + ); +}; + +// --------------------------------------------------------------------------- +// Tier 2: in-context stories (product framing) +// +// Storybook-only mocks. No production imports of ChartSeriesEditor or +// ChartDisplaySettingsDrawer; those land in PRs 2b through 6b. The mocks +// approximate the layouts so the design review can rule on placement +// before any consumer PR opens. +// --------------------------------------------------------------------------- + +type SeriesMockData = { + name: string; + token: ChartPaletteToken | undefined; + lineStyle: 'solid' | 'dashed' | 'dotted'; +}; + +const DEFAULT_SERIES_MOCK: SeriesMockData[] = [ + { name: 'errors', token: 'chart-error', lineStyle: 'solid' }, + { name: 'warnings', token: 'chart-warning', lineStyle: 'dashed' }, + { name: 'successes', token: 'chart-success', lineStyle: 'solid' }, +]; - return setColor(value)} />; +const MOCK_TIME_DATA = Array.from({ length: 12 }).map((_, i) => ({ + t: `${i}:00`, + errors: Math.round(20 + 8 * Math.sin(i / 2)), + warnings: Math.round(40 + 12 * Math.cos(i / 3)), + successes: Math.round(120 + 25 * Math.sin(i / 4 + 1)), +})); + +const LINE_STYLE_DASHARRAY: Record = { + solid: '0', + dashed: '4 3', + dotted: '2 2', +}; + +function parseLineStyle(value: string | null): SeriesMockData['lineStyle'] { + if (value === 'dashed' || value === 'dotted') return value; + return 'solid'; +} + +function MockSeriesPreview({ + series, + variant, + height = 140, +}: { + series: SeriesMockData[]; + variant: 'line' | 'bar'; + height?: number; +}) { + const ChartCmp = variant === 'line' ? LineChart : BarChart; + return ( + + + + + + + {series.map(s => { + const color = s.token + ? getColorFromCSSToken(s.token) + : 'var(--color-text-muted)'; + return variant === 'line' ? ( + + ) : ( + + ); + })} + + + ); +} + +function SeriesRow({ + series, + onChange, +}: { + series: SeriesMockData; + onChange: (next: SeriesMockData) => void; +}) { + return ( + + + {series.name} + + + count(*) + + + onChange({ ...series, token })} + /> +