@principal-ai/subsystems-react 0.28.1 → 0.28.3

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.
@@ -0,0 +1,628 @@
1
+ /**
2
+ * WalkthroughsPanel — the sidebar's flows panel: the ordered list of a
3
+ * subsystem's walkthroughs, each rendered as a collapsible `WalkthroughFlow`
4
+ * row. Extracted from `SubsystemComponentGraph`'s `Inner` so the panel (and the
5
+ * drag-to-reorder controller it hosts) is a component boundary of its own.
6
+ *
7
+ * Ordering is authored order: the list renders `walkthroughs` in array order.
8
+ * When `onReorder` is supplied, each row grows a grip in its header and dropping
9
+ * a row onto another emits the reordered array (see `reorderWalkthroughs`); the
10
+ * host owns persistence.
11
+ */
12
+
13
+ import { useCallback, useEffect, useRef, useState } from 'react';
14
+ import type { DragEvent as ReactDragEvent } from 'react';
15
+ import { useTheme } from '@principal-ade/industry-theme';
16
+ import { ChevronDown, GripVertical, Pause, Play } from 'lucide-react';
17
+ import {
18
+ PROPOSED_COLOR,
19
+ reorderTargetIndex,
20
+ reorderWalkthroughs,
21
+ walkthroughStepGraphEdgeId,
22
+ type SubsystemWalkthrough,
23
+ } from './model';
24
+
25
+ /** Pause (ms) between steps when a walkthrough autoplays. */
26
+ export const WALKTHROUGH_PLAY_PAUSE_MS = 2500;
27
+
28
+ /** Drag state handed to a row's grip while a reorder is in flight. */
29
+ interface WalkthroughReorderHandle {
30
+ index: number;
31
+ isDragging: boolean;
32
+ onDragStart: (index: number, e: ReactDragEvent) => void;
33
+ onDragEnd: () => void;
34
+ }
35
+
36
+ /** One collapsible walkthrough in the sidebar's flows panel. Clicking the
37
+ * title: closed → open + select; open with a step selected → select the whole
38
+ * flow (deselect the step, stay open); open with the whole flow selected →
39
+ * close + clear focus. The right-aligned close button collapses without
40
+ * selecting. A step row focuses that step's edge. */
41
+ function WalkthroughFlow({
42
+ walkthrough,
43
+ collapsed,
44
+ active,
45
+ onToggleCollapsed,
46
+ onFocusFlow,
47
+ onClearFocus,
48
+ onFocusStep,
49
+ onHoverStep,
50
+ onHoverFlow,
51
+ onLeaveStep,
52
+ reorder,
53
+ dragActive,
54
+ isHoverSuppressed,
55
+ proposedAliases,
56
+ }: {
57
+ walkthrough: SubsystemWalkthrough;
58
+ collapsed: boolean;
59
+ /** `{ stepIndex: null }` = whole flow focused; `{ stepIndex }` = one step. */
60
+ active: { stepIndex: number | null } | null;
61
+ onToggleCollapsed: (tlId: string) => void;
62
+ onFocusFlow: (tl: SubsystemWalkthrough) => void;
63
+ onClearFocus: () => void;
64
+ onFocusStep: (tl: SubsystemWalkthrough, stepIndex: number) => void;
65
+ onHoverStep: (tl: SubsystemWalkthrough, stepIndex: number) => void;
66
+ /** Preview the whole flow on the canvas (used while the row is collapsed). */
67
+ onHoverFlow: (tl: SubsystemWalkthrough) => void;
68
+ onLeaveStep: () => void;
69
+ /** When set, renders a grip in the header that starts a reorder drag. */
70
+ reorder?: WalkthroughReorderHandle;
71
+ /** True while any row is mid-drag; used to reset stale hover styling. */
72
+ dragActive?: boolean;
73
+ /** True just after a drop; ignores the browser's synthetic hover until the pointer moves. */
74
+ isHoverSuppressed?: () => boolean;
75
+ /**
76
+ * Aliases of components marked `proposed`. A row whose steps touch one gets
77
+ * its title tinted, and each such step (index and title) is tinted too
78
+ * (darkgoldenrod, same as the proposed node treatment).
79
+ */
80
+ proposedAliases?: ReadonlySet<string>;
81
+ }) {
82
+ const { theme } = useTheme();
83
+ const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
84
+ const hoverBg = theme.colors.background;
85
+ const wholeFlowActive = active !== null && active.stepIndex === null;
86
+ // A step is "proposed" when either endpoint is a proposed component.
87
+ const stepProposed = (from: string, to: string) =>
88
+ proposedAliases != null && (proposedAliases.has(from) || proposedAliases.has(to));
89
+ const touchesProposed = walkthrough.steps.some((s) => stepProposed(s.from, s.to));
90
+ const [headerHover, setHeaderHover] = useState(false);
91
+ const [collapseHover, setCollapseHover] = useState(false);
92
+ const [playHover, setPlayHover] = useState(false);
93
+ const [gripHover, setGripHover] = useState(false);
94
+ const [hoveredStep, setHoveredStep] = useState<number | null>(null);
95
+ const stepButtonRefs = useRef<(HTMLButtonElement | null)[]>([]);
96
+ // Autoplay: stepping through the flow's steps with a pause between each.
97
+ const [playing, setPlaying] = useState(false);
98
+ const playTimerRef = useRef<number | null>(null);
99
+
100
+ const stopPlaying = useCallback(() => {
101
+ if (playTimerRef.current != null) {
102
+ window.clearTimeout(playTimerRef.current);
103
+ playTimerRef.current = null;
104
+ }
105
+ setPlaying(false);
106
+ }, []);
107
+
108
+ // Clear any pending timer on unmount.
109
+ // eslint-disable-next-line react-hooks/exhaustive-deps
110
+ useEffect(() => () => stopPlaying(), []);
111
+
112
+ // Native HTML5 drag suppresses mouse events, so a row hovered when the drag
113
+ // began never gets its mouseleave — its hover styling would stick after the
114
+ // drop. Reset every row's hover whenever a drag starts or ends.
115
+ useEffect(() => {
116
+ setHeaderHover(false);
117
+ setCollapseHover(false);
118
+ setPlayHover(false);
119
+ setGripHover(false);
120
+ setHoveredStep(null);
121
+ }, [dragActive]);
122
+
123
+ const startPlaying = useCallback(() => {
124
+ if (collapsed) onToggleCollapsed(walkthrough.id);
125
+ if (active === null || active.stepIndex !== null) onFocusFlow(walkthrough);
126
+ const stepCount = walkthrough.steps.length;
127
+ if (stepCount === 0) return;
128
+ setPlaying(true);
129
+ let i = 0;
130
+ const tick = () => {
131
+ if (i >= stepCount) {
132
+ playTimerRef.current = null;
133
+ setPlaying(false);
134
+ return;
135
+ }
136
+ onFocusStep(walkthrough, i);
137
+ i += 1;
138
+ playTimerRef.current = window.setTimeout(tick, WALKTHROUGH_PLAY_PAUSE_MS);
139
+ };
140
+ tick();
141
+ }, [collapsed, active, onToggleCollapsed, onFocusFlow, walkthrough, onFocusStep]);
142
+
143
+ const togglePlay = useCallback(() => {
144
+ if (playing) {
145
+ stopPlaying();
146
+ } else {
147
+ startPlaying();
148
+ }
149
+ }, [playing, stopPlaying, startPlaying]);
150
+
151
+ // Keep DOM focus on the active step so the browser focus ring (and
152
+ // subsequent arrow keys) follow arrow navigation, not the originally
153
+ // clicked button.
154
+ useEffect(() => {
155
+ if (active?.stepIndex == null) return;
156
+ stepButtonRefs.current[active.stepIndex]?.focus({ preventScroll: true });
157
+ }, [active?.stepIndex]);
158
+
159
+ return (
160
+ <div>
161
+ <div
162
+ onMouseEnter={() => {
163
+ if (isHoverSuppressed?.()) return;
164
+ setHeaderHover(true);
165
+ if (collapsed) onHoverFlow(walkthrough);
166
+ }}
167
+ onMouseLeave={() => {
168
+ setHeaderHover(false);
169
+ if (collapsed) onLeaveStep();
170
+ }}
171
+ style={{
172
+ display: 'flex',
173
+ alignItems: 'center',
174
+ gap: 4,
175
+ padding: '0 16px',
176
+ background: wholeFlowActive || headerHover ? hoverBg : 'transparent',
177
+ transition: 'background 120ms ease',
178
+ }}
179
+ >
180
+ <button
181
+ type="button"
182
+ onClick={() => {
183
+ if (collapsed) {
184
+ onToggleCollapsed(walkthrough.id);
185
+ onFocusFlow(walkthrough);
186
+ } else if (active !== null && active.stepIndex === null) {
187
+ // Whole flow already selected → collapse + clear.
188
+ onToggleCollapsed(walkthrough.id);
189
+ onClearFocus();
190
+ } else {
191
+ // Expanded with nothing (or a step) selected → select the whole
192
+ // flow, which deselects any focused step without collapsing.
193
+ onFocusFlow(walkthrough);
194
+ }
195
+ }}
196
+ style={{
197
+ flex: 1,
198
+ display: 'flex',
199
+ alignItems: 'center',
200
+ minWidth: 0,
201
+ padding: '10px 0',
202
+ border: 'none',
203
+ background: 'transparent',
204
+ textAlign: 'left',
205
+ cursor: 'pointer',
206
+ }}
207
+ >
208
+ <span
209
+ style={{
210
+ overflow: 'hidden',
211
+ textOverflow: 'ellipsis',
212
+ whiteSpace: 'nowrap',
213
+ fontSize: theme.fontSizes[1],
214
+ fontFamily: theme.fonts.monospace,
215
+ fontWeight: 600,
216
+ color: touchesProposed ? PROPOSED_COLOR : theme.colors.text,
217
+ }}
218
+ >
219
+ {walkthrough.title}
220
+ </span>
221
+ </button>
222
+ {!collapsed && (
223
+ <>
224
+ <button
225
+ type="button"
226
+ aria-label={playing ? `Pause ${walkthrough.title} autoplay` : `Play ${walkthrough.title}`}
227
+ title={playing ? 'Pause' : 'Play through steps'}
228
+ onMouseEnter={() => setPlayHover(true)}
229
+ onMouseLeave={() => setPlayHover(false)}
230
+ onClick={(e) => {
231
+ e.stopPropagation();
232
+ togglePlay();
233
+ }}
234
+ style={{
235
+ display: 'inline-flex',
236
+ alignItems: 'center',
237
+ justifyContent: 'center',
238
+ flexShrink: 0,
239
+ width: 22,
240
+ height: 22,
241
+ padding: 0,
242
+ border: 'none',
243
+ borderRadius: 4,
244
+ background: playing || playHover ? theme.colors.border : 'transparent',
245
+ color: playing || playHover ? theme.colors.text : muted,
246
+ cursor: 'pointer',
247
+ transition: 'background 120ms ease, color 120ms ease',
248
+ }}
249
+ >
250
+ {playing ? <Pause size={12} strokeWidth={2} /> : <Play size={12} strokeWidth={2} />}
251
+ </button>
252
+ <button
253
+ type="button"
254
+ aria-label={`Collapse ${walkthrough.title}`}
255
+ title="Collapse"
256
+ onMouseEnter={() => setCollapseHover(true)}
257
+ onMouseLeave={() => setCollapseHover(false)}
258
+ onClick={(e) => {
259
+ e.stopPropagation();
260
+ onToggleCollapsed(walkthrough.id);
261
+ if (active !== null) onClearFocus();
262
+ }}
263
+ style={{
264
+ display: 'inline-flex',
265
+ alignItems: 'center',
266
+ justifyContent: 'center',
267
+ flexShrink: 0,
268
+ width: 22,
269
+ height: 22,
270
+ padding: 0,
271
+ border: 'none',
272
+ borderRadius: 4,
273
+ background: collapseHover ? theme.colors.border : 'transparent',
274
+ color: collapseHover ? theme.colors.text : muted,
275
+ cursor: 'pointer',
276
+ transition: 'background 120ms ease, color 120ms ease',
277
+ }}
278
+ >
279
+ <ChevronDown size={14} strokeWidth={2} />
280
+ </button>
281
+ </>
282
+ )}
283
+ {reorder && (
284
+ <span
285
+ role="button"
286
+ tabIndex={-1}
287
+ draggable
288
+ aria-label={`Reorder ${walkthrough.title}`}
289
+ title="Drag to reorder"
290
+ onMouseEnter={() => {
291
+ if (!isHoverSuppressed?.()) setGripHover(true);
292
+ }}
293
+ onMouseLeave={() => setGripHover(false)}
294
+ onDragStart={(e) => reorder.onDragStart(reorder.index, e)}
295
+ onDragEnd={reorder.onDragEnd}
296
+ onClick={(e) => e.stopPropagation()}
297
+ style={{
298
+ display: 'inline-flex',
299
+ alignItems: 'center',
300
+ flexShrink: 0,
301
+ marginRight: -8,
302
+ padding: '2px 0',
303
+ color: gripHover || reorder.isDragging ? theme.colors.text : muted,
304
+ cursor: reorder.isDragging ? 'grabbing' : 'grab',
305
+ transition: 'color 120ms ease',
306
+ }}
307
+ >
308
+ <GripVertical size={14} strokeWidth={2} />
309
+ </span>
310
+ )}
311
+ </div>
312
+ {!collapsed && (
313
+ <div
314
+ style={{ display: 'flex', flexDirection: 'column' }}
315
+ onMouseLeave={() => {
316
+ setHoveredStep(null);
317
+ onLeaveStep();
318
+ }}
319
+ >
320
+ {walkthrough.steps.map((step, i) => {
321
+ const stepActive = active !== null && active.stepIndex === i;
322
+ const proposed = stepProposed(step.from, step.to);
323
+ return (
324
+ <button
325
+ key={`${walkthroughStepGraphEdgeId(step)}-${i}`}
326
+ ref={(el) => {
327
+ stepButtonRefs.current[i] = el;
328
+ }}
329
+ type="button"
330
+ title={proposed ? 'Step touches a proposed component' : undefined}
331
+ onMouseEnter={() => {
332
+ setHoveredStep(i);
333
+ onHoverStep(walkthrough, i);
334
+ }}
335
+ onClick={() => onFocusStep(walkthrough, i)}
336
+ style={{
337
+ display: 'flex',
338
+ alignItems: 'center',
339
+ gap: 8,
340
+ minWidth: 0,
341
+ padding: '8px 8px 8px 17px',
342
+ textAlign: 'left',
343
+ borderRadius: 0,
344
+ border: 'none',
345
+ outline: 'none',
346
+ background: stepActive || hoveredStep === i ? hoverBg : 'transparent',
347
+ cursor: 'pointer',
348
+ transition: 'background 120ms ease',
349
+ }}
350
+ >
351
+ <span
352
+ style={{
353
+ flexShrink: 0,
354
+ width: 14,
355
+ fontSize: theme.fontSizes[0],
356
+ fontFamily: theme.fonts.monospace,
357
+ fontWeight: proposed ? 700 : undefined,
358
+ color: proposed
359
+ ? PROPOSED_COLOR
360
+ : stepActive
361
+ ? theme.colors.text
362
+ : muted,
363
+ }}
364
+ >
365
+ {i + 1}
366
+ </span>
367
+ <span
368
+ style={{
369
+ flex: 1,
370
+ minWidth: 0,
371
+ overflow: 'hidden',
372
+ textOverflow: 'ellipsis',
373
+ whiteSpace: 'nowrap',
374
+ fontSize: theme.fontSizes[1],
375
+ fontFamily: theme.fonts.monospace,
376
+ color: proposed ? PROPOSED_COLOR : theme.colors.text,
377
+ }}
378
+ >
379
+ {step.symbol}
380
+ </span>
381
+ </button>
382
+ );
383
+ })}
384
+ </div>
385
+ )}
386
+ </div>
387
+ );
388
+ }
389
+
390
+ export interface WalkthroughsPanelProps {
391
+ /** Ordered walkthroughs — the list renders them in array order. */
392
+ walkthroughs: SubsystemWalkthrough[];
393
+ /** Ids of the rows whose step lists are expanded. */
394
+ expandedWalkthroughs: Set<string>;
395
+ focusedWalkthroughId: string | null;
396
+ focusedStepIndex: number | null;
397
+ hoveredWalkthroughStep: {
398
+ walkthroughId: string;
399
+ stepIndex: number | null;
400
+ } | null;
401
+ onToggleCollapsed: (tlId: string) => void;
402
+ onFocusFlow: (tl: SubsystemWalkthrough) => void;
403
+ onClearFocus: () => void;
404
+ onFocusStep: (tl: SubsystemWalkthrough, stepIndex: number) => void;
405
+ onHoverStep: (tl: SubsystemWalkthrough, stepIndex: number) => void;
406
+ onHoverFlow: (tl: SubsystemWalkthrough) => void;
407
+ onLeaveStep: () => void;
408
+ /**
409
+ * When set, rows grow a drag grip (and become drop targets) so a drop emits
410
+ * the reordered array. Grips show only while every row is collapsed. Omit
411
+ * for a read-only panel.
412
+ */
413
+ onReorder?: (next: SubsystemWalkthrough[]) => void;
414
+ /**
415
+ * Aliases of components marked `proposed`. A row whose steps touch one gets
416
+ * its title tinted, and each such step (index and title) is tinted too
417
+ * (darkgoldenrod, same as the proposed node treatment).
418
+ */
419
+ proposedAliases?: ReadonlySet<string>;
420
+ }
421
+
422
+ /**
423
+ * The flows panel: a scroll container of `WalkthroughFlow` rows, optionally
424
+ * drag-reorderable. The host owns the array; a drop calls `onReorder` with the
425
+ * next order and the host re-renders with it.
426
+ */
427
+ export function WalkthroughsPanel({
428
+ walkthroughs,
429
+ expandedWalkthroughs,
430
+ focusedWalkthroughId,
431
+ focusedStepIndex,
432
+ hoveredWalkthroughStep,
433
+ onToggleCollapsed,
434
+ onFocusFlow,
435
+ onClearFocus,
436
+ onFocusStep,
437
+ onHoverStep,
438
+ onHoverFlow,
439
+ onLeaveStep,
440
+ onReorder,
441
+ proposedAliases,
442
+ }: WalkthroughsPanelProps) {
443
+ const { theme } = useTheme();
444
+ // Reordering is offered only while every row is collapsed: the compact list
445
+ // is what you drag, and expanded step lists would make drop targets tall and
446
+ // the landing boundary ambiguous.
447
+ const allCollapsed = walkthroughs.every((w) => !expandedWalkthroughs.has(w.id));
448
+ const reorderable = onReorder != null && allCollapsed;
449
+ // Row being dragged, and the gap the pointer is over (boundaries 0..n
450
+ // between rows). The boundary is where the dragged row will be inserted.
451
+ const [dragIndex, setDragIndex] = useState<number | null>(null);
452
+ const [dropBoundary, setDropBoundary] = useState<number | null>(null);
453
+ // After a native drag ends the browser fires a mouseenter on whatever sits
454
+ // under the cursor, which would light up a row the user never moved onto.
455
+ // Ignore hover until the pointer actually moves. A ref (not state) so the
456
+ // guard is synchronous — the synthetic event can beat a re-render.
457
+ const suppressHoverRef = useRef(false);
458
+ const hoverClearRef = useRef<(() => void) | null>(null);
459
+
460
+ const suppressHoverUntilMove = useCallback(() => {
461
+ suppressHoverRef.current = true;
462
+ if (hoverClearRef.current) return;
463
+ const clear = () => {
464
+ suppressHoverRef.current = false;
465
+ window.removeEventListener('mousemove', clear);
466
+ hoverClearRef.current = null;
467
+ };
468
+ hoverClearRef.current = clear;
469
+ window.addEventListener('mousemove', clear);
470
+ }, []);
471
+
472
+ useEffect(
473
+ () => () => {
474
+ if (hoverClearRef.current) {
475
+ window.removeEventListener('mousemove', hoverClearRef.current);
476
+ hoverClearRef.current = null;
477
+ }
478
+ },
479
+ [],
480
+ );
481
+
482
+ const resetDrag = useCallback(() => {
483
+ setDragIndex(null);
484
+ setDropBoundary(null);
485
+ suppressHoverUntilMove();
486
+ }, [suppressHoverUntilMove]);
487
+
488
+ const handleDragStart = useCallback((index: number, e: ReactDragEvent) => {
489
+ suppressHoverRef.current = false;
490
+ setDragIndex(index);
491
+ e.dataTransfer.effectAllowed = 'move';
492
+ // Firefox requires data to be set for a drag to begin.
493
+ e.dataTransfer.setData('text/plain', String(index));
494
+ }, []);
495
+
496
+ // Pick the boundary nearest the pointer: above the row when over its top
497
+ // half, below it when over the bottom half. The line marks that gap, and the
498
+ // drop inserts there — so the indicator always matches the result.
499
+ const handleDragOver = useCallback(
500
+ (e: ReactDragEvent, index: number) => {
501
+ if (dragIndex == null) return;
502
+ e.preventDefault();
503
+ e.dataTransfer.dropEffect = 'move';
504
+ const rect = e.currentTarget.getBoundingClientRect();
505
+ const below = e.clientY - rect.top > rect.height / 2;
506
+ setDropBoundary(index + (below ? 1 : 0));
507
+ },
508
+ [dragIndex],
509
+ );
510
+
511
+ const handleDrop = useCallback(
512
+ (e: ReactDragEvent) => {
513
+ e.preventDefault();
514
+ const boundary = dropBoundary;
515
+ const from = dragIndex ?? Number(e.dataTransfer.getData('text/plain'));
516
+ resetDrag();
517
+ if (!onReorder || boundary == null || !Number.isInteger(from)) return;
518
+ // Removing the dragged row shifts every boundary after it down one.
519
+ const target = reorderTargetIndex(boundary, from);
520
+ if (target === from) return;
521
+ onReorder(reorderWalkthroughs(walkthroughs, from, target));
522
+ },
523
+ [dragIndex, dropBoundary, onReorder, resetDrag, walkthroughs],
524
+ );
525
+
526
+ const accent = theme.colors.accent ?? theme.colors.primary ?? theme.colors.text;
527
+ const isHoverSuppressed = useCallback(() => suppressHoverRef.current, []);
528
+
529
+ return (
530
+ <div
531
+ style={{
532
+ flex: 1,
533
+ minHeight: 0,
534
+ overflowY: 'auto',
535
+ padding: '0 0 12px',
536
+ }}
537
+ onDragEnd={reorderable ? resetDrag : undefined}
538
+ >
539
+ {walkthroughs.map((tl, i) => {
540
+ const isDragging = dragIndex === i;
541
+ // One indicator per boundary: a row's top edge for boundaries above it,
542
+ // the last row's bottom edge for the boundary at the very end.
543
+ const showTop = reorderable && dropBoundary === i;
544
+ const showBottom =
545
+ reorderable &&
546
+ i === walkthroughs.length - 1 &&
547
+ dropBoundary === walkthroughs.length;
548
+ return (
549
+ <div
550
+ key={tl.id}
551
+ data-walkthrough-row={tl.id}
552
+ data-reorder-index={i}
553
+ onDragOver={reorderable ? (e) => handleDragOver(e, i) : undefined}
554
+ onDrop={reorderable ? handleDrop : undefined}
555
+ style={{
556
+ position: 'relative',
557
+ opacity: isDragging ? 0.45 : 1,
558
+ transition: 'opacity 120ms ease',
559
+ }}
560
+ >
561
+ {/* Overlays so the divider/drop line stay visible above the row's
562
+ own (opaque) hover background. Accent paints over the divider. */}
563
+ {i > 0 && (
564
+ <div
565
+ aria-hidden
566
+ style={{
567
+ position: 'absolute',
568
+ top: 0,
569
+ left: 0,
570
+ right: 0,
571
+ height: 1,
572
+ background: theme.colors.border,
573
+ pointerEvents: 'none',
574
+ }}
575
+ />
576
+ )}
577
+ {(showTop || showBottom) && (
578
+ <div
579
+ aria-hidden
580
+ style={{
581
+ position: 'absolute',
582
+ left: 0,
583
+ right: 0,
584
+ height: 2,
585
+ zIndex: 2,
586
+ background: accent,
587
+ pointerEvents: 'none',
588
+ ...(showTop ? { top: 0 } : { bottom: 0 }),
589
+ }}
590
+ />
591
+ )}
592
+ <WalkthroughFlow
593
+ walkthrough={tl}
594
+ collapsed={!expandedWalkthroughs.has(tl.id)}
595
+ active={
596
+ focusedWalkthroughId === tl.id
597
+ ? { stepIndex: focusedStepIndex }
598
+ : hoveredWalkthroughStep?.walkthroughId === tl.id
599
+ ? { stepIndex: hoveredWalkthroughStep.stepIndex }
600
+ : null
601
+ }
602
+ onToggleCollapsed={onToggleCollapsed}
603
+ onFocusFlow={onFocusFlow}
604
+ onClearFocus={onClearFocus}
605
+ onFocusStep={onFocusStep}
606
+ onHoverStep={onHoverStep}
607
+ onHoverFlow={onHoverFlow}
608
+ onLeaveStep={onLeaveStep}
609
+ reorder={
610
+ reorderable
611
+ ? {
612
+ index: i,
613
+ isDragging,
614
+ onDragStart: handleDragStart,
615
+ onDragEnd: resetDrag,
616
+ }
617
+ : undefined
618
+ }
619
+ dragActive={dragIndex != null}
620
+ isHoverSuppressed={isHoverSuppressed}
621
+ proposedAliases={proposedAliases}
622
+ />
623
+ </div>
624
+ );
625
+ })}
626
+ </div>
627
+ );
628
+ }