@nocobase/app-template-default 1.0.0-beta.75 → 1.0.0-beta.76

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 (43) hide show
  1. package/.agents/rules/build-and-deploy.md +4 -0
  2. package/.agents/rules/components.md +3 -1
  3. package/README.MD +2 -2
  4. package/client/components/data-table/data-table.tsx +2 -0
  5. package/client/components/data-table/pagination.tsx +6 -6
  6. package/client/components/flow-diagram.tsx +645 -83
  7. package/client/components/flow-layout.ts +117 -7
  8. package/client/components/lifecycle-diagram.tsx +175 -37
  9. package/client/components/record-link.tsx +20 -0
  10. package/client/components/required-role-setup.tsx +93 -0
  11. package/client/components/role-list.tsx +21 -5
  12. package/client/components/setup-readiness-state.ts +135 -0
  13. package/client/components/setup-readiness.tsx +91 -0
  14. package/client/components/ui/combobox.tsx +300 -0
  15. package/client/extensions/nocobase-workflow-management/README.md +3 -1
  16. package/client/extensions/nocobase-workflow-management/definition-list.tsx +19 -13
  17. package/client/extensions/nocobase-workflow-management/layout.tsx +1 -1
  18. package/client/hooks/use-table-url-state.ts +93 -0
  19. package/client/lib/required-role-setup.ts +98 -0
  20. package/client/lib/return-to.ts +19 -0
  21. package/client/locales/system/en-US.ts +10 -0
  22. package/client/locales/system/settings-en-US.ts +37 -1
  23. package/client/locales/system/settings-zh-CN.ts +33 -2
  24. package/client/locales/system/zh-CN.ts +10 -0
  25. package/client/pages/settings/default-access-form.tsx +4 -1
  26. package/client/pages/settings/default-access.tsx +40 -8
  27. package/client/pages/settings/role-form.tsx +1 -1
  28. package/client/pages/settings/roles.tsx +44 -6
  29. package/client/pages/settings/shared.tsx +15 -13
  30. package/client/pages/settings/user-detail.tsx +15 -259
  31. package/client/pages/settings/user-form.tsx +299 -147
  32. package/client/pages/settings/users.tsx +211 -123
  33. package/config.example.yml +6 -5
  34. package/package.json +11 -11
  35. package/tests/components/default-access.test.tsx +141 -2
  36. package/tests/components/settings-continuity.test.tsx +426 -0
  37. package/tests/components/settings-feedback.test.tsx +304 -0
  38. package/tests/components/settings.test.tsx +1 -1
  39. package/tests/components/setup-readiness.test.tsx +370 -0
  40. package/tests/components/table-url-state.test.tsx +95 -0
  41. package/tests/components/user-drawer.test.tsx +526 -0
  42. package/tests/logic/return-to.test.ts +19 -0
  43. package/tsconfig.json +1 -0
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  useEffect,
3
+ useLayoutEffect,
3
4
  useMemo,
4
5
  useRef,
5
6
  useState,
@@ -7,14 +8,17 @@ import {
7
8
  type ReactElement,
8
9
  type ReactNode,
9
10
  } from 'react';
11
+ import { createPortal } from 'react-dom';
10
12
  import {
11
- Background,
13
+ BaseEdge,
14
+ Controls,
15
+ ControlButton,
12
16
  MiniMap,
13
17
  Panel,
18
+ Background,
14
19
  useReactFlow,
15
20
  useViewport,
16
- BaseEdge,
17
- EdgeText,
21
+ type MiniMapNodeProps,
18
22
  Handle,
19
23
  MarkerType,
20
24
  Position,
@@ -27,6 +31,16 @@ import {
27
31
  import { useTranslation } from '@nocobase/i18n/client';
28
32
  import { cn } from 'cn';
29
33
  import {
34
+ Expand,
35
+ Shrink,
36
+ X,
37
+ UserCheck,
38
+ Wrench,
39
+ CircleCheck,
40
+ CircleX,
41
+ TriangleAlert,
42
+ ArrowLeftIcon,
43
+ ArrowRightIcon,
30
44
  CheckIcon,
31
45
  CircleDotIcon,
32
46
  FlagIcon,
@@ -43,10 +57,13 @@ import {
43
57
  TooltipContent,
44
58
  TooltipTrigger,
45
59
  } from '#components/ui/tooltip';
60
+
46
61
  import '@xyflow/react/dist/style.css';
47
62
 
48
63
  import {
49
64
  layoutFlowDiagram,
65
+ flowLabelLines,
66
+ revealRoute,
50
67
  type FlowDiagramDirection,
51
68
  type FlowDiagramEdge,
52
69
  type FlowDiagramGraph,
@@ -65,8 +82,28 @@ type DiagramNode = Node<{
65
82
  type DiagramEdge = Edge<{
66
83
  points: readonly FlowDiagramPoint[];
67
84
  labelPosition?: FlowDiagramPoint;
85
+ labelAnchor?: 'end' | 'middle';
86
+ title?: string;
68
87
  }>;
69
88
 
89
+ function DiagramMiniMapNode(props: MiniMapNodeProps): ReactElement {
90
+ return (
91
+ <rect
92
+ x={props.x}
93
+ y={props.y}
94
+ width={props.width}
95
+ height={props.height}
96
+ rx={props.className.includes('minimap-pill') ? props.height / 2 : 8}
97
+ fill={props.color}
98
+ stroke={props.strokeColor}
99
+ strokeWidth={props.strokeWidth}
100
+ onClick={
101
+ props.onClick ? (event) => props.onClick?.(event, props.id) : undefined
102
+ }
103
+ />
104
+ );
105
+ }
106
+
70
107
  function DiagramState({ data, id }: NodeProps<DiagramNode>): ReactElement {
71
108
  const { t } = useTranslation();
72
109
  const flow = useReactFlow();
@@ -86,6 +123,27 @@ function DiagramState({ data, id }: NodeProps<DiagramNode>): ReactElement {
86
123
  : data.item.kind === 'final'
87
124
  ? t('flowDiagram.final', { defaultValue: 'Final' })
88
125
  : t('flowDiagram.state', { defaultValue: 'State' });
126
+ const inPosition = data.direction === 'RIGHT' ? Position.Left : Position.Top;
127
+ const outPosition =
128
+ data.direction === 'RIGHT' ? Position.Right : Position.Bottom;
129
+ if (data.item.bare)
130
+ return (
131
+ <div className={cn('h-full w-full', data.item.className)}>
132
+ <Handle
133
+ id='in'
134
+ type='target'
135
+ position={inPosition}
136
+ className='invisible'
137
+ />
138
+ {data.content}
139
+ <Handle
140
+ id='out'
141
+ type='source'
142
+ position={outPosition}
143
+ className='invisible'
144
+ />
145
+ </div>
146
+ );
89
147
  return (
90
148
  <div
91
149
  onFocusCapture={(event) => {
@@ -237,6 +295,60 @@ function routePath(points: readonly FlowDiagramPoint[]): string {
237
295
  return `${path} L ${last.x} ${last.y}`;
238
296
  }
239
297
 
298
+ /** Measure the actual SVG bounds so multiline labels have equal chip padding. */
299
+ function TransitionLabel({
300
+ x,
301
+ y,
302
+ label,
303
+ anchor = 'middle',
304
+ }: {
305
+ x: number;
306
+ y: number;
307
+ label: ReactNode;
308
+ anchor?: 'end' | 'middle';
309
+ }) {
310
+ const ref = useRef<SVGTextElement>(null);
311
+ const backgroundRef = useRef<SVGRectElement>(null);
312
+ useLayoutEffect(() => {
313
+ const box = ref.current?.getBBox();
314
+ if (!box || !backgroundRef.current) return;
315
+ for (const [key, value] of Object.entries({
316
+ x: box.x - 6,
317
+ y: box.y - 3,
318
+ width: box.width + 12,
319
+ height: box.height + 6,
320
+ })) {
321
+ backgroundRef.current.setAttribute(key, String(value));
322
+ }
323
+ }, [label, anchor]);
324
+ return (
325
+ <g transform={`translate(${x} ${y})`}>
326
+ <rect ref={backgroundRef} rx={6} fill='var(--muted)' />
327
+ <text
328
+ ref={ref}
329
+ textAnchor={anchor}
330
+ dominantBaseline='central'
331
+ fill='var(--muted-foreground)'
332
+ fontSize='var(--text-xs)'
333
+ >
334
+ {typeof label === 'string'
335
+ ? flowLabelLines(label).map((line, index, lines) => (
336
+ <tspan
337
+ // Label lines have no state; duplicate text needs a position key.
338
+ // eslint-disable-next-line @eslint-react/no-array-index-key
339
+ key={`${index}:${line}`}
340
+ x={0}
341
+ y={(index - (lines.length - 1) / 2) * 16}
342
+ >
343
+ {line}
344
+ </tspan>
345
+ ))
346
+ : label}
347
+ </text>
348
+ </g>
349
+ );
350
+ }
351
+
240
352
  function DiagramConnection(props: EdgeProps<DiagramEdge>): ReactElement {
241
353
  const points = props.data?.points ?? [];
242
354
  const path = routePath(points);
@@ -249,24 +361,23 @@ function DiagramConnection(props: EdgeProps<DiagramEdge>): ReactElement {
249
361
  markerEnd={props.markerEnd}
250
362
  style={props.style}
251
363
  />
364
+ {props.data?.title ? (
365
+ // A wide transparent stroke so the hover text is easy to reach.
366
+ <path d={path} fill='none' stroke='transparent' strokeWidth={12}>
367
+ <title>{props.data.title}</title>
368
+ </path>
369
+ ) : null}
252
370
  {label && props.label ? (
253
- <EdgeText
254
- x={label.x}
255
- y={label.y}
256
- label={props.label}
257
- labelStyle={{
258
- fill: 'var(--foreground)',
259
- fontSize: 'var(--text-xs)',
260
- fontWeight: 500,
261
- }}
262
- labelBgStyle={{
263
- fill: 'var(--card)',
264
- stroke: 'var(--border)',
265
- strokeWidth: 1,
266
- }}
267
- labelBgPadding={[9, 5]}
268
- labelBgBorderRadius={7}
269
- />
371
+ <g
372
+ style={{ opacity: props.style?.opacity, transition: 'opacity 150ms' }}
373
+ >
374
+ <TransitionLabel
375
+ x={label.x}
376
+ y={label.y}
377
+ label={props.label}
378
+ anchor={props.data?.labelAnchor}
379
+ />
380
+ </g>
270
381
  ) : null}
271
382
  </>
272
383
  );
@@ -277,19 +388,32 @@ const edgeTypes = { diagram: DiagramConnection };
277
388
 
278
389
  export interface FlowDiagramProps extends FlowDiagramGraph {
279
390
  readonly direction?: FlowDiagramDirection;
391
+ /** reduce layer spacing while retaining room for multiline conditions. */
392
+ readonly compact?: boolean;
393
+ readonly controls?: boolean;
280
394
  /** Expand vertically to show the entire graph at the available width. */
281
395
  readonly autoHeight?: boolean;
396
+ /** Limit automatic fitting to this scale; manual zoom remains available. */
397
+ readonly initialZoomLimit?: number;
282
398
  readonly className?: string;
283
399
  readonly style?: CSSProperties;
284
400
  readonly ariaLabel: string;
285
- /** Interactive controls shown in the node footer. */
401
+ /** transitions left out of the layout. Hovering or focusing a node draws
402
+ * the ones touching it in lanes beside the graph and dims unrelated states. */
403
+ readonly revealEdges?: readonly FlowDiagramEdge[];
404
+ /** Routes left off the canvas, such as returns a node lists in its own
405
+ * details. Hovering or focusing a node keeps the states they connect it to at
406
+ * full strength, dims the rest and passes `linked` to `renderNode`; nothing
407
+ * is drawn. */
408
+ readonly auxiliaryEdges?: readonly FlowDiagramEdge[];
286
409
  readonly nodeActions?: (node: FlowDiagramNode) => ReactNode;
287
- /** Supporting content below the node title; provide sufficient fixed node dimensions. */
288
410
  readonly nodeDescription?: (node: FlowDiagramNode) => ReactNode;
289
- /** Initial fit prioritizes legibility; the Fit control still shows the entire graph. */
290
411
  readonly initialMinZoom?: number;
291
412
  readonly showMiniMap?: boolean;
292
- readonly renderNode?: (node: FlowDiagramNode) => ReactNode;
413
+ readonly renderNode?: (
414
+ node: FlowDiagramNode,
415
+ state: { active: boolean; linked: boolean },
416
+ ) => ReactNode;
293
417
  readonly onNodeClick?: (node: FlowDiagramNode) => void;
294
418
  readonly onEdgeClick?: (edge: FlowDiagramEdge) => void;
295
419
  readonly onLayoutError?: (error: unknown) => void;
@@ -300,10 +424,15 @@ export function FlowDiagram({
300
424
  nodes,
301
425
  edges,
302
426
  direction = 'RIGHT',
427
+ compact = false,
428
+ controls = false,
303
429
  autoHeight = false,
430
+ initialZoomLimit = 1,
304
431
  className,
305
432
  style,
306
433
  ariaLabel,
434
+ revealEdges,
435
+ auxiliaryEdges,
307
436
  renderNode,
308
437
  nodeActions,
309
438
  nodeDescription,
@@ -314,16 +443,25 @@ export function FlowDiagram({
314
443
  onLayoutError,
315
444
  }: FlowDiagramProps): ReactElement {
316
445
  const { t } = useTranslation();
446
+ const [expanded, setExpanded] = useState(false);
447
+ useEffect(() => {
448
+ if (!expanded) return;
449
+ const onKeyDown = (event: KeyboardEvent) => {
450
+ if (event.key === 'Escape') setExpanded(false);
451
+ };
452
+ document.addEventListener('keydown', onKeyDown);
453
+ return () => document.removeEventListener('keydown', onKeyDown);
454
+ }, [expanded]);
317
455
  const containerRef = useRef<HTMLDivElement>(null);
318
456
  const [width, setWidth] = useState(0);
319
457
  useEffect(() => {
320
- if (!autoHeight || !containerRef.current) return;
458
+ if (!autoHeight || expanded || !containerRef.current) return;
321
459
  const observer = new ResizeObserver(([entry]) => {
322
460
  if (entry) setWidth(entry.contentRect.width);
323
461
  });
324
462
  observer.observe(containerRef.current);
325
463
  return () => observer.disconnect();
326
- }, [autoHeight]);
464
+ }, [autoHeight, expanded]);
327
465
  // Layout only structural changes; polling a record or highlighting a node preserves the viewport.
328
466
  const structure = JSON.stringify({
329
467
  nodes: nodes.map(({ id, width, height }) => ({
@@ -343,6 +481,7 @@ export function FlowDiagram({
343
481
  : label,
344
482
  })),
345
483
  direction,
484
+ compact,
346
485
  });
347
486
  const [result, setResult] = useState<{
348
487
  key: string;
@@ -353,8 +492,9 @@ export function FlowDiagram({
353
492
  let active = true;
354
493
  const input = JSON.parse(structure) as FlowDiagramGraph & {
355
494
  direction: FlowDiagramDirection;
495
+ compact: boolean;
356
496
  };
357
- void layoutFlowDiagram(input, input.direction).then(
497
+ void layoutFlowDiagram(input, input.direction, input.compact).then(
358
498
  (layout) => {
359
499
  if (active) setResult({ key: structure, layout });
360
500
  },
@@ -371,6 +511,49 @@ export function FlowDiagram({
371
511
  onLayoutError?.(result.error);
372
512
  }, [result, structure, onLayoutError]);
373
513
  const layout = result?.key === structure ? result.layout : undefined;
514
+ const [hovered, setHovered] = useState<string>();
515
+ const [focused, setFocused] = useState<string>();
516
+ const active = hovered ?? focused;
517
+ const revealed = useMemo(
518
+ () =>
519
+ active
520
+ ? (revealEdges ?? []).filter(
521
+ (edge) => edge.source === active || edge.target === active,
522
+ )
523
+ : [],
524
+ [active, revealEdges],
525
+ );
526
+ // States joined to the active one only by routes left off the canvas.
527
+ const linked = useMemo(
528
+ () =>
529
+ new Set(
530
+ (active ? (auxiliaryEdges ?? []) : [])
531
+ .flatMap((edge) =>
532
+ edge.source === active
533
+ ? [edge.target]
534
+ : edge.target === active
535
+ ? [edge.source]
536
+ : [],
537
+ )
538
+ .filter((id) => id !== active),
539
+ ),
540
+ [active, auxiliaryEdges],
541
+ );
542
+ // The active state and every state one transition away stay at full strength.
543
+ const related = useMemo(
544
+ () =>
545
+ active && (revealEdges || auxiliaryEdges)
546
+ ? new Set(
547
+ [...edges, ...revealed]
548
+ .filter(
549
+ (edge) => edge.source === active || edge.target === active,
550
+ )
551
+ .flatMap((edge) => [edge.source, edge.target])
552
+ .concat(active, ...linked),
553
+ )
554
+ : undefined,
555
+ [active, edges, revealed, revealEdges, auxiliaryEdges, linked],
556
+ );
374
557
  const placedNodes = useMemo<DiagramNode[]>(
375
558
  () =>
376
559
  (layout?.nodes ?? []).map((placed) => {
@@ -381,7 +564,14 @@ export function FlowDiagram({
381
564
  focusable: !item.decorative,
382
565
  selectable: !item.decorative,
383
566
  position: { x: placed.x, y: placed.y },
384
- style: { width: placed.width, height: placed.height },
567
+ // Raised so content the active node draws outside its box stays on top.
568
+ zIndex: item.id === active ? 1 : 0,
569
+ style: {
570
+ width: placed.width,
571
+ height: placed.height,
572
+ opacity: related && !related.has(item.id) ? 0.35 : undefined,
573
+ transition: 'opacity 150ms',
574
+ },
385
575
  // ELK owns fixed geometry. Recreated controlled nodes must not clear
386
576
  // React Flow's measurements and briefly remove every connected edge.
387
577
  width: placed.width,
@@ -412,13 +602,28 @@ export function FlowDiagram({
412
602
  data: {
413
603
  item,
414
604
  direction,
415
- content: renderNode ? renderNode(item) : item.label,
605
+ content: renderNode
606
+ ? renderNode(item, {
607
+ active: item.id === active,
608
+ linked: linked.has(item.id),
609
+ })
610
+ : item.label,
416
611
  actions: nodeActions?.(item),
417
612
  description: nodeDescription?.(item),
418
613
  },
419
614
  };
420
615
  }),
421
- [layout, nodes, direction, renderNode, nodeActions, nodeDescription],
616
+ [
617
+ layout,
618
+ nodes,
619
+ direction,
620
+ renderNode,
621
+ active,
622
+ related,
623
+ linked,
624
+ nodeActions,
625
+ nodeDescription,
626
+ ],
422
627
  );
423
628
  const placedEdges = useMemo<DiagramEdge[]>(
424
629
  () =>
@@ -428,41 +633,97 @@ export function FlowDiagram({
428
633
  ? 'color-mix(in srgb, var(--primary) 80%, var(--foreground))'
429
634
  : item.highlighted
430
635
  ? 'var(--primary)'
431
- : 'color-mix(in srgb, var(--muted-foreground) 65%, transparent)';
636
+ : item.faint
637
+ ? 'color-mix(in srgb, var(--muted-foreground) 25%, transparent)'
638
+ : 'color-mix(in srgb, var(--muted-foreground) 45%, transparent)';
432
639
  return {
433
640
  ...item,
434
641
  label: placed.label,
435
- type: 'diagram',
436
642
  animated: Boolean(item.highlighted && !item.visited),
643
+ type: 'diagram',
437
644
  focusable: !item.decorative,
438
645
  selectable: !item.decorative,
439
646
  sourceHandle: 'out',
440
647
  targetHandle: 'in',
441
- ariaLabel: placed.label ?? `${item.source} → ${item.target}`,
648
+ ariaLabel:
649
+ item.title ?? item.label ?? `${item.source} → ${item.target}`,
442
650
  markerEnd: { type: MarkerType.ArrowClosed, color },
443
651
  style: {
444
652
  stroke: color,
445
- strokeWidth: item.visited || item.highlighted ? 2.5 : 1.5,
653
+ strokeWidth:
654
+ item.visited || item.highlighted ? 2.5 : item.faint ? 1 : 1.5,
446
655
  strokeDasharray: item.dashed ? '5 4' : undefined,
656
+ opacity:
657
+ (revealEdges || auxiliaryEdges) &&
658
+ active &&
659
+ item.source !== active &&
660
+ item.target !== active
661
+ ? 0.25
662
+ : undefined,
663
+ transition: 'opacity 150ms',
664
+ },
665
+ data: {
666
+ points: placed.points,
667
+ labelPosition: placed.labelPosition,
668
+ title: item.title,
447
669
  },
448
- data: { points: placed.points, labelPosition: placed.labelPosition },
449
670
  };
450
671
  }),
451
- [layout, edges],
672
+ [layout, edges, active, revealEdges, auxiliaryEdges],
452
673
  );
674
+ const revealedEdges = useMemo<DiagramEdge[]>(() => {
675
+ if (!layout) return [];
676
+ const placed = new Map(layout.nodes.map((node) => [node.id, node]));
677
+ const routes = revealed.flatMap((item) => {
678
+ const source = placed.get(item.source),
679
+ target = placed.get(item.target);
680
+ return source && target ? [{ item, source, target }] : [];
681
+ });
682
+ // Shorter spans take the inner lanes, so nested routes never cross.
683
+ const span = ({ source, target }: (typeof routes)[number]) =>
684
+ Math.abs(
685
+ direction === 'DOWN' ? source.y - target.y : source.x - target.x,
686
+ );
687
+ routes.sort((a, b) => span(a) - span(b));
688
+ const color = 'var(--primary)';
689
+ return routes.map(({ item, source, target }, lane) => {
690
+ const route = revealRoute(layout.nodes, source, target, direction, lane);
691
+ return {
692
+ ...item,
693
+ id: `reveal:${item.id}`,
694
+ type: 'diagram',
695
+ focusable: false,
696
+ selectable: false,
697
+ sourceHandle: 'out',
698
+ targetHandle: 'in',
699
+ ariaLabel:
700
+ item.title ?? item.label ?? `${item.source} → ${item.target}`,
701
+ markerEnd: { type: MarkerType.ArrowClosed, color },
702
+ style: { stroke: color, strokeWidth: 1.5, strokeDasharray: '5 4' },
703
+ data: { ...route, title: item.title },
704
+ };
705
+ });
706
+ }, [layout, revealed, direction]);
453
707
  const zoom =
454
708
  layout && width
455
709
  ? Math.max(
456
- 0.1,
457
710
  initialMinZoom,
458
- Math.min(1, (width - 48) / Math.max(1, layout.width)),
711
+ Math.min(initialZoomLimit, (width - 48) / Math.max(1, layout.width)),
459
712
  )
460
713
  : 1;
461
- // Reserve room above and below the graph for the canvas chrome.
462
- const height = layout ? Math.max(320, layout.height * zoom + 144) : 320;
463
- return (
714
+ const height = layout
715
+ ? Math.max(compact ? 240 : 320, layout.height * zoom + (compact ? 48 : 144))
716
+ : 320;
717
+ const canvas = (
464
718
  <div
465
719
  ref={containerRef}
720
+ onFocus={(event) => {
721
+ const target = event.target as HTMLElement;
722
+ setFocused(
723
+ target.closest<HTMLElement>('.react-flow__node')?.dataset.id,
724
+ );
725
+ }}
726
+ onBlur={() => setFocused(undefined)}
466
727
  onKeyDown={(event) => {
467
728
  if (event.key !== 'Enter' && event.key !== ' ') return;
468
729
  const target = event.target as HTMLElement | SVGElement;
@@ -483,10 +744,12 @@ export function FlowDiagram({
483
744
  }}
484
745
  aria-label={ariaLabel}
485
746
  className={cn(
486
- 'relative isolate h-96 w-full overflow-hidden rounded-xl border border-border bg-linear-to-br from-primary/5 via-background to-muted/40 text-foreground [&_.react-flow__attribution]:bg-card/80 [&_.react-flow__attribution]:text-muted-foreground [&_.react-flow__edge.selected_path.react-flow__edge-path]:stroke-primary [&_.react-flow__edge.animated_path]:motion-reduce:animate-none',
747
+ 'relative h-96 w-full rounded-lg border border-border bg-muted/20 text-foreground [&_.react-flow__edge.animated_path]:motion-reduce:animate-none',
487
748
  className,
749
+ expanded &&
750
+ 'fixed inset-0 z-[100] h-dvh! w-screen rounded-none bg-background',
488
751
  )}
489
- style={{ ...(autoHeight ? { height } : {}), ...style }}
752
+ style={{ ...(autoHeight && !expanded ? { height } : {}), ...style }}
490
753
  >
491
754
  {!nodes.length ? (
492
755
  <p role='status' className='p-4 text-sm text-muted-foreground'>
@@ -504,19 +767,24 @@ export function FlowDiagram({
504
767
  </p>
505
768
  ) : (
506
769
  <ReactFlow<DiagramNode, DiagramEdge>
507
- key={autoHeight ? `${structure}:${width}` : structure}
770
+ key={autoHeight ? `${structure}:${width}:${zoom}` : structure}
771
+ proOptions={controls ? { hideAttribution: true } : undefined}
508
772
  nodes={placedNodes}
509
- edges={placedEdges}
773
+ edges={
774
+ revealedEdges.length
775
+ ? [...placedEdges, ...revealedEdges]
776
+ : placedEdges
777
+ }
510
778
  nodeTypes={nodeTypes}
511
779
  edgeTypes={edgeTypes}
512
- fitView={!autoHeight}
780
+ fitView={expanded || !autoHeight}
513
781
  fitViewOptions={{
514
- padding: 0.2,
782
+ padding: 0.08,
515
783
  minZoom: initialMinZoom,
516
- maxZoom: 1,
784
+ maxZoom: initialZoomLimit,
517
785
  }}
518
786
  defaultViewport={
519
- autoHeight
787
+ autoHeight && !expanded
520
788
  ? {
521
789
  x: (width - layout.width * zoom) / 2,
522
790
  y: (height - layout.height * zoom) / 2,
@@ -524,21 +792,27 @@ export function FlowDiagram({
524
792
  }
525
793
  : undefined
526
794
  }
527
- // A full-height process is read by scrolling the page, not wheel-zooming the canvas.
528
- zoomOnScroll={!autoHeight}
529
- preventScrolling={!autoHeight}
530
795
  minZoom={0.1}
531
796
  maxZoom={2}
797
+ zoomOnScroll={false}
798
+ preventScrolling={false}
532
799
  nodesDraggable={false}
533
800
  nodesConnectable={false}
534
801
  edgesReconnectable={false}
535
802
  deleteKeyCode={null}
536
803
  onNodeClick={(_, node) => onNodeClick?.(node.data.item)}
804
+ onNodeMouseEnter={(_, node) => setHovered(node.id)}
805
+ onNodeMouseLeave={() => setHovered(undefined)}
537
806
  onEdgeClick={(_, edge) => {
538
807
  const item = edges.find((candidate) => candidate.id === edge.id);
539
808
  if (item) onEdgeClick?.(item);
540
809
  }}
541
810
  ariaLabelConfig={{
811
+ 'controls.zoomIn.ariaLabel': t('flowDiagram.zoomIn'),
812
+ 'controls.zoomOut.ariaLabel': t('flowDiagram.zoomOut'),
813
+ 'controls.fitView.ariaLabel': t('flowDiagram.fitView', {
814
+ defaultValue: 'Fit to view',
815
+ }),
542
816
  'node.a11yDescription.keyboardDisabled': t('flowDiagram.nodeHelp', {
543
817
  defaultValue: 'Press Enter or Space to select a state.',
544
818
  }),
@@ -550,47 +824,117 @@ export function FlowDiagram({
550
824
  }),
551
825
  }}
552
826
  >
553
- <Background color='var(--border)' gap={24} size={1.5} />
554
- {showMiniMap && (
555
- <MiniMap
827
+ {!compact && <Background color='var(--border)' gap={24} size={1.5} />}
828
+ {!compact && (
829
+ <Panel position='top-left' className='pointer-events-none!'>
830
+ <div className='inline-flex items-center gap-3 rounded-lg border bg-card/95 px-3 py-2 text-xs text-muted-foreground shadow-xs'>
831
+ <span className='inline-flex items-center gap-1.5'>
832
+ <GitBranchIcon className='size-3.5' aria-hidden />
833
+ {t('flowDiagram.states', { defaultValue: 'States' })}
834
+ <span className='font-semibold text-foreground tabular-nums'>
835
+ {nodes.filter((node) => !node.decorative).length}
836
+ </span>
837
+ </span>
838
+ <span className='h-3 w-px bg-border' aria-hidden />
839
+ <span>
840
+ {t('flowDiagram.transitions', {
841
+ defaultValue: 'Transitions',
842
+ })}{' '}
843
+ <span className='font-semibold text-foreground tabular-nums'>
844
+ {edges.filter((edge) => !edge.decorative).length}
845
+ </span>
846
+ </span>
847
+ </div>
848
+ </Panel>
849
+ )}
850
+ {!controls && <DiagramToolbar top={autoHeight} />}
851
+ {controls && (
852
+ <>
853
+ <Controls
854
+ fitViewOptions={{
855
+ padding: 0.08,
856
+ minZoom: initialMinZoom,
857
+ maxZoom: initialZoomLimit,
858
+ }}
859
+ showInteractive={false}
860
+ aria-label={t('flowDiagram.controls', {
861
+ defaultValue: 'Diagram controls',
862
+ })}
863
+ >
864
+ <ControlButton
865
+ type='button'
866
+ onClick={() => setExpanded((value) => !value)}
867
+ aria-label={t(
868
+ expanded
869
+ ? 'flowDiagram.exitFullscreen'
870
+ : 'flowDiagram.fullscreen',
871
+ {
872
+ defaultValue: expanded ? 'Exit fullscreen' : 'Fullscreen',
873
+ },
874
+ )}
875
+ title={t(
876
+ expanded
877
+ ? 'flowDiagram.exitFullscreen'
878
+ : 'flowDiagram.fullscreen',
879
+ {
880
+ defaultValue: expanded ? 'Exit fullscreen' : 'Fullscreen',
881
+ },
882
+ )}
883
+ >
884
+ {expanded ? <Shrink /> : <Expand />}
885
+ </ControlButton>
886
+ </Controls>
887
+ {expanded && (
888
+ <Panel position='top-right'>
889
+ <div className='flex items-center gap-3 rounded-lg border bg-card p-2 text-card-foreground shadow-sm'>
890
+ <span className='text-xs text-muted-foreground'>
891
+ {t('flowDiagram.escapeHint', {
892
+ defaultValue: 'Press Escape to exit',
893
+ })}
894
+ </span>
895
+ <Button
896
+ variant='outline'
897
+ size='sm'
898
+ onClick={() => setExpanded(false)}
899
+ >
900
+ <X />
901
+ {t('flowDiagram.exitFullscreen', {
902
+ defaultValue: 'Exit fullscreen',
903
+ })}
904
+ </Button>
905
+ </div>
906
+ </Panel>
907
+ )}
908
+ </>
909
+ )}
910
+ {(expanded || showMiniMap) && (
911
+ <MiniMap<DiagramNode>
556
912
  pannable
557
913
  zoomable
558
- ariaLabel={t('flowDiagram.overview', {
914
+ ariaLabel={t('flowDiagram.minimap', {
559
915
  defaultValue: 'Diagram overview',
560
916
  })}
561
- nodeColor='var(--muted-foreground)'
562
- maskColor='color-mix(in srgb, var(--background) 75%, transparent)'
563
- style={{
564
- background: 'var(--card)',
565
- border: '1px solid var(--border)',
566
- borderRadius: 'var(--radius)',
567
- }}
568
- className='hidden sm:block'
917
+ style={{ background: 'var(--card)' }}
918
+ nodeComponent={DiagramMiniMapNode}
919
+ nodeClassName={(node) =>
920
+ node.data.item.minimapShape === 'pill' ? 'minimap-pill' : ''
921
+ }
922
+ nodeColor={(node) =>
923
+ `color-mix(in srgb, ${node.data.item.minimapColor ?? 'var(--muted-foreground)'} 18%, var(--card))`
924
+ }
925
+ nodeStrokeColor={(node) =>
926
+ `color-mix(in srgb, ${node.data.item.minimapColor ?? 'var(--muted-foreground)'} 35%, var(--card))`
927
+ }
928
+ nodeStrokeWidth={2}
929
+ maskColor='color-mix(in srgb, var(--muted) 70%, transparent)'
930
+ className='max-w-[35%]'
569
931
  />
570
932
  )}
571
- <Panel position='top-left' className='pointer-events-none!'>
572
- <div className='inline-flex items-center gap-3 rounded-lg border bg-card/95 px-3 py-2 text-xs text-muted-foreground shadow-xs'>
573
- <span className='inline-flex items-center gap-1.5'>
574
- <GitBranchIcon className='size-3.5' aria-hidden />
575
- {t('flowDiagram.states', { defaultValue: 'States' })}
576
- <span className='font-semibold text-foreground tabular-nums'>
577
- {nodes.filter((node) => !node.decorative).length}
578
- </span>
579
- </span>
580
- <span className='h-3 w-px bg-border' aria-hidden />
581
- <span>
582
- {t('flowDiagram.transitions', { defaultValue: 'Transitions' })}{' '}
583
- <span className='font-semibold text-foreground tabular-nums'>
584
- {edges.filter((edge) => !edge.decorative).length}
585
- </span>
586
- </span>
587
- </div>
588
- </Panel>
589
- <DiagramToolbar top={autoHeight} />
590
933
  </ReactFlow>
591
934
  )}
592
935
  </div>
593
936
  );
937
+ return expanded ? createPortal(canvas, document.body) : canvas;
594
938
  }
595
939
 
596
940
  /** View controls belong inside React Flow so they always use this canvas's viewport. */
@@ -672,3 +1016,221 @@ function DiagramToolbar({
672
1016
  </Panel>
673
1017
  );
674
1018
  }
1019
+
1020
+ export type FlowDiagramStepKind =
1021
+ 'start' | 'approval' | 'work' | 'success' | 'negative' | 'exception';
1022
+
1023
+ const stepIcons = {
1024
+ start: PlayIcon,
1025
+ approval: UserCheck,
1026
+ work: Wrench,
1027
+ success: CircleCheck,
1028
+ negative: CircleX,
1029
+ exception: TriangleAlert,
1030
+ };
1031
+
1032
+ // Only intermediate steps use filled icon tiles; boundary icons inherit their pill tone.
1033
+ const stepIconTones = {
1034
+ approval: 'bg-primary text-primary-foreground',
1035
+ work: 'bg-[var(--area-administration,var(--chart-2))] text-background',
1036
+ };
1037
+
1038
+ // The tone of the state at the other end of a route listed beside a node.
1039
+ const stepDotTones: Record<FlowDiagramStepKind, string> = {
1040
+ start: 'bg-muted-foreground',
1041
+ approval: 'bg-primary',
1042
+ work: 'bg-[var(--area-administration,var(--chart-2))]',
1043
+ success: 'bg-(--status-success)',
1044
+ negative: 'bg-destructive',
1045
+ exception: 'bg-(--status-warning)',
1046
+ };
1047
+
1048
+ export interface FlowDiagramStepProps {
1049
+ readonly kind: FlowDiagramStepKind;
1050
+ readonly label: ReactNode;
1051
+ readonly description?: ReactNode;
1052
+ readonly icon?: ReactNode;
1053
+ readonly progress?: 'plan' | 'current' | 'visited' | 'unreached';
1054
+ /** Joined to the active state by a route left off the canvas. */
1055
+ readonly linked?: boolean;
1056
+ readonly actions?: ReactNode;
1057
+ /** Supporting exits or details revealed by the canvas's active state. */
1058
+ readonly children?: ReactNode;
1059
+ }
1060
+
1061
+ /** Shared process vocabulary: boundary pills, work cards and actual execution status. */
1062
+ export function FlowDiagramStep({
1063
+ kind,
1064
+ label,
1065
+ description,
1066
+ icon,
1067
+ progress = 'plan',
1068
+ linked = false,
1069
+ actions,
1070
+ children,
1071
+ }: FlowDiagramStepProps): ReactElement {
1072
+ const { t } = useTranslation();
1073
+ const card = kind === 'approval' || kind === 'work';
1074
+ const Icon = stepIcons[kind];
1075
+ return (
1076
+ <div
1077
+ data-execution-state={progress}
1078
+ className={cn(
1079
+ 'relative flex h-full border bg-card text-card-foreground transition-[border-color,box-shadow,opacity]',
1080
+ card
1081
+ ? 'items-center gap-3 rounded-lg px-3 shadow-xs'
1082
+ : 'flex-col items-center justify-center rounded-full px-4',
1083
+ kind === 'success' &&
1084
+ 'border-(--status-success)/30 bg-(--status-success-background) text-(--status-success)',
1085
+ kind === 'negative' &&
1086
+ 'border-dashed border-destructive/40 bg-destructive/5 text-destructive',
1087
+ kind === 'exception' &&
1088
+ 'border-dashed border-(--status-warning)/35 bg-(--status-warning-background) text-(--status-warning)',
1089
+ progress === 'current' && 'border-primary ring-2 ring-primary/40',
1090
+ progress === 'unreached' && 'border-dashed opacity-55',
1091
+ linked &&
1092
+ 'opacity-100 outline-2 outline-offset-2 outline-primary/60 outline-dashed',
1093
+ )}
1094
+ >
1095
+ <span
1096
+ className={cn(
1097
+ 'flex max-w-full items-center gap-1.5',
1098
+ card && 'w-full gap-3',
1099
+ )}
1100
+ >
1101
+ <span
1102
+ aria-hidden
1103
+ className={cn(
1104
+ 'flex shrink-0 items-center justify-center',
1105
+ card && 'size-8 rounded-md shadow-xs',
1106
+ card && stepIconTones[kind],
1107
+ )}
1108
+ >
1109
+ {icon ?? <Icon className='size-4' />}
1110
+ </span>
1111
+ <span
1112
+ className={cn(
1113
+ 'flex min-w-0 flex-1 flex-col text-sm font-medium',
1114
+ card && 'text-left',
1115
+ )}
1116
+ >
1117
+ <span className='truncate'>{label}</span>
1118
+ {description && (
1119
+ <span className='truncate text-xs font-normal text-muted-foreground'>
1120
+ {description}
1121
+ </span>
1122
+ )}
1123
+ </span>
1124
+ {card && actions}
1125
+ </span>
1126
+ {!card && actions}
1127
+ {progress === 'current' && (
1128
+ <span className='absolute -top-2.5 right-2 rounded-full bg-primary px-2 py-0.5 text-xs font-medium text-primary-foreground'>
1129
+ {t('flowDiagram.current', { defaultValue: 'Current' })}
1130
+ </span>
1131
+ )}
1132
+ {progress === 'visited' && card && (
1133
+ <span
1134
+ className='absolute -top-2 right-2 flex size-4 items-center justify-center rounded-full bg-(--status-success) text-primary-foreground'
1135
+ title={t('flowDiagram.visited', { defaultValue: 'Visited' })}
1136
+ >
1137
+ <CheckIcon aria-hidden className='size-3' />
1138
+ <span className='sr-only'>
1139
+ {t('flowDiagram.visited', { defaultValue: 'Visited' })}
1140
+ </span>
1141
+ </span>
1142
+ )}
1143
+ {children}
1144
+ </div>
1145
+ );
1146
+ }
1147
+
1148
+ export interface FlowDiagramTransitionNote {
1149
+ readonly id: string;
1150
+ /** Short translated action. */
1151
+ readonly label: ReactNode;
1152
+ /** Complete translated route, read by assistive technology. */
1153
+ readonly detail?: string;
1154
+ /** Tone of the state at the other end of the route. */
1155
+ readonly kind?: FlowDiagramStepKind;
1156
+ /** Lists the route under "Leaves to" (the default) or "Arrives from". */
1157
+ readonly direction?: 'out' | 'in';
1158
+ /** Translated labels of the states at the other end of the route. */
1159
+ readonly peers?: readonly string[];
1160
+ }
1161
+
1162
+ /** Routes left off the canvas, listed in one card beside the active node. */
1163
+ export function FlowDiagramTransitionNotes({
1164
+ notes,
1165
+ }: {
1166
+ readonly notes: readonly FlowDiagramTransitionNote[];
1167
+ }): ReactElement | null {
1168
+ const { t } = useTranslation();
1169
+ if (!notes.length) return null;
1170
+ const sections = [
1171
+ {
1172
+ direction: 'out',
1173
+ title: t('flowDiagram.leavesTo', { defaultValue: 'Leaves to' }),
1174
+ icon: ArrowRightIcon,
1175
+ },
1176
+ {
1177
+ direction: 'in',
1178
+ title: t('flowDiagram.arrivesFrom', { defaultValue: 'Arrives from' }),
1179
+ icon: ArrowLeftIcon,
1180
+ },
1181
+ ] as const;
1182
+ return (
1183
+ <div
1184
+ role='group'
1185
+ aria-label={t('flowDiagram.otherRoutes', {
1186
+ defaultValue: 'Other routes',
1187
+ })}
1188
+ className='nodrag nopan absolute top-1/2 left-full z-10 -translate-y-1/2 pl-2 text-left'
1189
+ >
1190
+ <div className='flex w-max max-w-72 flex-col gap-2 rounded-lg border bg-popover p-2.5 text-xs text-popover-foreground shadow-md'>
1191
+ {sections.map(({ direction, title, icon: Icon }) => {
1192
+ const items = notes.filter(
1193
+ (note) => (note.direction ?? 'out') === direction,
1194
+ );
1195
+ if (!items.length) return null;
1196
+ return (
1197
+ <div key={direction} className='flex flex-col gap-1'>
1198
+ <p className='font-medium text-muted-foreground'>{title}</p>
1199
+ <ul className='flex flex-col gap-1'>
1200
+ {items.map((note) => (
1201
+ <li key={note.id}>
1202
+ <span
1203
+ role='note'
1204
+ aria-label={note.detail}
1205
+ className='flex min-w-0 items-center gap-1.5'
1206
+ >
1207
+ <span
1208
+ aria-hidden
1209
+ className={cn(
1210
+ 'size-1.5 shrink-0 rounded-full',
1211
+ stepDotTones[note.kind ?? 'start'],
1212
+ )}
1213
+ />
1214
+ <span className='shrink-0 font-medium'>{note.label}</span>
1215
+ {note.peers?.length ? (
1216
+ <>
1217
+ <Icon
1218
+ aria-hidden
1219
+ className='size-3 shrink-0 text-muted-foreground'
1220
+ />
1221
+ <span className='truncate text-muted-foreground'>
1222
+ {note.peers.join(', ')}
1223
+ </span>
1224
+ </>
1225
+ ) : null}
1226
+ </span>
1227
+ </li>
1228
+ ))}
1229
+ </ul>
1230
+ </div>
1231
+ );
1232
+ })}
1233
+ </div>
1234
+ </div>
1235
+ );
1236
+ }