bits-ui 2.15.7 → 2.15.8

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.
@@ -260,6 +260,7 @@ export class PopoverContentState {
260
260
  enabled: () => this.root.opts.open.current &&
261
261
  this.root.openedViaHover &&
262
262
  !this.root.hasInteractedWithContent,
263
+ throttlePointerMove: true,
263
264
  onPointerExit: () => {
264
265
  this.root.handleDelayedHoverClose();
265
266
  },
@@ -92,6 +92,7 @@ interface TooltipContentStateOpts extends WithRefOpts, ReadableBoxedValues<{
92
92
  }> {
93
93
  }
94
94
  export declare class TooltipContentState {
95
+ #private;
95
96
  static create(opts: TooltipContentStateOpts): TooltipContentState;
96
97
  readonly opts: TooltipContentStateOpts;
97
98
  readonly root: TooltipRootState;
@@ -26,6 +26,20 @@ export class TooltipProviderState {
26
26
  this.#timerFn = new TimeoutFn(() => {
27
27
  this.isOpenDelayed = true;
28
28
  }, this.opts.skipDelayDuration.current);
29
+ onMountEffect(() => on(window, "scroll", (e) => {
30
+ const activeTooltip = this.#openTooltip;
31
+ if (!activeTooltip)
32
+ return;
33
+ const triggerNode = activeTooltip.triggerNode;
34
+ if (!triggerNode)
35
+ return;
36
+ const target = e.target;
37
+ if (!(target instanceof Element || target instanceof Document))
38
+ return;
39
+ if (target.contains(triggerNode)) {
40
+ activeTooltip.handleClose();
41
+ }
42
+ }));
29
43
  }
30
44
  #startTimer = () => {
31
45
  const skipDuration = this.opts.skipDelayDuration.current;
@@ -222,11 +236,21 @@ export class TooltipTriggerState {
222
236
  this.root.onTriggerEnter();
223
237
  this.#hasPointerMoveOpened = true;
224
238
  };
225
- #onpointerleave = () => {
239
+ #onpointerleave = (e) => {
226
240
  if (this.#isDisabled)
227
241
  return;
228
242
  this.#clearTransitCheck();
229
- this.root.onTriggerLeave();
243
+ const relatedTarget = e.relatedTarget;
244
+ if (!this.root.disableHoverableContent &&
245
+ this.root.opts.open.current &&
246
+ isElement(relatedTarget) &&
247
+ this.root.contentNode &&
248
+ !this.root.contentNode.contains(relatedTarget)) {
249
+ this.root.handleClose();
250
+ }
251
+ else {
252
+ this.root.onTriggerLeave();
253
+ }
230
254
  this.#hasPointerMoveOpened = false;
231
255
  };
232
256
  #onfocus = (e) => {
@@ -275,11 +299,11 @@ export class TooltipContentState {
275
299
  opts;
276
300
  root;
277
301
  attachment;
278
- constructor(opts, root) {
279
- this.opts = opts;
280
- this.root = root;
281
- this.attachment = attachRef(this.opts.ref, (v) => (this.root.contentNode = v));
282
- new SafePolygon({
302
+ #safePolygon = null;
303
+ #createSafePolygon = () => {
304
+ if (this.#safePolygon)
305
+ return;
306
+ this.#safePolygon = new SafePolygon({
283
307
  triggerNode: () => this.root.triggerNode,
284
308
  contentNode: () => this.root.contentNode,
285
309
  enabled: () => this.root.opts.open.current && !this.root.disableHoverableContent,
@@ -289,14 +313,16 @@ export class TooltipContentState {
289
313
  }
290
314
  },
291
315
  });
292
- onMountEffect(() => on(window, "scroll", (e) => {
293
- const target = e.target;
294
- if (!target)
316
+ };
317
+ constructor(opts, root) {
318
+ this.opts = opts;
319
+ this.root = root;
320
+ this.attachment = attachRef(this.opts.ref, (v) => (this.root.contentNode = v));
321
+ watch(() => this.root.opts.open.current && !this.root.disableHoverableContent, (shouldTrackSafePolygon) => {
322
+ if (!shouldTrackSafePolygon)
295
323
  return;
296
- if (target.contains(this.root.triggerNode)) {
297
- this.root.handleClose();
298
- }
299
- }));
324
+ this.#createSafePolygon();
325
+ }, { lazy: true });
300
326
  }
301
327
  onInteractOutside = (e) => {
302
328
  if (isElement(e.target) &&
@@ -181,6 +181,7 @@ export class FloatingContentState {
181
181
  constructor(opts, root) {
182
182
  this.opts = opts;
183
183
  this.root = root;
184
+ this.#updatePositionStrategy = opts.updatePositionStrategy;
184
185
  if (opts.customAnchor) {
185
186
  this.root.customAnchorNode.current = opts.customAnchor.current;
186
187
  }
@@ -208,10 +209,21 @@ export class FloatingContentState {
208
209
  this.opts.onPlaced?.current();
209
210
  });
210
211
  watch(() => this.contentRef.current, (contentNode) => {
211
- if (!contentNode)
212
+ if (!contentNode || !this.opts.enabled.current)
212
213
  return;
213
214
  const win = getWindow(contentNode);
214
- this.contentZIndex = win.getComputedStyle(contentNode).zIndex;
215
+ const rafId = win.requestAnimationFrame(() => {
216
+ // avoid applying stale values when refs change quickly
217
+ if (this.contentRef.current !== contentNode || !this.opts.enabled.current)
218
+ return;
219
+ const zIndex = win.getComputedStyle(contentNode).zIndex;
220
+ if (zIndex !== this.contentZIndex) {
221
+ this.contentZIndex = zIndex;
222
+ }
223
+ });
224
+ return () => {
225
+ win.cancelAnimationFrame(rafId);
226
+ };
215
227
  });
216
228
  $effect(() => {
217
229
  this.floating.floating.current = this.wrapperRef.current;
@@ -22,6 +22,7 @@ export function useFloating(options) {
22
22
  let placement = $state(placementOption);
23
23
  let middlewareData = $state({});
24
24
  let isPositioned = $state(false);
25
+ let hasWhileMountedPosition = false;
25
26
  const floatingStyles = $derived.by(() => {
26
27
  // preserve last known position when floating ref is null (during transitions)
27
28
  const xVal = floating.current ? roundByDPR(floating.current, x) : x;
@@ -82,6 +83,8 @@ export function useFloating(options) {
82
83
  update();
83
84
  return;
84
85
  }
86
+ if (!openOption)
87
+ return;
85
88
  if (reference.current === null || floating.current === null)
86
89
  return;
87
90
  whileElementsMountedCleanup = whileElementsMountedOption(reference.current, floating.current, update);
@@ -91,8 +94,43 @@ export function useFloating(options) {
91
94
  isPositioned = false;
92
95
  }
93
96
  }
94
- $effect(update);
97
+ function trackWhileMountedDeps() {
98
+ return [
99
+ middlewareOption,
100
+ placementOption,
101
+ strategyOption,
102
+ sideOffsetOption,
103
+ alignOffsetOption,
104
+ openOption,
105
+ ];
106
+ }
107
+ $effect(() => {
108
+ if (whileElementsMountedOption !== undefined)
109
+ return;
110
+ if (!openOption)
111
+ return;
112
+ update();
113
+ });
95
114
  $effect(attach);
115
+ $effect(() => {
116
+ if (whileElementsMountedOption === undefined)
117
+ return;
118
+ trackWhileMountedDeps();
119
+ if (!openOption) {
120
+ hasWhileMountedPosition = false;
121
+ return;
122
+ }
123
+ if (!isPositioned) {
124
+ hasWhileMountedPosition = false;
125
+ return;
126
+ }
127
+ // skip the first post-position run, since autoUpdate already computed it
128
+ if (!hasWhileMountedPosition) {
129
+ hasWhileMountedPosition = true;
130
+ return;
131
+ }
132
+ update();
133
+ });
96
134
  $effect(reset);
97
135
  $effect(() => cleanup);
98
136
  return {
@@ -5,6 +5,7 @@ export interface SafePolygonOptions {
5
5
  contentNode: Getter<HTMLElement | null>;
6
6
  onPointerExit: () => void;
7
7
  buffer?: number;
8
+ throttlePointerMove?: boolean;
8
9
  }
9
10
  /**
10
11
  * Creates a safe polygon area that allows users to move their cursor between
@@ -46,18 +46,52 @@ export class SafePolygon {
46
46
  #exitPoint = null;
47
47
  // tracks what we're moving toward: "content" when leaving trigger, "trigger" when leaving content
48
48
  #exitTarget = null;
49
+ #triggerRect = null;
50
+ #contentRect = null;
51
+ #pointerMoveRafId = null;
52
+ #pendingClientPoint = null;
53
+ #leaveFallbackRafId = null;
54
+ #cancelLeaveFallback() {
55
+ if (this.#leaveFallbackRafId !== null) {
56
+ cancelAnimationFrame(this.#leaveFallbackRafId);
57
+ this.#leaveFallbackRafId = null;
58
+ }
59
+ }
60
+ #scheduleLeaveFallback() {
61
+ this.#cancelLeaveFallback();
62
+ this.#leaveFallbackRafId = requestAnimationFrame(() => {
63
+ this.#leaveFallbackRafId = null;
64
+ if (!this.#exitPoint || !this.#exitTarget)
65
+ return;
66
+ this.#clearTracking();
67
+ this.#opts.onPointerExit();
68
+ });
69
+ }
70
+ #closeIfPointerEnteredOutside(target, triggerNode, contentNode) {
71
+ if (!isElement(target))
72
+ return false;
73
+ if (triggerNode.contains(target) || contentNode.contains(target))
74
+ return false;
75
+ this.#clearTracking();
76
+ this.#opts.onPointerExit();
77
+ return true;
78
+ }
49
79
  constructor(opts) {
50
80
  this.#opts = opts;
51
81
  this.#buffer = opts.buffer ?? 1;
52
82
  watch([opts.triggerNode, opts.contentNode, opts.enabled], ([triggerNode, contentNode, enabled]) => {
53
83
  if (!triggerNode || !contentNode || !enabled) {
54
- this.#exitPoint = null;
55
- this.#exitTarget = null;
84
+ this.#clearTracking();
56
85
  return;
57
86
  }
58
87
  const doc = getDocument(triggerNode);
59
88
  const handlePointerMove = (e) => {
60
- this.#onPointerMove(e, triggerNode, contentNode);
89
+ if (this.#opts.throttlePointerMove) {
90
+ this.#onPointerMoveThrottled(e, triggerNode, contentNode);
91
+ }
92
+ else {
93
+ this.#onPointerMove([e.clientX, e.clientY], triggerNode, contentNode);
94
+ }
61
95
  };
62
96
  const handleTriggerLeave = (e) => {
63
97
  // when leaving trigger toward content, record exit point
@@ -66,18 +100,21 @@ export class SafePolygon {
66
100
  if (isElement(target) && contentNode.contains(target)) {
67
101
  return;
68
102
  }
103
+ if (this.#closeIfPointerEnteredOutside(target, triggerNode, contentNode)) {
104
+ return;
105
+ }
69
106
  this.#exitPoint = [e.clientX, e.clientY];
70
107
  this.#exitTarget = "content";
108
+ this.#captureRects(triggerNode, contentNode);
109
+ this.#scheduleLeaveFallback();
71
110
  };
72
111
  const handleTriggerEnter = () => {
73
112
  // reached trigger, clear tracking
74
- this.#exitPoint = null;
75
- this.#exitTarget = null;
113
+ this.#clearTracking();
76
114
  };
77
115
  const handleContentEnter = () => {
78
116
  // reached content, clear tracking
79
- this.#exitPoint = null;
80
- this.#exitTarget = null;
117
+ this.#clearTracking();
81
118
  };
82
119
  const handleContentLeave = (e) => {
83
120
  // when leaving content, check if going directly back to trigger
@@ -86,9 +123,14 @@ export class SafePolygon {
86
123
  // going directly to trigger, no polygon tracking needed
87
124
  return;
88
125
  }
126
+ if (this.#closeIfPointerEnteredOutside(target, triggerNode, contentNode)) {
127
+ return;
128
+ }
89
129
  // might be traversing gap back to trigger, set up polygon tracking
90
130
  this.#exitPoint = [e.clientX, e.clientY];
91
131
  this.#exitTarget = "trigger";
132
+ this.#captureRects(triggerNode, contentNode);
133
+ this.#scheduleLeaveFallback();
92
134
  };
93
135
  return [
94
136
  on(doc, "pointermove", handlePointerMove),
@@ -102,22 +144,40 @@ export class SafePolygon {
102
144
  }, () => { });
103
145
  });
104
146
  }
105
- #onPointerMove(e, triggerNode, contentNode) {
147
+ #onPointerMoveThrottled(e, triggerNode, contentNode) {
148
+ if (this.#pointerMoveRafId === null) {
149
+ // handle the first move in the frame immediately so close checks
150
+ // are not deferred when only a single pointermove fires.
151
+ this.#pointerMoveRafId = requestAnimationFrame(() => {
152
+ this.#pointerMoveRafId = null;
153
+ const point = this.#pendingClientPoint;
154
+ this.#pendingClientPoint = null;
155
+ if (!point)
156
+ return;
157
+ this.#onPointerMove(point, triggerNode, contentNode);
158
+ });
159
+ this.#onPointerMove([e.clientX, e.clientY], triggerNode, contentNode);
160
+ return;
161
+ }
162
+ this.#pendingClientPoint = [e.clientX, e.clientY];
163
+ }
164
+ #onPointerMove(clientPoint, triggerNode, contentNode) {
106
165
  // if no exit point recorded, nothing to check
107
166
  if (!this.#exitPoint || !this.#exitTarget)
108
167
  return;
109
- const clientPoint = [e.clientX, e.clientY];
110
- const triggerRect = triggerNode.getBoundingClientRect();
111
- const contentRect = contentNode.getBoundingClientRect();
168
+ this.#cancelLeaveFallback();
169
+ if (!this.#triggerRect || !this.#contentRect) {
170
+ this.#captureRects(triggerNode, contentNode);
171
+ }
172
+ const triggerRect = this.#triggerRect ?? triggerNode.getBoundingClientRect();
173
+ const contentRect = this.#contentRect ?? contentNode.getBoundingClientRect();
112
174
  // check if pointer reached the target
113
175
  if (this.#exitTarget === "content" && isInsideRect(clientPoint, contentRect)) {
114
- this.#exitPoint = null;
115
- this.#exitTarget = null;
176
+ this.#clearTracking();
116
177
  return;
117
178
  }
118
179
  if (this.#exitTarget === "trigger" && isInsideRect(clientPoint, triggerRect)) {
119
- this.#exitPoint = null;
120
- this.#exitTarget = null;
180
+ this.#clearTracking();
121
181
  return;
122
182
  }
123
183
  // check if pointer is in the rectangular corridor between trigger and content
@@ -133,9 +193,24 @@ export class SafePolygon {
133
193
  return;
134
194
  }
135
195
  // pointer is outside all safe zones - close
196
+ this.#clearTracking();
197
+ this.#opts.onPointerExit();
198
+ }
199
+ #captureRects(triggerNode, contentNode) {
200
+ this.#triggerRect = triggerNode.getBoundingClientRect();
201
+ this.#contentRect = contentNode.getBoundingClientRect();
202
+ }
203
+ #clearTracking() {
136
204
  this.#exitPoint = null;
137
205
  this.#exitTarget = null;
138
- this.#opts.onPointerExit();
206
+ this.#triggerRect = null;
207
+ this.#contentRect = null;
208
+ this.#pendingClientPoint = null;
209
+ if (this.#pointerMoveRafId !== null) {
210
+ cancelAnimationFrame(this.#pointerMoveRafId);
211
+ this.#pointerMoveRafId = null;
212
+ }
213
+ this.#cancelLeaveFallback();
139
214
  }
140
215
  /**
141
216
  * 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.15.7",
3
+ "version": "2.15.8",
4
4
  "license": "MIT",
5
5
  "repository": "github:huntabyte/bits-ui",
6
6
  "funding": "https://github.com/sponsors/huntabyte",