@sentropic/design-system-react 0.1.0 → 0.2.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.
package/dist/catalog.js CHANGED
@@ -399,12 +399,373 @@ export function Footer({ brand, columns, links, copyright, className, ...rest })
399
399
  const groups = columns ?? (links ? [{ links }] : []);
400
400
  return (_jsxs("footer", { ...rest, className: classNames("st-footer", className), children: [_jsxs("div", { className: "st-footer__top", children: [brand ? _jsx("div", { className: "st-footer__brand", children: brand }) : null, _jsx("div", { className: "st-footer__columns", children: groups.map((group, index) => (_jsxs("nav", { children: [group.title ? _jsx("h2", { children: group.title }) : null, group.links.map((link) => (_jsx("a", { href: link.href, children: link.label }, link.href)))] }, index))) })] }), copyright ? _jsx("div", { className: "st-footer__copyright", children: copyright }) : null] }));
401
401
  }
402
- export function ForceGraph({ nodes, edges, label = "Force graph", selectedIds = [], focusId = null, onSelect, onOpenEntity, className, ...rest }) {
403
- return (_jsxs("figure", { ...rest, className: classNames("st-forceGraph st-forceGraph--static", className), "aria-label": label, children: [_jsx("span", { className: "st-visually-hidden", children: label }), _jsxs("svg", { viewBox: "0 0 360 220", "aria-hidden": "true", children: [_jsx("g", { className: "st-forceGraph__edges", children: edges.map((edge, index) => (_jsx("line", { className: classNames("st-forceGraph__edge", edge.weak && "st-forceGraph__edge--weak"), x1: "40", y1: 40 + index * 20, x2: "260", y2: 80 + index * 20 }, `${edge.source}-${edge.target}-${index}`))) }), _jsx("g", { className: "st-forceGraph__nodes", children: nodes.map((graphNode, index) => {
404
- const x = graphNode.fx ?? 48 + (index % 5) * 64;
405
- const y = graphNode.fy ?? 56 + Math.floor(index / 5) * 56;
406
- return (_jsxs("g", { className: classNames("st-forceGraph__node", `st-forceGraph__node--${graphNode.tone ?? DATA_TONES[index % DATA_TONES.length]}`, selectedIds.includes(graphNode.id) && "st-forceGraph__node--selected", focusId === graphNode.id && "st-forceGraph__node--focus"), tabIndex: 0, onClick: () => onSelect?.(graphNode.id), onDoubleClick: () => onOpenEntity?.(graphNode.id), children: [_jsx("circle", { className: "st-forceGraph__dot", cx: x, cy: y, r: 8 * (graphNode.weight ?? 1) }), _jsx("text", { className: "st-forceGraph__label", x: x + 12, y: y + 4, children: graphNode.label ?? graphNode.id })] }, graphNode.id));
407
- }) })] })] }));
402
+ // ---------------------------------------------------------------------------
403
+ // SVG path helpers for the various node shapes.
404
+ // All shapes are centered at (0,0) and sized to inscribe within radius r.
405
+ // ---------------------------------------------------------------------------
406
+ export function nodeShapePath(shape, r) {
407
+ const s = shape ?? "dot";
408
+ if (s === "dot" || s === "circle")
409
+ return null; // use <circle>
410
+ if (s === "diamond") {
411
+ return `M 0 ${-r} L ${r} 0 L 0 ${r} L ${-r} 0 Z`;
412
+ }
413
+ if (s === "star") {
414
+ const outer = r;
415
+ const inner = r * 0.42;
416
+ const pts = [];
417
+ for (let i = 0; i < 10; i++) {
418
+ const angle = (i * Math.PI) / 5 - Math.PI / 2;
419
+ const rad = i % 2 === 0 ? outer : inner;
420
+ pts.push(`${rad * Math.cos(angle)},${rad * Math.sin(angle)}`);
421
+ }
422
+ return `M ${pts.join(" L ")} Z`;
423
+ }
424
+ if (s === "hexagon") {
425
+ const pts = [];
426
+ for (let i = 0; i < 6; i++) {
427
+ const angle = (i * Math.PI) / 3 - Math.PI / 6;
428
+ pts.push(`${r * Math.cos(angle)},${r * Math.sin(angle)}`);
429
+ }
430
+ return `M ${pts.join(" L ")} Z`;
431
+ }
432
+ if (s === "box" || s === "square") {
433
+ const h = r * 0.85;
434
+ return `M ${-h} ${-h} L ${h} ${-h} L ${h} ${h} L ${-h} ${h} Z`;
435
+ }
436
+ if (s === "triangle") {
437
+ const h = r * 1.1;
438
+ return `M 0 ${-h} L ${h * 0.9} ${h * 0.6} L ${-h * 0.9} ${h * 0.6} Z`;
439
+ }
440
+ return null;
441
+ }
442
+ const FORCE_GRAPH_TONES = [
443
+ "category1",
444
+ "category2",
445
+ "category3",
446
+ "category4",
447
+ "category5",
448
+ "category6",
449
+ "category7",
450
+ "category8",
451
+ ];
452
+ // ---------------------------------------------------------------------------
453
+ // Tone assignment: explicit tone wins, else stable per-group, else per-index.
454
+ // ---------------------------------------------------------------------------
455
+ function buildForceGraphToneMap(ns) {
456
+ const groups = [];
457
+ const seen = new Set();
458
+ for (const n of ns) {
459
+ if (n.group === undefined)
460
+ continue;
461
+ if (seen.has(n.group))
462
+ continue;
463
+ seen.add(n.group);
464
+ groups.push(n.group);
465
+ }
466
+ const groupTone = new Map();
467
+ groups.forEach((g, i) => groupTone.set(g, FORCE_GRAPH_TONES[i % FORCE_GRAPH_TONES.length]));
468
+ const map = new Map();
469
+ ns.forEach((n, i) => {
470
+ if (n.tone)
471
+ map.set(n.id, n.tone);
472
+ else if (n.group !== undefined && groupTone.has(n.group))
473
+ map.set(n.id, groupTone.get(n.group));
474
+ else
475
+ map.set(n.id, FORCE_GRAPH_TONES[i % FORCE_GRAPH_TONES.length]);
476
+ });
477
+ return map;
478
+ }
479
+ function forceGraphMulberry32(seed) {
480
+ let a = seed >>> 0;
481
+ return () => {
482
+ a |= 0;
483
+ a = (a + 0x6d2b79f5) | 0;
484
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
485
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
486
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
487
+ };
488
+ }
489
+ function runForceGraphSimulation(ns, es, w, h, ticks, nodeRadius) {
490
+ const cx = w / 2;
491
+ const cy = h / 2;
492
+ const rand = forceGraphMulberry32(ns.length * 2654435761 + es.length);
493
+ const idIndex = new Map();
494
+ const sim = ns.map((n, i) => {
495
+ idIndex.set(n.id, i);
496
+ const fixed = typeof n.fx === "number" && typeof n.fy === "number";
497
+ // Seed on a loose ring so the first ticks fan the graph out predictably.
498
+ const angle = (i / Math.max(ns.length, 1)) * Math.PI * 2;
499
+ const r = Math.min(w, h) * 0.3 * (0.5 + rand() * 0.5);
500
+ return {
501
+ id: n.id,
502
+ x: fixed ? n.fx : cx + Math.cos(angle) * r,
503
+ y: fixed ? n.fy : cy + Math.sin(angle) * r,
504
+ vx: 0,
505
+ vy: 0,
506
+ fixed,
507
+ };
508
+ });
509
+ const links = es
510
+ .map((e) => ({ s: idIndex.get(e.source), t: idIndex.get(e.target) }))
511
+ .filter((l) => l.s !== undefined && l.t !== undefined);
512
+ const area = w * h;
513
+ const k = Math.sqrt(area / Math.max(ns.length, 1)); // ideal node distance
514
+ const repulsion = k * k * 0.9;
515
+ const restLength = k * 0.8;
516
+ const springK = 0.04;
517
+ const gravity = 0.012;
518
+ const damping = 0.85;
519
+ let temperature = Math.min(w, h) * 0.08;
520
+ const cooling = ticks > 0 ? Math.pow(0.02, 1 / ticks) : 0.95;
521
+ for (let step = 0; step < ticks; step++) {
522
+ // Repulsion between all node pairs.
523
+ for (let i = 0; i < sim.length; i++) {
524
+ for (let j = i + 1; j < sim.length; j++) {
525
+ let dx = sim[i].x - sim[j].x;
526
+ let dy = sim[i].y - sim[j].y;
527
+ let dist2 = dx * dx + dy * dy;
528
+ if (dist2 < 0.01) {
529
+ dx = (rand() - 0.5) * 0.1;
530
+ dy = (rand() - 0.5) * 0.1;
531
+ dist2 = dx * dx + dy * dy + 0.01;
532
+ }
533
+ const dist = Math.sqrt(dist2);
534
+ const force = repulsion / dist2;
535
+ const fx = (dx / dist) * force;
536
+ const fy = (dy / dist) * force;
537
+ sim[i].vx += fx;
538
+ sim[i].vy += fy;
539
+ sim[j].vx -= fx;
540
+ sim[j].vy -= fy;
541
+ }
542
+ }
543
+ // Spring attraction along links.
544
+ for (const l of links) {
545
+ const a = sim[l.s];
546
+ const b = sim[l.t];
547
+ const dx = b.x - a.x;
548
+ const dy = b.y - a.y;
549
+ const dist = Math.sqrt(dx * dx + dy * dy) || 0.01;
550
+ const force = (dist - restLength) * springK;
551
+ const fx = (dx / dist) * force;
552
+ const fy = (dy / dist) * force;
553
+ a.vx += fx;
554
+ a.vy += fy;
555
+ b.vx -= fx;
556
+ b.vy -= fy;
557
+ }
558
+ // Gravity toward centre + integrate with capped, cooling step.
559
+ for (const sn of sim) {
560
+ if (sn.fixed) {
561
+ sn.vx = 0;
562
+ sn.vy = 0;
563
+ continue;
564
+ }
565
+ sn.vx += (cx - sn.x) * gravity;
566
+ sn.vy += (cy - sn.y) * gravity;
567
+ sn.vx *= damping;
568
+ sn.vy *= damping;
569
+ const speed = Math.sqrt(sn.vx * sn.vx + sn.vy * sn.vy);
570
+ if (speed > temperature) {
571
+ sn.vx = (sn.vx / speed) * temperature;
572
+ sn.vy = (sn.vy / speed) * temperature;
573
+ }
574
+ sn.x += sn.vx;
575
+ sn.y += sn.vy;
576
+ // Keep inside a padded viewport.
577
+ sn.x = Math.max(nodeRadius * 2, Math.min(w - nodeRadius * 2, sn.x));
578
+ sn.y = Math.max(nodeRadius * 2, Math.min(h - nodeRadius * 2, sn.y));
579
+ }
580
+ temperature *= cooling;
581
+ }
582
+ const out = new Map();
583
+ for (const sn of sim)
584
+ out.set(sn.id, { x: sn.x, y: sn.y });
585
+ return out;
586
+ }
587
+ export function ForceGraph({ nodes, edges, label, width = 480, height = 360, nodeRadius = 7, showLabels = true, iterations = 300, selectedIds = [], focusId = null, onSelect, onOpenEntity, onEdgeHover, legend, className, ...rest }) {
588
+ // SSR-safe reduced-motion check (window may be undefined during SSR/tests).
589
+ const prefersReducedMotion = typeof window !== "undefined" &&
590
+ typeof window.matchMedia === "function" &&
591
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches;
592
+ const toneMap = React.useMemo(() => buildForceGraphToneMap(nodes), [nodes]);
593
+ // The whole layout is recomputed when inputs change. The same settled
594
+ // layout is used as the rendered target — a static, deterministic frame —
595
+ // which keeps the component framework-light and test-friendly while still
596
+ // honouring the motion preference (no rAF loop, no jitter).
597
+ const layout = React.useMemo(() => {
598
+ const ticks = Math.max(1, Math.round(iterations));
599
+ return runForceGraphSimulation(nodes, edges, width, height, ticks, nodeRadius);
600
+ }, [nodes, edges, width, height, iterations, nodeRadius]);
601
+ const positionedNodes = React.useMemo(() => nodes.map((n, i) => {
602
+ const p = layout.get(n.id) ?? { x: width / 2, y: height / 2 };
603
+ const r = nodeRadius * Math.sqrt(Math.max(n.weight ?? 1, 0.25));
604
+ const shapePath = nodeShapePath(n.shape, r);
605
+ return {
606
+ node: n,
607
+ i,
608
+ x: p.x,
609
+ y: p.y,
610
+ r,
611
+ tone: toneMap.get(n.id) ?? "category1",
612
+ title: n.label ?? n.id,
613
+ shapePath,
614
+ };
615
+ }), [nodes, layout, width, height, nodeRadius, toneMap]);
616
+ const positionedEdges = React.useMemo(() => {
617
+ const nodeById = new Map(nodes.map((n) => [n.id, n]));
618
+ return edges
619
+ .map((e, i) => {
620
+ const a = layout.get(e.source);
621
+ const b = layout.get(e.target);
622
+ if (!a || !b)
623
+ return null;
624
+ const srcNode = nodeById.get(e.source);
625
+ const tgtNode = nodeById.get(e.target);
626
+ return {
627
+ edge: e,
628
+ i,
629
+ x1: a.x,
630
+ y1: a.y,
631
+ x2: b.x,
632
+ y2: b.y,
633
+ srcLabel: srcNode?.label ?? e.source,
634
+ tgtLabel: tgtNode?.label ?? e.target,
635
+ };
636
+ })
637
+ .filter((e) => e !== null);
638
+ }, [nodes, edges, layout]);
639
+ const [hoveredNodeIndex, setHoveredNodeIndex] = React.useState(null);
640
+ const [hoveredEdgeIndex, setHoveredEdgeIndex] = React.useState(null);
641
+ const selectedSet = React.useMemo(() => new Set(selectedIds), [selectedIds]);
642
+ // Keyboard handler for a node element: Space/Enter → onSelect, Enter → onOpenEntity.
643
+ function handleNodeKeydown(id, e) {
644
+ if (e.key === "Enter" || e.key === " ") {
645
+ e.preventDefault();
646
+ onSelect?.(id);
647
+ }
648
+ if (e.key === "Enter") {
649
+ onOpenEntity?.(id);
650
+ }
651
+ }
652
+ // ---------------------------------------------------------------------------
653
+ // Zoom + pan state. Store zoom as a scale multiplier + pan offset so syncing
654
+ // with width/height props is trivial.
655
+ // vbW = width / zoomScale, vbH = height / zoomScale
656
+ // vbX / vbY = pan offset in SVG coordinate space
657
+ // ---------------------------------------------------------------------------
658
+ const [zoomScale, setZoomScale] = React.useState(1);
659
+ const [panX, setPanX] = React.useState(0);
660
+ const [panY, setPanY] = React.useState(0);
661
+ const isPanningRef = React.useRef(false);
662
+ const panStartRef = React.useRef({ x: 0, y: 0, panX: 0, panY: 0 });
663
+ const svgRef = React.useRef(null);
664
+ const [isPanning, setIsPanning] = React.useState(false);
665
+ const vbW = width / zoomScale;
666
+ const vbH = height / zoomScale;
667
+ const vbX = panX;
668
+ const vbY = panY;
669
+ function resetView() {
670
+ setZoomScale(1);
671
+ setPanX(0);
672
+ setPanY(0);
673
+ }
674
+ function handleWheel(ev) {
675
+ if (prefersReducedMotion)
676
+ return;
677
+ ev.preventDefault();
678
+ // Zoom factor: ~10% per step.
679
+ const factor = ev.deltaY > 0 ? 0.9 : 1.1;
680
+ // Clamp zoom: 0.2x – 8x.
681
+ const newScale = Math.min(Math.max(zoomScale * factor, 0.2), 8);
682
+ // Anchor zoom around the cursor position in SVG coords.
683
+ if (svgRef.current) {
684
+ const rect = svgRef.current.getBoundingClientRect();
685
+ const cursorSvgX = panX + ((ev.clientX - rect.left) / rect.width) * (width / zoomScale);
686
+ const cursorSvgY = panY + ((ev.clientY - rect.top) / rect.height) * (height / zoomScale);
687
+ const newVbW = width / newScale;
688
+ const newVbH = height / newScale;
689
+ const ratioX = (cursorSvgX - panX) / (width / zoomScale);
690
+ const ratioY = (cursorSvgY - panY) / (height / zoomScale);
691
+ setPanX(cursorSvgX - ratioX * newVbW);
692
+ setPanY(cursorSvgY - ratioY * newVbH);
693
+ }
694
+ setZoomScale(newScale);
695
+ }
696
+ function handleBgMouseDown(ev) {
697
+ // Only start pan when clicking the background (not a node/edge element).
698
+ if (ev.target.closest(".st-forceGraph__node"))
699
+ return;
700
+ if (prefersReducedMotion)
701
+ return;
702
+ isPanningRef.current = true;
703
+ setIsPanning(true);
704
+ panStartRef.current = { x: ev.clientX, y: ev.clientY, panX, panY };
705
+ }
706
+ function handleMouseMove(ev) {
707
+ if (!isPanningRef.current || !svgRef.current)
708
+ return;
709
+ const rect = svgRef.current.getBoundingClientRect();
710
+ const dx = ((ev.clientX - panStartRef.current.x) / rect.width) * vbW;
711
+ const dy = ((ev.clientY - panStartRef.current.y) / rect.height) * vbH;
712
+ setPanX(panStartRef.current.panX - dx);
713
+ setPanY(panStartRef.current.panY - dy);
714
+ }
715
+ function handleMouseUp() {
716
+ isPanningRef.current = false;
717
+ setIsPanning(false);
718
+ }
719
+ const viewBox = `${vbX} ${vbY} ${vbW} ${vbH}`;
720
+ const isZoomed = zoomScale !== 1 || panX !== 0 || panY !== 0;
721
+ const hoveredNode = hoveredNodeIndex !== null ? positionedNodes[hoveredNodeIndex] : null;
722
+ const hoveredEdge = hoveredEdgeIndex !== null ? positionedEdges.find((pe) => pe.i === hoveredEdgeIndex) : null;
723
+ const hoveredNodeRelCount = hoveredNode
724
+ ? positionedEdges.filter((e) => e.edge.source === hoveredNode.node.id || e.edge.target === hoveredNode.node.id).length
725
+ : 0;
726
+ return (_jsxs("div", { ...rest, className: classNames("st-forceGraph", prefersReducedMotion && "st-forceGraph--static", className), role: "img", "aria-label": label, children: [_jsxs("svg", { ref: svgRef, viewBox: viewBox, preserveAspectRatio: "xMidYMid meet", width: "100%", height: "100%", focusable: "false", "aria-hidden": "true", className: classNames(isPanning && "st-forceGraph__svg--panning"), onWheel: handleWheel, onMouseDown: handleBgMouseDown, onMouseMove: handleMouseMove, onMouseUp: handleMouseUp, onMouseLeave: handleMouseUp, children: [_jsx("g", { className: "st-forceGraph__edges", children: positionedEdges.map((e) => (_jsxs(React.Fragment, { children: [_jsx("line", { className: "st-forceGraph__edgeHit", role: "presentation", x1: e.x1, y1: e.y1, x2: e.x2, y2: e.y2, onMouseEnter: () => {
727
+ setHoveredEdgeIndex(e.i);
728
+ onEdgeHover?.(e.edge);
729
+ }, onMouseLeave: () => setHoveredEdgeIndex(null) }), _jsx("line", { className: classNames("st-forceGraph__edge", e.edge.weak && "st-forceGraph__edge--weak", hoveredEdgeIndex === e.i && "st-forceGraph__edge--hovered"), x1: e.x1, y1: e.y1, x2: e.x2, y2: e.y2, pointerEvents: "none" })] }, e.i))) }), _jsx("g", { className: "st-forceGraph__nodes", children: positionedNodes.map((p) => {
730
+ const ariaLabel = `${p.title}${p.node.group !== undefined ? `: ${p.node.group}` : ""}`;
731
+ const pressed = selectedSet.has(p.node.id);
732
+ const shapeProps = {
733
+ className: "st-forceGraph__shape st-forceGraph__dot",
734
+ tabIndex: 0,
735
+ role: "button",
736
+ "aria-label": ariaLabel,
737
+ "aria-pressed": pressed,
738
+ onMouseEnter: () => setHoveredNodeIndex(p.i),
739
+ onMouseLeave: () => setHoveredNodeIndex(null),
740
+ onFocus: () => setHoveredNodeIndex(p.i),
741
+ onBlur: () => setHoveredNodeIndex(null),
742
+ onClick: () => onSelect?.(p.node.id),
743
+ onDoubleClick: () => onOpenEntity?.(p.node.id),
744
+ onKeyDown: (e) => handleNodeKeydown(p.node.id, e),
745
+ };
746
+ return (_jsxs("g", { className: classNames("st-forceGraph__node", `st-forceGraph__node--${p.tone}`, hoveredNodeIndex !== null && hoveredNodeIndex !== p.i && "st-forceGraph__node--dim", pressed && "st-forceGraph__node--selected", focusId === p.node.id && "st-forceGraph__node--focus"), transform: `translate(${p.x} ${p.y})`, children: [p.shapePath ? (_jsx("path", { ...shapeProps, d: p.shapePath })) : (_jsx("circle", { ...shapeProps, r: p.r })), showLabels ? (_jsx("text", { className: "st-forceGraph__label", x: p.r + 3, y: 0, dominantBaseline: "middle", children: p.title })) : null] }, p.node.id));
747
+ }) })] }), hoveredNode ? (_jsxs("div", { className: "st-forceGraph__tooltip", role: "presentation", style: {
748
+ left: `${((hoveredNode.x - vbX) / vbW) * 100}%`,
749
+ top: `${((hoveredNode.y - vbY) / vbH) * 100}%`,
750
+ }, children: [_jsx("span", { className: "st-forceGraph__tooltipLabel", children: hoveredNode.title }), hoveredNode.node.group !== undefined ? (_jsx("span", { className: "st-forceGraph__tooltipMeta", children: hoveredNode.node.group })) : null, hoveredNodeRelCount > 0 ? (_jsxs("span", { className: "st-forceGraph__tooltipMeta", children: [hoveredNodeRelCount, " relation", hoveredNodeRelCount === 1 ? "" : "s"] })) : null] })) : null, hoveredEdge ? (_jsxs("div", { className: "st-forceGraph__tooltip st-forceGraph__tooltip--edge", role: "presentation", style: {
751
+ left: `${(((hoveredEdge.x1 + hoveredEdge.x2) / 2 - vbX) / vbW) * 100}%`,
752
+ top: `${(((hoveredEdge.y1 + hoveredEdge.y2) / 2 - vbY) / vbH) * 100}%`,
753
+ }, children: [_jsx("span", { className: "st-forceGraph__tooltipLabel", children: hoveredEdge.srcLabel }), hoveredEdge.edge.relation ? (_jsx("span", { className: "st-forceGraph__tooltipRelation", children: hoveredEdge.edge.relation })) : null, _jsx("span", { className: "st-forceGraph__tooltipLabel", children: hoveredEdge.tgtLabel })] })) : null, isZoomed ? (_jsx("button", { className: "st-forceGraph__resetBtn", type: "button", "aria-label": "Reset view", onClick: resetView, children: "\u21BA" })) : null, legend && legend.length > 0 ? (_jsx("div", { className: "st-forceGraph__legend", "aria-label": "Graph legend", children: legend.map((entry, idx) => {
754
+ const swatchPath = entry.shape !== undefined ? nodeShapePath(entry.shape, 7) : null;
755
+ const swatchTone = entry.tone ?? "category1";
756
+ return (_jsxs("div", { className: "st-forceGraph__legendEntry", children: [entry.shape !== undefined ? (
757
+ // Node shape legend entry
758
+ _jsx("svg", { className: "st-forceGraph__legendSwatch", viewBox: "-8 -8 16 16", width: "16", height: "16", "aria-hidden": "true", children: swatchPath ? (_jsx("path", { d: swatchPath, className: `st-forceGraph__legendShape st-forceGraph__legendShape--${swatchTone}` })) : (_jsx("circle", { r: "7", className: `st-forceGraph__legendShape st-forceGraph__legendShape--${swatchTone}` })) })) : (
759
+ // Edge style legend entry
760
+ _jsx("svg", { className: "st-forceGraph__legendSwatch", viewBox: "0 0 16 8", width: "16", height: "8", "aria-hidden": "true", children: _jsx("line", { x1: "0", y1: "4", x2: "16", y2: "4", className: classNames("st-forceGraph__legendEdge", entry.weak && "st-forceGraph__legendEdge--weak") }) })), _jsx("span", { className: "st-forceGraph__legendLabel", children: entry.label })] }, idx));
761
+ }) })) : null] }));
762
+ }
763
+ export function GraphLegend({ entries, title, className, ...rest }) {
764
+ return (_jsxs("div", { ...rest, className: classNames("st-graphLegend", className), "aria-label": title ?? "Graph legend", children: [title ? _jsx("p", { className: "st-graphLegend__title", children: title }) : null, _jsx("ul", { className: "st-graphLegend__list", role: "list", children: entries.map((entry, idx) => {
765
+ const swatchPath = entry.shape !== undefined ? nodeShapePath(entry.shape, 7) : null;
766
+ const swatchTone = entry.tone ?? "category1";
767
+ return (_jsxs("li", { className: "st-graphLegend__entry", children: [entry.shape !== undefined ? (_jsx("svg", { className: "st-graphLegend__swatch", viewBox: "-8 -8 16 16", width: "16", height: "16", "aria-hidden": "true", children: swatchPath ? (_jsx("path", { d: swatchPath, className: `st-graphLegend__shape st-graphLegend__shape--${swatchTone}` })) : (_jsx("circle", { r: "7", className: `st-graphLegend__shape st-graphLegend__shape--${swatchTone}` })) })) : (_jsx("svg", { className: "st-graphLegend__swatch", viewBox: "0 0 16 8", width: "16", height: "8", "aria-hidden": "true", children: _jsx("line", { x1: "0", y1: "4", x2: "16", y2: "4", className: classNames("st-graphLegend__edge", entry.weak && "st-graphLegend__edge--weak") }) })), _jsx("span", { className: "st-graphLegend__label", children: entry.label })] }, idx));
768
+ }) })] }));
408
769
  }
409
770
  export function Form({ status = "idle", message, children, className, ...rest }) {
410
771
  return (_jsxs("form", { ...rest, className: classNames("st-form", className), children: [_jsx("div", { className: "st-form__body", children: children }), message ? _jsx("p", { className: classNames("st-form__message", `st-form__message--${status === "submitted" ? "success" : status === "error" ? "error" : "help"}`), children: message }) : null] }));