bits-ui 2.15.7 → 2.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.
@@ -23,6 +23,7 @@
23
23
  sticky = "partial",
24
24
  strategy,
25
25
  hideWhenDetached = false,
26
+ customAnchor,
26
27
  collisionPadding = 0,
27
28
  onInteractOutside = noop,
28
29
  onEscapeKeydown = noop,
@@ -51,6 +52,7 @@
51
52
  hideWhenDetached,
52
53
  collisionPadding,
53
54
  strategy,
55
+ customAnchor: customAnchor ?? contentState.root.triggerNode,
54
56
  });
55
57
 
56
58
  const mergedProps = $derived(mergeProps(restProps, floatingProps, contentState.props));
@@ -1,9 +1,12 @@
1
- <script lang="ts">
1
+ <script lang="ts" module>
2
+ type T = unknown;
3
+ </script>
4
+
5
+ <script lang="ts" generics="T = never">
2
6
  import { boxWith, mergeProps } from "svelte-toolbelt";
3
7
  import type { TooltipTriggerProps } from "../types.js";
4
8
  import { TooltipTriggerState } from "../tooltip.svelte.js";
5
9
  import { createId } from "../../../internal/create-id.js";
6
- import FloatingLayerAnchor from "../../utilities/floating-layer/components/floating-layer-anchor.svelte";
7
10
 
8
11
  const uid = $props.id();
9
12
 
@@ -12,16 +15,20 @@
12
15
  child,
13
16
  id = createId(uid),
14
17
  disabled = false,
18
+ payload,
19
+ tether,
15
20
  type = "button",
16
21
  tabindex = 0,
17
22
  ref = $bindable(null),
18
23
  ...restProps
19
- }: TooltipTriggerProps = $props();
24
+ }: TooltipTriggerProps<T> = $props();
20
25
 
21
26
  const triggerState = TooltipTriggerState.create({
22
27
  id: boxWith(() => id),
23
28
  disabled: boxWith(() => disabled ?? false),
24
29
  tabindex: boxWith(() => tabindex ?? 0),
30
+ payload: boxWith(() => payload),
31
+ tether: boxWith(() => tether),
25
32
  ref: boxWith(
26
33
  () => ref,
27
34
  (v) => (ref = v)
@@ -31,12 +38,10 @@
31
38
  const mergedProps = $derived(mergeProps(restProps, triggerState.props, { type }));
32
39
  </script>
33
40
 
34
- <FloatingLayerAnchor {id} ref={triggerState.opts.ref} tooltip={true}>
35
- {#if child}
36
- {@render child({ props: mergedProps })}
37
- {:else}
38
- <button {...mergedProps}>
39
- {@render children?.()}
40
- </button>
41
- {/if}
42
- </FloatingLayerAnchor>
41
+ {#if child}
42
+ {@render child({ props: mergedProps })}
43
+ {:else}
44
+ <button {...mergedProps}>
45
+ {@render children?.()}
46
+ </button>
47
+ {/if}
@@ -1,4 +1,18 @@
1
1
  import type { TooltipTriggerProps } from "../types.js";
2
- declare const TooltipTrigger: import("svelte").Component<TooltipTriggerProps, {}, "ref">;
3
- type TooltipTrigger = ReturnType<typeof TooltipTrigger>;
2
+ declare class __sveltets_Render<T = never> {
3
+ props(): TooltipTriggerProps<T>;
4
+ events(): {};
5
+ slots(): {};
6
+ bindings(): "ref";
7
+ exports(): {};
8
+ }
9
+ interface $$IsomorphicComponent {
10
+ new <T = never>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
11
+ $$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
12
+ } & ReturnType<__sveltets_Render<T>['exports']>;
13
+ <T = never>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
14
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
15
+ }
16
+ declare const TooltipTrigger: $$IsomorphicComponent;
17
+ type TooltipTrigger<T = never> = InstanceType<typeof TooltipTrigger<T>>;
4
18
  export default TooltipTrigger;
@@ -1,4 +1,8 @@
1
- <script lang="ts">
1
+ <script lang="ts" module>
2
+ type T = unknown;
3
+ </script>
4
+
5
+ <script lang="ts" generics="T = never">
2
6
  import { boxWith } from "svelte-toolbelt";
3
7
  import type { TooltipRootProps } from "../types.js";
4
8
  import { TooltipRootState } from "../tooltip.svelte.js";
@@ -7,6 +11,7 @@
7
11
 
8
12
  let {
9
13
  open = $bindable(false),
14
+ triggerId = $bindable<string | null>(null),
10
15
  onOpenChange = noop,
11
16
  onOpenChangeComplete = noop,
12
17
  disabled,
@@ -14,10 +19,11 @@
14
19
  disableCloseOnTriggerClick,
15
20
  disableHoverableContent,
16
21
  ignoreNonKeyboardFocus,
22
+ tether,
17
23
  children,
18
- }: TooltipRootProps = $props();
24
+ }: TooltipRootProps<T> = $props();
19
25
 
20
- TooltipRootState.create({
26
+ const rootState = TooltipRootState.create({
21
27
  open: boxWith(
22
28
  () => open,
23
29
  (v) => {
@@ -25,15 +31,26 @@
25
31
  onOpenChange(v);
26
32
  }
27
33
  ),
34
+ triggerId: boxWith(
35
+ () => triggerId,
36
+ (v) => {
37
+ triggerId = v;
38
+ }
39
+ ),
28
40
  delayDuration: boxWith(() => delayDuration),
29
41
  disableCloseOnTriggerClick: boxWith(() => disableCloseOnTriggerClick),
30
42
  disableHoverableContent: boxWith(() => disableHoverableContent),
31
43
  ignoreNonKeyboardFocus: boxWith(() => ignoreNonKeyboardFocus),
32
44
  disabled: boxWith(() => disabled),
33
45
  onOpenChangeComplete: boxWith(() => onOpenChangeComplete),
46
+ tether: boxWith(() => tether),
34
47
  });
35
48
  </script>
36
49
 
37
50
  <FloatingLayer tooltip>
38
- {@render children?.()}
51
+ {@render children?.({
52
+ open: rootState.opts.open.current,
53
+ triggerId: rootState.activeTriggerId,
54
+ payload: rootState.activePayload as [T] extends [never] ? null : T | null,
55
+ })}
39
56
  </FloatingLayer>
@@ -1,3 +1,18 @@
1
- declare const Tooltip: import("svelte").Component<import("../types.js").TooltipRootPropsWithoutHTML, {}, "open">;
2
- type Tooltip = ReturnType<typeof Tooltip>;
1
+ import type { TooltipRootProps } from "../types.js";
2
+ declare class __sveltets_Render<T = never> {
3
+ props(): TooltipRootProps<T>;
4
+ events(): {};
5
+ slots(): {};
6
+ bindings(): "open" | "triggerId";
7
+ exports(): {};
8
+ }
9
+ interface $$IsomorphicComponent {
10
+ new <T = never>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
11
+ $$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
12
+ } & ReturnType<__sveltets_Render<T>['exports']>;
13
+ <T = never>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
14
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
15
+ }
16
+ declare const Tooltip: $$IsomorphicComponent;
17
+ type Tooltip<T = never> = InstanceType<typeof Tooltip<T>>;
3
18
  export default Tooltip;
@@ -5,5 +5,6 @@ export { default as Trigger } from "./components/tooltip-trigger.svelte";
5
5
  export { default as Arrow } from "./components/tooltip-arrow.svelte";
6
6
  export { default as Provider } from "./components/tooltip-provider.svelte";
7
7
  export { default as Portal } from "../utilities/portal/portal.svelte";
8
- export type { TooltipProviderPropsWithoutHTML as ProviderProps, TooltipRootPropsWithoutHTML as RootProps, TooltipArrowProps as ArrowProps, TooltipContentProps as ContentProps, TooltipContentStaticProps as ContentStaticProps, TooltipTriggerProps as TriggerProps, } from "./types.js";
8
+ export { createTooltipTether as createTether } from "./tooltip.svelte.js";
9
+ export type { TooltipProviderPropsWithoutHTML as ProviderProps, TooltipRootPropsWithoutHTML as RootProps, TooltipArrowProps as ArrowProps, TooltipContentProps as ContentProps, TooltipContentStaticProps as ContentStaticProps, TooltipTriggerProps as TriggerProps, TooltipTether as Tether, TooltipRootSnippetProps as RootSnippetProps, } from "./types.js";
9
10
  export type { PortalProps } from "../utilities/portal/types.js";
@@ -5,3 +5,4 @@ export { default as Trigger } from "./components/tooltip-trigger.svelte";
5
5
  export { default as Arrow } from "./components/tooltip-arrow.svelte";
6
6
  export { default as Provider } from "./components/tooltip-provider.svelte";
7
7
  export { default as Portal } from "../utilities/portal/portal.svelte";
8
+ export { createTooltipTether as createTether } from "./tooltip.svelte.js";
@@ -3,6 +3,38 @@ import type { OnChangeFn, RefAttachment, WithRefOpts } from "../../internal/type
3
3
  import type { FocusEventHandler, MouseEventHandler, PointerEventHandler } from "svelte/elements";
4
4
  import { PresenceManager } from "../../internal/presence-manager.svelte.js";
5
5
  export declare const tooltipAttrs: import("../../internal/attrs.js").CreateBitsAttrsReturn<readonly ["content", "trigger"]>;
6
+ type TooltipTriggerRecord = {
7
+ id: string;
8
+ node: HTMLElement | null;
9
+ payload: unknown;
10
+ disabled: boolean;
11
+ };
12
+ declare class TooltipTriggerRegistryState {
13
+ #private;
14
+ triggers: Map<string, TooltipTriggerRecord>;
15
+ activeTriggerId: string | null;
16
+ activeTriggerNode: HTMLElement | null;
17
+ activePayload: {} | null;
18
+ register: (record: TooltipTriggerRecord) => void;
19
+ update: (record: TooltipTriggerRecord) => void;
20
+ unregister: (id: string) => void;
21
+ setActiveTrigger: (id: string | null) => void;
22
+ get: (id: string) => TooltipTriggerRecord | undefined;
23
+ has: (id: string) => boolean;
24
+ getFirstTriggerId: () => string | null;
25
+ }
26
+ declare class TooltipTetherState {
27
+ readonly registry: TooltipTriggerRegistryState;
28
+ root: TooltipRootState | null;
29
+ }
30
+ export declare class TooltipTether<Payload = never> {
31
+ #private;
32
+ get state(): TooltipTetherState;
33
+ open(triggerId: string): void;
34
+ close(): void;
35
+ get isOpen(): boolean;
36
+ }
37
+ export declare function createTooltipTether<Payload = never>(): TooltipTether<Payload>;
6
38
  interface TooltipProviderStateOpts extends ReadableBoxedValues<{
7
39
  delayDuration: number;
8
40
  disableHoverableContent: boolean;
@@ -30,8 +62,10 @@ interface TooltipRootStateOpts extends ReadableBoxedValues<{
30
62
  disabled: boolean | undefined;
31
63
  ignoreNonKeyboardFocus: boolean | undefined;
32
64
  onOpenChangeComplete: OnChangeFn<boolean>;
65
+ tether: TooltipTether<unknown> | undefined;
33
66
  }>, WritableBoxedValues<{
34
67
  open: boolean;
68
+ triggerId: string | null;
35
69
  }> {
36
70
  }
37
71
  export declare class TooltipRootState {
@@ -44,34 +78,47 @@ export declare class TooltipRootState {
44
78
  readonly disableCloseOnTriggerClick: boolean;
45
79
  readonly disabled: boolean;
46
80
  readonly ignoreNonKeyboardFocus: boolean;
81
+ readonly registry: TooltipTriggerRegistryState;
82
+ readonly tether: TooltipTetherState | null;
47
83
  contentNode: HTMLElement | null;
48
84
  contentPresence: PresenceManager;
49
- triggerNode: HTMLElement | null;
50
85
  readonly stateAttr: string;
51
86
  constructor(opts: TooltipRootStateOpts, provider: TooltipProviderState);
52
87
  handleOpen: () => void;
53
88
  handleClose: () => void;
54
- onTriggerEnter: () => void;
89
+ onTriggerEnter: (triggerId: string) => void;
55
90
  onTriggerLeave: () => void;
91
+ ensureActiveTrigger: () => void;
92
+ setActiveTrigger: (triggerId: string | null) => void;
93
+ registerTrigger: (trigger: TooltipTriggerRecord) => void;
94
+ updateTrigger: (trigger: TooltipTriggerRecord) => void;
95
+ unregisterTrigger: (id: string) => void;
96
+ isActiveTrigger: (triggerId: string) => boolean;
97
+ get triggerNode(): HTMLElement | null;
98
+ get activePayload(): {} | null;
99
+ get activeTriggerId(): string | null;
56
100
  }
57
101
  interface TooltipTriggerStateOpts extends WithRefOpts, ReadableBoxedValues<{
58
102
  disabled: boolean;
59
103
  tabindex: number;
104
+ payload: unknown;
105
+ tether: TooltipTether<unknown> | undefined;
60
106
  }> {
61
107
  }
62
108
  export declare class TooltipTriggerState {
63
109
  #private;
64
110
  static create(opts: TooltipTriggerStateOpts): TooltipTriggerState;
65
111
  readonly opts: TooltipTriggerStateOpts;
66
- readonly root: TooltipRootState;
112
+ readonly root: TooltipRootState | null;
113
+ readonly tether: TooltipTetherState | null;
67
114
  readonly attachment: RefAttachment;
68
115
  domContext: DOMContext;
69
- constructor(opts: TooltipTriggerStateOpts, root: TooltipRootState);
116
+ constructor(opts: TooltipTriggerStateOpts, root: TooltipRootState | null, tether: TooltipTetherState | null);
70
117
  handlePointerUp: () => void;
71
118
  readonly props: {
72
119
  readonly id: string;
73
120
  readonly "aria-describedby": string | undefined;
74
- readonly "data-state": "closed" | "delayed-open" | "instant-open";
121
+ readonly "data-state": "closed" | "delayed-open" | "instant-open" | undefined;
75
122
  readonly "data-disabled": "" | undefined;
76
123
  readonly "data-delay-duration": `${number}`;
77
124
  readonly tabindex: number | undefined;
@@ -12,6 +12,103 @@ export const tooltipAttrs = createBitsAttrs({
12
12
  });
13
13
  const TooltipProviderContext = new Context("Tooltip.Provider");
14
14
  const TooltipRootContext = new Context("Tooltip.Root");
15
+ class TooltipTriggerRegistryState {
16
+ triggers = $state(new Map());
17
+ activeTriggerId = $state(null);
18
+ activeTriggerNode = $derived.by(() => {
19
+ const activeTriggerId = this.activeTriggerId;
20
+ if (activeTriggerId === null)
21
+ return null;
22
+ return this.triggers.get(activeTriggerId)?.node ?? null;
23
+ });
24
+ activePayload = $derived.by(() => {
25
+ const activeTriggerId = this.activeTriggerId;
26
+ if (activeTriggerId === null)
27
+ return null;
28
+ return this.triggers.get(activeTriggerId)?.payload ?? null;
29
+ });
30
+ register = (record) => {
31
+ const next = new Map(this.triggers);
32
+ next.set(record.id, record);
33
+ this.triggers = next;
34
+ this.#coerceActiveTrigger();
35
+ };
36
+ update = (record) => {
37
+ const next = new Map(this.triggers);
38
+ next.set(record.id, record);
39
+ this.triggers = next;
40
+ this.#coerceActiveTrigger();
41
+ };
42
+ unregister = (id) => {
43
+ if (!this.triggers.has(id))
44
+ return;
45
+ const next = new Map(this.triggers);
46
+ next.delete(id);
47
+ this.triggers = next;
48
+ if (this.activeTriggerId === id) {
49
+ this.activeTriggerId = null;
50
+ }
51
+ };
52
+ setActiveTrigger = (id) => {
53
+ if (id === null) {
54
+ this.activeTriggerId = null;
55
+ return;
56
+ }
57
+ if (!this.triggers.has(id)) {
58
+ this.activeTriggerId = null;
59
+ return;
60
+ }
61
+ this.activeTriggerId = id;
62
+ };
63
+ get = (id) => {
64
+ return this.triggers.get(id);
65
+ };
66
+ has = (id) => {
67
+ return this.triggers.has(id);
68
+ };
69
+ getFirstTriggerId = () => {
70
+ const firstEntry = this.triggers.entries().next();
71
+ if (firstEntry.done)
72
+ return null;
73
+ return firstEntry.value[0];
74
+ };
75
+ #coerceActiveTrigger = () => {
76
+ const activeTriggerId = this.activeTriggerId;
77
+ if (activeTriggerId === null)
78
+ return;
79
+ if (!this.triggers.has(activeTriggerId)) {
80
+ this.activeTriggerId = null;
81
+ }
82
+ };
83
+ }
84
+ class TooltipTetherState {
85
+ registry = new TooltipTriggerRegistryState();
86
+ root = $state(null);
87
+ }
88
+ // oxlint-disable-next-line no-unused-vars
89
+ export class TooltipTether {
90
+ #state = new TooltipTetherState();
91
+ get state() {
92
+ return this.#state;
93
+ }
94
+ open(triggerId) {
95
+ if (!this.#state.registry.has(triggerId)) {
96
+ return;
97
+ }
98
+ this.#state.registry.setActiveTrigger(triggerId);
99
+ this.#state.root?.setActiveTrigger(triggerId);
100
+ this.#state.root?.handleOpen();
101
+ }
102
+ close() {
103
+ this.#state.root?.handleClose();
104
+ }
105
+ get isOpen() {
106
+ return this.#state.root?.opts.open.current ?? false;
107
+ }
108
+ }
109
+ export function createTooltipTether() {
110
+ return new TooltipTether();
111
+ }
15
112
  export class TooltipProviderState {
16
113
  static create(opts) {
17
114
  return TooltipProviderContext.set(new TooltipProviderState(opts));
@@ -26,10 +123,26 @@ export class TooltipProviderState {
26
123
  this.#timerFn = new TimeoutFn(() => {
27
124
  this.isOpenDelayed = true;
28
125
  }, this.opts.skipDelayDuration.current);
126
+ onMountEffect(() => on(window, "scroll", (e) => {
127
+ const activeTooltip = this.#openTooltip;
128
+ if (!activeTooltip)
129
+ return;
130
+ const triggerNode = activeTooltip.triggerNode;
131
+ if (!triggerNode)
132
+ return;
133
+ const target = e.target;
134
+ if (!(target instanceof Element || target instanceof Document))
135
+ return;
136
+ if (target.contains(triggerNode)) {
137
+ activeTooltip.handleClose();
138
+ }
139
+ }));
29
140
  }
30
141
  #startTimer = () => {
31
142
  const skipDuration = this.opts.skipDelayDuration.current;
32
143
  if (skipDuration === 0) {
144
+ // no grace period — reset immediately so next trigger waits the full delay
145
+ this.isOpenDelayed = true;
33
146
  return;
34
147
  }
35
148
  else {
@@ -71,9 +184,10 @@ export class TooltipRootState {
71
184
  disabled = $derived.by(() => this.opts.disabled.current ?? this.provider.opts.disabled.current);
72
185
  ignoreNonKeyboardFocus = $derived.by(() => this.opts.ignoreNonKeyboardFocus.current ??
73
186
  this.provider.opts.ignoreNonKeyboardFocus.current);
187
+ registry;
188
+ tether;
74
189
  contentNode = $state(null);
75
190
  contentPresence;
76
- triggerNode = $state(null);
77
191
  #wasOpenDelayed = $state(false);
78
192
  #timerFn;
79
193
  stateAttr = $derived.by(() => {
@@ -84,10 +198,22 @@ export class TooltipRootState {
84
198
  constructor(opts, provider) {
85
199
  this.opts = opts;
86
200
  this.provider = provider;
201
+ this.tether = opts.tether.current?.state ?? null;
202
+ this.registry = this.tether?.registry ?? new TooltipTriggerRegistryState();
87
203
  this.#timerFn = new TimeoutFn(() => {
88
204
  this.#wasOpenDelayed = true;
89
205
  this.opts.open.current = true;
90
206
  }, this.delayDuration ?? 0);
207
+ if (this.tether) {
208
+ this.tether.root = this;
209
+ onMountEffect(() => {
210
+ return () => {
211
+ if (this.tether?.root === this) {
212
+ this.tether.root = null;
213
+ }
214
+ };
215
+ });
216
+ }
91
217
  this.contentPresence = new PresenceManager({
92
218
  open: this.opts.open,
93
219
  ref: boxWith(() => this.contentNode),
@@ -105,16 +231,28 @@ export class TooltipRootState {
105
231
  });
106
232
  watch(() => this.opts.open.current, (isOpen) => {
107
233
  if (isOpen) {
234
+ this.ensureActiveTrigger();
108
235
  this.provider.onOpen(this);
109
236
  }
110
237
  else {
111
238
  this.provider.onClose(this);
112
239
  }
113
240
  }, { lazy: true });
241
+ watch(() => this.opts.triggerId.current, (triggerId) => {
242
+ if (triggerId === this.registry.activeTriggerId)
243
+ return;
244
+ this.registry.setActiveTrigger(triggerId);
245
+ });
246
+ watch(() => this.registry.activeTriggerId, (activeTriggerId) => {
247
+ if (this.opts.triggerId.current === activeTriggerId)
248
+ return;
249
+ this.opts.triggerId.current = activeTriggerId;
250
+ });
114
251
  }
115
252
  handleOpen = () => {
116
253
  this.#timerFn.stop();
117
254
  this.#wasOpenDelayed = false;
255
+ this.ensureActiveTrigger();
118
256
  this.opts.open.current = true;
119
257
  };
120
258
  handleClose = () => {
@@ -127,8 +265,7 @@ export class TooltipRootState {
127
265
  const delayDuration = this.delayDuration ?? 0;
128
266
  // if no delay needed (either skip delay active or delay is 0), open immediately
129
267
  if (shouldSkipDelay || delayDuration === 0) {
130
- // set wasOpenDelayed based on whether we actually had a delay
131
- this.#wasOpenDelayed = delayDuration > 0 && shouldSkipDelay;
268
+ this.#wasOpenDelayed = false;
132
269
  this.opts.open.current = true;
133
270
  }
134
271
  else {
@@ -136,7 +273,8 @@ export class TooltipRootState {
136
273
  this.#timerFn.start();
137
274
  }
138
275
  };
139
- onTriggerEnter = () => {
276
+ onTriggerEnter = (triggerId) => {
277
+ this.setActiveTrigger(triggerId);
140
278
  this.#handleDelayedOpen();
141
279
  };
142
280
  onTriggerLeave = () => {
@@ -147,25 +285,161 @@ export class TooltipRootState {
147
285
  this.#timerFn.stop();
148
286
  }
149
287
  };
288
+ ensureActiveTrigger = () => {
289
+ if (this.registry.activeTriggerId !== null &&
290
+ this.registry.has(this.registry.activeTriggerId)) {
291
+ return;
292
+ }
293
+ if (this.opts.triggerId.current !== null &&
294
+ this.registry.has(this.opts.triggerId.current)) {
295
+ this.registry.setActiveTrigger(this.opts.triggerId.current);
296
+ return;
297
+ }
298
+ const firstTriggerId = this.registry.getFirstTriggerId();
299
+ this.registry.setActiveTrigger(firstTriggerId);
300
+ };
301
+ setActiveTrigger = (triggerId) => {
302
+ this.registry.setActiveTrigger(triggerId);
303
+ };
304
+ registerTrigger = (trigger) => {
305
+ this.registry.register(trigger);
306
+ if (trigger.disabled &&
307
+ this.registry.activeTriggerId === trigger.id &&
308
+ this.opts.open.current) {
309
+ this.handleClose();
310
+ }
311
+ };
312
+ updateTrigger = (trigger) => {
313
+ this.registry.update(trigger);
314
+ if (trigger.disabled &&
315
+ this.registry.activeTriggerId === trigger.id &&
316
+ this.opts.open.current) {
317
+ this.handleClose();
318
+ }
319
+ };
320
+ unregisterTrigger = (id) => {
321
+ const isActive = this.registry.activeTriggerId === id;
322
+ this.registry.unregister(id);
323
+ if (isActive && this.opts.open.current) {
324
+ this.handleClose();
325
+ }
326
+ };
327
+ isActiveTrigger = (triggerId) => {
328
+ return this.registry.activeTriggerId === triggerId;
329
+ };
330
+ get triggerNode() {
331
+ return this.registry.activeTriggerNode;
332
+ }
333
+ get activePayload() {
334
+ return this.registry.activePayload;
335
+ }
336
+ get activeTriggerId() {
337
+ return this.registry.activeTriggerId;
338
+ }
150
339
  }
151
340
  export class TooltipTriggerState {
152
341
  static create(opts) {
153
- return new TooltipTriggerState(opts, TooltipRootContext.get());
342
+ if (opts.tether.current) {
343
+ return new TooltipTriggerState(opts, null, opts.tether.current.state);
344
+ }
345
+ return new TooltipTriggerState(opts, TooltipRootContext.get(), null);
154
346
  }
155
347
  opts;
156
348
  root;
349
+ tether;
157
350
  attachment;
158
351
  #isPointerDown = simpleBox(false);
159
352
  #hasPointerMoveOpened = $state(false);
160
- #isDisabled = $derived.by(() => this.opts.disabled.current || this.root.disabled);
161
353
  domContext;
162
354
  #transitCheckTimeout = null;
163
- constructor(opts, root) {
355
+ #mounted = false;
356
+ #lastRegisteredId = null;
357
+ constructor(opts, root, tether) {
164
358
  this.opts = opts;
165
359
  this.root = root;
360
+ this.tether = tether;
166
361
  this.domContext = new DOMContext(opts.ref);
167
- this.attachment = attachRef(this.opts.ref, (v) => (this.root.triggerNode = v));
362
+ this.attachment = attachRef(this.opts.ref, (v) => this.#register(v));
363
+ watch(() => this.opts.id.current, () => {
364
+ this.#register(this.opts.ref.current);
365
+ });
366
+ watch(() => this.opts.payload.current, () => {
367
+ this.#register(this.opts.ref.current);
368
+ });
369
+ watch(() => this.opts.disabled.current, () => {
370
+ this.#register(this.opts.ref.current);
371
+ });
372
+ onMountEffect(() => {
373
+ this.#mounted = true;
374
+ this.#register(this.opts.ref.current);
375
+ return () => {
376
+ const root = this.#getRoot();
377
+ const id = this.#lastRegisteredId;
378
+ if (id) {
379
+ if (this.tether) {
380
+ this.tether.registry.unregister(id);
381
+ }
382
+ else {
383
+ root?.unregisterTrigger(id);
384
+ }
385
+ }
386
+ this.#lastRegisteredId = null;
387
+ this.#mounted = false;
388
+ };
389
+ });
168
390
  }
391
+ #getRoot = () => {
392
+ return this.tether?.root ?? this.root;
393
+ };
394
+ #isDisabled = () => {
395
+ const root = this.#getRoot();
396
+ return this.opts.disabled.current || Boolean(root?.disabled);
397
+ };
398
+ #register = (node) => {
399
+ if (!this.#mounted)
400
+ return;
401
+ const id = this.opts.id.current;
402
+ const payload = this.opts.payload.current;
403
+ const disabled = this.opts.disabled.current;
404
+ if (this.#lastRegisteredId && this.#lastRegisteredId !== id) {
405
+ const root = this.#getRoot();
406
+ if (this.tether) {
407
+ this.tether.registry.unregister(this.#lastRegisteredId);
408
+ }
409
+ else {
410
+ root?.unregisterTrigger(this.#lastRegisteredId);
411
+ }
412
+ }
413
+ const triggerRecord = {
414
+ id,
415
+ node,
416
+ payload,
417
+ disabled,
418
+ };
419
+ const root = this.#getRoot();
420
+ if (this.tether) {
421
+ if (this.tether.registry.has(id)) {
422
+ this.tether.registry.update(triggerRecord);
423
+ }
424
+ else {
425
+ this.tether.registry.register(triggerRecord);
426
+ }
427
+ if (disabled &&
428
+ this.tether.registry.activeTriggerId === id &&
429
+ root?.opts.open.current) {
430
+ root.handleClose();
431
+ }
432
+ }
433
+ else {
434
+ if (root?.registry.has(id)) {
435
+ root.updateTrigger(triggerRecord);
436
+ }
437
+ else {
438
+ root?.registerTrigger(triggerRecord);
439
+ }
440
+ }
441
+ this.#lastRegisteredId = id;
442
+ };
169
443
  #clearTransitCheck = () => {
170
444
  if (this.#transitCheckTimeout !== null) {
171
445
  clearTimeout(this.#transitCheckTimeout);
@@ -176,12 +450,12 @@ export class TooltipTriggerState {
176
450
  this.#isPointerDown.current = false;
177
451
  };
178
452
  #onpointerup = () => {
179
- if (this.#isDisabled)
453
+ if (this.#isDisabled())
180
454
  return;
181
455
  this.#isPointerDown.current = false;
182
456
  };
183
457
  #onpointerdown = () => {
184
- if (this.#isDisabled)
458
+ if (this.#isDisabled())
185
459
  return;
186
460
  this.#isPointerDown.current = true;
187
461
  this.domContext.getDocument().addEventListener("pointerup", () => {
@@ -189,84 +463,133 @@ export class TooltipTriggerState {
189
463
  }, { once: true });
190
464
  };
191
465
  #onpointerenter = (e) => {
192
- if (this.#isDisabled)
466
+ const root = this.#getRoot();
467
+ if (!root)
468
+ return;
469
+ if (this.#isDisabled()) {
470
+ if (root.opts.open.current) {
471
+ root.handleClose();
472
+ }
193
473
  return;
474
+ }
194
475
  if (e.pointerType === "touch")
195
476
  return;
196
477
  // if in transit, wait briefly to see if user is actually heading to old content or staying here
197
- if (this.root.provider.isPointerInTransit.current) {
478
+ if (root.provider.isPointerInTransit.current) {
198
479
  this.#clearTransitCheck();
199
480
  this.#transitCheckTimeout = window.setTimeout(() => {
200
481
  // if still in transit after delay, user is likely staying on this trigger
201
- if (this.root.provider.isPointerInTransit.current) {
202
- this.root.provider.isPointerInTransit.current = false;
203
- this.root.onTriggerEnter();
482
+ if (root.provider.isPointerInTransit.current) {
483
+ root.provider.isPointerInTransit.current = false;
484
+ root.onTriggerEnter(this.opts.id.current);
204
485
  this.#hasPointerMoveOpened = true;
205
486
  }
206
487
  }, 250);
207
488
  return;
208
489
  }
209
- this.root.onTriggerEnter();
490
+ root.onTriggerEnter(this.opts.id.current);
210
491
  this.#hasPointerMoveOpened = true;
211
492
  };
212
493
  #onpointermove = (e) => {
213
- if (this.#isDisabled)
494
+ const root = this.#getRoot();
495
+ if (!root)
496
+ return;
497
+ if (this.#isDisabled()) {
498
+ if (root.opts.open.current) {
499
+ root.handleClose();
500
+ }
214
501
  return;
502
+ }
215
503
  if (e.pointerType === "touch")
216
504
  return;
217
505
  if (this.#hasPointerMoveOpened)
218
506
  return;
219
507
  // moving within trigger means we're definitely not in transit anymore
220
508
  this.#clearTransitCheck();
221
- this.root.provider.isPointerInTransit.current = false;
222
- this.root.onTriggerEnter();
509
+ root.provider.isPointerInTransit.current = false;
510
+ root.onTriggerEnter(this.opts.id.current);
223
511
  this.#hasPointerMoveOpened = true;
224
512
  };
225
- #onpointerleave = () => {
226
- if (this.#isDisabled)
513
+ #onpointerleave = (e) => {
514
+ const root = this.#getRoot();
515
+ if (!root)
516
+ return;
517
+ if (this.#isDisabled())
227
518
  return;
228
519
  this.#clearTransitCheck();
229
- this.root.onTriggerLeave();
520
+ if (!root.isActiveTrigger(this.opts.id.current)) {
521
+ this.#hasPointerMoveOpened = false;
522
+ return;
523
+ }
524
+ const relatedTarget = e.relatedTarget;
525
+ // when moving to a sibling trigger and skip delay is active, don't close —
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) {
530
+ for (const record of root.registry.triggers.values()) {
531
+ if (record.node === relatedTarget) {
532
+ this.#hasPointerMoveOpened = false;
533
+ return;
534
+ }
535
+ }
536
+ }
537
+ root.onTriggerLeave();
230
538
  this.#hasPointerMoveOpened = false;
231
539
  };
232
540
  #onfocus = (e) => {
233
- if (this.#isPointerDown.current || this.#isDisabled)
541
+ const root = this.#getRoot();
542
+ if (!root)
234
543
  return;
235
- if (this.root.ignoreNonKeyboardFocus && !isFocusVisible(e.currentTarget))
544
+ if (this.#isPointerDown.current)
545
+ return;
546
+ if (this.#isDisabled()) {
547
+ if (root.opts.open.current) {
548
+ root.handleClose();
549
+ }
236
550
  return;
237
- this.root.handleOpen();
551
+ }
552
+ if (root.ignoreNonKeyboardFocus && !isFocusVisible(e.currentTarget))
553
+ return;
554
+ root.setActiveTrigger(this.opts.id.current);
555
+ root.handleOpen();
238
556
  };
239
557
  #onblur = () => {
240
- if (this.#isDisabled)
558
+ const root = this.#getRoot();
559
+ if (!root || this.#isDisabled())
241
560
  return;
242
- this.root.handleClose();
561
+ root.handleClose();
243
562
  };
244
563
  #onclick = () => {
245
- if (this.root.disableCloseOnTriggerClick || this.#isDisabled)
564
+ const root = this.#getRoot();
565
+ if (!root || root.disableCloseOnTriggerClick || this.#isDisabled())
246
566
  return;
247
- this.root.handleClose();
567
+ root.handleClose();
248
568
  };
249
- props = $derived.by(() => ({
250
- id: this.opts.id.current,
251
- "aria-describedby": this.root.opts.open.current
252
- ? this.root.contentNode?.id
253
- : undefined,
254
- "data-state": this.root.stateAttr,
255
- "data-disabled": boolToEmptyStrOrUndef(this.#isDisabled),
256
- "data-delay-duration": `${this.root.delayDuration}`,
257
- [tooltipAttrs.trigger]: "",
258
- tabindex: this.#isDisabled ? undefined : this.opts.tabindex.current,
259
- disabled: this.opts.disabled.current,
260
- onpointerup: this.#onpointerup,
261
- onpointerdown: this.#onpointerdown,
262
- onpointerenter: this.#onpointerenter,
263
- onpointermove: this.#onpointermove,
264
- onpointerleave: this.#onpointerleave,
265
- onfocus: this.#onfocus,
266
- onblur: this.#onblur,
267
- onclick: this.#onclick,
268
- ...this.attachment,
269
- }));
569
+ props = $derived.by(() => {
570
+ const root = this.#getRoot();
571
+ const isOpenForTrigger = Boolean(root?.opts.open.current && root.isActiveTrigger(this.opts.id.current));
572
+ const isDisabled = this.#isDisabled();
573
+ return {
574
+ id: this.opts.id.current,
575
+ "aria-describedby": isOpenForTrigger ? root?.contentNode?.id : undefined,
576
+ "data-state": isOpenForTrigger ? root?.stateAttr : "closed",
577
+ "data-disabled": boolToEmptyStrOrUndef(isDisabled),
578
+ "data-delay-duration": `${root?.delayDuration ?? 0}`,
579
+ [tooltipAttrs.trigger]: "",
580
+ tabindex: isDisabled ? undefined : this.opts.tabindex.current,
581
+ disabled: this.opts.disabled.current,
582
+ onpointerup: this.#onpointerup,
583
+ onpointerdown: this.#onpointerdown,
584
+ onpointerenter: this.#onpointerenter,
585
+ onpointermove: this.#onpointermove,
586
+ onpointerleave: this.#onpointerleave,
587
+ onfocus: this.#onfocus,
588
+ onblur: this.#onblur,
589
+ onclick: this.#onclick,
590
+ ...this.attachment,
591
+ };
592
+ });
270
593
  }
271
594
  export class TooltipContentState {
272
595
  static create(opts) {
@@ -283,20 +606,26 @@ export class TooltipContentState {
283
606
  triggerNode: () => this.root.triggerNode,
284
607
  contentNode: () => this.root.contentNode,
285
608
  enabled: () => this.root.opts.open.current && !this.root.disableHoverableContent,
609
+ ignoredTargets: () => {
610
+ // only skip closing for sibling triggers when there's a skip-delay grace period;
611
+ // with skipDelayDuration=0 the close+reopen is intentional (full delay + re-animation)
612
+ if (this.root.provider.opts.skipDelayDuration.current === 0)
613
+ return [];
614
+ const nodes = [];
615
+ const activeTriggerNode = this.root.triggerNode;
616
+ for (const record of this.root.registry.triggers.values()) {
617
+ if (record.node && record.node !== activeTriggerNode) {
618
+ nodes.push(record.node);
619
+ }
620
+ }
621
+ return nodes;
622
+ },
286
623
  onPointerExit: () => {
287
624
  if (this.root.provider.isTooltipOpen(this.root)) {
288
625
  this.root.handleClose();
289
626
  }
290
627
  },
291
628
  });
292
- onMountEffect(() => on(window, "scroll", (e) => {
293
- const target = e.target;
294
- if (!target)
295
- return;
296
- if (target.contains(this.root.triggerNode)) {
297
- this.root.handleClose();
298
- }
299
- }));
300
629
  }
301
630
  onInteractOutside = (e) => {
302
631
  if (isElement(e.target) &&
@@ -2,10 +2,18 @@ import type { FloatingLayerContentProps } from "../utilities/floating-layer/type
2
2
  import type { ArrowProps, ArrowPropsWithoutHTML } from "../utilities/arrow/types.js";
3
3
  import type { DismissibleLayerProps } from "../utilities/dismissible-layer/types.js";
4
4
  import type { EscapeLayerProps } from "../utilities/escape-layer/types.js";
5
+ import type { Snippet } from "svelte";
5
6
  import type { OnChangeFn, WithChild, WithChildNoChildrenSnippetProps, WithChildren, Without } from "../../internal/types.js";
6
7
  import type { BitsPrimitiveButtonAttributes, BitsPrimitiveDivAttributes } from "../../shared/attributes.js";
7
8
  import type { PortalProps } from "../utilities/portal/types.js";
8
9
  import type { FloatingContentSnippetProps, StaticContentSnippetProps } from "../../shared/types.js";
10
+ import type { TooltipTether as TooltipTetherImpl } from "./tooltip.svelte.js";
11
+ export type TooltipTether<Payload = never> = TooltipTetherImpl<Payload>;
12
+ export type TooltipRootSnippetProps<Payload = never> = {
13
+ open: boolean;
14
+ triggerId: string | null;
15
+ payload: [Payload] extends [never] ? null : Payload | null;
16
+ };
9
17
  export type TooltipProviderPropsWithoutHTML = WithChildren<{
10
18
  /**
11
19
  * The delay in milliseconds before the tooltip opens.
@@ -46,7 +54,7 @@ export type TooltipProviderPropsWithoutHTML = WithChildren<{
46
54
  ignoreNonKeyboardFocus?: boolean;
47
55
  }>;
48
56
  export type TooltipProviderProps = TooltipProviderPropsWithoutHTML;
49
- export type TooltipRootPropsWithoutHTML = WithChildren<{
57
+ export type TooltipRootPropsWithoutHTML<Payload = never> = Omit<WithChildren<{
50
58
  /**
51
59
  * The open state of the tooltip.
52
60
  *
@@ -92,8 +100,18 @@ export type TooltipRootPropsWithoutHTML = WithChildren<{
92
100
  * @defaultValue false
93
101
  */
94
102
  ignoreNonKeyboardFocus?: boolean;
95
- }>;
96
- export type TooltipRootProps = TooltipRootPropsWithoutHTML;
103
+ /**
104
+ * The active trigger id for controlled single tooltip mode.
105
+ */
106
+ triggerId?: string | null;
107
+ /**
108
+ * Shared tether used to connect detached triggers and infer payload types.
109
+ */
110
+ tether?: TooltipTether<Payload> | undefined;
111
+ }>, "children"> & {
112
+ children?: Snippet | Snippet<[TooltipRootSnippetProps<Payload>]>;
113
+ };
114
+ export type TooltipRootProps<Payload = never> = TooltipRootPropsWithoutHTML<Payload>;
97
115
  export type TooltipContentPropsWithoutHTML = WithChildNoChildrenSnippetProps<Omit<FloatingLayerContentProps, "content" | "preventScroll"> & Omit<DismissibleLayerProps, "onInteractOutsideStart"> & EscapeLayerProps & {
98
116
  /**
99
117
  * When `true`, the tooltip will be forced to mount in the DOM.
@@ -116,12 +134,20 @@ export type TooltipArrowPropsWithoutHTML = ArrowPropsWithoutHTML;
116
134
  export type TooltipArrowProps = ArrowProps;
117
135
  export type TooltipPortalPropsWithoutHTML = PortalProps;
118
136
  export type TooltipPortalProps = PortalProps;
119
- export type TooltipTriggerPropsWithoutHTML = WithChild<{
137
+ export type TooltipTriggerPropsWithoutHTML<Payload = never> = WithChild<{
120
138
  /**
121
139
  * Whether the tooltip trigger is disabled or not.
122
140
  *
123
141
  * @defaultValue false
124
142
  */
125
143
  disabled?: boolean | null | undefined;
144
+ /**
145
+ * Payload for the trigger used by singleton tooltip root snippets.
146
+ */
147
+ payload?: [Payload] extends [never] ? unknown : Payload;
148
+ /**
149
+ * Shared tether used to connect detached triggers and infer payload types.
150
+ */
151
+ tether?: TooltipTether<Payload> | undefined;
126
152
  }>;
127
- export type TooltipTriggerProps = TooltipTriggerPropsWithoutHTML & Without<BitsPrimitiveButtonAttributes, TooltipTriggerPropsWithoutHTML>;
153
+ export type TooltipTriggerProps<Payload = never> = TooltipTriggerPropsWithoutHTML<Payload> & Without<BitsPrimitiveButtonAttributes, TooltipTriggerPropsWithoutHTML<Payload>>;
@@ -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,8 @@ export interface SafePolygonOptions {
5
5
  contentNode: Getter<HTMLElement | null>;
6
6
  onPointerExit: () => void;
7
7
  buffer?: number;
8
+ /** nodes that should not trigger a close when they become the relatedTarget on trigger leave (e.g. sibling triggers in singleton mode) */
9
+ ignoredTargets?: Getter<HTMLElement[]>;
8
10
  }
9
11
  /**
10
12
  * Creates a safe polygon area that allows users to move their cursor between
@@ -46,18 +46,34 @@ 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
+ #leaveFallbackRafId = null;
50
+ #cancelLeaveFallback() {
51
+ if (this.#leaveFallbackRafId !== null) {
52
+ cancelAnimationFrame(this.#leaveFallbackRafId);
53
+ this.#leaveFallbackRafId = null;
54
+ }
55
+ }
56
+ #scheduleLeaveFallback() {
57
+ this.#cancelLeaveFallback();
58
+ this.#leaveFallbackRafId = requestAnimationFrame(() => {
59
+ this.#leaveFallbackRafId = null;
60
+ if (!this.#exitPoint || !this.#exitTarget)
61
+ return;
62
+ this.#clearTracking();
63
+ this.#opts.onPointerExit();
64
+ });
65
+ }
49
66
  constructor(opts) {
50
67
  this.#opts = opts;
51
68
  this.#buffer = opts.buffer ?? 1;
52
69
  watch([opts.triggerNode, opts.contentNode, opts.enabled], ([triggerNode, contentNode, enabled]) => {
53
70
  if (!triggerNode || !contentNode || !enabled) {
54
- this.#exitPoint = null;
55
- this.#exitTarget = null;
71
+ this.#clearTracking();
56
72
  return;
57
73
  }
58
74
  const doc = getDocument(triggerNode);
59
75
  const handlePointerMove = (e) => {
60
- this.#onPointerMove(e, triggerNode, contentNode);
76
+ this.#onPointerMove([e.clientX, e.clientY], triggerNode, contentNode);
61
77
  };
62
78
  const handleTriggerLeave = (e) => {
63
79
  // when leaving trigger toward content, record exit point
@@ -66,18 +82,34 @@ export class SafePolygon {
66
82
  if (isElement(target) && contentNode.contains(target)) {
67
83
  return;
68
84
  }
85
+ // if moving to an ignored target (e.g. a sibling trigger), don't close —
86
+ // the sibling's enter handler will take over
87
+ const ignoredTargets = this.#opts.ignoredTargets?.() ?? [];
88
+ if (isElement(target) &&
89
+ ignoredTargets.some((n) => n === target || n.contains(target))) {
90
+ return;
91
+ }
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
+ }
69
102
  this.#exitPoint = [e.clientX, e.clientY];
70
103
  this.#exitTarget = "content";
104
+ this.#scheduleLeaveFallback();
71
105
  };
72
106
  const handleTriggerEnter = () => {
73
107
  // reached trigger, clear tracking
74
- this.#exitPoint = null;
75
- this.#exitTarget = null;
108
+ this.#clearTracking();
76
109
  };
77
110
  const handleContentEnter = () => {
78
111
  // reached content, clear tracking
79
- this.#exitPoint = null;
80
- this.#exitTarget = null;
112
+ this.#clearTracking();
81
113
  };
82
114
  const handleContentLeave = (e) => {
83
115
  // when leaving content, check if going directly back to trigger
@@ -86,9 +118,10 @@ export class SafePolygon {
86
118
  // going directly to trigger, no polygon tracking needed
87
119
  return;
88
120
  }
89
- // might be traversing gap back to trigger, set up polygon tracking
121
+ // set up polygon tracking toward trigger pointermove decides whether to close
90
122
  this.#exitPoint = [e.clientX, e.clientY];
91
123
  this.#exitTarget = "trigger";
124
+ this.#scheduleLeaveFallback();
92
125
  };
93
126
  return [
94
127
  on(doc, "pointermove", handlePointerMove),
@@ -102,22 +135,20 @@ export class SafePolygon {
102
135
  }, () => { });
103
136
  });
104
137
  }
105
- #onPointerMove(e, triggerNode, contentNode) {
138
+ #onPointerMove(clientPoint, triggerNode, contentNode) {
106
139
  // if no exit point recorded, nothing to check
107
140
  if (!this.#exitPoint || !this.#exitTarget)
108
141
  return;
109
- const clientPoint = [e.clientX, e.clientY];
142
+ this.#cancelLeaveFallback();
110
143
  const triggerRect = triggerNode.getBoundingClientRect();
111
144
  const contentRect = contentNode.getBoundingClientRect();
112
145
  // check if pointer reached the target
113
146
  if (this.#exitTarget === "content" && isInsideRect(clientPoint, contentRect)) {
114
- this.#exitPoint = null;
115
- this.#exitTarget = null;
147
+ this.#clearTracking();
116
148
  return;
117
149
  }
118
150
  if (this.#exitTarget === "trigger" && isInsideRect(clientPoint, triggerRect)) {
119
- this.#exitPoint = null;
120
- this.#exitTarget = null;
151
+ this.#clearTracking();
121
152
  return;
122
153
  }
123
154
  // check if pointer is in the rectangular corridor between trigger and content
@@ -133,9 +164,13 @@ export class SafePolygon {
133
164
  return;
134
165
  }
135
166
  // pointer is outside all safe zones - close
167
+ this.#clearTracking();
168
+ this.#opts.onPointerExit();
169
+ }
170
+ #clearTracking() {
136
171
  this.#exitPoint = null;
137
172
  this.#exitTarget = null;
138
- this.#opts.onPointerExit();
173
+ this.#cancelLeaveFallback();
139
174
  }
140
175
  /**
141
176
  * 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.16.0",
4
4
  "license": "MIT",
5
5
  "repository": "github:huntabyte/bits-ui",
6
6
  "funding": "https://github.com/sponsors/huntabyte",