bits-ui 2.16.2 → 2.16.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.
@@ -524,14 +524,19 @@ export class TooltipTriggerState {
524
524
  const relatedTarget = e.relatedTarget;
525
525
  // when moving to a sibling trigger and skip delay is active, don't close —
526
526
  // the sibling's enter handler will switch the active trigger instantly.
527
- // if skipDelayDuration is 0 there's no grace period, so let the tooltip
528
- // close and make the sibling wait through the full delay (and re-animate).
529
- if (isElement(relatedTarget) && root.provider.opts.skipDelayDuration.current > 0) {
527
+ // if skipDelayDuration is 0 there's no grace period, so close now and let
528
+ // the sibling wait through the full delay (and re-animate).
529
+ if (isElement(relatedTarget)) {
530
530
  for (const record of root.registry.triggers.values()) {
531
- if (record.node === relatedTarget) {
531
+ if (record.node !== relatedTarget)
532
+ continue;
533
+ if (root.provider.opts.skipDelayDuration.current > 0) {
532
534
  this.#hasPointerMoveOpened = false;
533
535
  return;
534
536
  }
537
+ root.handleClose();
538
+ this.#hasPointerMoveOpened = false;
539
+ return;
535
540
  }
536
541
  }
537
542
  root.onTriggerLeave();
@@ -606,6 +611,7 @@ export class TooltipContentState {
606
611
  triggerNode: () => this.root.triggerNode,
607
612
  contentNode: () => this.root.contentNode,
608
613
  enabled: () => this.root.opts.open.current && !this.root.disableHoverableContent,
614
+ transitIntentTimeout: 180,
609
615
  ignoredTargets: () => {
610
616
  // only skip closing for sibling triggers when there's a skip-delay grace period;
611
617
  // with skipDelayDuration=0 the close+reopen is intentional (full delay + re-animation)
@@ -105,7 +105,7 @@ export function useFloating(options) {
105
105
  whileElementsMountedCleanup = whileElementsMountedOption(reference.current, floating.current, update);
106
106
  }
107
107
  function reset() {
108
- if (!openOption) {
108
+ if (!openOption && floating.current === null) {
109
109
  isPositioned = false;
110
110
  }
111
111
  }
@@ -5,6 +5,7 @@ export interface SafePolygonOptions {
5
5
  contentNode: Getter<HTMLElement | null>;
6
6
  onPointerExit: () => void;
7
7
  buffer?: number;
8
+ transitIntentTimeout?: number;
8
9
  /** nodes that should not trigger a close when they become the relatedTarget on trigger leave (e.g. sibling triggers in singleton mode) */
9
10
  ignoredTargets?: Getter<HTMLElement[]>;
10
11
  }
@@ -42,11 +42,15 @@ function getSide(triggerRect, contentRect) {
42
42
  export class SafePolygon {
43
43
  #opts;
44
44
  #buffer;
45
+ #transitIntentTimeout;
45
46
  // tracks the cursor position when leaving trigger or content
46
47
  #exitPoint = null;
47
48
  // tracks what we're moving toward: "content" when leaving trigger, "trigger" when leaving content
48
49
  #exitTarget = null;
50
+ #transitTargets = [];
51
+ #trackedTriggerNode = null;
49
52
  #leaveFallbackRafId = null;
53
+ #transitIntentTimeoutId = null;
50
54
  #cancelLeaveFallback() {
51
55
  if (this.#leaveFallbackRafId !== null) {
52
56
  cancelAnimationFrame(this.#leaveFallbackRafId);
@@ -63,14 +67,42 @@ export class SafePolygon {
63
67
  this.#opts.onPointerExit();
64
68
  });
65
69
  }
70
+ #cancelTransitIntentTimeout() {
71
+ if (this.#transitIntentTimeoutId !== null) {
72
+ clearTimeout(this.#transitIntentTimeoutId);
73
+ this.#transitIntentTimeoutId = null;
74
+ }
75
+ }
76
+ #scheduleTransitIntentTimeout() {
77
+ if (this.#transitIntentTimeout === null)
78
+ return;
79
+ this.#cancelTransitIntentTimeout();
80
+ this.#transitIntentTimeoutId = window.setTimeout(() => {
81
+ this.#transitIntentTimeoutId = null;
82
+ if (!this.#exitPoint || !this.#exitTarget)
83
+ return;
84
+ this.#clearTracking();
85
+ this.#opts.onPointerExit();
86
+ }, this.#transitIntentTimeout);
87
+ }
66
88
  constructor(opts) {
67
89
  this.#opts = opts;
68
90
  this.#buffer = opts.buffer ?? 1;
91
+ const transitIntentTimeout = opts.transitIntentTimeout;
92
+ this.#transitIntentTimeout =
93
+ typeof transitIntentTimeout === "number" && transitIntentTimeout > 0
94
+ ? transitIntentTimeout
95
+ : null;
69
96
  watch([opts.triggerNode, opts.contentNode, opts.enabled], ([triggerNode, contentNode, enabled]) => {
70
97
  if (!triggerNode || !contentNode || !enabled) {
98
+ this.#trackedTriggerNode = null;
71
99
  this.#clearTracking();
72
100
  return;
73
101
  }
102
+ if (this.#trackedTriggerNode && this.#trackedTriggerNode !== triggerNode) {
103
+ this.#clearTracking();
104
+ }
105
+ this.#trackedTriggerNode = triggerNode;
74
106
  const doc = getDocument(triggerNode);
75
107
  const handlePointerMove = (e) => {
76
108
  this.#onPointerMove([e.clientX, e.clientY], triggerNode, contentNode);
@@ -89,16 +121,13 @@ export class SafePolygon {
89
121
  ignoredTargets.some((n) => n === target || n.contains(target))) {
90
122
  return;
91
123
  }
92
- // if relatedTarget is completely unrelated to the floating tree
93
- // (not an ancestor of content, not inside content/trigger), close now
94
- if (isElement(target) &&
95
- !triggerNode.contains(target) &&
96
- !contentNode.contains(target) &&
97
- !target.contains(contentNode)) {
98
- this.#clearTracking();
99
- this.#opts.onPointerExit();
100
- return;
101
- }
124
+ this.#transitTargets =
125
+ isElement(target) && ignoredTargets.length > 0
126
+ ? ignoredTargets.filter((n) => target.contains(n))
127
+ : [];
128
+ // for unrelated elements, defer close decisions to pointer geometry checks.
129
+ // this allows the cursor to pass through intermediate elements on the way
130
+ // to content without immediately closing.
102
131
  this.#exitPoint = [e.clientX, e.clientY];
103
132
  this.#exitTarget = "content";
104
133
  this.#scheduleLeaveFallback();
@@ -140,6 +169,7 @@ export class SafePolygon {
140
169
  if (!this.#exitPoint || !this.#exitTarget)
141
170
  return;
142
171
  this.#cancelLeaveFallback();
172
+ this.#scheduleTransitIntentTimeout();
143
173
  const triggerRect = triggerNode.getBoundingClientRect();
144
174
  const contentRect = contentNode.getBoundingClientRect();
145
175
  // check if pointer reached the target
@@ -151,6 +181,17 @@ export class SafePolygon {
151
181
  this.#clearTracking();
152
182
  return;
153
183
  }
184
+ if (this.#exitTarget === "content" && this.#transitTargets.length > 0) {
185
+ for (const transitTarget of this.#transitTargets) {
186
+ const transitRect = transitTarget.getBoundingClientRect();
187
+ if (isInsideRect(clientPoint, transitRect))
188
+ return;
189
+ const transitSide = getSide(triggerRect, transitRect);
190
+ const transitCorridor = this.#getCorridorPolygon(triggerRect, transitRect, transitSide);
191
+ if (transitCorridor && isPointInPolygon(clientPoint, transitCorridor))
192
+ return;
193
+ }
194
+ }
154
195
  // check if pointer is in the rectangular corridor between trigger and content
155
196
  const side = getSide(triggerRect, contentRect);
156
197
  const corridorPoly = this.#getCorridorPolygon(triggerRect, contentRect, side);
@@ -170,7 +211,9 @@ export class SafePolygon {
170
211
  #clearTracking() {
171
212
  this.#exitPoint = null;
172
213
  this.#exitTarget = null;
214
+ this.#transitTargets = [];
173
215
  this.#cancelLeaveFallback();
216
+ this.#cancelTransitIntentTimeout();
174
217
  }
175
218
  /**
176
219
  * Creates a rectangular corridor between trigger and content
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bits-ui",
3
- "version": "2.16.2",
3
+ "version": "2.16.3",
4
4
  "license": "MIT",
5
5
  "repository": "github:huntabyte/bits-ui",
6
6
  "funding": "https://github.com/sponsors/huntabyte",