@eocrm/design-system 0.6.23 → 0.6.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AI-PRIMER.md +1 -0
- package/docs/components/Chart.md +1 -0
- package/docs/components/FunnelChart.md +96 -0
- package/docs/components/index.json +5 -0
- package/package.json +2 -2
- package/src/_meta/manifest.ts +1 -0
- package/src/components/Chart/Chart.tsx +1 -22
- package/src/components/Chart/ChartTooltip.tsx +5 -3
- package/src/components/FunnelChart/FunnelChart.module.scss +265 -0
- package/src/components/FunnelChart/FunnelChart.tokens.scss +72 -0
- package/src/components/FunnelChart/FunnelChart.tsx +607 -0
- package/src/components/FunnelChart/funnelModel.ts +124 -0
- package/src/components/FunnelChart/index.ts +2 -0
- package/src/components/_internal/useSize.ts +23 -0
- package/src/components.manifest.json +18 -2
- package/src/i18n/en.ts +10 -0
- package/src/i18n/messages.ts +18 -0
- package/src/i18n/ru.ts +11 -0
- package/src/index.ts +7 -0
package/AI-PRIMER.md
CHANGED
|
@@ -89,6 +89,7 @@ Each `<Name>` below is documented in `docs/components/<Name>.md`.
|
|
|
89
89
|
- `StatTile` — one KPI tile (label, value, trend, footnote, icon, sparkline, quota `progress`) inside a StatGroup (`filled` or `card` surface)
|
|
90
90
|
- `Sparkline` — compact static line / area / bar trend for KPI cards (StatTile slot, kpi DashboardWidget child); no axes, summary in the accessible name
|
|
91
91
|
- `Chart` — line / bar / stacked bar / area time-series chart for a chart DashboardWidget (smoothed lines, static markers, legend, tooltip, keyboard, data table, empty state)
|
|
92
|
+
- `FunnelChart` — stage funnel for pipeline / campaign conversion: height = drop-off, pill = step conversion, one-hue ramp with a hero first stage; optional stage click drill-through, vertical slices plus a table when narrow
|
|
92
93
|
- `TreeView` — lazily loaded hierarchy (WAI-ARIA tree) as a file-explorer list or a top-down org chart, with loading / error-retry / load-more rows
|
|
93
94
|
- `LiquidEditor` — Liquid template editor
|
|
94
95
|
- `RichTextEditor` — controlled rich-text editor (contentEditable)
|
package/docs/components/Chart.md
CHANGED
|
@@ -102,6 +102,7 @@ import { Chart, DashboardWidget, ErrorState } from '@eocrm/design-system';
|
|
|
102
102
|
- ❌ One headline number → `DashboardWidget variant="kpi"` or `StatGroup`.
|
|
103
103
|
- ❌ More than 8 groups at once → fold the tail into an "Other" series in the app (the DS can't know whether your metric is additive).
|
|
104
104
|
- ❌ Two measures with different units → two charts. There is no dual axis.
|
|
105
|
+
- ❌ Stage conversion (pipeline / campaign) → `FunnelChart`.
|
|
105
106
|
- ❌ Non-time categorical comparisons that need sorting, pies, scatter or zoom — not supported.
|
|
106
107
|
|
|
107
108
|
#### Anti-patterns
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# `<FunnelChart>` — stage funnel for pipeline and campaign conversion
|
|
2
|
+
|
|
3
|
+
Ordered stages (e.g. Leads → Qualified → Proposal → Won) drawn as one connected silhouette in a one-hue ramp: the height of each stage shows how much of the funnel is left, a pill in each later stage shows the step conversion, and the first stage carries its value and label inside its band. Fills a `DashboardWidget variant="chart"` body or sits inline on a page, and ships its own tooltip, keyboard inspection, data table and empty state. The app owns data, formatting, loading and errors.
|
|
4
|
+
|
|
5
|
+
```tsx
|
|
6
|
+
import { useState } from 'react';
|
|
7
|
+
import {
|
|
8
|
+
FunnelChart,
|
|
9
|
+
DashboardWidget,
|
|
10
|
+
ErrorState,
|
|
11
|
+
Stack,
|
|
12
|
+
type FunnelStage,
|
|
13
|
+
} from '@eocrm/design-system';
|
|
14
|
+
|
|
15
|
+
const stages = [
|
|
16
|
+
{ key: 'leads', label: 'Leads', value: 1200 },
|
|
17
|
+
{ key: 'qualified', label: 'Qualified', value: 640 },
|
|
18
|
+
{ key: 'proposal', label: 'Proposal', value: 210 },
|
|
19
|
+
{ key: 'won', label: 'Won', value: 64 },
|
|
20
|
+
]; // keep the array and `formatValue` referentially stable (module level or useMemo / useCallback)
|
|
21
|
+
|
|
22
|
+
<DashboardWidget variant="chart" title="Sales pipeline" loading={isLoading}>
|
|
23
|
+
{error ? (
|
|
24
|
+
<ErrorState title="Could not load" headingLevel={4} />
|
|
25
|
+
) : (
|
|
26
|
+
<FunnelChart label="Sales pipeline" stages={stages} formatValue={formatCount} />
|
|
27
|
+
)}
|
|
28
|
+
</DashboardWidget>;
|
|
29
|
+
|
|
30
|
+
// Inline on a page: a definite-height parent is filled; an auto-height one gets the default height
|
|
31
|
+
<div style={{ height: 280 }}>
|
|
32
|
+
<FunnelChart label="Campaign funnel" stages={stages} formatValue={formatCount} shape="sharp" />
|
|
33
|
+
</div>;
|
|
34
|
+
|
|
35
|
+
// Drill-through plus an external headline that follows the hovered / focused stage
|
|
36
|
+
const [active, setActive] = useState<FunnelStage | null>(null);
|
|
37
|
+
|
|
38
|
+
<Stack gap="sm">
|
|
39
|
+
<strong>{active ? `${active.label}: ${formatCount(active.value)}` : 'Hover a stage'}</strong>
|
|
40
|
+
<FunnelChart
|
|
41
|
+
label="Sales pipeline"
|
|
42
|
+
stages={stages}
|
|
43
|
+
formatValue={formatCount}
|
|
44
|
+
onStageClick={(stage) => navigate(`/deals?stage=${stage.key}`)}
|
|
45
|
+
onActiveStageChange={setActive}
|
|
46
|
+
footnote="Closed deals only"
|
|
47
|
+
/>
|
|
48
|
+
</Stack>;
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
<!-- props:start -->
|
|
52
|
+
|
|
53
|
+
## Props
|
|
54
|
+
|
|
55
|
+
<!-- prettier-ignore -->
|
|
56
|
+
| Prop | Type | Required | Description |
|
|
57
|
+
|---|---|---|---|
|
|
58
|
+
| `label` | `string` | yes | Accessible name of the figure and caption of its data table, e.g. "Sales pipeline". Required. |
|
|
59
|
+
| `stages` | `FunnelStage[]` | yes | Stages, top of the funnel first. Heights are relative to the largest value. Colour is a one-hue ramp from `--chart-series-1` (first stage) toward the surface (paler toward the foreground in dark); a stage's `color` slot overrides it. |
|
|
60
|
+
| `formatValue` | `(n: number) => string` | yes | Formats a stage value for the footer, tooltip and data table (the app owns i18n and currency). Required. |
|
|
61
|
+
| `shape` | `'taper' \| 'sharp'` | no | Silhouette between stages, in both layouts (vertical slices follow the same geometry turned on its side). Default `'taper'`. - `'taper'` — each stage holds its height for most of its column, then a short straight diagonal leads into the next one's height. - `'sharp'` — full-column trapezoids from one stage's height to the next. |
|
|
62
|
+
| `onStageClick` | `((stage: FunnelStage, index: number) => void)` | no | Makes every stage a button that calls this with the consumer's original stage object and its index, e.g. to drill through to the deals in that stage. Omit for a read-only funnel. |
|
|
63
|
+
| `onActiveStageChange` | `((stage: FunnelStage \| null) => void)` | no | Called with the hovered or focused stage (the consumer's original object), or `null` when none is active, e.g. so a surrounding headline can follow along. |
|
|
64
|
+
| `emptyMessage` | `ReactNode` | no | EmptyState title when there are no stages or every value is 0. Default: translated "No data for this period". |
|
|
65
|
+
| `footnote` | `ReactNode` | no | Short note under the funnel, e.g. "Closed deals only". Takes its own row (wraps, never truncates); also describes the plot for screen readers. Shown in the empty state too. |
|
|
66
|
+
| …native | | | plus native HTML attributes |
|
|
67
|
+
|
|
68
|
+
<!-- props:end -->
|
|
69
|
+
|
|
70
|
+
- **Percent semantics.** The pill between two stages is the _step conversion_ (next value ÷ this value). The tooltip and data table add the _share of the first stage_. Both are computed by the component; pass raw counts.
|
|
71
|
+
- **Heights** are relative to the largest value; stages are drawn top of funnel first, in the order given.
|
|
72
|
+
- **Colour** is a one-hue ramp, because stages are ordered: the first stage is `--chart-series-1` at full strength, each later stage a lighter tint mixed toward `--funnel-chart-ramp-target` (the surface in light, 48% max; `--color-fg` in dark, 65% max, so later stages get paler and stay crisp on the dark surface; also `--funnel-chart-ramp-color`, `--funnel-chart-ramp-max-mix`). The lightest stage stays clearly visible against the surface in both themes. With 5+ stages adjacent tints get close; the labels, table and tooltip carry identity. To highlight a stage (e.g. Won in green), give it `color` (a palette slot 1–8); it then uses `--chart-series-N` at full strength. There is no stage limit.
|
|
73
|
+
- **Hero first stage** (horizontal): the first stage's value (large) and label sit inside its band, and its footer cell stays empty. When the band is too short or narrow for that text, or the first stage has a `color` slot, the first stage falls back to the normal footer, so nothing is hidden. The hero text is `--funnel-chart-hero-fg` (the surface colour) set as WCAG large text, since it clears 3:1 but not 4.5:1 on `--chart-series-1`; recheck it if you override `--funnel-chart-ramp-color`. Later stages show the conversion pill in the band and value + label underneath, with dashed rules between footer columns.
|
|
74
|
+
- **Shape** (both layouts): `'taper'` (default) holds each stage's size for most of its column, then a short straight diagonal leads into the next; `'sharp'` draws full-column trapezoids. The last stage has no neck.
|
|
75
|
+
- **Layout.** Horizontal while every stage gets at least 96px of width. Narrower, it switches to vertical: one funnel slice per row (the same `shape`, turned on its side, thin gaps between slices) beside a table-like grid with a header (Stage, Value, vs previous) and per row a colour swatch, the label (wrapping, never truncated), the value and the step pill (the first row shows "—"). The visible header is hidden from screen readers; the hidden data table is their source. As a last resort the rows scroll inside the plot. Unmeasured (first paint, SSR) it assumes horizontal.
|
|
76
|
+
- **Clickable stages.** `onStageClick(stage, index)` makes every stage cell a button (Tab, Enter / Space) and receives your original stage object. Omit it for a read-only funnel. Escape dismisses the active stage.
|
|
77
|
+
- **`onActiveStageChange(stage | null)`** fires with the hovered or focused stage (your original object) and `null` when none is active. Use it for a headline outside the chart; do not put that headline in the widget body next to the chart.
|
|
78
|
+
- **Tooltip** follows hover, focus and tap, and tracks scroll and resize. **Screen readers** get a visually hidden data table (caption = `label`) with the exact values, conversion and share.
|
|
79
|
+
- **Empty** (no stages, or every value 0) renders `EmptyState` with `emptyMessage` (default "No data for this period"). `footnote` stays visible there.
|
|
80
|
+
- **Footnote** is a short muted note in its own row under the funnel (wraps, never truncates) and is the plot's accessible description.
|
|
81
|
+
- **Sizing.** Fills its parent (`width/height: 100%`). A definite-height parent (a `DashboardWidget` chart body, a `height` style) is filled; in an auto-height parent the shapes take an intrinsic default height (`--funnel-chart-min-height`).
|
|
82
|
+
|
|
83
|
+
#### When NOT to use
|
|
84
|
+
|
|
85
|
+
- ❌ Values over time → `Chart`.
|
|
86
|
+
- ❌ A single conversion ratio → `StatGroup` / `DashboardWidget variant="kpi"`.
|
|
87
|
+
- ❌ Unordered categories, or stages that don't narrow → `Chart type="bar"`.
|
|
88
|
+
- ❌ Many stages (beyond about 8) → fold the tail into an "Other" stage in the app; the ramp steps become hard to tell apart.
|
|
89
|
+
|
|
90
|
+
#### Anti-patterns
|
|
91
|
+
|
|
92
|
+
- ❌ Passing an inline `stages` array or `formatValue` on every render of a busy parent; geometry is recomputed when their identity changes.
|
|
93
|
+
- ❌ Wrapping `FunnelChart` in a fixed-height or scrolling div inside the widget body. The cell is the size.
|
|
94
|
+
- ❌ Rendering a note next to the chart in the widget body (it steals height). Use `footnote`.
|
|
95
|
+
- ❌ Passing `loading` / error UI into `FunnelChart`. Use `DashboardWidget loading` and an `ErrorState` child.
|
|
96
|
+
- ❌ Pre-computing percentages into `value`. Pass counts; the component derives conversion and share.
|
|
@@ -244,6 +244,11 @@
|
|
|
244
244
|
"summary": "titled group of fields",
|
|
245
245
|
"cluster": "Forms"
|
|
246
246
|
},
|
|
247
|
+
{
|
|
248
|
+
"name": "FunnelChart",
|
|
249
|
+
"summary": "stage funnel for pipeline and campaign conversion",
|
|
250
|
+
"cluster": "Display"
|
|
251
|
+
},
|
|
247
252
|
{
|
|
248
253
|
"name": "Grid",
|
|
249
254
|
"summary": "2D layout primitive",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eocrm/design-system",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.24",
|
|
4
4
|
"description": "Design system for the EOCRM product — React 19 + SCSS modules + CSS custom properties.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"@dnd-kit/core": "^6.3.1",
|
|
51
51
|
"@dnd-kit/sortable": "^10.0.0",
|
|
52
52
|
"@dnd-kit/utilities": "^3.2.2",
|
|
53
|
-
"@eocrm/design-tokens": "0.6.
|
|
53
|
+
"@eocrm/design-tokens": "0.6.24",
|
|
54
54
|
"@floating-ui/react-dom": "^2.1.8",
|
|
55
55
|
"@types/d3-scale": "^4.0.9",
|
|
56
56
|
"@types/d3-shape": "^3.2.0",
|
package/src/_meta/manifest.ts
CHANGED
|
@@ -2,7 +2,6 @@ import {
|
|
|
2
2
|
forwardRef,
|
|
3
3
|
useEffect,
|
|
4
4
|
useId,
|
|
5
|
-
useLayoutEffect,
|
|
6
5
|
useMemo,
|
|
7
6
|
useState,
|
|
8
7
|
type CSSProperties,
|
|
@@ -16,6 +15,7 @@ import { EmptyState } from '../EmptyState';
|
|
|
16
15
|
import { useTranslation } from '../../i18n/useTranslation';
|
|
17
16
|
import { useControllableState } from '../_internal/useControllableState';
|
|
18
17
|
import { mergeRefs } from '../_internal/refs';
|
|
18
|
+
import { useSize } from '../_internal/useSize';
|
|
19
19
|
import { ChartLegend } from './ChartLegend';
|
|
20
20
|
import { ChartTable } from './ChartTable';
|
|
21
21
|
import { ChartTooltip } from './ChartTooltip';
|
|
@@ -131,27 +131,6 @@ function useDevWarnings(dropped: string[], overflowCount: number, droppedMarkers
|
|
|
131
131
|
}, [overflowCount]);
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
-
function useSize<T extends HTMLElement>() {
|
|
135
|
-
const [el, setEl] = useState<T | null>(null);
|
|
136
|
-
const [size, setSize] = useState({ width: 0, height: 0 });
|
|
137
|
-
useLayoutEffect(() => {
|
|
138
|
-
if (!el || typeof ResizeObserver === 'undefined') return;
|
|
139
|
-
const measure = () => {
|
|
140
|
-
const { width, height } = el.getBoundingClientRect();
|
|
141
|
-
setSize((prev) =>
|
|
142
|
-
prev.width === Math.floor(width) && prev.height === Math.floor(height)
|
|
143
|
-
? prev
|
|
144
|
-
: { width: Math.floor(width), height: Math.floor(height) },
|
|
145
|
-
);
|
|
146
|
-
};
|
|
147
|
-
measure();
|
|
148
|
-
const ro = new ResizeObserver(measure);
|
|
149
|
-
ro.observe(el);
|
|
150
|
-
return () => ro.disconnect();
|
|
151
|
-
}, [el]);
|
|
152
|
-
return [setEl, size, el] as const;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
134
|
/**
|
|
156
135
|
* Time-series chart (line, bar, stacked bar, area) that fills a `DashboardWidget variant="chart"` body.
|
|
157
136
|
* @see docs/components/Chart.md
|
|
@@ -9,12 +9,14 @@ export interface ChartTooltipRow {
|
|
|
9
9
|
slot: number;
|
|
10
10
|
comparison: boolean;
|
|
11
11
|
value: string;
|
|
12
|
+
/** CSS colour for the swatch; overrides `slot` (FunnelChart's ramp colours). */
|
|
13
|
+
color?: string;
|
|
12
14
|
}
|
|
13
15
|
|
|
14
|
-
const swatchStyle = (slot: number) =>
|
|
16
|
+
const swatchStyle = (slot: number, color?: string) =>
|
|
15
17
|
({
|
|
16
18
|
'--chart-series-color':
|
|
17
|
-
slot === 0 ? 'var(--chart-total-comparison)' : `var(--chart-series-${slot})
|
|
19
|
+
color ?? (slot === 0 ? 'var(--chart-total-comparison)' : `var(--chart-series-${slot})`),
|
|
18
20
|
}) as CSSProperties;
|
|
19
21
|
|
|
20
22
|
/**
|
|
@@ -72,7 +74,7 @@ export function ChartTooltip({
|
|
|
72
74
|
<div key={r.key} className={styles.tooltipRow} data-nested={r.comparison || undefined}>
|
|
73
75
|
<span
|
|
74
76
|
className={styles.swatch}
|
|
75
|
-
style={swatchStyle(r.slot)}
|
|
77
|
+
style={swatchStyle(r.slot, r.color)}
|
|
76
78
|
data-comparison={r.comparison || undefined}
|
|
77
79
|
/>
|
|
78
80
|
<span className={styles.tooltipLabel}>{r.label}</span>
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
@use './FunnelChart.tokens';
|
|
2
|
+
@use '../../styles/mixins' as *;
|
|
3
|
+
|
|
4
|
+
.root {
|
|
5
|
+
display: grid;
|
|
6
|
+
grid-template-rows: minmax(0, 1fr);
|
|
7
|
+
gap: var(--funnel-chart-gap);
|
|
8
|
+
width: 100%;
|
|
9
|
+
height: 100%;
|
|
10
|
+
min-width: 0;
|
|
11
|
+
min-height: 0;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.root[data-empty] {
|
|
15
|
+
place-items: center;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// The footnote takes its own row; the plot (or EmptyState) fills the rest.
|
|
19
|
+
.root[data-footnote] {
|
|
20
|
+
grid-template-rows: minmax(0, 1fr) auto;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.footnote {
|
|
24
|
+
color: var(--chart-footnote-fg);
|
|
25
|
+
font-size: var(--chart-footnote-size);
|
|
26
|
+
line-height: var(--chart-footnote-line-height);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.plot {
|
|
30
|
+
display: grid;
|
|
31
|
+
grid-template-rows: minmax(var(--funnel-chart-min-height), 1fr) auto;
|
|
32
|
+
gap: var(--funnel-chart-gap);
|
|
33
|
+
min-height: 0;
|
|
34
|
+
border-radius: var(--chart-plot-radius);
|
|
35
|
+
outline: none;
|
|
36
|
+
touch-action: pan-y;
|
|
37
|
+
|
|
38
|
+
&:focus-visible {
|
|
39
|
+
@include focus-ring(var(--funnel-chart-ring));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.shapes {
|
|
44
|
+
min-height: 0;
|
|
45
|
+
overflow: hidden;
|
|
46
|
+
|
|
47
|
+
// The SVG's explicit height must not feed back into the row size, or the strip only ever grows
|
|
48
|
+
// in an auto-height parent. Size containment makes the row fall back to min-height / parent height.
|
|
49
|
+
contain: size;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.plot[data-clickable] .shapes {
|
|
53
|
+
cursor: pointer;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.svg {
|
|
57
|
+
display: block;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.stage {
|
|
61
|
+
transition: opacity var(--funnel-chart-transition);
|
|
62
|
+
|
|
63
|
+
&[data-dimmed] {
|
|
64
|
+
opacity: var(--funnel-chart-dim-opacity);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.shape {
|
|
69
|
+
fill: var(--chart-series-color, currentColor);
|
|
70
|
+
|
|
71
|
+
// ponytail: CSS `d` transitions don't animate in Safari (it snaps). Use a JS tween only if Safari users notice.
|
|
72
|
+
transition: d var(--funnel-chart-transition);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.pill {
|
|
76
|
+
fill: var(--funnel-chart-pill-bg);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.heroValue,
|
|
80
|
+
.heroLabel {
|
|
81
|
+
fill: var(--funnel-chart-hero-fg);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.heroValue {
|
|
85
|
+
font-size: var(--funnel-chart-hero-value-size);
|
|
86
|
+
font-weight: var(--font-weight-semibold);
|
|
87
|
+
font-variant-numeric: tabular-nums;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.heroLabel {
|
|
91
|
+
font-size: var(--funnel-chart-hero-label-size);
|
|
92
|
+
font-weight: var(--funnel-chart-hero-label-weight);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.pillText {
|
|
96
|
+
fill: var(--funnel-chart-pill-fg);
|
|
97
|
+
font-size: var(--funnel-chart-pill-size);
|
|
98
|
+
font-weight: var(--font-weight-semibold);
|
|
99
|
+
font-variant-numeric: tabular-nums;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// One column per stage, aligned with the shapes above; dashed rules between columns.
|
|
103
|
+
.footer {
|
|
104
|
+
display: grid;
|
|
105
|
+
grid-auto-columns: minmax(0, 1fr);
|
|
106
|
+
grid-auto-flow: column;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.footer > .cell + .cell {
|
|
110
|
+
border-inline-start: var(--border-width) dashed var(--funnel-chart-separator);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.cell {
|
|
114
|
+
display: grid;
|
|
115
|
+
align-content: start;
|
|
116
|
+
justify-items: center;
|
|
117
|
+
padding-inline: var(--space-1);
|
|
118
|
+
text-align: center;
|
|
119
|
+
overflow-wrap: anywhere;
|
|
120
|
+
transition: opacity var(--funnel-chart-transition);
|
|
121
|
+
|
|
122
|
+
&[data-dimmed] {
|
|
123
|
+
opacity: var(--funnel-chart-dim-opacity);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// A focused stage hovered away from keeps its focus ring readable (WCAG 2.4.7).
|
|
127
|
+
&[data-dimmed]:focus-visible {
|
|
128
|
+
opacity: initial;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Hero shown: the first cell keeps its value and label, invisible, so it is a normal footer-height
|
|
132
|
+
// box (and a full-size focus ring when it is a button) under the band that shows them.
|
|
133
|
+
&[data-hero-cell] > * {
|
|
134
|
+
visibility: hidden;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Clickable stages render the cell as a <button> (reset.scss strips its chrome).
|
|
138
|
+
&:is(button) {
|
|
139
|
+
border-radius: var(--chart-plot-radius);
|
|
140
|
+
cursor: pointer;
|
|
141
|
+
outline: none;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
&:is(button):focus-visible {
|
|
145
|
+
@include focus-ring(var(--funnel-chart-ring));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.value {
|
|
150
|
+
// Only labels break anywhere; numbers stay whole.
|
|
151
|
+
overflow-wrap: normal;
|
|
152
|
+
font-size: var(--funnel-chart-value-size);
|
|
153
|
+
font-weight: var(--funnel-chart-value-weight);
|
|
154
|
+
font-variant-numeric: tabular-nums;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.label {
|
|
158
|
+
color: var(--funnel-chart-label-fg);
|
|
159
|
+
font-size: var(--funnel-chart-label-size);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Vertical layout: a slice of the funnel per row (same `shape`, turned on its side, thin gaps
|
|
163
|
+
// between slices) beside a table-like grid; the rows scroll as a last resort.
|
|
164
|
+
.root[data-orientation='vertical'] .plot {
|
|
165
|
+
grid-template-rows: none;
|
|
166
|
+
grid-auto-rows: min-content;
|
|
167
|
+
|
|
168
|
+
// One column template shared by the header and every row (subgrid): band track (flexible share, so
|
|
169
|
+
// band widths stay proportional), swatch, label (wraps when tight), value, step pill.
|
|
170
|
+
grid-template-columns:
|
|
171
|
+
minmax(var(--funnel-chart-row-track-min), 1fr) auto minmax(0, max-content)
|
|
172
|
+
max-content max-content;
|
|
173
|
+
align-content: start;
|
|
174
|
+
gap: var(--funnel-chart-slice-gap) var(--funnel-chart-gap);
|
|
175
|
+
overflow-y: auto;
|
|
176
|
+
|
|
177
|
+
// Room for the stage buttons' focus rings, which the scroll container would clip.
|
|
178
|
+
padding: calc(var(--ring-width) + var(--ring-offset));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Header and rows span the plot's columns and inherit them (subgrid).
|
|
182
|
+
.header,
|
|
183
|
+
.root[data-orientation='vertical'] .cell {
|
|
184
|
+
// stylelint-disable-next-line property-disallowed-list -- internal row spanning the plot's own columns (subgrid), not consumer layout
|
|
185
|
+
grid-column: 1 / -1;
|
|
186
|
+
display: grid;
|
|
187
|
+
grid-template-columns: subgrid;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
.header {
|
|
191
|
+
padding-block-end: var(--space-1);
|
|
192
|
+
color: var(--funnel-chart-header-fg);
|
|
193
|
+
font-size: var(--funnel-chart-header-size);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
.headerValue {
|
|
197
|
+
text-align: end;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Items stretch to the row (the band needs the full height); text items centre themselves.
|
|
201
|
+
.root[data-orientation='vertical'] .cell {
|
|
202
|
+
align-content: stretch;
|
|
203
|
+
align-items: stretch;
|
|
204
|
+
justify-items: stretch;
|
|
205
|
+
min-block-size: var(--funnel-chart-row-min-height);
|
|
206
|
+
padding-inline: 0;
|
|
207
|
+
text-align: start;
|
|
208
|
+
|
|
209
|
+
.label,
|
|
210
|
+
.value,
|
|
211
|
+
.swatch,
|
|
212
|
+
.step {
|
|
213
|
+
display: grid;
|
|
214
|
+
align-content: center;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
.value {
|
|
218
|
+
font-size: var(--funnel-chart-label-size);
|
|
219
|
+
text-align: end;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// The slice stretches to the row; size containment keeps the SVG's aspect ratio out of the row
|
|
224
|
+
// height, which comes from the text (or the min height).
|
|
225
|
+
.bandTrack {
|
|
226
|
+
display: block;
|
|
227
|
+
contain: size;
|
|
228
|
+
|
|
229
|
+
.svg {
|
|
230
|
+
inline-size: 100%;
|
|
231
|
+
block-size: 100%;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
.swatch::before {
|
|
236
|
+
content: '';
|
|
237
|
+
inline-size: var(--chart-swatch);
|
|
238
|
+
block-size: var(--chart-swatch);
|
|
239
|
+
border-radius: var(--radius-sm);
|
|
240
|
+
background: var(--chart-series-color, currentColor);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
.step {
|
|
244
|
+
justify-items: start;
|
|
245
|
+
color: var(--funnel-chart-label-fg);
|
|
246
|
+
font-size: var(--funnel-chart-pill-size);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
.rowPill {
|
|
250
|
+
padding-inline: var(--space-2);
|
|
251
|
+
border-radius: var(--radius-full);
|
|
252
|
+
background: var(--funnel-chart-row-pill-bg);
|
|
253
|
+
color: var(--funnel-chart-pill-fg);
|
|
254
|
+
font-size: var(--funnel-chart-pill-size);
|
|
255
|
+
font-weight: var(--font-weight-semibold);
|
|
256
|
+
font-variant-numeric: tabular-nums;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
@media (prefers-reduced-motion: reduce) {
|
|
260
|
+
.stage,
|
|
261
|
+
.shape,
|
|
262
|
+
.cell {
|
|
263
|
+
transition: none;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// FunnelChart.tokens.scss — component tokens for <FunnelChart>. Stages are a one-hue ramp from
|
|
2
|
+
// --chart-series-1 toward the surface (a stage's `color` slot uses --chart-series-N instead);
|
|
3
|
+
// everything else defaults to primitives or chart tokens.
|
|
4
|
+
@use '../Chart/Chart.tokens';
|
|
5
|
+
|
|
6
|
+
:root {
|
|
7
|
+
--funnel-chart-gap: var(--space-2);
|
|
8
|
+
|
|
9
|
+
// Ramp: stage i of n is ramp-color mixed (oklab) toward ramp-target by max-mix * i / (n - 1). Light:
|
|
10
|
+
// target is the surface, 48% keeps the lightest step >= 2:1 against it. Dark (below): target is
|
|
11
|
+
// --color-fg so later stages get paler, not muddier; 65% passes validate_palette.js --ordinal
|
|
12
|
+
// --mode dark for 3-4 stages (6 stages: ΔL ~0.04), palest 4-stage step #a6c5eb is still clearly blue.
|
|
13
|
+
// ponytail: from 5 stages on, adjacent steps fall under the 0.06 OKLCH ΔL ideal (~0.03 at 8); labels,
|
|
14
|
+
// the vertical table and the tooltip carry identity. A wider ramp needs a darker first stage than series-1.
|
|
15
|
+
--funnel-chart-ramp-color: var(--chart-series-1);
|
|
16
|
+
--funnel-chart-surface: var(--color-bg);
|
|
17
|
+
--funnel-chart-ramp-target: var(--funnel-chart-surface);
|
|
18
|
+
--funnel-chart-ramp-max-mix: 48%;
|
|
19
|
+
|
|
20
|
+
// Hero: the first stage's value and label inside its band (horizontal). Paired with the band
|
|
21
|
+
// (--funnel-chart-ramp-color, i.e. --chart-series-1), not the accent: the surface colour knocked out
|
|
22
|
+
// of the band, the same pair the chart series are gated on (3:1). It misses 4.5:1 (only near-black,
|
|
23
|
+
// no neutral token, clears that in light), so both lines are WCAG large text (24px; 20px bold), 3:1:
|
|
24
|
+
// @contrast --color-bg on --color-chart-series-1 = 4.42:1 light
|
|
25
|
+
// @contrast --color-bg on --color-chart-series-1 = 4.45:1 dark
|
|
26
|
+
// Override --funnel-chart-ramp-color and this pair is yours to recheck; a stage-0 `color` slot hides the hero.
|
|
27
|
+
--funnel-chart-hero-fg: var(--color-bg);
|
|
28
|
+
--funnel-chart-hero-value-size: var(--font-size-2xl);
|
|
29
|
+
--funnel-chart-hero-label-size: var(--font-size-xl);
|
|
30
|
+
--funnel-chart-hero-label-weight: var(--font-weight-bold);
|
|
31
|
+
|
|
32
|
+
// Dashed rule between horizontal footer columns, from stage 2 on.
|
|
33
|
+
--funnel-chart-separator: var(--color-border);
|
|
34
|
+
|
|
35
|
+
// Intrinsic height of the shapes strip when the parent gives no height.
|
|
36
|
+
--funnel-chart-min-height: calc(var(--space-16) * 2);
|
|
37
|
+
--funnel-chart-pill-bg: var(--color-bg);
|
|
38
|
+
--funnel-chart-pill-fg: var(--color-fg);
|
|
39
|
+
--funnel-chart-pill-size: var(--font-size-xs);
|
|
40
|
+
--funnel-chart-value-size: var(--font-size-lg);
|
|
41
|
+
--funnel-chart-value-weight: var(--font-weight-semibold);
|
|
42
|
+
--funnel-chart-label-fg: var(--color-fg-muted);
|
|
43
|
+
--funnel-chart-label-size: var(--font-size-sm);
|
|
44
|
+
--funnel-chart-dim-opacity: var(--chart-dim-opacity);
|
|
45
|
+
--funnel-chart-ring: var(--chart-ring);
|
|
46
|
+
--funnel-chart-transition: var(--transition-slow);
|
|
47
|
+
|
|
48
|
+
// Vertical layout (narrow containers): rows of a funnel slice beside a table-like grid (swatch,
|
|
49
|
+
// stage, value, step pill). The min height keeps a short row's taper readable; the slice gap
|
|
50
|
+
// separates the slices with a thin line of surface.
|
|
51
|
+
--funnel-chart-row-min-height: var(--space-10);
|
|
52
|
+
--funnel-chart-slice-gap: var(--space-05);
|
|
53
|
+
--funnel-chart-header-fg: var(--color-fg-muted);
|
|
54
|
+
--funnel-chart-header-size: var(--font-size-xs);
|
|
55
|
+
|
|
56
|
+
// Narrowest the band track gets beside the table, so the band never vanishes.
|
|
57
|
+
--funnel-chart-row-track-min: var(--space-16);
|
|
58
|
+
--funnel-chart-row-pill-bg: var(--color-bg-muted);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Dark ramp: same scopes as the generated dark.scss (forced dark, and OS dark unless forced light).
|
|
62
|
+
:root[data-theme='dark'] {
|
|
63
|
+
--funnel-chart-ramp-target: var(--color-fg);
|
|
64
|
+
--funnel-chart-ramp-max-mix: 65%;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
@media (prefers-color-scheme: dark) {
|
|
68
|
+
:root:not([data-theme='light']) {
|
|
69
|
+
--funnel-chart-ramp-target: var(--color-fg);
|
|
70
|
+
--funnel-chart-ramp-max-mix: 65%;
|
|
71
|
+
}
|
|
72
|
+
}
|