@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
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
import {
|
|
2
|
+
forwardRef,
|
|
3
|
+
useEffect,
|
|
4
|
+
useId,
|
|
5
|
+
useLayoutEffect,
|
|
6
|
+
useMemo,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
type FocusEvent,
|
|
10
|
+
type CSSProperties,
|
|
11
|
+
type HTMLAttributes,
|
|
12
|
+
type KeyboardEvent,
|
|
13
|
+
type MouseEvent,
|
|
14
|
+
type PointerEvent,
|
|
15
|
+
type ReactNode,
|
|
16
|
+
} from 'react';
|
|
17
|
+
import clsx from 'clsx';
|
|
18
|
+
import { EmptyState } from '../EmptyState';
|
|
19
|
+
import { LiveRegion } from '../LiveRegion';
|
|
20
|
+
import { VisuallyHidden } from '../VisuallyHidden';
|
|
21
|
+
import { ChartTooltip } from '../Chart/ChartTooltip';
|
|
22
|
+
import { useTranslation } from '../../i18n/useTranslation';
|
|
23
|
+
import { useLocale } from '../../i18n/useLocale';
|
|
24
|
+
import { mergeRefs } from '../_internal/refs';
|
|
25
|
+
import { useSize } from '../_internal/useSize';
|
|
26
|
+
import {
|
|
27
|
+
formatPercent,
|
|
28
|
+
MIN_THICKNESS,
|
|
29
|
+
orientationFor,
|
|
30
|
+
resolveStages,
|
|
31
|
+
stageGeometry,
|
|
32
|
+
stageIndexAt,
|
|
33
|
+
TAPER_HOLD,
|
|
34
|
+
type FunnelShape,
|
|
35
|
+
type FunnelStage,
|
|
36
|
+
type ResolvedStage,
|
|
37
|
+
} from './funnelModel';
|
|
38
|
+
import styles from './FunnelChart.module.scss';
|
|
39
|
+
|
|
40
|
+
export type { FunnelShape, FunnelSlot, FunnelStage } from './funnelModel';
|
|
41
|
+
|
|
42
|
+
export interface FunnelChartProps extends Omit<HTMLAttributes<HTMLElement>, 'children'> {
|
|
43
|
+
/** Accessible name of the figure and caption of its data table, e.g. "Sales pipeline". Required. */
|
|
44
|
+
label: string;
|
|
45
|
+
/**
|
|
46
|
+
* Stages, top of the funnel first. Heights are relative to the largest value. Colour is a one-hue ramp
|
|
47
|
+
* from `--chart-series-1` (first stage) toward the surface (paler toward the foreground in dark); a stage's `color` slot overrides it.
|
|
48
|
+
*/
|
|
49
|
+
stages: FunnelStage[];
|
|
50
|
+
/** Formats a stage value for the footer, tooltip and data table (the app owns i18n and currency). Required. */
|
|
51
|
+
formatValue: (n: number) => string;
|
|
52
|
+
/**
|
|
53
|
+
* Silhouette between stages, in both layouts (vertical slices follow the same geometry turned on
|
|
54
|
+
* its side). Default `'taper'`.
|
|
55
|
+
* - `'taper'` — each stage holds its height for most of its column, then a short straight diagonal
|
|
56
|
+
* leads into the next one's height.
|
|
57
|
+
* - `'sharp'` — full-column trapezoids from one stage's height to the next.
|
|
58
|
+
*/
|
|
59
|
+
shape?: FunnelShape;
|
|
60
|
+
/**
|
|
61
|
+
* Makes every stage a button that calls this with the consumer's original stage object and its index,
|
|
62
|
+
* e.g. to drill through to the deals in that stage. Omit for a read-only funnel.
|
|
63
|
+
*/
|
|
64
|
+
onStageClick?: (stage: FunnelStage, index: number) => void;
|
|
65
|
+
/**
|
|
66
|
+
* Called with the hovered or focused stage (the consumer's original object), or `null` when none is
|
|
67
|
+
* active, e.g. so a surrounding headline can follow along.
|
|
68
|
+
*/
|
|
69
|
+
onActiveStageChange?: (stage: FunnelStage | null) => void;
|
|
70
|
+
/** EmptyState title when there are no stages or every value is 0. Default: translated "No data for this period". */
|
|
71
|
+
emptyMessage?: ReactNode;
|
|
72
|
+
/**
|
|
73
|
+
* Short note under the funnel, e.g. "Closed deals only". Takes its own row (wraps, never truncates);
|
|
74
|
+
* also describes the plot for screen readers. Shown in the empty state too.
|
|
75
|
+
*/
|
|
76
|
+
footnote?: ReactNode;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Pill box height and text-width estimate (SVG text can't be measured before paint). */
|
|
80
|
+
const PILL_HEIGHT = 20;
|
|
81
|
+
const PILL_PADDING_X = 8;
|
|
82
|
+
// ponytail: per-character width estimate for the pill; measure with getBBox if wide glyphs overflow it.
|
|
83
|
+
const PILL_CHAR_WIDTH = 7;
|
|
84
|
+
|
|
85
|
+
/** Hero (first stage's value and label inside its band): px estimates, as for the pill. */
|
|
86
|
+
const HERO_PADDING = 8;
|
|
87
|
+
// ponytail: sizes mirror --funnel-chart-hero-*-size (24px / 20px bold) at line-height 1.2; measure with getBBox if those tokens change.
|
|
88
|
+
const HERO_MIN_HEIGHT = 72;
|
|
89
|
+
const HERO_VALUE_CHAR_WIDTH = 15;
|
|
90
|
+
const HERO_LABEL_CHAR_WIDTH = 12;
|
|
91
|
+
|
|
92
|
+
/** Stage colour: the `color` slot, else step `i` of `n` on the one-hue ramp (computed by CSS). */
|
|
93
|
+
const stageColor = (s: ResolvedStage, i: number, n: number) =>
|
|
94
|
+
s.slot !== null
|
|
95
|
+
? `var(--chart-series-${s.slot})`
|
|
96
|
+
: `color-mix(in oklab, var(--funnel-chart-ramp-color), var(--funnel-chart-ramp-target) calc(var(--funnel-chart-ramp-max-mix) * ${n > 1 ? Math.round((i / (n - 1)) * 1000) / 1000 : 0}))`;
|
|
97
|
+
const colorStyle = (color: string) => ({ '--chart-series-color': color }) as CSSProperties;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Stage funnel (pipeline, campaign) showing drop-off by height and step conversion by pill.
|
|
101
|
+
* @see docs/components/FunnelChart.md
|
|
102
|
+
*/
|
|
103
|
+
// {...rest} FIRST (Pattern B) so the figure's role and accessible name (incl. aria-labelledby) always win.
|
|
104
|
+
export const FunnelChart = forwardRef<HTMLElement, FunnelChartProps>(function FunnelChart(
|
|
105
|
+
{
|
|
106
|
+
label,
|
|
107
|
+
stages,
|
|
108
|
+
formatValue,
|
|
109
|
+
shape = 'taper',
|
|
110
|
+
onStageClick,
|
|
111
|
+
onActiveStageChange,
|
|
112
|
+
emptyMessage,
|
|
113
|
+
footnote,
|
|
114
|
+
className,
|
|
115
|
+
...rest
|
|
116
|
+
},
|
|
117
|
+
ref,
|
|
118
|
+
) {
|
|
119
|
+
const t = useTranslation();
|
|
120
|
+
const locale = useLocale();
|
|
121
|
+
const resolved = useMemo(() => resolveStages(stages), [stages]);
|
|
122
|
+
const empty = resolved.every((s) => s.value === 0);
|
|
123
|
+
|
|
124
|
+
const hintId = useId();
|
|
125
|
+
const footnoteId = useId();
|
|
126
|
+
const hasFootnote = footnote != null && footnote !== false && footnote !== '';
|
|
127
|
+
const footnoteEl = hasFootnote && (
|
|
128
|
+
<div id={footnoteId} className={styles.footnote}>
|
|
129
|
+
{footnote}
|
|
130
|
+
</div>
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const [plotEl, setPlotEl] = useState<HTMLDivElement | null>(null);
|
|
134
|
+
const [setShapesEl, size, shapesEl] = useSize<HTMLDivElement>();
|
|
135
|
+
const [setRootEl, rootSize] = useSize<HTMLElement>();
|
|
136
|
+
const figureRef = useMemo(
|
|
137
|
+
() => mergeRefs(ref, (el: HTMLElement | null) => setRootEl(el)),
|
|
138
|
+
[ref, setRootEl],
|
|
139
|
+
);
|
|
140
|
+
const n = resolved.length;
|
|
141
|
+
// Unmeasured (first paint, SSR): assume horizontal rather than flash the narrow layout.
|
|
142
|
+
const orientation = rootSize.width > 0 ? orientationFor(rootSize.width, n) : 'horizontal';
|
|
143
|
+
const vertical = orientation === 'vertical';
|
|
144
|
+
|
|
145
|
+
const geometry = useMemo(
|
|
146
|
+
() =>
|
|
147
|
+
!empty && !vertical && size.width > 0 && size.height > 0
|
|
148
|
+
? stageGeometry({ stages: resolved, width: size.width, height: size.height, shape })
|
|
149
|
+
: null,
|
|
150
|
+
[empty, vertical, resolved, size.width, size.height, shape],
|
|
151
|
+
);
|
|
152
|
+
// Hero: the first stage's value and label inside its band, centred in its column, when the band is
|
|
153
|
+
// tall enough at the text's right edge (the band narrows there) and the text fits the column. Not
|
|
154
|
+
// over a `color` slot: the hero colour is only checked against the ramp's first stage.
|
|
155
|
+
const heroValue = n > 0 ? formatValue(resolved[0].value) : '';
|
|
156
|
+
const hero = (() => {
|
|
157
|
+
if (!geometry || resolved[0].slot !== null) return null;
|
|
158
|
+
const g = geometry[0];
|
|
159
|
+
const colW = g.x1 - g.x0;
|
|
160
|
+
const thick = (s: ResolvedStage) => Math.max(MIN_THICKNESS, s.heightRatio * size.height);
|
|
161
|
+
const a = thick(resolved[0]);
|
|
162
|
+
const b = n > 1 ? thick(resolved[1]) : a;
|
|
163
|
+
const hold = shape === 'taper' ? colW * TAPER_HOLD : 0;
|
|
164
|
+
const textW = Math.max(
|
|
165
|
+
heroValue.length * HERO_VALUE_CHAR_WIDTH,
|
|
166
|
+
resolved[0].label.length * HERO_LABEL_CHAR_WIDTH,
|
|
167
|
+
);
|
|
168
|
+
const edge = colW / 2 + textW / 2 + HERO_PADDING;
|
|
169
|
+
const f = Math.min(1, Math.max(0, (edge - hold) / (colW - hold || 1)));
|
|
170
|
+
const fits = textW + 2 * HERO_PADDING <= colW && a + (b - a) * f >= HERO_MIN_HEIGHT;
|
|
171
|
+
return fits ? { x: g.cx, y: g.cy } : null;
|
|
172
|
+
})();
|
|
173
|
+
|
|
174
|
+
// Vertical: the same outline in a 100-unit box per stage (flow x ∈ [100i, 100i+100], cross y ∈
|
|
175
|
+
// [0, 100]); each row's SVG views its slice with the axes swapped and stretches it to the row.
|
|
176
|
+
// MIN_THICKNESS (px in the horizontal strip) becomes 6% of the band track here.
|
|
177
|
+
const bands = useMemo(
|
|
178
|
+
() =>
|
|
179
|
+
!empty && vertical
|
|
180
|
+
? stageGeometry({ stages: resolved, width: 100 * n, height: 100, shape })
|
|
181
|
+
: null,
|
|
182
|
+
[empty, vertical, resolved, n, shape],
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
const [active, setActive] = useState<number | null>(null);
|
|
186
|
+
// 'focus' = a stage button has focus; its accessible name already says what the live region would.
|
|
187
|
+
const [activeSource, setActiveSource] = useState<'pointer' | 'keyboard' | 'focus' | null>(null);
|
|
188
|
+
// Stages shrank past the active index: drop it (during render, React's "adjust state on prop
|
|
189
|
+
// change" pattern), or regrowing would revive it with no user action.
|
|
190
|
+
if (active !== null && active >= n) {
|
|
191
|
+
setActive(null);
|
|
192
|
+
setActiveSource(null);
|
|
193
|
+
}
|
|
194
|
+
const current = active !== null && active < n ? active : null;
|
|
195
|
+
const clickable = onStageClick !== undefined;
|
|
196
|
+
|
|
197
|
+
// Fire once per change of the active stage's key (not on mount, not for a new-but-equal stages array).
|
|
198
|
+
const activeKey = current === null ? null : resolved[current].key;
|
|
199
|
+
const lastKey = useRef<string | null>(null);
|
|
200
|
+
useEffect(() => {
|
|
201
|
+
if (activeKey === lastKey.current) return;
|
|
202
|
+
lastKey.current = activeKey;
|
|
203
|
+
onActiveStageChange?.(current === null ? null : stages[current]);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const inspect = (index: number | null, source: 'pointer' | 'keyboard' | 'focus' | null) => {
|
|
207
|
+
setActive(index);
|
|
208
|
+
setActiveSource(index === null ? null : source);
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
|
212
|
+
// Clickable: buttons are the tab stops and own their keys; only Escape (dismiss tooltip) applies.
|
|
213
|
+
if (clickable && e.key !== 'Escape') return;
|
|
214
|
+
let next: number | null;
|
|
215
|
+
switch (e.key) {
|
|
216
|
+
case 'ArrowDown':
|
|
217
|
+
case 'ArrowUp':
|
|
218
|
+
// Horizontal: leave Up/Down to page scrolling.
|
|
219
|
+
if (!vertical) return;
|
|
220
|
+
next =
|
|
221
|
+
e.key === 'ArrowDown'
|
|
222
|
+
? current === null
|
|
223
|
+
? 0
|
|
224
|
+
: Math.min(n - 1, current + 1)
|
|
225
|
+
: current === null
|
|
226
|
+
? n - 1
|
|
227
|
+
: Math.max(0, current - 1);
|
|
228
|
+
break;
|
|
229
|
+
case 'ArrowRight':
|
|
230
|
+
next = current === null ? 0 : Math.min(n - 1, current + 1);
|
|
231
|
+
break;
|
|
232
|
+
case 'ArrowLeft':
|
|
233
|
+
next = current === null ? n - 1 : Math.max(0, current - 1);
|
|
234
|
+
break;
|
|
235
|
+
case 'Home':
|
|
236
|
+
next = 0;
|
|
237
|
+
break;
|
|
238
|
+
case 'End':
|
|
239
|
+
next = n - 1;
|
|
240
|
+
break;
|
|
241
|
+
case 'Escape':
|
|
242
|
+
if (current === null) return; // let a surrounding dialog close
|
|
243
|
+
next = null;
|
|
244
|
+
break;
|
|
245
|
+
default:
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
e.preventDefault();
|
|
249
|
+
inspect(next, 'keyboard');
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
// A tap leaves the tooltip open (touch fires pointerleave right after pointerup, which we
|
|
253
|
+
// ignore); dismiss it on the next press outside the plot, as Chart does.
|
|
254
|
+
const pointerActive = activeSource === 'pointer' && current !== null;
|
|
255
|
+
useEffect(() => {
|
|
256
|
+
if (!pointerActive || !plotEl) return;
|
|
257
|
+
const dismiss = (e: Event) => {
|
|
258
|
+
// composedPath, not target: inside a shadow root the target is retargeted to the host.
|
|
259
|
+
if (e.composedPath().includes(plotEl)) return;
|
|
260
|
+
setActive(null);
|
|
261
|
+
setActiveSource(null);
|
|
262
|
+
};
|
|
263
|
+
document.addEventListener('pointerdown', dismiss, true);
|
|
264
|
+
return () => document.removeEventListener('pointerdown', dismiss, true);
|
|
265
|
+
}, [pointerActive, plotEl]);
|
|
266
|
+
|
|
267
|
+
const onPointer = (e: PointerEvent<HTMLDivElement>) => {
|
|
268
|
+
if (!geometry || !shapesEl) return;
|
|
269
|
+
const rect = shapesEl.getBoundingClientRect();
|
|
270
|
+
inspect(stageIndexAt(size.width, n, e.clientX - rect.left), 'pointer');
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const onStripClick = (e: MouseEvent<HTMLDivElement>) => {
|
|
274
|
+
if (!onStageClick || !shapesEl) return;
|
|
275
|
+
const rect = shapesEl.getBoundingClientRect();
|
|
276
|
+
const i = stageIndexAt(size.width, n, e.clientX - rect.left);
|
|
277
|
+
if (i !== null) onStageClick(stages[i], i);
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
// Moving focus between stage buttons must not clear (and re-fire onActiveStageChange) in between.
|
|
281
|
+
const onBlur = (e: FocusEvent<HTMLDivElement>) => {
|
|
282
|
+
if (!(e.relatedTarget instanceof Node && plotEl?.contains(e.relatedTarget)))
|
|
283
|
+
inspect(null, null);
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const pct = (ratio: number | null) =>
|
|
287
|
+
ratio === null ? t('funnelChart.noData') : formatPercent(ratio, locale);
|
|
288
|
+
const ofStage = (ratio: number | null, i: number) =>
|
|
289
|
+
t('funnelChart.ofStage', { percent: pct(ratio), stage: resolved[i].label });
|
|
290
|
+
const notesFor = (i: number) =>
|
|
291
|
+
i === 0
|
|
292
|
+
? [ofStage(resolved[0].shareRatio, 0)]
|
|
293
|
+
: [ofStage(resolved[i].stepRatio, i - 1), ofStage(resolved[i].shareRatio, 0)];
|
|
294
|
+
// Button name: like the notes, but undefined ratios are left out rather than read as "—".
|
|
295
|
+
const buttonName = (i: number) => {
|
|
296
|
+
const s = resolved[i];
|
|
297
|
+
const parts = [`${s.label}: ${formatValue(s.value)}`];
|
|
298
|
+
if (i > 0 && s.stepRatio !== null) parts.push(ofStage(s.stepRatio, i - 1));
|
|
299
|
+
if (s.shareRatio !== null) parts.push(ofStage(s.shareRatio, 0));
|
|
300
|
+
return `${parts.join('. ')}.`;
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
// One cell per stage (horizontal: footer column; vertical: a table-like row with its funnel slice); a
|
|
304
|
+
// button when clickable. Spans, not divs, since the cell may be a <button>. With the hero shown, the
|
|
305
|
+
// first footer cell keeps its content invisibly (CSS) for alignment, hover and a normal-size focus
|
|
306
|
+
// ring. Read-only cells are aria-hidden: the hidden data table is the screen reader's copy.
|
|
307
|
+
const renderCell = (s: ResolvedStage, i: number) => {
|
|
308
|
+
const content = vertical ? (
|
|
309
|
+
<>
|
|
310
|
+
<span className={styles.bandTrack} data-band="">
|
|
311
|
+
{bands && (
|
|
312
|
+
<svg
|
|
313
|
+
className={styles.svg}
|
|
314
|
+
viewBox={`0 ${100 * i} 100 100`}
|
|
315
|
+
preserveAspectRatio="none"
|
|
316
|
+
aria-hidden="true"
|
|
317
|
+
focusable="false"
|
|
318
|
+
>
|
|
319
|
+
{/* Swap x/y so the funnel flows top to bottom. */}
|
|
320
|
+
<path className={styles.shape} d={bands[i].d} transform="matrix(0 1 1 0 0 0)" />
|
|
321
|
+
</svg>
|
|
322
|
+
)}
|
|
323
|
+
</span>
|
|
324
|
+
<span className={styles.swatch} data-swatch="" />
|
|
325
|
+
<span className={styles.label}>{s.label}</span>
|
|
326
|
+
<span className={styles.value}>{formatValue(s.value)}</span>
|
|
327
|
+
<span className={styles.step}>
|
|
328
|
+
{i > 0 && s.stepRatio !== null ? (
|
|
329
|
+
<span className={styles.rowPill}>{pct(s.stepRatio)}</span>
|
|
330
|
+
) : (
|
|
331
|
+
t('funnelChart.noData')
|
|
332
|
+
)}
|
|
333
|
+
</span>
|
|
334
|
+
</>
|
|
335
|
+
) : (
|
|
336
|
+
<>
|
|
337
|
+
<span className={styles.value}>{formatValue(s.value)}</span>
|
|
338
|
+
<span className={styles.label}>{s.label}</span>
|
|
339
|
+
</>
|
|
340
|
+
);
|
|
341
|
+
const common = {
|
|
342
|
+
className: styles.cell,
|
|
343
|
+
style: colorStyle(stageColor(s, i, n)),
|
|
344
|
+
'data-stage-cell': s.key,
|
|
345
|
+
'data-dimmed': dimmed(i),
|
|
346
|
+
'data-hero-cell': i === 0 && hero ? '' : undefined,
|
|
347
|
+
// Vertical rows are hit-tested per row; horizontal uses the plot's x position (stageIndexAt).
|
|
348
|
+
onPointerEnter: vertical ? () => inspect(i, 'pointer') : undefined,
|
|
349
|
+
};
|
|
350
|
+
return onStageClick ? (
|
|
351
|
+
<button
|
|
352
|
+
key={s.key}
|
|
353
|
+
{...common}
|
|
354
|
+
type="button"
|
|
355
|
+
aria-label={buttonName(i)}
|
|
356
|
+
onFocus={() => inspect(i, 'focus')}
|
|
357
|
+
onClick={() => onStageClick(stages[i], i)}
|
|
358
|
+
>
|
|
359
|
+
{content}
|
|
360
|
+
</button>
|
|
361
|
+
) : (
|
|
362
|
+
<div key={s.key} {...common} aria-hidden="true">
|
|
363
|
+
{content}
|
|
364
|
+
</div>
|
|
365
|
+
);
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
const stage = current === null ? null : resolved[current];
|
|
369
|
+
const notes = current === null ? [] : notesFor(current);
|
|
370
|
+
const rows = stage
|
|
371
|
+
? [
|
|
372
|
+
{
|
|
373
|
+
key: 'value',
|
|
374
|
+
label: t('funnelChart.value'),
|
|
375
|
+
slot: 0,
|
|
376
|
+
color: stageColor(stage, current!, n),
|
|
377
|
+
comparison: false,
|
|
378
|
+
value: formatValue(stage.value),
|
|
379
|
+
},
|
|
380
|
+
]
|
|
381
|
+
: [];
|
|
382
|
+
|
|
383
|
+
// The shapes strip sits at the plot's top-left, so geometry is plot-local; scalars keep `point` stable.
|
|
384
|
+
const anchorX = current !== null && geometry ? geometry[current].cx : undefined;
|
|
385
|
+
const anchorY =
|
|
386
|
+
stage && geometry
|
|
387
|
+
? geometry[current!].cy - Math.max(MIN_THICKNESS / 2, (stage.heightRatio * size.height) / 2)
|
|
388
|
+
: undefined;
|
|
389
|
+
const horizontalPoint = useMemo(
|
|
390
|
+
() => (anchorX === undefined || anchorY === undefined ? null : { x: anchorX, y: anchorY }),
|
|
391
|
+
[anchorX, anchorY],
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
// Vertical: rows scroll inside the plot, so read the active band's position from the DOM
|
|
395
|
+
// (bounding rects already include the scroll offset), again on plot scroll and on resize.
|
|
396
|
+
// Keyboard inspection scrolls the row in first.
|
|
397
|
+
const [verticalPoint, setVerticalPoint] = useState<{ x: number; y: number } | null>(null);
|
|
398
|
+
useLayoutEffect(() => {
|
|
399
|
+
const row =
|
|
400
|
+
vertical && activeKey !== null && plotEl
|
|
401
|
+
? plotEl.querySelector<HTMLElement>(`[data-stage-cell="${CSS.escape(activeKey)}"]`)
|
|
402
|
+
: null;
|
|
403
|
+
const band = row?.querySelector<HTMLElement>('[data-band]');
|
|
404
|
+
if (!row || !band || !plotEl) {
|
|
405
|
+
setVerticalPoint(null);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
const measure = () => {
|
|
409
|
+
const p = plotEl.getBoundingClientRect();
|
|
410
|
+
const b = band.getBoundingClientRect();
|
|
411
|
+
const x = b.left - p.left + b.width / 2;
|
|
412
|
+
const y = b.top - p.top;
|
|
413
|
+
setVerticalPoint((prev) => (prev && prev.x === x && prev.y === y ? prev : { x, y }));
|
|
414
|
+
};
|
|
415
|
+
if (activeSource === 'keyboard') row.scrollIntoView?.({ block: 'nearest' });
|
|
416
|
+
measure();
|
|
417
|
+
plotEl.addEventListener('scroll', measure, { passive: true });
|
|
418
|
+
return () => plotEl.removeEventListener('scroll', measure);
|
|
419
|
+
}, [vertical, activeKey, activeSource, plotEl, rootSize.width, rootSize.height]);
|
|
420
|
+
const point = vertical ? verticalPoint : horizontalPoint;
|
|
421
|
+
|
|
422
|
+
const liveText =
|
|
423
|
+
activeSource === 'keyboard' && stage
|
|
424
|
+
? `${stage.label}: ${formatValue(stage.value)}. ${notes.join('. ')}.`
|
|
425
|
+
: '';
|
|
426
|
+
const dimmed = (i: number) => (current !== null && i !== current ? '' : undefined);
|
|
427
|
+
|
|
428
|
+
if (empty) {
|
|
429
|
+
return (
|
|
430
|
+
<figure
|
|
431
|
+
{...rest}
|
|
432
|
+
ref={ref}
|
|
433
|
+
role="figure"
|
|
434
|
+
aria-label={label}
|
|
435
|
+
aria-labelledby={undefined}
|
|
436
|
+
data-empty=""
|
|
437
|
+
data-footnote={hasFootnote || undefined}
|
|
438
|
+
className={clsx(styles.root, className)}
|
|
439
|
+
>
|
|
440
|
+
<EmptyState title={emptyMessage || t('chart.empty')} size="sm" />
|
|
441
|
+
{footnoteEl}
|
|
442
|
+
</figure>
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return (
|
|
447
|
+
<figure
|
|
448
|
+
{...rest}
|
|
449
|
+
ref={figureRef}
|
|
450
|
+
role="figure"
|
|
451
|
+
aria-label={label}
|
|
452
|
+
aria-labelledby={undefined}
|
|
453
|
+
data-orientation={orientation}
|
|
454
|
+
data-shape={shape}
|
|
455
|
+
data-footnote={hasFootnote || undefined}
|
|
456
|
+
className={clsx(styles.root, className)}
|
|
457
|
+
>
|
|
458
|
+
{!clickable && <VisuallyHidden id={hintId}>{t('funnelChart.keyboardHint')}</VisuallyHidden>}
|
|
459
|
+
{/* Clickable: the stage buttons are the tab stops, so the plot isn't one and needs no hint. */}
|
|
460
|
+
<div
|
|
461
|
+
ref={setPlotEl}
|
|
462
|
+
className={styles.plot}
|
|
463
|
+
role="group"
|
|
464
|
+
aria-label={label}
|
|
465
|
+
aria-describedby={
|
|
466
|
+
[!clickable && hintId, hasFootnote && footnoteId].filter(Boolean).join(' ') || undefined
|
|
467
|
+
}
|
|
468
|
+
tabIndex={clickable ? undefined : 0}
|
|
469
|
+
data-clickable={clickable || undefined}
|
|
470
|
+
onKeyDown={onKeyDown}
|
|
471
|
+
onPointerMove={onPointer}
|
|
472
|
+
onPointerDown={onPointer}
|
|
473
|
+
// Touch fires pointerleave right after pointerup; keep the tapped tooltip (an outside press clears it).
|
|
474
|
+
onPointerLeave={(e) =>
|
|
475
|
+
e.pointerType !== 'touch' && activeSource === 'pointer' && inspect(null, null)
|
|
476
|
+
}
|
|
477
|
+
onBlur={onBlur}
|
|
478
|
+
>
|
|
479
|
+
{vertical ? (
|
|
480
|
+
<>
|
|
481
|
+
{/* Entering the header leaves the rows: clear a hovered stage, as leaving the plot does. */}
|
|
482
|
+
<div
|
|
483
|
+
className={styles.header}
|
|
484
|
+
aria-hidden="true"
|
|
485
|
+
data-funnel-header=""
|
|
486
|
+
onPointerEnter={() => activeSource === 'pointer' && inspect(null, null)}
|
|
487
|
+
>
|
|
488
|
+
<span />
|
|
489
|
+
<span />
|
|
490
|
+
<span>{t('funnelChart.stage')}</span>
|
|
491
|
+
<span className={styles.headerValue}>{t('funnelChart.value')}</span>
|
|
492
|
+
<span>{t('funnelChart.vsPrevious')}</span>
|
|
493
|
+
</div>
|
|
494
|
+
{resolved.map(renderCell)}
|
|
495
|
+
</>
|
|
496
|
+
) : (
|
|
497
|
+
<>
|
|
498
|
+
<div ref={setShapesEl} className={styles.shapes} onClick={onStripClick}>
|
|
499
|
+
{geometry && (
|
|
500
|
+
<svg
|
|
501
|
+
className={styles.svg}
|
|
502
|
+
width={size.width}
|
|
503
|
+
height={size.height}
|
|
504
|
+
aria-hidden="true"
|
|
505
|
+
focusable="false"
|
|
506
|
+
>
|
|
507
|
+
{resolved.map((s, i) => {
|
|
508
|
+
const g = geometry[i];
|
|
509
|
+
const pill = i > 0 && s.stepRatio !== null ? pct(s.stepRatio) : null;
|
|
510
|
+
const pillWidth = pill ? pill.length * PILL_CHAR_WIDTH + PILL_PADDING_X * 2 : 0;
|
|
511
|
+
return (
|
|
512
|
+
<g
|
|
513
|
+
key={s.key}
|
|
514
|
+
className={styles.stage}
|
|
515
|
+
style={colorStyle(stageColor(s, i, n))}
|
|
516
|
+
data-stage={s.key}
|
|
517
|
+
data-dimmed={dimmed(i)}
|
|
518
|
+
>
|
|
519
|
+
<path className={styles.shape} d={g.d} />
|
|
520
|
+
{i === 0 && hero && (
|
|
521
|
+
<g data-hero="">
|
|
522
|
+
<text
|
|
523
|
+
className={styles.heroValue}
|
|
524
|
+
x={hero.x}
|
|
525
|
+
y={hero.y}
|
|
526
|
+
dy="-0.15em"
|
|
527
|
+
textAnchor="middle"
|
|
528
|
+
>
|
|
529
|
+
{heroValue}
|
|
530
|
+
</text>
|
|
531
|
+
<text
|
|
532
|
+
className={styles.heroLabel}
|
|
533
|
+
x={hero.x}
|
|
534
|
+
y={hero.y}
|
|
535
|
+
dy="1.1em"
|
|
536
|
+
textAnchor="middle"
|
|
537
|
+
>
|
|
538
|
+
{s.label}
|
|
539
|
+
</text>
|
|
540
|
+
</g>
|
|
541
|
+
)}
|
|
542
|
+
{pill && (
|
|
543
|
+
<>
|
|
544
|
+
<rect
|
|
545
|
+
className={styles.pill}
|
|
546
|
+
x={g.cx - pillWidth / 2}
|
|
547
|
+
y={g.cy - PILL_HEIGHT / 2}
|
|
548
|
+
width={pillWidth}
|
|
549
|
+
height={PILL_HEIGHT}
|
|
550
|
+
rx={PILL_HEIGHT / 2}
|
|
551
|
+
/>
|
|
552
|
+
<text
|
|
553
|
+
className={styles.pillText}
|
|
554
|
+
x={g.cx}
|
|
555
|
+
y={g.cy}
|
|
556
|
+
dy="0.35em"
|
|
557
|
+
textAnchor="middle"
|
|
558
|
+
>
|
|
559
|
+
{pill}
|
|
560
|
+
</text>
|
|
561
|
+
</>
|
|
562
|
+
)}
|
|
563
|
+
</g>
|
|
564
|
+
);
|
|
565
|
+
})}
|
|
566
|
+
</svg>
|
|
567
|
+
)}
|
|
568
|
+
</div>
|
|
569
|
+
<div className={styles.footer}>{resolved.map(renderCell)}</div>
|
|
570
|
+
</>
|
|
571
|
+
)}
|
|
572
|
+
</div>
|
|
573
|
+
{footnoteEl}
|
|
574
|
+
<ChartTooltip
|
|
575
|
+
anchor={plotEl}
|
|
576
|
+
point={point}
|
|
577
|
+
title={stage?.label ?? ''}
|
|
578
|
+
rows={rows}
|
|
579
|
+
notes={notes}
|
|
580
|
+
/>
|
|
581
|
+
<LiveRegion>{liveText}</LiveRegion>
|
|
582
|
+
<VisuallyHidden as="div">
|
|
583
|
+
<table>
|
|
584
|
+
<caption>{label}</caption>
|
|
585
|
+
<thead>
|
|
586
|
+
<tr>
|
|
587
|
+
<th scope="col">{t('funnelChart.stage')}</th>
|
|
588
|
+
<th scope="col">{t('funnelChart.value')}</th>
|
|
589
|
+
<th scope="col">{t('funnelChart.stepConversion')}</th>
|
|
590
|
+
<th scope="col">{t('funnelChart.shareOfFirst')}</th>
|
|
591
|
+
</tr>
|
|
592
|
+
</thead>
|
|
593
|
+
<tbody>
|
|
594
|
+
{resolved.map((s) => (
|
|
595
|
+
<tr key={s.key}>
|
|
596
|
+
<th scope="row">{s.label}</th>
|
|
597
|
+
<td>{formatValue(s.value)}</td>
|
|
598
|
+
<td>{pct(s.stepRatio)}</td>
|
|
599
|
+
<td>{pct(s.shareRatio)}</td>
|
|
600
|
+
</tr>
|
|
601
|
+
))}
|
|
602
|
+
</tbody>
|
|
603
|
+
</table>
|
|
604
|
+
</VisuallyHidden>
|
|
605
|
+
</figure>
|
|
606
|
+
);
|
|
607
|
+
});
|