@tensor_1/react 2.26.2 → 2.26.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.
@@ -2,43 +2,174 @@
2
2
  import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
3
3
  import { computePosition } from "../overlay-position.js";
4
4
  const useIsomorphicLayoutEffect = typeof document !== "undefined" ? useLayoutEffect : useEffect;
5
- const resolve = (a) => a && typeof a === "object" && "current" in a ? a.current : a ?? null;
5
+ const resolveAnchor = (a) => a && typeof a === "object" && "current" in a ? a.current : a ?? null;
6
+ let anchoringSupported;
7
+ function supportsAnchoring() {
8
+ if (anchoringSupported === void 0) {
9
+ try {
10
+ const css = typeof CSS !== "undefined" ? CSS : void 0;
11
+ anchoringSupported = !!css && typeof css.supports === "function" && css.supports("anchor-name", "--uix-a") && css.supports("position-anchor", "--uix-a") && css.supports("top", "round(anchor(bottom) + 1px, 1px)");
12
+ } catch {
13
+ anchoringSupported = false;
14
+ }
15
+ }
16
+ return anchoringSupported;
17
+ }
18
+ let anchorCount = 0;
19
+ const namesOf = (value) => value.split(",").map((n) => n.trim()).filter((n) => n.startsWith("--"));
20
+ function addAnchorName(el, name) {
21
+ const names = namesOf(getComputedStyle(el).getPropertyValue("anchor-name"));
22
+ if (!names.includes(name)) names.push(name);
23
+ el.style.setProperty("anchor-name", names.join(", "));
24
+ }
25
+ function removeAnchorName(el, name) {
26
+ const names = namesOf(el.style.getPropertyValue("anchor-name")).filter((n) => n !== name);
27
+ if (names.length) el.style.setProperty("anchor-name", names.join(", "));
28
+ else el.style.removeProperty("anchor-name");
29
+ }
30
+ function translationOf(el) {
31
+ const t = getComputedStyle(el).transform;
32
+ if (!t || t === "none" || typeof DOMMatrixReadOnly === "undefined") return { x: 0, y: 0 };
33
+ try {
34
+ const m = new DOMMatrixReadOnly(t);
35
+ return { x: m.m41, y: m.m42 };
36
+ } catch {
37
+ return { x: 0, y: 0 };
38
+ }
39
+ }
40
+ const signed = (n) => `${n < 0 ? "-" : "+"} ${Math.round(Math.abs(n) * 100) / 100}px`;
41
+ const prepared = /* @__PURE__ */ new WeakSet();
6
42
  function useAnchoredPosition(anchor, floatingRef, { open, placement = "bottom-start", offset = 6, padding = 8 }) {
7
43
  const opts = useRef({ placement, offset, padding });
8
44
  opts.current = { placement, offset, padding };
45
+ const session = useRef(false);
46
+ const side = useRef(null);
47
+ const written = useRef("");
48
+ const anchoring = useRef(null);
49
+ const anchoringFailed = useRef(false);
50
+ const anchorName = useRef("");
51
+ const release = useCallback((keepPlace) => {
52
+ const current = anchoring.current;
53
+ if (!current) return;
54
+ anchoring.current = null;
55
+ written.current = "";
56
+ const floating = floatingRef.current;
57
+ if (floating) {
58
+ const s = floating.style;
59
+ if (keepPlace && current.anchor.isConnected) {
60
+ const a = current.anchor.getBoundingClientRect();
61
+ s.top = `${Math.round((current.yEdge === "bottom" ? a.bottom : a.top) + current.dy)}px`;
62
+ s.left = `${Math.round((current.xEdge === "right" ? a.right : a.left) + current.dx)}px`;
63
+ }
64
+ s.removeProperty("position-anchor");
65
+ }
66
+ removeAnchorName(current.anchor, current.name);
67
+ }, [floatingRef]);
9
68
  const reposition = useCallback(() => {
10
- const anchorEl = resolve(anchor);
69
+ const anchorEl = resolveAnchor(anchor);
11
70
  const floating = floatingRef.current;
12
71
  if (!anchorEl || !floating) return;
13
72
  const s = floating.style;
14
- s.position = "fixed";
15
- s.inset = "auto";
16
- s.margin = "0";
73
+ if (!prepared.has(floating)) {
74
+ s.position = "fixed";
75
+ s.inset = "auto";
76
+ s.margin = "0";
77
+ prepared.add(floating);
78
+ written.current = "";
79
+ }
17
80
  const a = anchorEl.getBoundingClientRect();
81
+ const size = { width: floating.offsetWidth, height: floating.offsetHeight };
18
82
  const { placement: p, offset: o, padding: pad } = opts.current;
19
83
  const pos = computePosition(
20
84
  { x: a.x, y: a.y, width: a.width, height: a.height },
21
- { width: floating.offsetWidth, height: floating.offsetHeight },
85
+ size,
22
86
  { width: window.innerWidth, height: window.innerHeight },
23
- { placement: p, offset: o, padding: pad }
87
+ { placement: p, offset: o, padding: pad, stickySide: side.current ?? void 0 }
24
88
  );
25
- s.left = `${Math.round(pos.x)}px`;
26
- s.top = `${Math.round(pos.y)}px`;
27
- floating.dataset.placement = pos.placement;
28
- }, [anchor, floatingRef]);
89
+ const measured = size.width > 0 || size.height > 0;
90
+ if (measured && session.current) side.current = pos.side;
91
+ if (floating.dataset.placement !== pos.placement) floating.dataset.placement = pos.placement;
92
+ const writeFixed = (at) => {
93
+ const left2 = `${Math.round(at.x)}px`;
94
+ const top2 = `${Math.round(at.y)}px`;
95
+ const key2 = `fixed ${left2} ${top2}`;
96
+ if (key2 === written.current) return;
97
+ s.left = left2;
98
+ s.top = top2;
99
+ written.current = key2;
100
+ };
101
+ if (anchoringFailed.current || !supportsAnchoring()) {
102
+ writeFixed(pos);
103
+ return;
104
+ }
105
+ if (anchoring.current && anchoring.current.anchor !== anchorEl) release(false);
106
+ if (!anchoring.current) {
107
+ anchorName.current || (anchorName.current = `--uix-anchor-${++anchorCount}`);
108
+ addAnchorName(anchorEl, anchorName.current);
109
+ s.setProperty("position-anchor", anchorName.current);
110
+ anchoring.current = { anchor: anchorEl, name: anchorName.current, yEdge: "bottom", xEdge: "left", dy: 0, dx: 0, verified: false };
111
+ written.current = "";
112
+ }
113
+ const current = anchoring.current;
114
+ current.yEdge = pos.side === "bottom" ? "bottom" : "top";
115
+ current.xEdge = pos.side === "right" ? "right" : "left";
116
+ current.dy = pos.y - (current.yEdge === "bottom" ? a.bottom : a.top);
117
+ current.dx = pos.x - (current.xEdge === "right" ? a.right : a.left);
118
+ const top = `round(anchor(${current.yEdge}) ${signed(current.dy)}, 1px)`;
119
+ const left = `round(anchor(${current.xEdge}) ${signed(current.dx)}, 1px)`;
120
+ const key = `anchored ${top} ${left}`;
121
+ if (key !== written.current) {
122
+ s.top = top;
123
+ s.left = left;
124
+ written.current = key;
125
+ }
126
+ if (!current.verified && measured) {
127
+ current.verified = true;
128
+ const box = floating.getBoundingClientRect();
129
+ const t = translationOf(floating);
130
+ const dxc = box.left + box.width / 2 - t.x - (pos.x + size.width / 2);
131
+ const dyc = box.top + box.height / 2 - t.y - (pos.y + size.height / 2);
132
+ if (Math.abs(dxc) > 2 || Math.abs(dyc) > 2) {
133
+ anchoringFailed.current = true;
134
+ release(false);
135
+ writeFixed(pos);
136
+ }
137
+ }
138
+ }, [anchor, floatingRef, release]);
29
139
  useIsomorphicLayoutEffect(() => {
30
140
  if (!open) return;
141
+ session.current = true;
31
142
  reposition();
32
- const onScroll = () => reposition();
143
+ const anchorEl = resolveAnchor(anchor);
144
+ const floating = floatingRef.current;
145
+ const onScroll = (event) => {
146
+ const target = event.target;
147
+ if (target && typeof target.contains === "function") {
148
+ if (floating?.contains(target)) return;
149
+ if (anchorEl && !target.contains(anchorEl)) return;
150
+ }
151
+ reposition();
152
+ };
153
+ const onResize = () => reposition();
33
154
  window.addEventListener("scroll", onScroll, { passive: true, capture: true });
34
- window.addEventListener("resize", onScroll);
155
+ window.addEventListener("resize", onResize);
156
+ const observer = typeof ResizeObserver === "function" ? new ResizeObserver(() => reposition()) : null;
157
+ if (floating) observer?.observe(floating);
158
+ if (anchorEl) observer?.observe(anchorEl);
35
159
  return () => {
36
160
  window.removeEventListener("scroll", onScroll, true);
37
- window.removeEventListener("resize", onScroll);
161
+ window.removeEventListener("resize", onResize);
162
+ observer?.disconnect();
163
+ session.current = false;
164
+ side.current = null;
165
+ anchoringFailed.current = false;
166
+ release(true);
38
167
  };
39
- }, [open, reposition]);
168
+ }, [open, reposition, anchor, floatingRef, release]);
169
+ useEffect(() => () => release(false), [release]);
40
170
  return reposition;
41
171
  }
42
172
  export {
173
+ resolveAnchor,
43
174
  useAnchoredPosition
44
175
  };
package/dist/index.cjs CHANGED
@@ -704,10 +704,15 @@ function fitsOn(side, a, f, vp, offset, padding) {
704
704
  return spaceOn(side, a, vp, offset) >= need + padding;
705
705
  }
706
706
  function computePosition(anchor, floating, viewport, options = {}) {
707
- const { placement = "bottom-start", offset = 6, padding = 8, flip = true, shift = true } = options;
707
+ const { placement = "bottom-start", offset = 6, padding = 8, flip = true, shift = true, stickySide } = options;
708
708
  const { side: preferred, align } = parsePlacement(placement);
709
709
  let side = preferred;
710
- if (flip && !fitsOn(side, anchor, floating, viewport, offset, padding)) {
710
+ const sticky = stickySide === preferred || stickySide === OPPOSITE[preferred] ? stickySide : void 0;
711
+ if (flip && sticky) {
712
+ side = sticky;
713
+ const opp = OPPOSITE[side];
714
+ if (!fitsOn(side, anchor, floating, viewport, offset, padding) && fitsOn(opp, anchor, floating, viewport, offset, padding)) side = opp;
715
+ } else if (flip && !fitsOn(side, anchor, floating, viewport, offset, padding)) {
711
716
  const opp = OPPOSITE[side];
712
717
  if (fitsOn(opp, anchor, floating, viewport, offset, padding) || spaceOn(opp, anchor, viewport, offset) > spaceOn(side, anchor, viewport, offset)) {
713
718
  side = opp;
@@ -743,41 +748,171 @@ function computePosition(anchor, floating, viewport, options = {}) {
743
748
 
744
749
  // src/hooks/useAnchoredPosition.ts
745
750
  var useIsomorphicLayoutEffect = typeof document !== "undefined" ? import_react4.useLayoutEffect : import_react4.useEffect;
746
- var resolve = (a) => a && typeof a === "object" && "current" in a ? a.current : a ?? null;
751
+ var resolveAnchor = (a) => a && typeof a === "object" && "current" in a ? a.current : a ?? null;
752
+ var anchoringSupported;
753
+ function supportsAnchoring() {
754
+ if (anchoringSupported === void 0) {
755
+ try {
756
+ const css = typeof CSS !== "undefined" ? CSS : void 0;
757
+ anchoringSupported = !!css && typeof css.supports === "function" && css.supports("anchor-name", "--uix-a") && css.supports("position-anchor", "--uix-a") && css.supports("top", "round(anchor(bottom) + 1px, 1px)");
758
+ } catch {
759
+ anchoringSupported = false;
760
+ }
761
+ }
762
+ return anchoringSupported;
763
+ }
764
+ var anchorCount = 0;
765
+ var namesOf = (value) => value.split(",").map((n) => n.trim()).filter((n) => n.startsWith("--"));
766
+ function addAnchorName(el, name) {
767
+ const names = namesOf(getComputedStyle(el).getPropertyValue("anchor-name"));
768
+ if (!names.includes(name)) names.push(name);
769
+ el.style.setProperty("anchor-name", names.join(", "));
770
+ }
771
+ function removeAnchorName(el, name) {
772
+ const names = namesOf(el.style.getPropertyValue("anchor-name")).filter((n) => n !== name);
773
+ if (names.length) el.style.setProperty("anchor-name", names.join(", "));
774
+ else el.style.removeProperty("anchor-name");
775
+ }
776
+ function translationOf(el) {
777
+ const t = getComputedStyle(el).transform;
778
+ if (!t || t === "none" || typeof DOMMatrixReadOnly === "undefined") return { x: 0, y: 0 };
779
+ try {
780
+ const m = new DOMMatrixReadOnly(t);
781
+ return { x: m.m41, y: m.m42 };
782
+ } catch {
783
+ return { x: 0, y: 0 };
784
+ }
785
+ }
786
+ var signed = (n) => `${n < 0 ? "-" : "+"} ${Math.round(Math.abs(n) * 100) / 100}px`;
787
+ var prepared = /* @__PURE__ */ new WeakSet();
747
788
  function useAnchoredPosition(anchor, floatingRef, { open, placement = "bottom-start", offset = 6, padding = 8 }) {
748
789
  const opts = (0, import_react4.useRef)({ placement, offset, padding });
749
790
  opts.current = { placement, offset, padding };
791
+ const session = (0, import_react4.useRef)(false);
792
+ const side = (0, import_react4.useRef)(null);
793
+ const written = (0, import_react4.useRef)("");
794
+ const anchoring = (0, import_react4.useRef)(null);
795
+ const anchoringFailed = (0, import_react4.useRef)(false);
796
+ const anchorName = (0, import_react4.useRef)("");
797
+ const release = (0, import_react4.useCallback)((keepPlace) => {
798
+ const current = anchoring.current;
799
+ if (!current) return;
800
+ anchoring.current = null;
801
+ written.current = "";
802
+ const floating = floatingRef.current;
803
+ if (floating) {
804
+ const s = floating.style;
805
+ if (keepPlace && current.anchor.isConnected) {
806
+ const a = current.anchor.getBoundingClientRect();
807
+ s.top = `${Math.round((current.yEdge === "bottom" ? a.bottom : a.top) + current.dy)}px`;
808
+ s.left = `${Math.round((current.xEdge === "right" ? a.right : a.left) + current.dx)}px`;
809
+ }
810
+ s.removeProperty("position-anchor");
811
+ }
812
+ removeAnchorName(current.anchor, current.name);
813
+ }, [floatingRef]);
750
814
  const reposition = (0, import_react4.useCallback)(() => {
751
- const anchorEl = resolve(anchor);
815
+ const anchorEl = resolveAnchor(anchor);
752
816
  const floating = floatingRef.current;
753
817
  if (!anchorEl || !floating) return;
754
818
  const s = floating.style;
755
- s.position = "fixed";
756
- s.inset = "auto";
757
- s.margin = "0";
819
+ if (!prepared.has(floating)) {
820
+ s.position = "fixed";
821
+ s.inset = "auto";
822
+ s.margin = "0";
823
+ prepared.add(floating);
824
+ written.current = "";
825
+ }
758
826
  const a = anchorEl.getBoundingClientRect();
827
+ const size = { width: floating.offsetWidth, height: floating.offsetHeight };
759
828
  const { placement: p, offset: o, padding: pad } = opts.current;
760
829
  const pos = computePosition(
761
830
  { x: a.x, y: a.y, width: a.width, height: a.height },
762
- { width: floating.offsetWidth, height: floating.offsetHeight },
831
+ size,
763
832
  { width: window.innerWidth, height: window.innerHeight },
764
- { placement: p, offset: o, padding: pad }
833
+ { placement: p, offset: o, padding: pad, stickySide: side.current ?? void 0 }
765
834
  );
766
- s.left = `${Math.round(pos.x)}px`;
767
- s.top = `${Math.round(pos.y)}px`;
768
- floating.dataset.placement = pos.placement;
769
- }, [anchor, floatingRef]);
835
+ const measured = size.width > 0 || size.height > 0;
836
+ if (measured && session.current) side.current = pos.side;
837
+ if (floating.dataset.placement !== pos.placement) floating.dataset.placement = pos.placement;
838
+ const writeFixed = (at) => {
839
+ const left2 = `${Math.round(at.x)}px`;
840
+ const top2 = `${Math.round(at.y)}px`;
841
+ const key2 = `fixed ${left2} ${top2}`;
842
+ if (key2 === written.current) return;
843
+ s.left = left2;
844
+ s.top = top2;
845
+ written.current = key2;
846
+ };
847
+ if (anchoringFailed.current || !supportsAnchoring()) {
848
+ writeFixed(pos);
849
+ return;
850
+ }
851
+ if (anchoring.current && anchoring.current.anchor !== anchorEl) release(false);
852
+ if (!anchoring.current) {
853
+ anchorName.current || (anchorName.current = `--uix-anchor-${++anchorCount}`);
854
+ addAnchorName(anchorEl, anchorName.current);
855
+ s.setProperty("position-anchor", anchorName.current);
856
+ anchoring.current = { anchor: anchorEl, name: anchorName.current, yEdge: "bottom", xEdge: "left", dy: 0, dx: 0, verified: false };
857
+ written.current = "";
858
+ }
859
+ const current = anchoring.current;
860
+ current.yEdge = pos.side === "bottom" ? "bottom" : "top";
861
+ current.xEdge = pos.side === "right" ? "right" : "left";
862
+ current.dy = pos.y - (current.yEdge === "bottom" ? a.bottom : a.top);
863
+ current.dx = pos.x - (current.xEdge === "right" ? a.right : a.left);
864
+ const top = `round(anchor(${current.yEdge}) ${signed(current.dy)}, 1px)`;
865
+ const left = `round(anchor(${current.xEdge}) ${signed(current.dx)}, 1px)`;
866
+ const key = `anchored ${top} ${left}`;
867
+ if (key !== written.current) {
868
+ s.top = top;
869
+ s.left = left;
870
+ written.current = key;
871
+ }
872
+ if (!current.verified && measured) {
873
+ current.verified = true;
874
+ const box = floating.getBoundingClientRect();
875
+ const t = translationOf(floating);
876
+ const dxc = box.left + box.width / 2 - t.x - (pos.x + size.width / 2);
877
+ const dyc = box.top + box.height / 2 - t.y - (pos.y + size.height / 2);
878
+ if (Math.abs(dxc) > 2 || Math.abs(dyc) > 2) {
879
+ anchoringFailed.current = true;
880
+ release(false);
881
+ writeFixed(pos);
882
+ }
883
+ }
884
+ }, [anchor, floatingRef, release]);
770
885
  useIsomorphicLayoutEffect(() => {
771
886
  if (!open) return;
887
+ session.current = true;
772
888
  reposition();
773
- const onScroll = () => reposition();
889
+ const anchorEl = resolveAnchor(anchor);
890
+ const floating = floatingRef.current;
891
+ const onScroll = (event) => {
892
+ const target = event.target;
893
+ if (target && typeof target.contains === "function") {
894
+ if (floating?.contains(target)) return;
895
+ if (anchorEl && !target.contains(anchorEl)) return;
896
+ }
897
+ reposition();
898
+ };
899
+ const onResize = () => reposition();
774
900
  window.addEventListener("scroll", onScroll, { passive: true, capture: true });
775
- window.addEventListener("resize", onScroll);
901
+ window.addEventListener("resize", onResize);
902
+ const observer = typeof ResizeObserver === "function" ? new ResizeObserver(() => reposition()) : null;
903
+ if (floating) observer?.observe(floating);
904
+ if (anchorEl) observer?.observe(anchorEl);
776
905
  return () => {
777
906
  window.removeEventListener("scroll", onScroll, true);
778
- window.removeEventListener("resize", onScroll);
907
+ window.removeEventListener("resize", onResize);
908
+ observer?.disconnect();
909
+ session.current = false;
910
+ side.current = null;
911
+ anchoringFailed.current = false;
912
+ release(true);
779
913
  };
780
- }, [open, reposition]);
914
+ }, [open, reposition, anchor, floatingRef, release]);
915
+ (0, import_react4.useEffect)(() => () => release(false), [release]);
781
916
  return reposition;
782
917
  }
783
918
 
@@ -1490,17 +1625,36 @@ var helpLabelFor = (thing) => typeof thing === "string" || typeof thing === "num
1490
1625
  // src/components/Popover.tsx
1491
1626
  var import_react14 = require("react");
1492
1627
  var import_jsx_runtime11 = require("react/jsx-runtime");
1628
+ var useIsomorphicLayoutEffect3 = typeof document !== "undefined" ? import_react14.useLayoutEffect : import_react14.useEffect;
1629
+ var PLACING = "data-uix-placing";
1630
+ var isPopoverOpen = (el) => {
1631
+ try {
1632
+ return el.matches(":popover-open");
1633
+ } catch {
1634
+ return false;
1635
+ }
1636
+ };
1637
+ var settle = (el) => {
1638
+ if (!el.hasAttribute(PLACING)) return;
1639
+ getComputedStyle(el).getPropertyValue("transform");
1640
+ el.removeAttribute(PLACING);
1641
+ };
1493
1642
  function Popover({
1494
1643
  popover = "auto",
1495
1644
  anchor,
1496
1645
  placement = "bottom-start",
1497
1646
  offset = 6,
1647
+ closeWhenAnchorHidden = false,
1648
+ onAnchorHidden,
1498
1649
  className,
1499
1650
  children,
1500
1651
  ...props
1501
1652
  }) {
1502
1653
  const ref = (0, import_react14.useRef)(null);
1503
1654
  const [open, setOpen] = (0, import_react14.useState)(false);
1655
+ const openRef = (0, import_react14.useRef)(false);
1656
+ const onAnchorHiddenRef = (0, import_react14.useRef)(onAnchorHidden);
1657
+ onAnchorHiddenRef.current = onAnchorHidden;
1504
1658
  const popoverAttr = { popover };
1505
1659
  const reposition = useAnchoredPosition(anchor ?? null, ref, {
1506
1660
  open: open && !!anchor,
@@ -1510,14 +1664,55 @@ function Popover({
1510
1664
  (0, import_react14.useEffect)(() => {
1511
1665
  const el = ref.current;
1512
1666
  if (!el || !anchor) return;
1667
+ const hold = (event) => {
1668
+ if (event.newState !== "open") return;
1669
+ el.setAttribute(PLACING, "");
1670
+ window.setTimeout(() => settle(el), 150);
1671
+ };
1513
1672
  const sync = () => {
1514
- const isOpen = el.matches(":popover-open");
1515
- if (isOpen) reposition();
1516
- setOpen(isOpen);
1673
+ if (!isPopoverOpen(el)) {
1674
+ el.removeAttribute(PLACING);
1675
+ setOpen(false);
1676
+ return;
1677
+ }
1678
+ reposition();
1679
+ window.setTimeout(() => {
1680
+ if (!isPopoverOpen(el)) return;
1681
+ if (!openRef.current) {
1682
+ setOpen(true);
1683
+ return;
1684
+ }
1685
+ reposition();
1686
+ settle(el);
1687
+ }, 0);
1517
1688
  };
1689
+ el.addEventListener("beforetoggle", hold);
1518
1690
  el.addEventListener("toggle", sync);
1519
- return () => el.removeEventListener("toggle", sync);
1691
+ return () => {
1692
+ el.removeEventListener("beforetoggle", hold);
1693
+ el.removeEventListener("toggle", sync);
1694
+ el.removeAttribute(PLACING);
1695
+ };
1520
1696
  }, [anchor, reposition]);
1697
+ useIsomorphicLayoutEffect3(() => {
1698
+ openRef.current = open;
1699
+ if (open && ref.current) settle(ref.current);
1700
+ }, [open]);
1701
+ (0, import_react14.useEffect)(() => {
1702
+ const el = ref.current;
1703
+ const anchorEl = resolveAnchor(anchor);
1704
+ if (!closeWhenAnchorHidden || !open || !el || !anchorEl || typeof IntersectionObserver !== "function") return;
1705
+ const observer = new IntersectionObserver((entries) => {
1706
+ const latest = entries[entries.length - 1];
1707
+ if (!latest || latest.isIntersecting || !isPopoverOpen(el)) return;
1708
+ onAnchorHiddenRef.current?.();
1709
+ const active = el.ownerDocument.activeElement;
1710
+ if (active && el.contains(active)) active.blur();
1711
+ el.hidePopover();
1712
+ });
1713
+ observer.observe(anchorEl);
1714
+ return () => observer.disconnect();
1715
+ }, [closeWhenAnchorHidden, open, anchor]);
1521
1716
  return /* @__PURE__ */ (0, import_jsx_runtime11.jsx)("div", { ref, className: cx("uix-popover", className), ...popoverAttr, ...props, children });
1522
1717
  }
1523
1718
 
@@ -3615,7 +3810,7 @@ var VIEW_MENU_COLUMN_LABELS = {
3615
3810
  };
3616
3811
  var fill = (template, values) => template.replace(/\{(\w+)\}/g, (match, name) => name in values ? String(values[name]) : match);
3617
3812
  var columnText = (column) => column.textLabel ?? (typeof column.label === "string" ? column.label : column.id);
3618
- var isPopoverOpen = (el) => {
3813
+ var isPopoverOpen2 = (el) => {
3619
3814
  try {
3620
3815
  return !!el?.matches(":popover-open");
3621
3816
  } catch {
@@ -3631,14 +3826,14 @@ function ColumnRowMenu({ label, actions, triggerRef }) {
3631
3826
  const enabled = () => items.current.filter((el) => !!el && !el.disabled);
3632
3827
  const close = (returnFocus) => {
3633
3828
  const el = document.getElementById(menuId);
3634
- if (isPopoverOpen(el)) el.hidePopover();
3829
+ if (isPopoverOpen2(el)) el.hidePopover();
3635
3830
  setOpen(false);
3636
3831
  if (returnFocus) button.current?.focus();
3637
3832
  };
3638
3833
  (0, import_react36.useEffect)(() => {
3639
3834
  if (!open) return;
3640
3835
  const el = document.getElementById(menuId);
3641
- if (el && typeof el.showPopover === "function" && !isPopoverOpen(el)) {
3836
+ if (el && typeof el.showPopover === "function" && !isPopoverOpen2(el)) {
3642
3837
  try {
3643
3838
  el.showPopover();
3644
3839
  } catch {
@@ -3689,7 +3884,7 @@ function ColumnRowMenu({ label, actions, triggerRef }) {
3689
3884
  "aria-expanded": open,
3690
3885
  "aria-controls": open ? menuId : void 0,
3691
3886
  onPointerDown: () => {
3692
- wasOpenAtPress.current = isPopoverOpen(document.getElementById(menuId));
3887
+ wasOpenAtPress.current = isPopoverOpen2(document.getElementById(menuId));
3693
3888
  },
3694
3889
  onClick: () => {
3695
3890
  const wasOpen = wasOpenAtPress.current;
@@ -7661,7 +7856,7 @@ function DiffViewer({
7661
7856
  const [internalResolutions, setInternalResolutions] = (0, import_react51.useState)({});
7662
7857
  const resolutions = controlledResolutions ?? internalResolutions;
7663
7858
  const summary = summarizeDiff(entries, resolutions);
7664
- const resolve2 = (path, resolution) => {
7859
+ const resolve = (path, resolution) => {
7665
7860
  if (controlledResolutions === void 0) setInternalResolutions((currentState) => ({ ...currentState, [path]: resolution }));
7666
7861
  onResolutionChange?.(path, resolution);
7667
7862
  };
@@ -7708,9 +7903,9 @@ function DiffViewer({
7708
7903
  /* @__PURE__ */ (0, import_jsx_runtime75.jsx)("pre", { children: renderValue(entry[version], labels.notPresent) })
7709
7904
  ] }, version)) }),
7710
7905
  /* @__PURE__ */ (0, import_jsx_runtime75.jsxs)("div", { className: "uix-diff-viewer__actions", role: "group", "aria-label": fillLabel(labels.resolve, { path: entry.path }), children: [
7711
- /* @__PURE__ */ (0, import_jsx_runtime75.jsx)("button", { type: "button", className: cx("uix-btn uix-btn--primary", buttonSize), "aria-label": fillLabel(labels.acceptIncomingFor, { path: entry.path }), "aria-pressed": resolution === "accept", onClick: () => resolve2(entry.path, "accept"), children: labels.acceptIncoming }),
7712
- /* @__PURE__ */ (0, import_jsx_runtime75.jsx)("button", { type: "button", className: cx("uix-btn uix-btn--secondary", buttonSize), "aria-label": fillLabel(labels.keepCurrentFor, { path: entry.path }), "aria-pressed": resolution === "skip", onClick: () => resolve2(entry.path, "skip"), children: labels.keepCurrent }),
7713
- /* @__PURE__ */ (0, import_jsx_runtime75.jsx)("button", { type: "button", className: cx("uix-btn uix-btn--ghost", buttonSize), "aria-label": fillLabel(labels.markPendingFor, { path: entry.path }), "aria-pressed": resolution === "pending", onClick: () => resolve2(entry.path, "pending"), children: labels.markPending })
7906
+ /* @__PURE__ */ (0, import_jsx_runtime75.jsx)("button", { type: "button", className: cx("uix-btn uix-btn--primary", buttonSize), "aria-label": fillLabel(labels.acceptIncomingFor, { path: entry.path }), "aria-pressed": resolution === "accept", onClick: () => resolve(entry.path, "accept"), children: labels.acceptIncoming }),
7907
+ /* @__PURE__ */ (0, import_jsx_runtime75.jsx)("button", { type: "button", className: cx("uix-btn uix-btn--secondary", buttonSize), "aria-label": fillLabel(labels.keepCurrentFor, { path: entry.path }), "aria-pressed": resolution === "skip", onClick: () => resolve(entry.path, "skip"), children: labels.keepCurrent }),
7908
+ /* @__PURE__ */ (0, import_jsx_runtime75.jsx)("button", { type: "button", className: cx("uix-btn uix-btn--ghost", buttonSize), "aria-label": fillLabel(labels.markPendingFor, { path: entry.path }), "aria-pressed": resolution === "pending", onClick: () => resolve(entry.path, "pending"), children: labels.markPending })
7714
7909
  ] })
7715
7910
  ] }, entry.path);
7716
7911
  })
package/dist/index.d.cts CHANGED
@@ -1,7 +1,7 @@
1
1
  import * as react from 'react';
2
2
  import { RefObject, ButtonHTMLAttributes, ReactNode, InputHTMLAttributes, AriaAttributes, HTMLAttributes, Ref, TextareaHTMLAttributes, SelectHTMLAttributes, AnchorHTMLAttributes, MouseEvent, DetailsHTMLAttributes, TdHTMLAttributes, TableHTMLAttributes, ThHTMLAttributes } from 'react';
3
- import { P as Placement } from './overlay-position-CEGSz6Z2.cjs';
4
- export { A as Align, a as PositionOptions, b as PositionResult, R as Rect, S as Side, c as Size, d as computePosition } from './overlay-position-CEGSz6Z2.cjs';
3
+ import { P as Placement } from './overlay-position-Cjv5E7mn.cjs';
4
+ export { A as Align, a as PositionOptions, b as PositionResult, R as Rect, S as Side, c as Size, d as computePosition } from './overlay-position-Cjv5E7mn.cjs';
5
5
 
6
6
  declare function cx(...args: (string | false | null | undefined | 0)[]): string;
7
7
 
@@ -182,10 +182,15 @@ interface UseAnchoredPositionOptions {
182
182
  * Position a floating element (a native-popover overlay) against an anchor with
183
183
  * cross-browser flip/shift — the DOM half of the overlay-position engine (UIX-FIX-02).
184
184
  *
185
- * Measures both elements with getBoundingClientRect, applies `position: fixed` + left/top
186
- * (overriding the popover's default centered `inset:0; margin:auto`), and keeps the overlay
187
- * glued to its anchor on scroll/resize while `open`. Returns a `reposition` fn for callers
188
- * that want to nudge it manually (e.g. right when a native popover's `toggle` fires).
185
+ * The engine decides the side, alignment and viewport clamp from getBoundingClientRect.
186
+ * Where the browser supports CSS anchor positioning, that result is written as an offset
187
+ * from the anchor (`position-anchor` + `anchor()`), so the browser keeps the overlay glued
188
+ * to its anchor while the page scrolls, with no frame of lag; elsewhere it is written as
189
+ * `position: fixed` + left/top and follows scroll/resize from JS. While `open`, the side
190
+ * chosen at open is kept until it no longer fits and the other side does, scrolls inside
191
+ * the overlay itself are ignored, and a size change of either element re-places it.
192
+ * Returns a `reposition` fn for callers that want to nudge it manually (e.g. right when a
193
+ * native popover's `toggle` fires).
189
194
  */
190
195
  declare function useAnchoredPosition(anchor: RefObject<HTMLElement | null> | HTMLElement | null | undefined, floatingRef: RefObject<HTMLElement | null>, { open, placement, offset, padding }: UseAnchoredPositionOptions): () => void;
191
196
 
@@ -1643,16 +1648,26 @@ interface PopoverProps extends HTMLAttributes<HTMLDivElement> {
1643
1648
  popover?: 'auto' | 'manual';
1644
1649
  /**
1645
1650
  * Element (or ref) to anchor against. When set, the popover is placed with
1646
- * cross-browser JS positioning — flip when it won't fit, shift to stay on-screen —
1647
- * instead of relying on CSS `anchor()`, which is Chromium-only and detaches the
1648
- * popover from its trigger everywhere else. The native Popover API still provides
1649
- * the top layer (so it escapes `overflow` clipping) and light-dismiss.
1651
+ * cross-browser JS positioning — flip when it won't fit, shift to stay on-screen.
1652
+ * Where the browser supports CSS anchor positioning, the placement is written as an
1653
+ * offset from the anchor, so it stays attached while the page scrolls; elsewhere it
1654
+ * follows scroll from JS. The native Popover API still provides the top layer (so it
1655
+ * escapes `overflow` clipping) and light-dismiss.
1650
1656
  */
1651
1657
  anchor?: RefObject<HTMLElement | null> | HTMLElement | null;
1652
1658
  /** Preferred placement when `anchor` is set. Default `'bottom-start'`. */
1653
1659
  placement?: Placement;
1654
1660
  /** Gap between the trigger and the popover, in px. Default 6. */
1655
1661
  offset?: number;
1662
+ /**
1663
+ * Close the popover once its `anchor` has scrolled completely out of view (outside the
1664
+ * viewport or a clipping ancestor), e.g. a picker opened from an editor that the user
1665
+ * scrolls away from. Focus inside the popover is let go rather than sent back to the
1666
+ * hidden anchor. Needs `anchor`. Default `false`.
1667
+ */
1668
+ closeWhenAnchorHidden?: boolean;
1669
+ /** Called when `closeWhenAnchorHidden` is about to close the popover. */
1670
+ onAnchorHidden?: () => void;
1656
1671
  children?: ReactNode;
1657
1672
  }
1658
1673
  /**
@@ -1660,7 +1675,7 @@ interface PopoverProps extends HTMLAttributes<HTMLDivElement> {
1660
1675
  * things like a filter popover, menu, or rich select by putting controls inside.
1661
1676
  * Pass `anchor` to get collision-aware, cross-browser placement (UIX-FIX-02).
1662
1677
  */
1663
- declare function Popover({ popover, anchor, placement, offset, className, children, ...props }: PopoverProps): react.JSX.Element;
1678
+ declare function Popover({ popover, anchor, placement, offset, closeWhenAnchorHidden, onAnchorHidden, className, children, ...props }: PopoverProps): react.JSX.Element;
1664
1679
 
1665
1680
  interface CommandPaletteProps extends HTMLAttributes<HTMLDivElement> {
1666
1681
  /** TENSOR RX-125 (UIX-04): translatable; English default. */
@@ -39,6 +39,13 @@ interface PositionOptions {
39
39
  flip?: boolean;
40
40
  /** Slide along the cross axis to stay on-screen. Default true. */
41
41
  shift?: boolean;
42
+ /**
43
+ * The side the element is already on, while it stays open. It is kept unless it no longer
44
+ * fits AND the opposite side does, so a size change or a small scroll never flips an open
45
+ * overlay back and forth. Ignored when it is not on `placement`'s axis. Unset = decide from
46
+ * `placement` alone (an overlay that is opening).
47
+ */
48
+ stickySide?: Side;
42
49
  }
43
50
  interface PositionResult {
44
51
  /** Viewport-space left, for `position: fixed`. */
@@ -39,6 +39,13 @@ interface PositionOptions {
39
39
  flip?: boolean;
40
40
  /** Slide along the cross axis to stay on-screen. Default true. */
41
41
  shift?: boolean;
42
+ /**
43
+ * The side the element is already on, while it stays open. It is kept unless it no longer
44
+ * fits AND the opposite side does, so a size change or a small scroll never flips an open
45
+ * overlay back and forth. Ignored when it is not on `placement`'s axis. Unset = decide from
46
+ * `placement` alone (an overlay that is opening).
47
+ */
48
+ stickySide?: Side;
42
49
  }
43
50
  interface PositionResult {
44
51
  /** Viewport-space left, for `position: fixed`. */