@elabs-ai/components-process 4.1.0 → 5.0.0

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.
Files changed (145) hide show
  1. package/README.md +8 -1
  2. package/dist/core/index.d.ts +801 -3
  3. package/dist/core/index.js +1334 -0
  4. package/dist/core/index.js.map +1 -1
  5. package/dist/index.d.ts +1889 -34
  6. package/dist/index.js +5512 -196
  7. package/dist/index.js.map +1 -1
  8. package/dist/test/index.d.ts +223 -5
  9. package/dist/test/index.js +346 -191
  10. package/dist/test/index.js.map +1 -1
  11. package/package.json +14 -13
  12. package/src/__contract__/case-table.contract.test.tsx +49 -0
  13. package/src/__contract__/compare-kpi-strip.contract.test.tsx +49 -0
  14. package/src/__contract__/conformance-overlay.contract.test.tsx +49 -0
  15. package/src/__contract__/happy-path-editor.contract.test.tsx +49 -0
  16. package/src/__contract__/process-kpi-strip.contract.test.tsx +49 -0
  17. package/src/__contract__/violation-list.contract.test.tsx +49 -0
  18. package/src/abstraction-controls/abstraction-controls-per-type.test.tsx +80 -0
  19. package/src/abstraction-controls/abstraction-controls.stories.tsx +43 -1
  20. package/src/abstraction-controls/abstraction-controls.tsx +198 -5
  21. package/src/case-table/case-table.stories.tsx +89 -0
  22. package/src/case-table/case-table.test.tsx +148 -0
  23. package/src/case-table/case-table.tsx +144 -0
  24. package/src/case-table/columns.ts +116 -0
  25. package/src/case-table/index.ts +11 -0
  26. package/src/case-timeline/case-timeline-model.test.ts +72 -0
  27. package/src/case-timeline/case-timeline-model.ts +112 -0
  28. package/src/case-timeline/case-timeline.stories.tsx +94 -0
  29. package/src/case-timeline/case-timeline.test.tsx +51 -0
  30. package/src/case-timeline/case-timeline.tsx +109 -0
  31. package/src/case-timeline/index.ts +9 -0
  32. package/src/conformance-overlay/conformance-fixture.ts +59 -0
  33. package/src/conformance-overlay/conformance-legend.tsx +109 -0
  34. package/src/conformance-overlay/conformance-overlay.stories.tsx +116 -0
  35. package/src/conformance-overlay/conformance-overlay.test.tsx +88 -0
  36. package/src/conformance-overlay/conformance-overlay.tsx +107 -0
  37. package/src/conformance-overlay/conformance-state.test.ts +79 -0
  38. package/src/conformance-overlay/conformance-state.ts +220 -0
  39. package/src/conformance-overlay/index.ts +4 -0
  40. package/src/core/activity-color-scale.test.ts +107 -0
  41. package/src/core/activity-color-scale.ts +133 -0
  42. package/src/core/adapters/ocel.test.ts +112 -0
  43. package/src/core/adapters/ocel.ts +359 -0
  44. package/src/core/adapters/xes.test.ts +293 -0
  45. package/src/core/adapters/xes.ts +384 -0
  46. package/src/core/cases-from-log.test.ts +72 -0
  47. package/src/core/cases-from-log.ts +85 -0
  48. package/src/core/conformance.test.ts +80 -0
  49. package/src/core/conformance.ts +91 -0
  50. package/src/core/diff-graphs.test.ts +151 -0
  51. package/src/core/diff-graphs.ts +118 -0
  52. package/src/core/discover-object-centric-graph.test.ts +94 -0
  53. package/src/core/discover-object-centric-graph.ts +296 -0
  54. package/src/core/fixtures/ocel-sample.ts +82 -0
  55. package/src/core/fixtures/sample.xes +68 -0
  56. package/src/core/index.ts +113 -0
  57. package/src/core/reference-model.test.ts +43 -0
  58. package/src/core/reference-model.ts +116 -0
  59. package/src/core/replay-timeline.test.ts +161 -0
  60. package/src/core/replay-timeline.ts +260 -0
  61. package/src/core/segments.test.ts +185 -0
  62. package/src/core/segments.ts +153 -0
  63. package/src/core/token-replay.test.ts +218 -0
  64. package/src/core/token-replay.ts +456 -0
  65. package/src/core/types.ts +2 -2
  66. package/src/dotted-chart/compute-dots.test.ts +176 -0
  67. package/src/dotted-chart/compute-dots.ts +241 -0
  68. package/src/dotted-chart/dotted-chart-labels.ts +93 -0
  69. package/src/dotted-chart/dotted-chart.stories.tsx +182 -0
  70. package/src/dotted-chart/dotted-chart.test.tsx +135 -0
  71. package/src/dotted-chart/dotted-chart.tsx +841 -0
  72. package/src/dotted-chart/index.ts +23 -0
  73. package/src/dotted-chart/use-element-size.ts +33 -0
  74. package/src/happy-path-editor/happy-path-editor-context.ts +81 -0
  75. package/src/happy-path-editor/happy-path-editor.stories.tsx +116 -0
  76. package/src/happy-path-editor/happy-path-editor.test.tsx +142 -0
  77. package/src/happy-path-editor/happy-path-editor.tsx +239 -0
  78. package/src/happy-path-editor/happy-path-step-node.tsx +175 -0
  79. package/src/happy-path-editor/index.ts +4 -0
  80. package/src/index.ts +51 -1
  81. package/src/performance-spectrum/aggregate-segments.test.ts +107 -0
  82. package/src/performance-spectrum/aggregate-segments.ts +174 -0
  83. package/src/performance-spectrum/index.ts +25 -0
  84. package/src/performance-spectrum/performance-spectrum-context.tsx +116 -0
  85. package/src/performance-spectrum/performance-spectrum.stories.tsx +128 -0
  86. package/src/performance-spectrum/performance-spectrum.test.tsx +190 -0
  87. package/src/performance-spectrum/performance-spectrum.tsx +870 -0
  88. package/src/process-compare/compare-kpi-strip.stories.tsx +48 -0
  89. package/src/process-compare/compare-kpi-strip.tsx +94 -0
  90. package/src/process-compare/compare-model.ts +83 -0
  91. package/src/process-compare/compare-side.tsx +42 -0
  92. package/src/process-compare/diff-to-graph.ts +104 -0
  93. package/src/process-compare/index.ts +23 -0
  94. package/src/process-compare/process-compare.stories.tsx +184 -0
  95. package/src/process-compare/process-compare.test.tsx +224 -0
  96. package/src/process-compare/process-compare.tsx +251 -0
  97. package/src/process-explorer.stories.tsx +1 -1
  98. package/src/process-filter-bar/index.ts +2 -0
  99. package/src/process-filter-bar/process-filter-bar.stories.tsx +156 -0
  100. package/src/process-filter-bar/process-filter-bar.test.tsx +201 -0
  101. package/src/process-filter-bar/process-filter-bar.tsx +167 -0
  102. package/src/process-kpi-strip/process-kpi-strip.stories.tsx +47 -0
  103. package/src/process-kpi-strip/process-kpi-strip.test.tsx +67 -0
  104. package/src/process-kpi-strip/process-kpi-strip.tsx +148 -8
  105. package/src/process-map/activity-accent.ts +25 -0
  106. package/src/process-map/index.ts +1 -0
  107. package/src/process-map/map-model.test.ts +16 -0
  108. package/src/process-map/map-model.ts +323 -1
  109. package/src/process-map/object-centric-map.test.tsx +132 -0
  110. package/src/process-map/process-activity-node.tsx +152 -14
  111. package/src/process-map/process-map-object-centric.stories.tsx +219 -0
  112. package/src/process-map/process-map.stories.tsx +64 -0
  113. package/src/process-map/process-map.tsx +240 -16
  114. package/src/process-map/process-transition-edge.test.tsx +47 -0
  115. package/src/process-map/process-transition-edge.tsx +134 -7
  116. package/src/process-map/use-process-layout.ts +30 -9
  117. package/src/process-replay/congestion-heat.tsx +107 -0
  118. package/src/process-replay/index.ts +14 -0
  119. package/src/process-replay/process-replay.stories.tsx +168 -0
  120. package/src/process-replay/process-replay.test.tsx +170 -0
  121. package/src/process-replay/process-replay.tsx +285 -0
  122. package/src/process-replay/replay-controls.tsx +147 -0
  123. package/src/process-replay/replay-format.ts +83 -0
  124. package/src/process-replay/replay-tokens-context.ts +30 -0
  125. package/src/process-replay/use-controllable-value.ts +30 -0
  126. package/src/templates-process-explorer.stories.tsx +1304 -0
  127. package/src/test/contract.test.ts +66 -0
  128. package/src/test/contract.ts +107 -6
  129. package/src/test/doubles.test.tsx +87 -1
  130. package/src/test/doubles.tsx +174 -3
  131. package/src/test/index.ts +25 -1
  132. package/src/use-process-explorer/use-process-explorer.test.ts +44 -0
  133. package/src/use-process-explorer/use-process-explorer.ts +34 -2
  134. package/src/variant-explorer/coverage-bar.tsx +36 -0
  135. package/src/variant-explorer/index.ts +16 -0
  136. package/src/variant-explorer/sequence-chips.tsx +103 -0
  137. package/src/variant-explorer/variant-explorer-model.ts +42 -0
  138. package/src/variant-explorer/variant-explorer.stories.tsx +226 -0
  139. package/src/variant-explorer/variant-explorer.test.tsx +302 -0
  140. package/src/variant-explorer/variant-explorer.tsx +567 -0
  141. package/src/variant-explorer/variant-row.tsx +137 -0
  142. package/src/violation-list/index.ts +2 -0
  143. package/src/violation-list/violation-list.stories.tsx +73 -0
  144. package/src/violation-list/violation-list.test.tsx +84 -0
  145. package/src/violation-list/violation-list.tsx +259 -0
@@ -0,0 +1,567 @@
1
+ "use client";
2
+
3
+ /**
4
+ * VariantExplorer — the ranked list of activity sequences (RM-054, issue #203).
5
+ *
6
+ * One row per variant: its activity sequence as colour-keyed chips, how many cases follow
7
+ * it, the share of all cases it covers, and its median end-to-end duration. Multi-select
8
+ * with checkboxes, or let the coverage slider pick "the fewest paths that explain N % of
9
+ * cases" in one move.
10
+ *
11
+ * ## It emits; it never filters
12
+ *
13
+ * `onSelect(ids, mode)` is the whole output. A playbook wires it to
14
+ * `useProcessExplorer().applyIntent({ kind: "variant", ids })` so the map re-derives from
15
+ * the chosen cases; the explorer itself never calls `filterLog`. The list NARROWS rather
16
+ * than ghosts under a filter (decided 2026-09-05): `selectionStates.variants` carries
17
+ * `selected` and only carries `excluded` when a host engine supplies it.
18
+ *
19
+ * ## Shared colours
20
+ *
21
+ * Chips are painted from the SAME `ActivityColorScale` instance `ProcessMap` receives as
22
+ * `colorScale`, through the same `activityAccentStyle`, so an activity is one colour in
23
+ * both views. Colour is never the only channel: every chip prints its label (or two-letter
24
+ * code) and every row has a full accessible name.
25
+ *
26
+ * ## Scale
27
+ *
28
+ * Rows are virtualized with `@tanstack/react-virtual`, so 2 000 variants mount only the
29
+ * rows in view plus a small overscan. Keyboard: the checkboxes form one roving tab stop;
30
+ * Arrow Up/Down, Page Up/Down, Home and End move between rows, Space toggles the focused
31
+ * row, Enter toggles it too (same as a click).
32
+ */
33
+ import {
34
+ forwardRef,
35
+ useCallback,
36
+ useId,
37
+ useLayoutEffect,
38
+ useMemo,
39
+ useRef,
40
+ useState,
41
+ type HTMLAttributes,
42
+ type KeyboardEvent,
43
+ } from "react";
44
+ import { useVirtualizer } from "@tanstack/react-virtual";
45
+ import {
46
+ Button,
47
+ Slider,
48
+ StatePanel,
49
+ Table,
50
+ TableBody,
51
+ TableCaption,
52
+ TableCell,
53
+ TableHead,
54
+ TableHeader,
55
+ TableRow,
56
+ useLocale,
57
+ } from "@elabs-ai/components-ui";
58
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
59
+ import type { ActivityColorScale } from "../core/activity-color-scale";
60
+ import type { Variant } from "../core/types";
61
+ import {
62
+ formatDurationMs,
63
+ type ProcessSelectionState,
64
+ type ProcessSelectionStates,
65
+ } from "../process-map/map-model";
66
+ import type { FilterIntent } from "../use-process-explorer";
67
+ import {
68
+ fillLabel,
69
+ selectVariantsByCoverage,
70
+ VARIANT_EXPLORER_COLUMNS,
71
+ type VariantExplorerColumn,
72
+ } from "./variant-explorer-model";
73
+ import { VariantRow, type VariantRowText } from "./variant-row";
74
+
75
+ /** Row height in px — fixed, so the virtualizer never has to measure. Matches `h-10`. */
76
+ export const VARIANT_EXPLORER_ROW_HEIGHT = 40;
77
+
78
+ /** How a selection change applies: replace the whole selection, or flip these ids. */
79
+ export type VariantSelectMode = "replace" | "toggle";
80
+
81
+ /**
82
+ * Every user-visible string. `{name}` placeholders are filled at render. Override any
83
+ * subset through `labels` to localize.
84
+ */
85
+ export interface VariantExplorerLabels {
86
+ /** Accessible name of the list. */
87
+ list: string;
88
+ columnVariant: string;
89
+ columnSequence: string;
90
+ columnCases: string;
91
+ columnCoverage: string;
92
+ columnMedianDuration: string;
93
+ columnState: string;
94
+ /** `{rank}`, `{activities}`, `{cases}`, `{coverage}` (a whole percent number). */
95
+ rowSummary: string;
96
+ /** `{activities}` — the sequence joined with commas. */
97
+ sequence: string;
98
+ activitiesOne: string;
99
+ activitiesOther: string;
100
+ casesOne: string;
101
+ casesOther: string;
102
+ selected: string;
103
+ excluded: string;
104
+ coverageTarget: string;
105
+ /** `{percent}`, `{count}`. */
106
+ coverageTargetValue: string;
107
+ filterToSelected: string;
108
+ tableCaption: string;
109
+ loading: string;
110
+ empty: string;
111
+ emptyBody: string;
112
+ }
113
+
114
+ /** The shipped English labels. */
115
+ export const VARIANT_EXPLORER_DEFAULT_LABELS: Readonly<VariantExplorerLabels> = Object.freeze({
116
+ list: "Variants",
117
+ columnVariant: "Variant",
118
+ columnSequence: "Sequence",
119
+ columnCases: "Cases",
120
+ columnCoverage: "Coverage",
121
+ columnMedianDuration: "Median duration",
122
+ columnState: "State",
123
+ rowSummary: "Variant {rank}, {activities}, {cases}, {coverage} percent coverage",
124
+ sequence: "Sequence: {activities}",
125
+ activitiesOne: "{count} activity",
126
+ activitiesOther: "{count} activities",
127
+ casesOne: "{count} case",
128
+ casesOther: "{count} cases",
129
+ selected: "selected",
130
+ excluded: "excluded",
131
+ coverageTarget: "Coverage target",
132
+ coverageTargetValue: "{percent} of cases · {count} variants",
133
+ filterToSelected: "Show only selected variants",
134
+ tableCaption: "Variants — cases, coverage and median duration per activity sequence",
135
+ loading: "Extracting variants…",
136
+ empty: "No variants",
137
+ emptyBody: "No cases match the current filters.",
138
+ });
139
+
140
+ /** Props for {@link VariantExplorer}. `onSelect` shadows the DOM handler, so it is omitted. */
141
+ export interface VariantExplorerProps extends Omit<HTMLAttributes<HTMLDivElement>, "onSelect"> {
142
+ /** Variants in `extractVariants` order (count descending). */
143
+ variants: Variant[];
144
+ /** The shared colour scale — hand `ProcessMap` the same instance. */
145
+ colorScale: ActivityColorScale;
146
+ /** Tri-state per variant id; an absent key means `"associated"`. */
147
+ selectionStates?: ProcessSelectionStates;
148
+ /** Fires with the ids to select and how to apply them. The explorer never filters. */
149
+ onSelect: (variantIds: string[], mode: VariantSelectMode) => void;
150
+ /** When given, a button emits `{ kind: "variant", ids }` for the selected variants. */
151
+ onFilterIntent?: (intent: FilterIntent) => void;
152
+ /** Numeric columns to show. @default ["cases", "coverage", "medianDuration"] */
153
+ columns?: VariantExplorerColumn[];
154
+ /**
155
+ * Initial coverage target, `0..1`. When given, a slider selects the smallest prefix of
156
+ * variants whose cumulative share reaches the target (mode `"replace"`).
157
+ */
158
+ coverageTarget?: number;
159
+ /** "Variant DNA" strip: two-character codes instead of full activity labels. */
160
+ abbreviate?: boolean;
161
+ /**
162
+ * `"swatch"`: each step is one identity block with no text — the densest strip, for a
163
+ * narrow rail. The strip keeps its accessible name and every block its `title`.
164
+ * @default "text"
165
+ */
166
+ sequenceDisplay?: "text" | "swatch";
167
+ /** Render the accessible table twin instead of the list. @default false */
168
+ tableView?: boolean;
169
+ /** No variants yet. Renders the loading panel rather than an empty list. */
170
+ loading?: boolean;
171
+ /** Override any user-visible string. */
172
+ labels?: Partial<VariantExplorerLabels>;
173
+ }
174
+
175
+ function stateOf(states: ProcessSelectionStates | undefined, id: string): ProcessSelectionState {
176
+ return states?.variants?.[id] ?? "associated";
177
+ }
178
+
179
+ /**
180
+ * The variant explorer.
181
+ *
182
+ * @example
183
+ * ```tsx
184
+ * const scale = useMemo(() => activityColorScale(fullGraph), [fullGraph]);
185
+ * <VariantExplorer
186
+ * variants={explorer.variants}
187
+ * colorScale={scale}
188
+ * selectionStates={explorer.selectionStates}
189
+ * onSelect={(ids) => explorer.applyIntent({ kind: "variant", ids })}
190
+ * />
191
+ * ```
192
+ */
193
+ export const VariantExplorer = forwardRef<HTMLDivElement, VariantExplorerProps>(
194
+ function VariantExplorer(
195
+ {
196
+ variants,
197
+ colorScale,
198
+ selectionStates,
199
+ onSelect,
200
+ onFilterIntent,
201
+ columns = VARIANT_EXPLORER_COLUMNS as VariantExplorerColumn[],
202
+ coverageTarget,
203
+ abbreviate = false,
204
+ sequenceDisplay = "text",
205
+ tableView = false,
206
+ loading = false,
207
+ labels: labelOverrides,
208
+ className,
209
+ ...props
210
+ },
211
+ ref,
212
+ ) {
213
+ const { locale, formatNumber } = useLocale();
214
+ const labels = useMemo<VariantExplorerLabels>(
215
+ () => ({ ...VARIANT_EXPLORER_DEFAULT_LABELS, ...labelOverrides }),
216
+ [labelOverrides],
217
+ );
218
+ const pluralRules = useMemo(() => new Intl.PluralRules(locale), [locale]);
219
+ const plural = useCallback(
220
+ (count: number, one: string, other: string) =>
221
+ fillLabel(pluralRules.select(count) === "one" ? one : other, {
222
+ count: formatNumber(count),
223
+ }),
224
+ [pluralRules, formatNumber],
225
+ );
226
+ const formatShare = useCallback(
227
+ (share: number) => formatNumber(share, { style: "percent", maximumFractionDigits: 1 }),
228
+ [formatNumber],
229
+ );
230
+
231
+ const selectedIds = useMemo(
232
+ () => variants.filter((v) => stateOf(selectionStates, v.id) === "selected").map((v) => v.id),
233
+ [variants, selectionStates],
234
+ );
235
+
236
+ const toggle = useCallback((id: string) => onSelect([id], "toggle"), [onSelect]);
237
+
238
+ const rowText = useCallback(
239
+ (variant: Variant, rank: number, state: ProcessSelectionState): VariantRowText => {
240
+ const activityNames = variant.sequence.map((id) => colorScale.labelFor(id));
241
+ let summary = fillLabel(labels.rowSummary, {
242
+ rank,
243
+ activities: plural(variant.sequence.length, labels.activitiesOne, labels.activitiesOther),
244
+ cases: plural(variant.count, labels.casesOne, labels.casesOther),
245
+ coverage: formatNumber(Math.round(variant.share * 100)),
246
+ });
247
+ if (state === "selected") summary = `${summary}, ${labels.selected}`;
248
+ if (state === "excluded") summary = `${summary}, ${labels.excluded}`;
249
+ return {
250
+ summary,
251
+ sequence: fillLabel(labels.sequence, { activities: activityNames.join(", ") }),
252
+ cases: formatNumber(variant.count),
253
+ coverage: formatShare(variant.share),
254
+ medianDuration: formatDurationMs(variant.duration.median),
255
+ };
256
+ },
257
+ [colorScale, labels, plural, formatNumber, formatShare],
258
+ );
259
+
260
+ // ── Coverage target ────────────────────────────────────────────────────
261
+ const sliderLabelId = useId();
262
+ const [targetPercent, setTargetPercent] = useState(() =>
263
+ Math.round(Math.min(1, Math.max(0, coverageTarget ?? 0)) * 100),
264
+ );
265
+ const targetCount = useMemo(
266
+ () => selectVariantsByCoverage(variants, targetPercent / 100).length,
267
+ [variants, targetPercent],
268
+ );
269
+
270
+ // ── Virtualized list + roving focus ────────────────────────────────────
271
+ const scrollRef = useRef<HTMLDivElement>(null);
272
+ const listRef = useRef<HTMLDivElement>(null);
273
+ const virtualizer = useVirtualizer({
274
+ count: variants.length,
275
+ getScrollElement: () => scrollRef.current,
276
+ estimateSize: () => VARIANT_EXPLORER_ROW_HEIGHT,
277
+ overscan: 6,
278
+ });
279
+ const [activeIndex, setActiveIndex] = useState(0);
280
+ const virtualRows = virtualizer.getVirtualItems();
281
+ // The roving tab stop is the last focused row — but only while that row is mounted. Once
282
+ // the reader scrolls it out of the window (wheel, scrollbar), the stop moves to the first
283
+ // row in view, so Tab can always reach the list and never lands on an unmounted row.
284
+ const clampedActive = Math.min(activeIndex, Math.max(0, variants.length - 1));
285
+ const tabIndex = virtualRows.some((item) => item.index === clampedActive)
286
+ ? clampedActive
287
+ : (virtualizer.range?.startIndex ?? virtualRows[0]?.index ?? clampedActive);
288
+ const pendingFocus = useRef<number | null>(null);
289
+
290
+ // A row scrolled into view by the keyboard mounts on a LATER render than the key press,
291
+ // so focus is handed over whenever the pending row finally exists. No deps on purpose:
292
+ // it has to look after every render until the row is there.
293
+ useLayoutEffect(() => {
294
+ const index = pendingFocus.current;
295
+ if (index === null) return;
296
+ const checkbox = listRef.current?.querySelector<HTMLElement>(
297
+ `[data-index="${index}"] [data-slot="checkbox"]`,
298
+ );
299
+ if (checkbox) {
300
+ pendingFocus.current = null;
301
+ checkbox.focus();
302
+ }
303
+ });
304
+
305
+ const handleKeyDown = useCallback(
306
+ (event: KeyboardEvent<HTMLDivElement>) => {
307
+ if (variants.length === 0) return;
308
+ const last = variants.length - 1;
309
+ const page = Math.max(
310
+ 1,
311
+ Math.floor((scrollRef.current?.clientHeight ?? 0) / VARIANT_EXPLORER_ROW_HEIGHT),
312
+ );
313
+ let next: number | null = null;
314
+ switch (event.key) {
315
+ case "ArrowDown":
316
+ next = Math.min(last, tabIndex + 1);
317
+ break;
318
+ case "ArrowUp":
319
+ next = Math.max(0, tabIndex - 1);
320
+ break;
321
+ case "PageDown":
322
+ next = Math.min(last, tabIndex + page);
323
+ break;
324
+ case "PageUp":
325
+ next = Math.max(0, tabIndex - page);
326
+ break;
327
+ case "Home":
328
+ next = 0;
329
+ break;
330
+ case "End":
331
+ next = last;
332
+ break;
333
+ case "Enter": {
334
+ event.preventDefault();
335
+ const variant = variants[tabIndex];
336
+ if (variant) toggle(variant.id);
337
+ return;
338
+ }
339
+ default:
340
+ return;
341
+ }
342
+ event.preventDefault();
343
+ setActiveIndex(next);
344
+ pendingFocus.current = next;
345
+ virtualizer.scrollToIndex(next, { align: "auto" });
346
+ },
347
+ [variants, tabIndex, toggle, virtualizer],
348
+ );
349
+
350
+ if (loading) {
351
+ return (
352
+ <div
353
+ ref={ref}
354
+ data-slot="variant-explorer"
355
+ data-state="loading"
356
+ className={cn("relative flex h-96 flex-col", className)}
357
+ {...props}
358
+ >
359
+ <StatePanel kind="loading" title={labels.loading} />
360
+ </div>
361
+ );
362
+ }
363
+
364
+ if (variants.length === 0) {
365
+ return (
366
+ <div
367
+ ref={ref}
368
+ data-slot="variant-explorer"
369
+ data-state="empty"
370
+ className={cn("relative flex h-96 flex-col", className)}
371
+ {...props}
372
+ >
373
+ <StatePanel kind="empty" title={labels.empty} description={labels.emptyBody} />
374
+ </div>
375
+ );
376
+ }
377
+
378
+ const toolbar =
379
+ coverageTarget !== undefined || onFilterIntent ? (
380
+ <div data-slot="variant-explorer-toolbar" className="flex flex-wrap items-center gap-3">
381
+ {coverageTarget !== undefined ? (
382
+ <div
383
+ data-slot="variant-explorer-coverage-target"
384
+ className="flex min-w-0 flex-1 items-center gap-3"
385
+ >
386
+ <span id={sliderLabelId} className="shrink-0 text-meta text-muted-foreground">
387
+ {labels.coverageTarget}
388
+ </span>
389
+ <Slider
390
+ aria-labelledby={sliderLabelId}
391
+ aria-valuetext={fillLabel(labels.coverageTargetValue, {
392
+ percent: formatShare(targetPercent / 100),
393
+ count: formatNumber(targetCount),
394
+ })}
395
+ className="min-w-24 max-w-64 flex-1"
396
+ min={0}
397
+ max={100}
398
+ step={1}
399
+ value={[targetPercent]}
400
+ onValueChange={(value) => setTargetPercent(value[0] ?? 0)}
401
+ onValueCommit={(value) =>
402
+ onSelect(selectVariantsByCoverage(variants, (value[0] ?? 0) / 100), "replace")
403
+ }
404
+ />
405
+ <span className="shrink-0 text-meta tabular-nums">
406
+ {fillLabel(labels.coverageTargetValue, {
407
+ percent: formatShare(targetPercent / 100),
408
+ count: formatNumber(targetCount),
409
+ })}
410
+ </span>
411
+ </div>
412
+ ) : null}
413
+ {onFilterIntent ? (
414
+ <Button
415
+ type="button"
416
+ variant="outline"
417
+ size="sm"
418
+ className="ms-auto"
419
+ disabled={selectedIds.length === 0}
420
+ onClick={() => onFilterIntent({ kind: "variant", ids: selectedIds })}
421
+ >
422
+ {labels.filterToSelected}
423
+ </Button>
424
+ ) : null}
425
+ </div>
426
+ ) : null;
427
+
428
+ if (tableView) {
429
+ return (
430
+ <div
431
+ ref={ref}
432
+ data-slot="variant-explorer"
433
+ data-view="table"
434
+ className={cn("flex flex-col gap-3", className)}
435
+ {...props}
436
+ >
437
+ {toolbar}
438
+ <Table data-slot="variant-explorer-table">
439
+ <TableCaption>{labels.tableCaption}</TableCaption>
440
+ <TableHeader>
441
+ <TableRow>
442
+ <TableHead scope="col">{labels.columnVariant}</TableHead>
443
+ <TableHead scope="col">{labels.columnSequence}</TableHead>
444
+ {columns.includes("cases") ? (
445
+ <TableHead scope="col">{labels.columnCases}</TableHead>
446
+ ) : null}
447
+ {columns.includes("coverage") ? (
448
+ <TableHead scope="col">{labels.columnCoverage}</TableHead>
449
+ ) : null}
450
+ {columns.includes("medianDuration") ? (
451
+ <TableHead scope="col">{labels.columnMedianDuration}</TableHead>
452
+ ) : null}
453
+ <TableHead scope="col">{labels.columnState}</TableHead>
454
+ </TableRow>
455
+ </TableHeader>
456
+ <TableBody>
457
+ {variants.map((variant, index) => {
458
+ const state = stateOf(selectionStates, variant.id);
459
+ const text = rowText(variant, index + 1, state);
460
+ return (
461
+ <TableRow
462
+ key={variant.id}
463
+ data-selection={state}
464
+ data-state={state === "selected" ? "selected" : undefined}
465
+ >
466
+ <TableCell className="tabular-nums">{index + 1}</TableCell>
467
+ <TableCell>
468
+ {variant.sequence.map((id) => colorScale.labelFor(id)).join(" → ")}
469
+ </TableCell>
470
+ {columns.includes("cases") ? (
471
+ <TableCell className="tabular-nums">{text.cases}</TableCell>
472
+ ) : null}
473
+ {columns.includes("coverage") ? (
474
+ <TableCell className="tabular-nums">{text.coverage}</TableCell>
475
+ ) : null}
476
+ {columns.includes("medianDuration") ? (
477
+ <TableCell className="tabular-nums">{text.medianDuration}</TableCell>
478
+ ) : null}
479
+ <TableCell>
480
+ {state === "selected"
481
+ ? labels.selected
482
+ : state === "excluded"
483
+ ? labels.excluded
484
+ : ""}
485
+ </TableCell>
486
+ </TableRow>
487
+ );
488
+ })}
489
+ </TableBody>
490
+ </Table>
491
+ </div>
492
+ );
493
+ }
494
+
495
+ return (
496
+ <div
497
+ ref={ref}
498
+ data-slot="variant-explorer"
499
+ data-view="list"
500
+ className={cn("flex h-96 min-h-0 flex-col gap-3", className)}
501
+ {...props}
502
+ >
503
+ {toolbar}
504
+ {/* header-band-exempt: a table column header row, not shell chrome */}
505
+ <div
506
+ aria-hidden="true"
507
+ data-slot="variant-explorer-header"
508
+ className="flex h-8 shrink-0 items-center gap-3 border-b border-border-strong px-3 text-meta text-muted-foreground"
509
+ >
510
+ <span className="w-4 shrink-0" />
511
+ <span className="w-8 shrink-0">#</span>
512
+ <span className="min-w-0 flex-1">{labels.columnSequence}</span>
513
+ {columns.includes("cases") ? (
514
+ <span className="w-16 shrink-0 text-end">{labels.columnCases}</span>
515
+ ) : null}
516
+ {columns.includes("coverage") ? (
517
+ <span className="w-32 shrink-0">{labels.columnCoverage}</span>
518
+ ) : null}
519
+ {columns.includes("medianDuration") ? (
520
+ <span className="w-20 shrink-0 truncate text-end">{labels.columnMedianDuration}</span>
521
+ ) : null}
522
+ </div>
523
+ <div
524
+ ref={scrollRef}
525
+ data-slot="variant-explorer-viewport"
526
+ className="relative min-h-0 flex-1 overflow-auto"
527
+ onKeyDown={handleKeyDown}
528
+ >
529
+ <div
530
+ ref={listRef}
531
+ role="list"
532
+ aria-label={labels.list}
533
+ data-slot="variant-explorer-list"
534
+ className="relative w-full"
535
+ style={{ height: virtualizer.getTotalSize() }}
536
+ >
537
+ {virtualRows.map((item) => {
538
+ const variant = variants[item.index] as Variant;
539
+ const state = stateOf(selectionStates, variant.id);
540
+ return (
541
+ <VariantRow
542
+ key={variant.id}
543
+ aria-setsize={variants.length}
544
+ aria-posinset={item.index + 1}
545
+ className="absolute inset-x-0 top-0"
546
+ style={{ transform: `translateY(${item.start}px)` }}
547
+ variant={variant}
548
+ rank={item.index + 1}
549
+ index={item.index}
550
+ selectionState={state}
551
+ colorScale={colorScale}
552
+ abbreviate={abbreviate}
553
+ sequenceDisplay={sequenceDisplay}
554
+ columns={columns}
555
+ text={rowText(variant, item.index + 1, state)}
556
+ tabbable={item.index === tabIndex}
557
+ onToggle={toggle}
558
+ onFocusRow={setActiveIndex}
559
+ />
560
+ );
561
+ })}
562
+ </div>
563
+ </div>
564
+ </div>
565
+ );
566
+ },
567
+ );
@@ -0,0 +1,137 @@
1
+ "use client";
2
+
3
+ /**
4
+ * One variant as a list row (RM-054): selection checkbox, rank, sequence chips and the
5
+ * requested numeric columns.
6
+ *
7
+ * The checkbox is the row's ONE focus target. Its accessible name is the whole row in
8
+ * words ("Variant 3, 6 activities, 214 cases, 12 percent coverage"), so a keyboard or
9
+ * screen-reader user gets every number without a second tab stop per row. A pointer click
10
+ * anywhere on the row toggles it too — the checkbox already offers the same action from the
11
+ * keyboard, so the row click is a convenience, never the only path.
12
+ */
13
+ import { forwardRef, type CSSProperties, type HTMLAttributes, type MouseEvent } from "react";
14
+ import { Checkbox } from "@elabs-ai/components-ui";
15
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
16
+ import type { ActivityColorScale } from "../core/activity-color-scale";
17
+ import type { Variant } from "../core/types";
18
+ import type { ProcessSelectionState } from "../process-map/map-model";
19
+ import { VariantCoverageBar } from "./coverage-bar";
20
+ import { VariantSequenceChips } from "./sequence-chips";
21
+ import type { VariantExplorerColumn } from "./variant-explorer-model";
22
+
23
+ /** Pre-formatted text a row prints — the explorer formats once, with its locale. */
24
+ export interface VariantRowText {
25
+ /** The checkbox's accessible name — the whole row in words. */
26
+ summary: string;
27
+ /** The sequence strip's accessible name. */
28
+ sequence: string;
29
+ cases: string;
30
+ coverage: string;
31
+ medianDuration: string;
32
+ }
33
+
34
+ export interface VariantRowProps extends Omit<HTMLAttributes<HTMLDivElement>, "onToggle"> {
35
+ variant: Variant;
36
+ /** 1-based position in the list. */
37
+ rank: number;
38
+ /** 0-based position in the list, stamped as `data-index` for the virtualizer. */
39
+ index: number;
40
+ selectionState: ProcessSelectionState;
41
+ colorScale: ActivityColorScale;
42
+ abbreviate: boolean;
43
+ sequenceDisplay?: "text" | "swatch";
44
+ columns: readonly VariantExplorerColumn[];
45
+ text: VariantRowText;
46
+ /** Whether this row's checkbox is the list's single tab stop. */
47
+ tabbable: boolean;
48
+ onToggle: (variantId: string) => void;
49
+ onFocusRow: (index: number) => void;
50
+ style?: CSSProperties;
51
+ }
52
+
53
+ export const VariantRow = forwardRef<HTMLDivElement, VariantRowProps>(function VariantRow(
54
+ {
55
+ variant,
56
+ rank,
57
+ index,
58
+ selectionState,
59
+ colorScale,
60
+ abbreviate,
61
+ sequenceDisplay,
62
+ columns,
63
+ text,
64
+ tabbable,
65
+ onToggle,
66
+ onFocusRow,
67
+ className,
68
+ ...props
69
+ },
70
+ ref,
71
+ ) {
72
+ const selected = selectionState === "selected";
73
+
74
+ const handleClick = (event: MouseEvent<HTMLDivElement>) => {
75
+ // The checkbox handles its own click; toggling here too would undo it.
76
+ if ((event.target as HTMLElement).closest('[data-slot="checkbox"]')) return;
77
+ onToggle(variant.id);
78
+ };
79
+
80
+ return (
81
+ <div
82
+ ref={ref}
83
+ role="listitem"
84
+ data-slot="variant-explorer-row"
85
+ data-index={index}
86
+ data-variant={variant.id}
87
+ data-selection={selectionState}
88
+ className={cn(
89
+ "flex h-10 items-center gap-3 border-b border-border-strong px-3 transition-colors duration-fast ease-standard motion-reduce:transition-none",
90
+ "hover:bg-muted/60 data-[selection=selected]:bg-accent data-[selection=excluded]:text-muted-foreground",
91
+ className,
92
+ )}
93
+ onClick={handleClick}
94
+ {...props}
95
+ >
96
+ <Checkbox
97
+ checked={selected}
98
+ aria-label={text.summary}
99
+ tabIndex={tabbable ? 0 : -1}
100
+ onCheckedChange={() => onToggle(variant.id)}
101
+ onFocus={() => onFocusRow(index)}
102
+ />
103
+ <span
104
+ aria-hidden="true"
105
+ className="w-8 shrink-0 text-meta text-muted-foreground tabular-nums"
106
+ >
107
+ {rank}
108
+ </span>
109
+ <VariantSequenceChips
110
+ className="flex-1"
111
+ sequence={variant.sequence}
112
+ colorScale={colorScale}
113
+ abbreviate={abbreviate}
114
+ display={sequenceDisplay}
115
+ label={text.sequence}
116
+ />
117
+ {columns.includes("cases") ? (
118
+ <span aria-hidden="true" className="w-16 shrink-0 text-end text-meta tabular-nums">
119
+ {text.cases}
120
+ </span>
121
+ ) : null}
122
+ {columns.includes("coverage") ? (
123
+ <VariantCoverageBar
124
+ aria-hidden="true"
125
+ className="w-32 shrink-0"
126
+ share={variant.share}
127
+ valueLabel={text.coverage}
128
+ />
129
+ ) : null}
130
+ {columns.includes("medianDuration") ? (
131
+ <span aria-hidden="true" className="w-20 shrink-0 text-end text-meta tabular-nums">
132
+ {text.medianDuration}
133
+ </span>
134
+ ) : null}
135
+ </div>
136
+ );
137
+ });