bits-ui 2.16.2 → 2.16.4

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.
@@ -183,6 +183,10 @@ export class MenuContentState {
183
183
  this.opts.onCloseAutoFocus.current?.(e);
184
184
  if (e.defaultPrevented || this.#isSub)
185
185
  return;
186
+ if (this.parentMenu.root.ignoreCloseAutoFocus) {
187
+ e.preventDefault();
188
+ return;
189
+ }
186
190
  if (this.parentMenu.triggerNode && isTabbable(this.parentMenu.triggerNode)) {
187
191
  e.preventDefault();
188
192
  this.parentMenu.triggerNode.focus();
@@ -306,7 +310,17 @@ export class MenuContentState {
306
310
  }
307
311
  if (e.target.closest(`#${triggerId}`)) {
308
312
  e.preventDefault();
313
+ return;
309
314
  }
315
+ /**
316
+ * when the menu closes due to an outside pointer interaction (for example,
317
+ * clicking another dropdown trigger), avoid focusing this menu's trigger
318
+ * to prevent stealing focus from the new interaction target.
319
+ */
320
+ this.parentMenu.root.ignoreCloseAutoFocus = true;
321
+ afterTick(() => {
322
+ this.parentMenu.root.ignoreCloseAutoFocus = false;
323
+ });
310
324
  }
311
325
  get shouldRender() {
312
326
  return this.parentMenu.contentPresence.shouldRender;
@@ -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)
@@ -52,6 +52,11 @@
52
52
  enabled: boolean;
53
53
  contentPointerEvents?: "auto" | "none";
54
54
  } = $props();
55
+
56
+ const resolvedPreventScroll = $derived(preventScroll ?? true);
57
+ const effectiveStrategy = $derived(
58
+ strategy ?? (resolvedPreventScroll ? "fixed" : "absolute")
59
+ );
55
60
  </script>
56
61
 
57
62
  <PopperContent
@@ -68,7 +73,7 @@
68
73
  {sticky}
69
74
  {hideWhenDetached}
70
75
  {updatePositionStrategy}
71
- {strategy}
76
+ strategy={effectiveStrategy}
72
77
  {dir}
73
78
  {wrapperId}
74
79
  {style}
@@ -79,9 +84,9 @@
79
84
  >
80
85
  {#snippet content({ props: floatingProps, wrapperProps })}
81
86
  {#if restProps.forceMount && enabled}
82
- <ScrollLock {preventScroll} />
87
+ <ScrollLock preventScroll={resolvedPreventScroll} />
83
88
  {:else if !restProps.forceMount}
84
- <ScrollLock {preventScroll} />
89
+ <ScrollLock preventScroll={resolvedPreventScroll} />
85
90
  {/if}
86
91
  <FocusScope
87
92
  {onOpenAutoFocus}
@@ -23,6 +23,7 @@ export function useFloating(options) {
23
23
  let middlewareData = $state({});
24
24
  let isPositioned = $state(false);
25
25
  let hasWhileMountedPosition = false;
26
+ let updateRequestId = 0;
26
27
  const floatingStyles = $derived.by(() => {
27
28
  // preserve last known position when floating ref is null (during transitions)
28
29
  const xVal = floating.current ? roundByDPR(floating.current, x) : x;
@@ -50,12 +51,20 @@ export function useFloating(options) {
50
51
  function update() {
51
52
  if (reference.current === null || floating.current === null)
52
53
  return;
53
- computePosition(reference.current, floating.current, {
54
+ const referenceNode = reference.current;
55
+ const floatingNode = floating.current;
56
+ const requestId = ++updateRequestId;
57
+ computePosition(referenceNode, floatingNode, {
54
58
  middleware: middlewareOption,
55
59
  placement: placementOption,
56
60
  strategy: strategyOption,
57
61
  }).then((position) => {
58
- const referenceNode = reference.current;
62
+ // ignore stale async resolutions when newer updates were requested.
63
+ if (requestId !== updateRequestId)
64
+ return;
65
+ // ignore stale resolutions after ref replacement.
66
+ if (reference.current !== referenceNode || floating.current !== floatingNode)
67
+ return;
59
68
  const referenceHidden = isReferenceHidden(referenceNode);
60
69
  if (referenceHidden) {
61
70
  // keep last good coordinates when the anchor disappears to avoid
@@ -91,6 +100,7 @@ export function useFloating(options) {
91
100
  whileElementsMountedCleanup();
92
101
  whileElementsMountedCleanup = undefined;
93
102
  }
103
+ updateRequestId++;
94
104
  }
95
105
  function attach() {
96
106
  cleanup();
@@ -105,7 +115,7 @@ export function useFloating(options) {
105
115
  whileElementsMountedCleanup = whileElementsMountedOption(reference.current, floating.current, update);
106
116
  }
107
117
  function reset() {
108
- if (!openOption) {
118
+ if (!openOption && floating.current === null) {
109
119
  isPositioned = false;
110
120
  }
111
121
  }
@@ -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.4",
4
4
  "license": "MIT",
5
5
  "repository": "github:huntabyte/bits-ui",
6
6
  "funding": "https://github.com/sponsors/huntabyte",