@sentropic/design-system-svelte 0.14.0 → 0.16.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.
@@ -249,6 +249,30 @@
249
249
  * null when the hover/focus ends. Intended for syncing an external panel.
250
250
  */
251
251
  onNodeHover?: (node: ForceGraphNode | null) => void;
252
+ /**
253
+ * Reconciliation merge animation (CLIENT-ONLY — driven by `$effect`, which
254
+ * never runs during SSR, so no merge is animated or resolved server-side).
255
+ *
256
+ * Pass a `{ id, from, into }` where both `from` and `into` exist in `nodes`:
257
+ * the `from` node animates toward the position of `into` while fading out
258
+ * (the node and its incident edges), then `onMergeComplete(pair)` fires
259
+ * exactly ONCE for that `id`. Purely visual — the component never mutates the
260
+ * data; the consumer removes `from` from `nodes` after the callback.
261
+ *
262
+ * Idempotent on `id`: re-passing the SAME `id` (even with a new identity for
263
+ * the object) is a no-op — the animation/callback are not replayed. Passing a
264
+ * NEW `id` (re)plays the merge, even for the same `from`/`into` pair. After
265
+ * completion the `from` node stays MASKED (hidden) until the consumer removes
266
+ * it or a new `mergePair` is supplied, so it does not flash back when the
267
+ * prop returns to null. Pass null (the default) for no merge in flight.
268
+ */
269
+ mergePair?: { id: string; from: string; into: string } | null;
270
+ /**
271
+ * Fired once the merge animation for the current `mergePair` completes (or
272
+ * immediately, on a microtask, under reduced motion). Fires at most ONCE per
273
+ * `id`. Receives the same pair so the consumer can drop `from` from the data.
274
+ */
275
+ onMergeComplete?: (pair: { id: string; from: string; into: string }) => void;
252
276
  class?: string;
253
277
  };
254
278
 
@@ -270,6 +294,8 @@
270
294
  edgeCurve = 0.15,
271
295
  repulsion = 1,
272
296
  onNodeHover,
297
+ mergePair = null,
298
+ onMergeComplete,
273
299
  class: className
274
300
  }: ForceGraphProps = $props();
275
301
 
@@ -320,6 +346,26 @@
320
346
  };
321
347
  }
322
348
 
349
+ // Stable seed from the SET of node ids (sorted), not from ns.length/es.length.
350
+ // A length-based seed reshuffled the whole layout whenever a node was added or
351
+ // removed (notably after a reconciliation merge), making the graph "jump". A
352
+ // hash over the sorted ids keeps the same topology → same layout, so removing
353
+ // one node leaves the rest essentially in place. (FNV-1a 32-bit over the joined
354
+ // sorted ids; deterministic and order-independent.)
355
+ function stableSeed(ns: ForceGraphNode[]): number {
356
+ const ids = ns.map((n) => n.id).sort();
357
+ let h = 0x811c9dc5; // FNV offset basis
358
+ const joined = ids.join("|");
359
+ for (let i = 0; i < joined.length; i++) {
360
+ h ^= joined.charCodeAt(i);
361
+ h = Math.imul(h, 0x01000193); // FNV prime
362
+ }
363
+ // Fold in the count too so wholly different graphs of equal id-hash still
364
+ // differ, but the dominant term is the (order-independent) id hash.
365
+ h ^= ns.length;
366
+ return h >>> 0;
367
+ }
368
+
323
369
  function runSimulation(
324
370
  ns: ForceGraphNode[],
325
371
  es: ForceGraphEdge[],
@@ -330,7 +376,9 @@
330
376
  ): Map<string, { x: number; y: number }> {
331
377
  const cx = w / 2;
332
378
  const cy = h / 2;
333
- const rand = mulberry32(ns.length * 2654435761 + es.length);
379
+ // Seed from the stable id-set hash so adding/removing a node does not
380
+ // reshuffle the whole layout (same topology → same layout).
381
+ const rand = mulberry32(stableSeed(ns));
334
382
  const idIndex = new Map<string, number>();
335
383
  const sim: SimNode[] = ns.map((n, i) => {
336
384
  idIndex.set(n.id, i);
@@ -549,6 +597,176 @@
549
597
  .filter((e): e is NonNullable<typeof e> => e !== null);
550
598
  });
551
599
 
600
+ // ---------------------------------------------------------------------------
601
+ // Merge animation (reconciliation): when `mergePair` becomes a new valid pair,
602
+ // the `from` node slides toward `into` while fading out (node + incident
603
+ // edges), then `onMergeComplete` fires. Purely visual — never mutates props.
604
+ // Driven by a single $state holding per-merge progress (0→1). Under reduced
605
+ // motion / SSR we skip straight to completion on a microtask.
606
+ // ---------------------------------------------------------------------------
607
+ const MERGE_DURATION_MS = 450;
608
+ // ease-in-out (cubic) for a smooth glide.
609
+ const easeInOut = (t: number) =>
610
+ t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
611
+
612
+ type MergeState = {
613
+ id: string;
614
+ from: string;
615
+ into: string;
616
+ /** Animation progress, 0..1. */
617
+ progress: number;
618
+ /** Pixel delta from the `from` start position to `into`, captured at start. */
619
+ dx: number;
620
+ dy: number;
621
+ } | null;
622
+
623
+ let mergeState = $state<MergeState>(null);
624
+ let mergeRaf: number | null = null;
625
+ // The id currently being (or already) handled, so the $effect only reacts to a
626
+ // genuinely NEW id. Re-passing the same id (even a fresh object) is a no-op.
627
+ let handledMergeId: string | null = null;
628
+ // The id of the `from` node to keep MASKED after a completed merge, until the
629
+ // consumer drops it from `nodes` (or a new pair arrives). Decouples the mask
630
+ // from `mergePair` returning to null (otherwise the node would flash back).
631
+ let maskedFromId = $state<string | null>(null);
632
+ // Set true on the component's own teardown so no queued microtask/frame fires a
633
+ // callback or touches reactive state after unmount.
634
+ let disposed = false;
635
+
636
+ function cancelMergeRaf() {
637
+ if (mergeRaf != null && typeof cancelAnimationFrame === "function") {
638
+ cancelAnimationFrame(mergeRaf);
639
+ }
640
+ mergeRaf = null;
641
+ }
642
+
643
+ // Component-lifetime teardown guard (separate from the per-pair effect below):
644
+ // marks the instance disposed and cancels any in-flight frame on unmount.
645
+ $effect(() => {
646
+ return () => {
647
+ disposed = true;
648
+ cancelMergeRaf();
649
+ };
650
+ });
651
+
652
+ $effect(() => {
653
+ const pair = mergePair;
654
+ const id = pair ? pair.id : null;
655
+
656
+ // Idempotent on id: same id (or still null) means nothing to (re)start. A new
657
+ // id always (re)plays, even for the same from/into pair.
658
+ if (id === handledMergeId) return;
659
+ handledMergeId = id;
660
+
661
+ // Tear down any in-flight animation for a previous pair.
662
+ cancelMergeRaf();
663
+ mergeState = null;
664
+
665
+ if (!pair) return;
666
+
667
+ // A genuinely new pair supersedes any lingering mask from a prior merge.
668
+ maskedFromId = null;
669
+
670
+ // Validate: both endpoints must currently exist.
671
+ const fromPos = layout.get(pair.from);
672
+ const intoPos = layout.get(pair.into);
673
+ if (!fromPos || !intoPos) return; // invalid pair: no-op, no crash, no callback
674
+
675
+ const captured = { id: pair.id, from: pair.from, into: pair.into };
676
+
677
+ const complete = () => {
678
+ // Keep `from` hidden until the consumer removes it (or a new pair arrives).
679
+ maskedFromId = captured.from;
680
+ onMergeComplete?.(captured);
681
+ };
682
+
683
+ // Reduced motion: no animation, resolve on a microtask. Guarded so a late
684
+ // microtask after unmount or after a newer id took over is a no-op.
685
+ if (prefersReducedMotion || typeof requestAnimationFrame !== "function") {
686
+ queueMicrotask(() => {
687
+ if (disposed || handledMergeId !== id) return;
688
+ complete();
689
+ });
690
+ return;
691
+ }
692
+
693
+ const dx = intoPos.x - fromPos.x;
694
+ const dy = intoPos.y - fromPos.y;
695
+ mergeState = { id: captured.id, from: captured.from, into: captured.into, progress: 0, dx, dy };
696
+
697
+ // Anchor the clock to the FIRST frame's own timestamp. rAF timestamps and
698
+ // performance.now() can use different time origins (notably under jsdom), so
699
+ // mixing them yields a negative/garbage elapsed time. Using the frame clock
700
+ // for both start and elapsed keeps the easing monotonic everywhere.
701
+ let start: number | null = null;
702
+ const tick = (now: number) => {
703
+ // Bail cleanly if the instance went away or a newer id superseded us — no
704
+ // callback, no dangling state.
705
+ if (disposed || handledMergeId !== id) {
706
+ mergeRaf = null;
707
+ return;
708
+ }
709
+ // Re-validate both endpoints every frame: if either disappears mid-flight
710
+ // (e.g. the consumer removed a node), cancel the glide WITHOUT firing
711
+ // onMergeComplete (no double-tir) and without a dangling frame.
712
+ if (!layout.has(captured.from) || !layout.has(captured.into)) {
713
+ cancelMergeRaf();
714
+ mergeState = null;
715
+ return;
716
+ }
717
+ if (start === null) start = now;
718
+ const t = Math.min(1, Math.max(0, (now - start) / MERGE_DURATION_MS));
719
+ if (mergeState) mergeState = { ...mergeState, progress: t };
720
+ if (t < 1) {
721
+ mergeRaf = requestAnimationFrame(tick);
722
+ } else {
723
+ mergeRaf = null;
724
+ mergeState = null;
725
+ complete();
726
+ }
727
+ };
728
+ mergeRaf = requestAnimationFrame(tick);
729
+
730
+ return () => cancelMergeRaf();
731
+ });
732
+
733
+ // ---------------------------------------------------------------------------
734
+ // The `from` node is hidden while it is the active merge source AND while it
735
+ // remains masked post-completion (until the consumer removes it). Both feed the
736
+ // same opacity helpers below.
737
+ // ---------------------------------------------------------------------------
738
+ const mergeFromId = $derived(mergeState?.from ?? null);
739
+ const mergeEased = $derived(mergeState ? easeInOut(mergeState.progress) : 0);
740
+
741
+ /** True when this id is the (post-merge) masked node — render it fully hidden. */
742
+ function isMasked(id: string): boolean {
743
+ return maskedFromId === id;
744
+ }
745
+
746
+ /** Extra translation applied to the merging node so it glides toward `into`. */
747
+ function mergeOffset(id: string): { x: number; y: number } {
748
+ if (!mergeState || mergeState.from !== id) return { x: 0, y: 0 };
749
+ return { x: mergeState.dx * mergeEased, y: mergeState.dy * mergeEased };
750
+ }
751
+
752
+ /**
753
+ * Opacity for a node during/after a merge. The animating `from` fades 1->0; a
754
+ * masked `from` (merge done, awaiting removal) stays at 0. Others unaffected.
755
+ */
756
+ function mergeNodeOpacity(id: string): number | null {
757
+ if (isMasked(id)) return 0;
758
+ if (mergeFromId !== id) return null;
759
+ return 1 - mergeEased;
760
+ }
761
+
762
+ /** Opacity for an edge incident to the merging/masked `from` node (fades too). */
763
+ function mergeEdgeOpacity(e: ForceGraphEdge): number | null {
764
+ const fromId = mergeFromId ?? maskedFromId;
765
+ if (fromId == null) return null;
766
+ if (e.source !== fromId && e.target !== fromId) return null;
767
+ return isMasked(fromId) ? 0 : 1 - mergeEased;
768
+ }
769
+
552
770
  let hoveredNodeIndex: number | null = $state(null);
553
771
  let hoveredEdgeIndex: number | null = $state(null);
554
772
 
@@ -779,6 +997,7 @@
779
997
  onmouseleave={() => { hoveredEdgeIndex = null; }}
780
998
  />
781
999
  {/if}
1000
+ {@const mEdgeOpacity = mergeEdgeOpacity(e.edge)}
782
1001
  {#if e.path}
783
1002
  <path
784
1003
  class="st-forceGraph__edge"
@@ -786,10 +1005,12 @@
786
1005
  class:st-forceGraph__edge--emphasis={e.edge.emphasis}
787
1006
  class:st-forceGraph__edge--hovered={hoveredEdgeIndex === e.i}
788
1007
  class:st-forceGraph__edge--dim={isEdgeSelectionDimmed(e.edge) || isHoverDimmedEdge(e.edge)}
1008
+ class:st-forceGraph__edge--merging={mEdgeOpacity !== null}
789
1009
  d={e.path}
790
1010
  fill="none"
791
1011
  stroke-dasharray={e.dashArray}
792
1012
  stroke-width={e.strokeWidth}
1013
+ opacity={mEdgeOpacity}
793
1014
  pointer-events="none"
794
1015
  />
795
1016
  {:else}
@@ -799,12 +1020,14 @@
799
1020
  class:st-forceGraph__edge--emphasis={e.edge.emphasis}
800
1021
  class:st-forceGraph__edge--hovered={hoveredEdgeIndex === e.i}
801
1022
  class:st-forceGraph__edge--dim={isEdgeSelectionDimmed(e.edge) || isHoverDimmedEdge(e.edge)}
1023
+ class:st-forceGraph__edge--merging={mEdgeOpacity !== null}
802
1024
  x1={e.x1}
803
1025
  y1={e.y1}
804
1026
  x2={e.x2}
805
1027
  y2={e.y2}
806
1028
  stroke-dasharray={e.dashArray}
807
1029
  stroke-width={e.strokeWidth}
1030
+ opacity={mEdgeOpacity}
808
1031
  pointer-events="none"
809
1032
  />
810
1033
  {/if}
@@ -813,12 +1036,18 @@
813
1036
 
814
1037
  <g class="st-forceGraph__nodes">
815
1038
  {#each positionedNodes as p (p.node.id)}
1039
+ {@const mOff = mergeOffset(p.node.id)}
1040
+ {@const mOpacity = mergeNodeOpacity(p.node.id)}
1041
+ {@const mMasked = isMasked(p.node.id)}
816
1042
  <g
817
1043
  class="st-forceGraph__node st-forceGraph__node--{p.tone}"
818
1044
  class:st-forceGraph__node--dim={isHoverDimmedNode(p.node.id) || isSelectionDimmed(p.node.id)}
819
1045
  class:st-forceGraph__node--selected={selectedSet.has(p.node.id)}
820
1046
  class:st-forceGraph__node--focus={focusId === p.node.id}
821
- transform="translate({p.x} {p.y})"
1047
+ class:st-forceGraph__node--merging={mergeFromId === p.node.id || mMasked}
1048
+ aria-hidden={mMasked ? "true" : undefined}
1049
+ opacity={mOpacity}
1050
+ transform="translate({p.x + mOff.x} {p.y + mOff.y})"
822
1051
  >
823
1052
  {#if p.shapePath}
824
1053
  <path
@@ -1022,6 +1251,14 @@
1022
1251
  .st-forceGraph__node { transition: opacity 120ms ease; }
1023
1252
  .st-forceGraph__node--dim { opacity: 0.3; }
1024
1253
 
1254
+ /* During a merge the opacity/transform are driven per-frame via rAF, so the
1255
+ CSS transitions are disabled to avoid a lag that would smear the glide. The
1256
+ fading/masked `from` node is also taken out of hit-testing so the invisible
1257
+ node cannot be hovered, focused or clicked. */
1258
+ .st-forceGraph__node--merging,
1259
+ .st-forceGraph__edge--merging { transition: none; }
1260
+ .st-forceGraph__node--merging { pointer-events: none; }
1261
+
1025
1262
  .st-forceGraph__dot {
1026
1263
  cursor: pointer;
1027
1264
  fill-opacity: 0.9;
@@ -138,6 +138,38 @@ type ForceGraphProps = {
138
138
  * null when the hover/focus ends. Intended for syncing an external panel.
139
139
  */
140
140
  onNodeHover?: (node: ForceGraphNode | null) => void;
141
+ /**
142
+ * Reconciliation merge animation (CLIENT-ONLY — driven by `$effect`, which
143
+ * never runs during SSR, so no merge is animated or resolved server-side).
144
+ *
145
+ * Pass a `{ id, from, into }` where both `from` and `into` exist in `nodes`:
146
+ * the `from` node animates toward the position of `into` while fading out
147
+ * (the node and its incident edges), then `onMergeComplete(pair)` fires
148
+ * exactly ONCE for that `id`. Purely visual — the component never mutates the
149
+ * data; the consumer removes `from` from `nodes` after the callback.
150
+ *
151
+ * Idempotent on `id`: re-passing the SAME `id` (even with a new identity for
152
+ * the object) is a no-op — the animation/callback are not replayed. Passing a
153
+ * NEW `id` (re)plays the merge, even for the same `from`/`into` pair. After
154
+ * completion the `from` node stays MASKED (hidden) until the consumer removes
155
+ * it or a new `mergePair` is supplied, so it does not flash back when the
156
+ * prop returns to null. Pass null (the default) for no merge in flight.
157
+ */
158
+ mergePair?: {
159
+ id: string;
160
+ from: string;
161
+ into: string;
162
+ } | null;
163
+ /**
164
+ * Fired once the merge animation for the current `mergePair` completes (or
165
+ * immediately, on a microtask, under reduced motion). Fires at most ONCE per
166
+ * `id`. Receives the same pair so the consumer can drop `from` from the data.
167
+ */
168
+ onMergeComplete?: (pair: {
169
+ id: string;
170
+ from: string;
171
+ into: string;
172
+ }) => void;
141
173
  class?: string;
142
174
  };
143
175
  declare const ForceGraph: import("svelte").Component<ForceGraphProps, {}, "">;
@@ -1 +1 @@
1
- {"version":3,"file":"ForceGraph.svelte.d.ts","sourceRoot":"","sources":["../src/lib/ForceGraph.svelte.ts"],"names":[],"mappings":"AAGE,MAAM,MAAM,cAAc,GACtB,WAAW,GAAG,WAAW,GAAG,WAAW,GAAG,WAAW,GACrD,WAAW,GAAG,WAAW,GAAG,WAAW,GAAG,WAAW,CAAC;AAE1D,MAAM,MAAM,mBAAmB,GAC3B,KAAK,GAAG,QAAQ,GAChB,SAAS,GACT,MAAM,GACN,SAAS,GACT,KAAK,GAAG,QAAQ,GAChB,YAAY,GACZ,UAAU,CAAC;AAEf,yCAAyC;AACzC,MAAM,MAAM,kBAAkB,GAAG,OAAO,GAAG,QAAQ,GAAG,QAAQ,GAAG,WAAW,CAAC;AAE7E,MAAM,MAAM,cAAc,GAAG;IAC3B,8CAA8C;IAC9C,EAAE,EAAE,MAAM,CAAC;IACX,wCAAwC;IACxC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACxB,gEAAgE;IAChE,IAAI,CAAC,EAAE,cAAc,CAAC;IACtB,mDAAmD;IACnD,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,oEAAoE;IACpE,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ;;;OAGG;IACH,KAAK,CAAC,EAAE,mBAAmB,CAAC;CAC7B,CAAC;AAEF,MAAM,MAAM,cAAc,GAAG;IAC3B,sBAAsB;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,sBAAsB;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,uEAAuE;IACvE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;OAIG;IACH,IAAI,CAAC,EAAE,OAAO,CAAC;IACf;;;;OAIG;IACH,IAAI,CAAC,EAAE,kBAAkB,CAAC;IAC1B;;;OAGG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,8EAA8E;IAC9E,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,MAAM,MAAM,qBAAqB,GAAG;IAClC,iCAAiC;IACjC,KAAK,EAAE,MAAM,CAAC;IACd,4EAA4E;IAC5E,KAAK,CAAC,EAAE,mBAAmB,CAAC;IAC5B,kDAAkD;IAClD,IAAI,CAAC,EAAE,cAAc,CAAC;IACtB,yDAAyD;IACzD,IAAI,CAAC,EAAE,OAAO,CAAC;IACf;;;OAGG;IACH,IAAI,CAAC,EAAE,kBAAkB,CAAC;CAC3B,CAAC;AAEF;;;GAGG;AACH,wBAAgB,aAAa,CAC3B,IAAI,EAAE,kBAAkB,GAAG,SAAS,EACpC,IAAI,CAAC,EAAE,OAAO,GACb,MAAM,GAAG,IAAI,CAUf;AA0BD,wBAAgB,aAAa,CAAC,KAAK,EAAE,mBAAmB,GAAG,SAAS,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAsD9F;AAED,KAAK,eAAe,GAAG;IACrB,KAAK,EAAE,cAAc,EAAE,CAAC;IACxB,KAAK,EAAE,cAAc,EAAE,CAAC;IACxB,iDAAiD;IACjD,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,sDAAsD;IACtD,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,sCAAsC;IACtC,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB;;;OAGG;IACH,QAAQ,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IAChC;;;;OAIG;IACH,YAAY,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC;;;OAGG;IACH,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,cAAc,KAAK,IAAI,CAAC;IAC7C;;;OAGG;IACH,MAAM,CAAC,EAAE,qBAAqB,EAAE,CAAC;IACjC;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;OAKG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;OAGG;IACH,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,cAAc,GAAG,IAAI,KAAK,IAAI,CAAC;IACpD,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAolBJ,QAAA,MAAM,UAAU,qDAAwC,CAAC;AACzD,KAAK,UAAU,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;AAChD,eAAe,UAAU,CAAC"}
1
+ {"version":3,"file":"ForceGraph.svelte.d.ts","sourceRoot":"","sources":["../src/lib/ForceGraph.svelte.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,317 @@
1
+ <script lang="ts" module>
2
+ import type { Snippet } from "svelte";
3
+ import Portal from "./Portal.svelte";
4
+
5
+ export type PopperStrategy = "absolute" | "fixed";
6
+
7
+ export type PopperPlacement =
8
+ | "top"
9
+ | "bottom"
10
+ | "left"
11
+ | "right"
12
+ | "top-start"
13
+ | "top-end"
14
+ | "bottom-start"
15
+ | "bottom-end"
16
+ | "left-start"
17
+ | "left-end"
18
+ | "right-start"
19
+ | "right-end";
20
+
21
+ export type PopperSide = "top" | "bottom" | "left" | "right";
22
+ export type PopperAlign = "start" | "center" | "end";
23
+
24
+ export type PopperProps = {
25
+ /** Reference element the panel is positioned against. */
26
+ anchor: HTMLElement | null;
27
+ /** Controlled open state. When false (or no anchor) nothing renders. */
28
+ open?: boolean;
29
+ /** Wanted placement of the panel relative to the anchor. */
30
+ placement?: PopperPlacement;
31
+ /** Main-axis distance (px) between the anchor and the panel. */
32
+ offset?: number;
33
+ /** Flip to the opposite side when the panel would overflow the viewport. */
34
+ flip?: boolean;
35
+ /** Shift along the cross axis to keep the panel within the viewport. */
36
+ shift?: boolean;
37
+ /** Expose a positioned arrow element. */
38
+ arrow?: boolean;
39
+ /** CSS positioning strategy. */
40
+ strategy?: PopperStrategy;
41
+ /** Render the panel into `document.body` via a Portal. */
42
+ portal?: boolean;
43
+ /** Optional class applied to the floating panel. */
44
+ class?: string;
45
+ /** Notified whenever the resolved placement changes (after flip). */
46
+ onPlacementChange?: (placement: PopperPlacement) => void;
47
+ children?: Snippet;
48
+ };
49
+
50
+ /** Split a placement into its side and (optional) alignment. */
51
+ export function splitPlacement(placement: PopperPlacement): {
52
+ side: PopperSide;
53
+ align: PopperAlign;
54
+ } {
55
+ const [side, align] = placement.split("-") as [PopperSide, PopperAlign?];
56
+ return { side, align: align ?? "center" };
57
+ }
58
+
59
+ /** Recompose a side + alignment into a placement string. */
60
+ export function joinPlacement(side: PopperSide, align: PopperAlign): PopperPlacement {
61
+ return (align === "center" ? side : `${side}-${align}`) as PopperPlacement;
62
+ }
63
+
64
+ const OPPOSITE: Record<PopperSide, PopperSide> = {
65
+ top: "bottom",
66
+ bottom: "top",
67
+ left: "right",
68
+ right: "left"
69
+ };
70
+
71
+ export type Rect = {
72
+ top: number;
73
+ left: number;
74
+ right: number;
75
+ bottom: number;
76
+ width: number;
77
+ height: number;
78
+ };
79
+
80
+ /**
81
+ * Pure geometry: compute the panel coordinates (in the chosen strategy's
82
+ * coordinate space) given the anchor rect, the panel size, and options.
83
+ * Returns the resolved placement (after flip) and the top/left coordinates,
84
+ * plus the arrow offset along the main edge.
85
+ *
86
+ * Coordinates are viewport-relative; callers add scroll offsets for the
87
+ * `absolute` strategy. No DOM access here — safe to unit test.
88
+ */
89
+ export function computePosition(
90
+ anchorRect: Rect,
91
+ panelWidth: number,
92
+ panelHeight: number,
93
+ options: {
94
+ placement: PopperPlacement;
95
+ offset: number;
96
+ flip: boolean;
97
+ shift: boolean;
98
+ viewportWidth: number;
99
+ viewportHeight: number;
100
+ }
101
+ ): { placement: PopperPlacement; top: number; left: number } {
102
+ const { offset, flip, shift, viewportWidth, viewportHeight } = options;
103
+ let { side, align } = splitPlacement(options.placement);
104
+
105
+ const place = (s: PopperSide, a: PopperAlign) => {
106
+ let top = 0;
107
+ let left = 0;
108
+ if (s === "top" || s === "bottom") {
109
+ top = s === "top" ? anchorRect.top - panelHeight - offset : anchorRect.bottom + offset;
110
+ if (a === "start") left = anchorRect.left;
111
+ else if (a === "end") left = anchorRect.right - panelWidth;
112
+ else left = anchorRect.left + anchorRect.width / 2 - panelWidth / 2;
113
+ } else {
114
+ left = s === "left" ? anchorRect.left - panelWidth - offset : anchorRect.right + offset;
115
+ if (a === "start") top = anchorRect.top;
116
+ else if (a === "end") top = anchorRect.bottom - panelHeight;
117
+ else top = anchorRect.top + anchorRect.height / 2 - panelHeight / 2;
118
+ }
119
+ return { top, left };
120
+ };
121
+
122
+ // Flip: if the panel overflows on the chosen side, try the opposite side.
123
+ if (flip) {
124
+ const candidate = place(side, align);
125
+ const overflows =
126
+ (side === "top" && candidate.top < 0) ||
127
+ (side === "bottom" && candidate.top + panelHeight > viewportHeight) ||
128
+ (side === "left" && candidate.left < 0) ||
129
+ (side === "right" && candidate.left + panelWidth > viewportWidth);
130
+ if (overflows) {
131
+ const flipped = OPPOSITE[side];
132
+ const flippedPos = place(flipped, align);
133
+ const flippedOverflows =
134
+ (flipped === "top" && flippedPos.top < 0) ||
135
+ (flipped === "bottom" && flippedPos.top + panelHeight > viewportHeight) ||
136
+ (flipped === "left" && flippedPos.left < 0) ||
137
+ (flipped === "right" && flippedPos.left + panelWidth > viewportWidth);
138
+ // Only flip if the opposite side fits better.
139
+ if (!flippedOverflows) side = flipped;
140
+ }
141
+ }
142
+
143
+ let { top, left } = place(side, align);
144
+
145
+ // Shift: clamp along the cross axis so the panel stays in the viewport.
146
+ if (shift) {
147
+ if (side === "top" || side === "bottom") {
148
+ const max = Math.max(0, viewportWidth - panelWidth);
149
+ left = Math.min(Math.max(0, left), max);
150
+ } else {
151
+ const max = Math.max(0, viewportHeight - panelHeight);
152
+ top = Math.min(Math.max(0, top), max);
153
+ }
154
+ }
155
+
156
+ return { placement: joinPlacement(side, align), top, left };
157
+ }
158
+ </script>
159
+
160
+ <script lang="ts">
161
+ let {
162
+ anchor,
163
+ open = false,
164
+ placement = "bottom",
165
+ offset = 8,
166
+ flip = true,
167
+ shift = true,
168
+ arrow = false,
169
+ strategy = "absolute",
170
+ portal = true,
171
+ class: className,
172
+ onPlacementChange,
173
+ children
174
+ }: PopperProps = $props();
175
+
176
+ let panel = $state<HTMLDivElement | undefined>();
177
+ let top = $state(0);
178
+ let left = $state(0);
179
+ // Placement actually applied (may differ from the requested `placement`
180
+ // after a flip). Initialised lazily; defaults to the requested placement.
181
+ let flippedPlacement = $state<PopperPlacement | undefined>();
182
+ const resolvedPlacement = $derived(flippedPlacement ?? placement);
183
+
184
+ function reposition() {
185
+ if (typeof window === "undefined") return;
186
+ if (!open || !anchor || !panel) return;
187
+
188
+ const anchorRect = anchor.getBoundingClientRect();
189
+ const panelRect = panel.getBoundingClientRect();
190
+
191
+ const result = computePosition(
192
+ {
193
+ top: anchorRect.top,
194
+ left: anchorRect.left,
195
+ right: anchorRect.right,
196
+ bottom: anchorRect.bottom,
197
+ width: anchorRect.width,
198
+ height: anchorRect.height
199
+ },
200
+ panelRect.width,
201
+ panelRect.height,
202
+ {
203
+ placement,
204
+ offset,
205
+ flip,
206
+ shift,
207
+ viewportWidth: window.innerWidth,
208
+ viewportHeight: window.innerHeight
209
+ }
210
+ );
211
+
212
+ // `absolute` is positioned relative to the document, so add scroll offsets.
213
+ // `fixed` is viewport-relative, so coordinates are used as-is.
214
+ if (strategy === "absolute") {
215
+ top = result.top + window.scrollY;
216
+ left = result.left + window.scrollX;
217
+ } else {
218
+ top = result.top;
219
+ left = result.left;
220
+ }
221
+
222
+ if (result.placement !== resolvedPlacement) {
223
+ flippedPlacement = result.placement;
224
+ onPlacementChange?.(result.placement);
225
+ }
226
+ }
227
+
228
+ $effect(() => {
229
+ // Client-only: register listeners and compute the position once mounted.
230
+ if (typeof window === "undefined") return;
231
+ if (!open || !anchor || !panel) return;
232
+
233
+ reposition();
234
+
235
+ const onScroll = () => reposition();
236
+ const onResize = () => reposition();
237
+ window.addEventListener("scroll", onScroll, true);
238
+ window.addEventListener("resize", onResize);
239
+
240
+ return () => {
241
+ window.removeEventListener("scroll", onScroll, true);
242
+ window.removeEventListener("resize", onResize);
243
+ };
244
+ });
245
+
246
+ const panelStyle = () =>
247
+ `position: ${strategy}; top: ${top}px; left: ${left}px;`;
248
+ const panelSide = () => splitPlacement(resolvedPlacement).side;
249
+ </script>
250
+
251
+ {#snippet floating()}
252
+ <div
253
+ bind:this={panel}
254
+ class={className ? `st-popper ${className}` : "st-popper"}
255
+ data-popper-placement={resolvedPlacement}
256
+ style={panelStyle()}
257
+ >
258
+ {@render children?.()}
259
+ {#if arrow}
260
+ <span
261
+ class="st-popper__arrow"
262
+ data-popper-side={panelSide()}
263
+ aria-hidden="true"
264
+ ></span>
265
+ {/if}
266
+ </div>
267
+ {/snippet}
268
+
269
+ {#if open && anchor}
270
+ {#if portal}
271
+ <Portal target="body">
272
+ {@render floating()}
273
+ </Portal>
274
+ {:else}
275
+ {@render floating()}
276
+ {/if}
277
+ {/if}
278
+
279
+ <style>
280
+ .st-popper {
281
+ z-index: var(--st-component-popover-zIndex, 80);
282
+ }
283
+
284
+ .st-popper__arrow {
285
+ position: absolute;
286
+ width: 0.5rem;
287
+ height: 0.5rem;
288
+ background: inherit;
289
+ border: inherit;
290
+ transform: rotate(45deg);
291
+ pointer-events: none;
292
+ }
293
+
294
+ .st-popper__arrow[data-popper-side="bottom"] {
295
+ top: -0.25rem;
296
+ left: 50%;
297
+ margin-left: -0.25rem;
298
+ }
299
+
300
+ .st-popper__arrow[data-popper-side="top"] {
301
+ bottom: -0.25rem;
302
+ left: 50%;
303
+ margin-left: -0.25rem;
304
+ }
305
+
306
+ .st-popper__arrow[data-popper-side="right"] {
307
+ left: -0.25rem;
308
+ top: 50%;
309
+ margin-top: -0.25rem;
310
+ }
311
+
312
+ .st-popper__arrow[data-popper-side="left"] {
313
+ right: -0.25rem;
314
+ top: 50%;
315
+ margin-top: -0.25rem;
316
+ }
317
+ </style>