bits-ui 2.14.3 → 2.15.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.
- package/dist/bits/checkbox/checkbox.svelte.js +7 -1
- package/dist/bits/command/command.svelte.js +17 -0
- package/dist/bits/context-menu/components/context-menu-content.svelte +6 -3
- package/dist/bits/context-menu/components/context-menu-content.svelte.d.ts +1 -2
- package/dist/bits/context-menu/types.d.ts +2 -2
- package/dist/bits/link-preview/types.d.ts +1 -1
- package/dist/bits/menu/menu.svelte.d.ts +1 -1
- package/dist/bits/menu/menu.svelte.js +6 -1
- package/dist/bits/popover/components/popover-content.svelte +16 -2
- package/dist/bits/popover/components/popover-trigger.svelte +6 -0
- package/dist/bits/popover/popover.svelte.d.ts +29 -2
- package/dist/bits/popover/popover.svelte.js +183 -4
- package/dist/bits/popover/types.d.ts +19 -1
- package/dist/bits/utilities/floating-layer/use-floating-layer.svelte.d.ts +5 -5
- package/dist/internal/body-scroll-lock.svelte.js +7 -2
- package/dist/internal/safe-polygon.svelte.d.ts +16 -0
- package/dist/internal/safe-polygon.svelte.js +237 -0
- package/package.json +1 -1
|
@@ -128,8 +128,14 @@ export class CheckboxRootState {
|
|
|
128
128
|
onkeydown(e) {
|
|
129
129
|
if (this.trueDisabled || this.trueReadonly)
|
|
130
130
|
return;
|
|
131
|
-
if (e.key === kbd.ENTER)
|
|
131
|
+
if (e.key === kbd.ENTER) {
|
|
132
132
|
e.preventDefault();
|
|
133
|
+
if (this.opts.type.current === "submit") {
|
|
134
|
+
const form = e.currentTarget.closest("form");
|
|
135
|
+
form?.requestSubmit();
|
|
136
|
+
}
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
133
139
|
if (e.key === kbd.SPACE) {
|
|
134
140
|
e.preventDefault();
|
|
135
141
|
this.#toggle();
|
|
@@ -134,6 +134,10 @@ export class CommandRootState {
|
|
|
134
134
|
if (!this._commandState.value || !this.#isInitialMount) {
|
|
135
135
|
this.#selectFirstItem();
|
|
136
136
|
}
|
|
137
|
+
else if (this.#isInitialMount && this._commandState.value) {
|
|
138
|
+
// scroll the initial value into view if it exists
|
|
139
|
+
this.#scrollInitialValue();
|
|
140
|
+
}
|
|
137
141
|
return;
|
|
138
142
|
}
|
|
139
143
|
const scores = this._commandState.filtered.items;
|
|
@@ -216,6 +220,19 @@ export class CommandRootState {
|
|
|
216
220
|
this.#isInitialMount = false;
|
|
217
221
|
});
|
|
218
222
|
}
|
|
223
|
+
/**
|
|
224
|
+
* Scrolls the initial value into view if it exists and is not the first item.
|
|
225
|
+
* Called during initial mount when a value is provided.
|
|
226
|
+
*/
|
|
227
|
+
#scrollInitialValue() {
|
|
228
|
+
afterTick(() => {
|
|
229
|
+
const shouldPreventScroll = this.opts.disableInitialScroll.current;
|
|
230
|
+
if (!shouldPreventScroll) {
|
|
231
|
+
this.#scrollSelectedIntoView();
|
|
232
|
+
}
|
|
233
|
+
this.#isInitialMount = false;
|
|
234
|
+
});
|
|
235
|
+
}
|
|
219
236
|
/**
|
|
220
237
|
* Updates filtered items/groups based on search.
|
|
221
238
|
* Recalculates scores and filtered count.
|
|
@@ -18,6 +18,9 @@
|
|
|
18
18
|
onCloseAutoFocus = noop,
|
|
19
19
|
onOpenAutoFocus = noop,
|
|
20
20
|
preventScroll = true,
|
|
21
|
+
side = "right",
|
|
22
|
+
sideOffset = 2,
|
|
23
|
+
align = "start",
|
|
21
24
|
// we need to explicitly pass this prop to the PopperLayer to override
|
|
22
25
|
// the default menu behavior of handling outside interactions on the trigger
|
|
23
26
|
onEscapeKeydown = noop,
|
|
@@ -74,9 +77,9 @@
|
|
|
74
77
|
{...mergedProps}
|
|
75
78
|
{...contentState.popperProps}
|
|
76
79
|
ref={contentState.opts.ref}
|
|
77
|
-
side
|
|
78
|
-
sideOffset
|
|
79
|
-
align
|
|
80
|
+
{side}
|
|
81
|
+
{sideOffset}
|
|
82
|
+
{align}
|
|
80
83
|
enabled={contentState.parentMenu.opts.open.current}
|
|
81
84
|
{preventScroll}
|
|
82
85
|
onInteractOutside={handleInteractOutside}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
declare const ContextMenuContent: import("svelte").Component<ContextMenuContentProps, {}, "ref">;
|
|
1
|
+
declare const ContextMenuContent: import("svelte").Component<import("../../menu/types.js").MenuContentProps, {}, "ref">;
|
|
3
2
|
type ContextMenuContent = ReturnType<typeof ContextMenuContent>;
|
|
4
3
|
export default ContextMenuContent;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { MenuContentProps, MenuContentPropsWithoutHTML } from "../menu/types.js";
|
|
2
2
|
import type { WithChild, Without } from "../../internal/types.js";
|
|
3
3
|
import type { BitsPrimitiveDivAttributes } from "../../shared/attributes.js";
|
|
4
|
-
export type ContextMenuContentPropsWithoutHTML =
|
|
5
|
-
export type ContextMenuContentProps =
|
|
4
|
+
export type ContextMenuContentPropsWithoutHTML = MenuContentPropsWithoutHTML;
|
|
5
|
+
export type ContextMenuContentProps = MenuContentProps;
|
|
6
6
|
export type ContextMenuTriggerPropsWithoutHTML = WithChild<{
|
|
7
7
|
/**
|
|
8
8
|
* Whether the context menu trigger is disabled. If disabled, the trigger will not
|
|
@@ -48,7 +48,7 @@ export type LinkPreviewRootPropsWithoutHTML = WithChildren<{
|
|
|
48
48
|
ignoreNonKeyboardFocus?: boolean;
|
|
49
49
|
}>;
|
|
50
50
|
export type LinkPreviewRootProps = LinkPreviewRootPropsWithoutHTML;
|
|
51
|
-
export type LinkPreviewContentPropsWithoutHTML = WithChildNoChildrenSnippetProps<Pick<FloatingLayerContentProps, "side" | "sideOffset" | "align" | "alignOffset" | "avoidCollisions" | "collisionBoundary" | "collisionPadding" | "arrowPadding" | "sticky" | "hideWhenDetached" | "dir"> & Omit<DismissibleLayerProps, "onInteractOutsideStart"> & EscapeLayerProps & {
|
|
51
|
+
export type LinkPreviewContentPropsWithoutHTML = WithChildNoChildrenSnippetProps<Pick<FloatingLayerContentProps, "side" | "sideOffset" | "align" | "alignOffset" | "avoidCollisions" | "collisionBoundary" | "collisionPadding" | "arrowPadding" | "sticky" | "hideWhenDetached" | "dir" | "customAnchor"> & Omit<DismissibleLayerProps, "onInteractOutsideStart"> & EscapeLayerProps & {
|
|
52
52
|
/**
|
|
53
53
|
* When `true`, the link preview content will be forced to mount in the DOM.
|
|
54
54
|
*
|
|
@@ -3,7 +3,7 @@ import { Context } from "runed";
|
|
|
3
3
|
import { CustomEventDispatcher } from "../../internal/events.js";
|
|
4
4
|
import type { AnyFn, BitsFocusEvent, BitsKeyboardEvent, BitsMouseEvent, BitsPointerEvent, OnChangeFn, RefAttachment, WithRefOpts } from "../../internal/types.js";
|
|
5
5
|
import type { Direction } from "../../shared/index.js";
|
|
6
|
-
import { IsUsingKeyboard } from "
|
|
6
|
+
import { IsUsingKeyboard } from "../utilities/is-using-keyboard/is-using-keyboard.svelte.js";
|
|
7
7
|
import type { KeyboardEventHandler, PointerEventHandler, MouseEventHandler } from "svelte/elements";
|
|
8
8
|
import { RovingFocusGroup } from "../../internal/roving-focus-group.js";
|
|
9
9
|
import { PresenceManager } from "../../internal/presence-manager.svelte.js";
|
|
@@ -6,13 +6,14 @@ import { CustomEventDispatcher } from "../../internal/events.js";
|
|
|
6
6
|
import { isElement, isElementOrSVGElement, isHTMLElement } from "../../internal/is.js";
|
|
7
7
|
import { kbd } from "../../internal/kbd.js";
|
|
8
8
|
import { createBitsAttrs, getAriaChecked, boolToStr, getDataOpenClosed, boolToEmptyStrOrUndef, } from "../../internal/attrs.js";
|
|
9
|
-
import { IsUsingKeyboard } from "
|
|
9
|
+
import { IsUsingKeyboard } from "../utilities/is-using-keyboard/is-using-keyboard.svelte.js";
|
|
10
10
|
import { getTabbableFrom } from "../../internal/tabbable.js";
|
|
11
11
|
import { isTabbable } from "tabbable";
|
|
12
12
|
import { DOMTypeahead } from "../../internal/dom-typeahead.svelte.js";
|
|
13
13
|
import { RovingFocusGroup } from "../../internal/roving-focus-group.js";
|
|
14
14
|
import { GraceArea } from "../../internal/grace-area.svelte.js";
|
|
15
15
|
import { PresenceManager } from "../../internal/presence-manager.svelte.js";
|
|
16
|
+
import { arraysAreEqual } from "../../internal/arrays.js";
|
|
16
17
|
export const CONTEXT_MENU_TRIGGER_ATTR = "data-context-menu-trigger";
|
|
17
18
|
export const CONTEXT_MENU_CONTENT_ATTR = "data-context-menu-content";
|
|
18
19
|
const MenuRootContext = new Context("Menu.Root");
|
|
@@ -942,6 +943,8 @@ export class MenuCheckboxGroupState {
|
|
|
942
943
|
if (!this.opts.value.current.includes(checkboxValue)) {
|
|
943
944
|
const newValue = [...$state.snapshot(this.opts.value.current), checkboxValue];
|
|
944
945
|
this.opts.value.current = newValue;
|
|
946
|
+
if (arraysAreEqual(this.opts.value.current, newValue))
|
|
947
|
+
return;
|
|
945
948
|
this.opts.onValueChange.current(newValue);
|
|
946
949
|
}
|
|
947
950
|
}
|
|
@@ -953,6 +956,8 @@ export class MenuCheckboxGroupState {
|
|
|
953
956
|
return;
|
|
954
957
|
const newValue = this.opts.value.current.filter((v) => v !== checkboxValue);
|
|
955
958
|
this.opts.value.current = newValue;
|
|
959
|
+
if (arraysAreEqual(this.opts.value.current, newValue))
|
|
960
|
+
return;
|
|
956
961
|
this.opts.onValueChange.current(newValue);
|
|
957
962
|
}
|
|
958
963
|
props = $derived.by(() => ({
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
ref = $bindable(null),
|
|
17
17
|
id = createId(uid),
|
|
18
18
|
forceMount = false,
|
|
19
|
+
onOpenAutoFocus = noop,
|
|
19
20
|
onCloseAutoFocus = noop,
|
|
20
21
|
onEscapeKeydown = noop,
|
|
21
22
|
onInteractOutside = noop,
|
|
@@ -37,6 +38,17 @@
|
|
|
37
38
|
});
|
|
38
39
|
|
|
39
40
|
const mergedProps = $derived(mergeProps(restProps, contentState.props));
|
|
41
|
+
|
|
42
|
+
// respect user's trapFocus setting, but disable when hover-opened without interaction
|
|
43
|
+
const effectiveTrapFocus = $derived(trapFocus && contentState.shouldTrapFocus);
|
|
44
|
+
|
|
45
|
+
// prevent auto-focus when opened via hover until user interacts
|
|
46
|
+
function handleOpenAutoFocus(e: Event) {
|
|
47
|
+
if (!contentState.shouldTrapFocus) {
|
|
48
|
+
e.preventDefault();
|
|
49
|
+
}
|
|
50
|
+
onOpenAutoFocus(e);
|
|
51
|
+
}
|
|
40
52
|
</script>
|
|
41
53
|
|
|
42
54
|
{#if forceMount}
|
|
@@ -46,11 +58,12 @@
|
|
|
46
58
|
ref={contentState.opts.ref}
|
|
47
59
|
enabled={contentState.root.opts.open.current}
|
|
48
60
|
{id}
|
|
49
|
-
{
|
|
61
|
+
trapFocus={effectiveTrapFocus}
|
|
50
62
|
{preventScroll}
|
|
51
63
|
loop
|
|
52
64
|
forceMount={true}
|
|
53
65
|
{customAnchor}
|
|
66
|
+
onOpenAutoFocus={handleOpenAutoFocus}
|
|
54
67
|
{onCloseAutoFocus}
|
|
55
68
|
shouldRender={contentState.shouldRender}
|
|
56
69
|
>
|
|
@@ -76,11 +89,12 @@
|
|
|
76
89
|
ref={contentState.opts.ref}
|
|
77
90
|
open={contentState.root.opts.open.current}
|
|
78
91
|
{id}
|
|
79
|
-
{
|
|
92
|
+
trapFocus={effectiveTrapFocus}
|
|
80
93
|
{preventScroll}
|
|
81
94
|
loop
|
|
82
95
|
forceMount={false}
|
|
83
96
|
{customAnchor}
|
|
97
|
+
onOpenAutoFocus={handleOpenAutoFocus}
|
|
84
98
|
{onCloseAutoFocus}
|
|
85
99
|
shouldRender={contentState.shouldRender}
|
|
86
100
|
>
|
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
ref = $bindable(null),
|
|
15
15
|
type = "button",
|
|
16
16
|
disabled = false,
|
|
17
|
+
openOnHover = false,
|
|
18
|
+
openDelay = 700,
|
|
19
|
+
closeDelay = 300,
|
|
17
20
|
...restProps
|
|
18
21
|
}: PopoverTriggerProps = $props();
|
|
19
22
|
|
|
@@ -24,6 +27,9 @@
|
|
|
24
27
|
(v) => (ref = v)
|
|
25
28
|
),
|
|
26
29
|
disabled: boxWith(() => Boolean(disabled)),
|
|
30
|
+
openOnHover: boxWith(() => openOnHover),
|
|
31
|
+
openDelay: boxWith(() => openDelay),
|
|
32
|
+
closeDelay: boxWith(() => closeDelay),
|
|
27
33
|
});
|
|
28
34
|
|
|
29
35
|
const mergedProps = $derived(mergeProps(restProps, triggerState.props, { type }));
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { type ReadableBoxedValues, type WritableBoxedValues } from "svelte-toolbelt";
|
|
2
|
-
import type { BitsKeyboardEvent, BitsMouseEvent, BitsPointerEvent, OnChangeFn, RefAttachment, WithRefOpts } from "../../internal/types.js";
|
|
1
|
+
import { type ReadableBoxedValues, type WritableBoxedValues, DOMContext } from "svelte-toolbelt";
|
|
2
|
+
import type { BitsFocusEvent, BitsKeyboardEvent, BitsMouseEvent, BitsPointerEvent, OnChangeFn, RefAttachment, WithRefOpts } from "../../internal/types.js";
|
|
3
3
|
import type { Measurable } from "../../internal/floating-svelte/types.js";
|
|
4
4
|
import { PresenceManager } from "../../internal/presence-manager.svelte.js";
|
|
5
5
|
interface PopoverRootStateOpts extends WritableBoxedValues<{
|
|
@@ -9,6 +9,7 @@ interface PopoverRootStateOpts extends WritableBoxedValues<{
|
|
|
9
9
|
}> {
|
|
10
10
|
}
|
|
11
11
|
export declare class PopoverRootState {
|
|
12
|
+
#private;
|
|
12
13
|
static create(opts: PopoverRootStateOpts): PopoverRootState;
|
|
13
14
|
readonly opts: PopoverRootStateOpts;
|
|
14
15
|
contentNode: HTMLElement | null;
|
|
@@ -16,12 +17,24 @@ export declare class PopoverRootState {
|
|
|
16
17
|
triggerNode: HTMLElement | null;
|
|
17
18
|
overlayNode: HTMLElement | null;
|
|
18
19
|
overlayPresence: PresenceManager;
|
|
20
|
+
openedViaHover: boolean;
|
|
21
|
+
hasInteractedWithContent: boolean;
|
|
22
|
+
closeDelay: number;
|
|
19
23
|
constructor(opts: PopoverRootStateOpts);
|
|
24
|
+
setDomContext(ctx: DOMContext): void;
|
|
20
25
|
toggleOpen(): void;
|
|
21
26
|
handleClose(): void;
|
|
27
|
+
handleHoverOpen(): void;
|
|
28
|
+
handleHoverClose(): void;
|
|
29
|
+
handleDelayedHoverClose(): void;
|
|
30
|
+
cancelDelayedClose(): void;
|
|
31
|
+
markInteraction(): void;
|
|
22
32
|
}
|
|
23
33
|
interface PopoverTriggerStateOpts extends WithRefOpts, ReadableBoxedValues<{
|
|
24
34
|
disabled: boolean;
|
|
35
|
+
openOnHover: boolean;
|
|
36
|
+
openDelay: number;
|
|
37
|
+
closeDelay: number;
|
|
25
38
|
}> {
|
|
26
39
|
}
|
|
27
40
|
export declare class PopoverTriggerState {
|
|
@@ -30,7 +43,10 @@ export declare class PopoverTriggerState {
|
|
|
30
43
|
readonly opts: PopoverTriggerStateOpts;
|
|
31
44
|
readonly root: PopoverRootState;
|
|
32
45
|
readonly attachment: RefAttachment;
|
|
46
|
+
readonly domContext: DOMContext;
|
|
33
47
|
constructor(opts: PopoverTriggerStateOpts, root: PopoverRootState);
|
|
48
|
+
onpointerenter(e: BitsPointerEvent): void;
|
|
49
|
+
onpointerleave(e: BitsPointerEvent): void;
|
|
34
50
|
onclick(e: BitsMouseEvent): void;
|
|
35
51
|
onkeydown(e: BitsKeyboardEvent): void;
|
|
36
52
|
readonly props: {
|
|
@@ -42,6 +58,8 @@ export declare class PopoverTriggerState {
|
|
|
42
58
|
readonly disabled: boolean;
|
|
43
59
|
readonly onkeydown: (e: BitsKeyboardEvent) => void;
|
|
44
60
|
readonly onclick: (e: BitsMouseEvent) => void;
|
|
61
|
+
readonly onpointerenter: (e: BitsPointerEvent) => void;
|
|
62
|
+
readonly onpointerleave: (e: BitsPointerEvent) => void;
|
|
45
63
|
};
|
|
46
64
|
}
|
|
47
65
|
interface PopoverContentStateOpts extends WithRefOpts, ReadableBoxedValues<{
|
|
@@ -56,9 +74,14 @@ export declare class PopoverContentState {
|
|
|
56
74
|
readonly root: PopoverRootState;
|
|
57
75
|
readonly attachment: RefAttachment;
|
|
58
76
|
constructor(opts: PopoverContentStateOpts, root: PopoverRootState);
|
|
77
|
+
onpointerdown(_: BitsPointerEvent): void;
|
|
78
|
+
onfocusin(e: BitsFocusEvent): void;
|
|
79
|
+
onpointerenter(e: BitsPointerEvent): void;
|
|
80
|
+
onpointerleave(e: BitsPointerEvent): void;
|
|
59
81
|
onInteractOutside: (e: PointerEvent) => void;
|
|
60
82
|
onEscapeKeydown: (e: KeyboardEvent) => void;
|
|
61
83
|
get shouldRender(): boolean;
|
|
84
|
+
get shouldTrapFocus(): boolean;
|
|
62
85
|
readonly snippetProps: {
|
|
63
86
|
open: boolean;
|
|
64
87
|
};
|
|
@@ -69,6 +92,10 @@ export declare class PopoverContentState {
|
|
|
69
92
|
readonly style: {
|
|
70
93
|
readonly pointerEvents: "auto";
|
|
71
94
|
};
|
|
95
|
+
readonly onpointerdown: (_: BitsPointerEvent) => void;
|
|
96
|
+
readonly onfocusin: (e: BitsFocusEvent) => void;
|
|
97
|
+
readonly onpointerenter: (e: BitsPointerEvent) => void;
|
|
98
|
+
readonly onpointerleave: (e: BitsPointerEvent) => void;
|
|
72
99
|
};
|
|
73
100
|
readonly popperProps: {
|
|
74
101
|
onInteractOutside: (e: PointerEvent) => void;
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { attachRef, boxWith, } from "svelte-toolbelt";
|
|
2
|
-
import { Context } from "runed";
|
|
1
|
+
import { attachRef, boxWith, DOMContext, } from "svelte-toolbelt";
|
|
2
|
+
import { Context, watch } from "runed";
|
|
3
3
|
import { kbd } from "../../internal/kbd.js";
|
|
4
4
|
import { createBitsAttrs, boolToStr, getDataOpenClosed } from "../../internal/attrs.js";
|
|
5
|
-
import { isElement } from "../../internal/is.js";
|
|
5
|
+
import { isElement, isTouch } from "../../internal/is.js";
|
|
6
6
|
import { PresenceManager } from "../../internal/presence-manager.svelte.js";
|
|
7
|
+
import { SafePolygon } from "../../internal/safe-polygon.svelte.js";
|
|
8
|
+
import { isTabbable } from "tabbable";
|
|
7
9
|
const popoverAttrs = createBitsAttrs({
|
|
8
10
|
component: "popover",
|
|
9
11
|
parts: ["root", "trigger", "content", "close", "overlay"],
|
|
@@ -19,6 +21,12 @@ export class PopoverRootState {
|
|
|
19
21
|
triggerNode = $state(null);
|
|
20
22
|
overlayNode = $state(null);
|
|
21
23
|
overlayPresence;
|
|
24
|
+
// hover tracking state
|
|
25
|
+
openedViaHover = $state(false);
|
|
26
|
+
hasInteractedWithContent = $state(false);
|
|
27
|
+
closeDelay = $state(0);
|
|
28
|
+
#closeTimeout = null;
|
|
29
|
+
#domContext = null;
|
|
22
30
|
constructor(opts) {
|
|
23
31
|
this.opts = opts;
|
|
24
32
|
this.contentPresence = new PresenceManager({
|
|
@@ -32,15 +40,73 @@ export class PopoverRootState {
|
|
|
32
40
|
ref: boxWith(() => this.overlayNode),
|
|
33
41
|
open: this.opts.open,
|
|
34
42
|
});
|
|
43
|
+
watch(() => this.opts.open.current, (isOpen) => {
|
|
44
|
+
if (!isOpen) {
|
|
45
|
+
this.openedViaHover = false;
|
|
46
|
+
this.hasInteractedWithContent = false;
|
|
47
|
+
this.#clearCloseTimeout();
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
setDomContext(ctx) {
|
|
52
|
+
this.#domContext = ctx;
|
|
53
|
+
}
|
|
54
|
+
#clearCloseTimeout() {
|
|
55
|
+
if (this.#closeTimeout !== null && this.#domContext) {
|
|
56
|
+
this.#domContext.clearTimeout(this.#closeTimeout);
|
|
57
|
+
this.#closeTimeout = null;
|
|
58
|
+
}
|
|
35
59
|
}
|
|
36
60
|
toggleOpen() {
|
|
61
|
+
this.#clearCloseTimeout();
|
|
37
62
|
this.opts.open.current = !this.opts.open.current;
|
|
38
63
|
}
|
|
39
64
|
handleClose() {
|
|
65
|
+
this.#clearCloseTimeout();
|
|
40
66
|
if (!this.opts.open.current)
|
|
41
67
|
return;
|
|
42
68
|
this.opts.open.current = false;
|
|
43
69
|
}
|
|
70
|
+
handleHoverOpen() {
|
|
71
|
+
this.#clearCloseTimeout();
|
|
72
|
+
if (this.opts.open.current)
|
|
73
|
+
return;
|
|
74
|
+
this.openedViaHover = true;
|
|
75
|
+
this.opts.open.current = true;
|
|
76
|
+
}
|
|
77
|
+
handleHoverClose() {
|
|
78
|
+
if (!this.opts.open.current)
|
|
79
|
+
return;
|
|
80
|
+
// only close if opened via hover and user hasn't interacted with content
|
|
81
|
+
if (this.openedViaHover && !this.hasInteractedWithContent) {
|
|
82
|
+
this.opts.open.current = false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
handleDelayedHoverClose() {
|
|
86
|
+
if (!this.opts.open.current)
|
|
87
|
+
return;
|
|
88
|
+
if (!this.openedViaHover || this.hasInteractedWithContent)
|
|
89
|
+
return;
|
|
90
|
+
this.#clearCloseTimeout();
|
|
91
|
+
if (this.closeDelay <= 0) {
|
|
92
|
+
this.opts.open.current = false;
|
|
93
|
+
}
|
|
94
|
+
else if (this.#domContext) {
|
|
95
|
+
this.#closeTimeout = this.#domContext.setTimeout(() => {
|
|
96
|
+
if (this.openedViaHover && !this.hasInteractedWithContent) {
|
|
97
|
+
this.opts.open.current = false;
|
|
98
|
+
}
|
|
99
|
+
this.#closeTimeout = null;
|
|
100
|
+
}, this.closeDelay);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
cancelDelayedClose() {
|
|
104
|
+
this.#clearCloseTimeout();
|
|
105
|
+
}
|
|
106
|
+
markInteraction() {
|
|
107
|
+
this.hasInteractedWithContent = true;
|
|
108
|
+
this.#clearCloseTimeout();
|
|
109
|
+
}
|
|
44
110
|
}
|
|
45
111
|
export class PopoverTriggerState {
|
|
46
112
|
static create(opts) {
|
|
@@ -49,18 +115,87 @@ export class PopoverTriggerState {
|
|
|
49
115
|
opts;
|
|
50
116
|
root;
|
|
51
117
|
attachment;
|
|
118
|
+
domContext;
|
|
119
|
+
#openTimeout = null;
|
|
120
|
+
#closeTimeout = null;
|
|
121
|
+
#isHovering = $state(false);
|
|
52
122
|
constructor(opts, root) {
|
|
53
123
|
this.opts = opts;
|
|
54
124
|
this.root = root;
|
|
55
125
|
this.attachment = attachRef(this.opts.ref, (v) => (this.root.triggerNode = v));
|
|
126
|
+
this.domContext = new DOMContext(opts.ref);
|
|
127
|
+
this.root.setDomContext(this.domContext);
|
|
56
128
|
this.onclick = this.onclick.bind(this);
|
|
57
129
|
this.onkeydown = this.onkeydown.bind(this);
|
|
130
|
+
this.onpointerenter = this.onpointerenter.bind(this);
|
|
131
|
+
this.onpointerleave = this.onpointerleave.bind(this);
|
|
132
|
+
watch(() => this.opts.closeDelay.current, (delay) => {
|
|
133
|
+
this.root.closeDelay = delay;
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
#clearOpenTimeout() {
|
|
137
|
+
if (this.#openTimeout !== null) {
|
|
138
|
+
this.domContext.clearTimeout(this.#openTimeout);
|
|
139
|
+
this.#openTimeout = null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
#clearCloseTimeout() {
|
|
143
|
+
if (this.#closeTimeout !== null) {
|
|
144
|
+
this.domContext.clearTimeout(this.#closeTimeout);
|
|
145
|
+
this.#closeTimeout = null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
#clearAllTimeouts() {
|
|
149
|
+
this.#clearOpenTimeout();
|
|
150
|
+
this.#clearCloseTimeout();
|
|
151
|
+
}
|
|
152
|
+
onpointerenter(e) {
|
|
153
|
+
if (this.opts.disabled.current)
|
|
154
|
+
return;
|
|
155
|
+
if (!this.opts.openOnHover.current)
|
|
156
|
+
return;
|
|
157
|
+
if (isTouch(e))
|
|
158
|
+
return;
|
|
159
|
+
this.#isHovering = true;
|
|
160
|
+
this.#clearCloseTimeout();
|
|
161
|
+
this.root.cancelDelayedClose();
|
|
162
|
+
if (this.root.opts.open.current)
|
|
163
|
+
return;
|
|
164
|
+
const delay = this.opts.openDelay.current;
|
|
165
|
+
if (delay <= 0) {
|
|
166
|
+
this.root.handleHoverOpen();
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
this.#openTimeout = this.domContext.setTimeout(() => {
|
|
170
|
+
this.root.handleHoverOpen();
|
|
171
|
+
this.#openTimeout = null;
|
|
172
|
+
}, delay);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
onpointerleave(e) {
|
|
176
|
+
if (this.opts.disabled.current)
|
|
177
|
+
return;
|
|
178
|
+
if (!this.opts.openOnHover.current)
|
|
179
|
+
return;
|
|
180
|
+
if (isTouch(e))
|
|
181
|
+
return;
|
|
182
|
+
this.#isHovering = false;
|
|
183
|
+
this.#clearOpenTimeout();
|
|
184
|
+
// let GraceArea handle the close - it will call handleHoverClose via onPointerExit
|
|
185
|
+
// we just need to stop any pending open timer
|
|
58
186
|
}
|
|
59
187
|
onclick(e) {
|
|
60
188
|
if (this.opts.disabled.current)
|
|
61
189
|
return;
|
|
62
190
|
if (e.button !== 0)
|
|
63
191
|
return;
|
|
192
|
+
this.#clearAllTimeouts();
|
|
193
|
+
// if clicked while hovering and popover is open, convert to click-based open
|
|
194
|
+
if (this.#isHovering && this.root.opts.open.current && this.root.openedViaHover) {
|
|
195
|
+
this.root.openedViaHover = false;
|
|
196
|
+
this.root.hasInteractedWithContent = true;
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
64
199
|
this.root.toggleOpen();
|
|
65
200
|
}
|
|
66
201
|
onkeydown(e) {
|
|
@@ -69,13 +204,13 @@ export class PopoverTriggerState {
|
|
|
69
204
|
if (!(e.key === kbd.ENTER || e.key === kbd.SPACE))
|
|
70
205
|
return;
|
|
71
206
|
e.preventDefault();
|
|
207
|
+
this.#clearAllTimeouts();
|
|
72
208
|
this.root.toggleOpen();
|
|
73
209
|
}
|
|
74
210
|
#getAriaControls() {
|
|
75
211
|
if (this.root.opts.open.current && this.root.contentNode?.id) {
|
|
76
212
|
return this.root.contentNode?.id;
|
|
77
213
|
}
|
|
78
|
-
return undefined;
|
|
79
214
|
}
|
|
80
215
|
props = $derived.by(() => ({
|
|
81
216
|
id: this.opts.id.current,
|
|
@@ -88,6 +223,8 @@ export class PopoverTriggerState {
|
|
|
88
223
|
//
|
|
89
224
|
onkeydown: this.onkeydown,
|
|
90
225
|
onclick: this.onclick,
|
|
226
|
+
onpointerenter: this.onpointerenter,
|
|
227
|
+
onpointerleave: this.onpointerleave,
|
|
91
228
|
...this.attachment,
|
|
92
229
|
}));
|
|
93
230
|
}
|
|
@@ -102,6 +239,39 @@ export class PopoverContentState {
|
|
|
102
239
|
this.opts = opts;
|
|
103
240
|
this.root = root;
|
|
104
241
|
this.attachment = attachRef(this.opts.ref, (v) => (this.root.contentNode = v));
|
|
242
|
+
this.onpointerdown = this.onpointerdown.bind(this);
|
|
243
|
+
this.onfocusin = this.onfocusin.bind(this);
|
|
244
|
+
this.onpointerenter = this.onpointerenter.bind(this);
|
|
245
|
+
this.onpointerleave = this.onpointerleave.bind(this);
|
|
246
|
+
new SafePolygon({
|
|
247
|
+
triggerNode: () => this.root.triggerNode,
|
|
248
|
+
contentNode: () => this.root.contentNode,
|
|
249
|
+
enabled: () => this.root.opts.open.current &&
|
|
250
|
+
this.root.openedViaHover &&
|
|
251
|
+
!this.root.hasInteractedWithContent,
|
|
252
|
+
onPointerExit: () => {
|
|
253
|
+
this.root.handleDelayedHoverClose();
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
onpointerdown(_) {
|
|
258
|
+
this.root.markInteraction();
|
|
259
|
+
}
|
|
260
|
+
onfocusin(e) {
|
|
261
|
+
const target = e.target;
|
|
262
|
+
if (isElement(target) && isTabbable(target)) {
|
|
263
|
+
this.root.markInteraction();
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
onpointerenter(e) {
|
|
267
|
+
if (isTouch(e))
|
|
268
|
+
return;
|
|
269
|
+
this.root.cancelDelayedClose();
|
|
270
|
+
}
|
|
271
|
+
onpointerleave(e) {
|
|
272
|
+
if (isTouch(e))
|
|
273
|
+
return;
|
|
274
|
+
// handled by grace area
|
|
105
275
|
}
|
|
106
276
|
onInteractOutside = (e) => {
|
|
107
277
|
this.opts.onInteractOutside.current(e);
|
|
@@ -134,6 +304,11 @@ export class PopoverContentState {
|
|
|
134
304
|
get shouldRender() {
|
|
135
305
|
return this.root.contentPresence.shouldRender;
|
|
136
306
|
}
|
|
307
|
+
get shouldTrapFocus() {
|
|
308
|
+
if (this.root.openedViaHover && !this.root.hasInteractedWithContent)
|
|
309
|
+
return false;
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
137
312
|
snippetProps = $derived.by(() => ({ open: this.root.opts.open.current }));
|
|
138
313
|
props = $derived.by(() => ({
|
|
139
314
|
id: this.opts.id.current,
|
|
@@ -143,6 +318,10 @@ export class PopoverContentState {
|
|
|
143
318
|
style: {
|
|
144
319
|
pointerEvents: "auto",
|
|
145
320
|
},
|
|
321
|
+
onpointerdown: this.onpointerdown,
|
|
322
|
+
onfocusin: this.onfocusin,
|
|
323
|
+
onpointerenter: this.onpointerenter,
|
|
324
|
+
onpointerleave: this.onpointerleave,
|
|
146
325
|
...this.attachment,
|
|
147
326
|
}));
|
|
148
327
|
popperProps = {
|
|
@@ -24,7 +24,25 @@ export type PopoverContentPropsWithoutHTML = WithChildNoChildrenSnippetProps<Omi
|
|
|
24
24
|
export type PopoverContentProps = PopoverContentPropsWithoutHTML & Without<BitsPrimitiveDivAttributes, PopoverContentPropsWithoutHTML>;
|
|
25
25
|
export type PopoverContentStaticPropsWithoutHTML = WithChildNoChildrenSnippetProps<Omit<PopperLayerStaticProps, "content" | "loop">, StaticContentSnippetProps>;
|
|
26
26
|
export type PopoverContentStaticProps = PopoverContentStaticPropsWithoutHTML & Without<BitsPrimitiveDivAttributes, PopoverContentStaticPropsWithoutHTML>;
|
|
27
|
-
export type PopoverTriggerPropsWithoutHTML = WithChild
|
|
27
|
+
export type PopoverTriggerPropsWithoutHTML = WithChild<{
|
|
28
|
+
/**
|
|
29
|
+
* Whether the popover should open when the trigger is hovered.
|
|
30
|
+
* @default false
|
|
31
|
+
*/
|
|
32
|
+
openOnHover?: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* How long to wait before opening the popover on hover (ms).
|
|
35
|
+
* Only applies when `openOnHover` is `true`.
|
|
36
|
+
* @default 700
|
|
37
|
+
*/
|
|
38
|
+
openDelay?: number;
|
|
39
|
+
/**
|
|
40
|
+
* How long to wait before closing the popover after hover ends (ms).
|
|
41
|
+
* Only applies when `openOnHover` is `true`.
|
|
42
|
+
* @default 300
|
|
43
|
+
*/
|
|
44
|
+
closeDelay?: number;
|
|
45
|
+
}>;
|
|
28
46
|
export type PopoverTriggerProps = PopoverTriggerPropsWithoutHTML & Without<BitsPrimitiveButtonAttributes, PopoverTriggerPropsWithoutHTML>;
|
|
29
47
|
export type PopoverClosePropsWithoutHTML = WithChild;
|
|
30
48
|
export type PopoverCloseProps = PopoverClosePropsWithoutHTML & Without<BitsPrimitiveButtonAttributes, PopoverClosePropsWithoutHTML>;
|
|
@@ -348,7 +348,7 @@ export declare class FloatingContentState {
|
|
|
348
348
|
readonly perspective?: import("csstype").Property.Perspective<0 | (string & {})> | undefined;
|
|
349
349
|
readonly perspectiveOrigin?: import("csstype").Property.PerspectiveOrigin<0 | (string & {})> | undefined;
|
|
350
350
|
readonly pointerEvents?: import("csstype").Property.PointerEvents | undefined;
|
|
351
|
-
position: "relative" | "absolute" | "
|
|
351
|
+
position: "relative" | "absolute" | "fixed" | "sticky" | "-moz-initial" | "inherit" | "initial" | "revert" | "revert-layer" | "unset" | "-webkit-sticky" | "static";
|
|
352
352
|
readonly printColorAdjust?: import("csstype").Property.PrintColorAdjust | undefined;
|
|
353
353
|
readonly quotes?: import("csstype").Property.Quotes | undefined;
|
|
354
354
|
readonly resize?: import("csstype").Property.Resize | undefined;
|
|
@@ -901,10 +901,10 @@ export declare class FloatingContentState {
|
|
|
901
901
|
readonly vectorEffect?: import("csstype").Property.VectorEffect | undefined;
|
|
902
902
|
readonly "pointer-events"?: string | undefined;
|
|
903
903
|
readonly "--bits-floating-transform-origin": `${any} ${any}`;
|
|
904
|
-
readonly "--bits-floating-available-width": `${number}px
|
|
905
|
-
readonly "--bits-floating-available-height": `${number}px
|
|
906
|
-
readonly "--bits-floating-anchor-width": `${number}px
|
|
907
|
-
readonly "--bits-floating-anchor-height": `${number}px
|
|
904
|
+
readonly "--bits-floating-available-width": "undefinedpx" | `${number}px`;
|
|
905
|
+
readonly "--bits-floating-available-height": "undefinedpx" | `${number}px`;
|
|
906
|
+
readonly "--bits-floating-anchor-width": "undefinedpx" | `${number}px`;
|
|
907
|
+
readonly "--bits-floating-anchor-height": "undefinedpx" | `${number}px`;
|
|
908
908
|
};
|
|
909
909
|
readonly dir: Direction;
|
|
910
910
|
};
|
|
@@ -88,7 +88,11 @@ const bodyLockStackCount = new SharedState(() => {
|
|
|
88
88
|
ensureInitialStyleCaptured();
|
|
89
89
|
// if we're applying lock styles, we're no longer in a cleanup transition
|
|
90
90
|
isInCleanupTransition = false;
|
|
91
|
+
const htmlStyle = getComputedStyle(document.documentElement);
|
|
91
92
|
const bodyStyle = getComputedStyle(document.body);
|
|
93
|
+
// check if scrollbar-gutter: stable is already handling scrollbar space
|
|
94
|
+
const hasStableGutter = htmlStyle.scrollbarGutter?.includes("stable") ||
|
|
95
|
+
bodyStyle.scrollbarGutter?.includes("stable");
|
|
92
96
|
// TODO: account for RTL direction, etc.
|
|
93
97
|
const verticalScrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
|
|
94
98
|
const paddingRight = Number.parseInt(bodyStyle.paddingRight ?? "0", 10);
|
|
@@ -96,12 +100,13 @@ const bodyLockStackCount = new SharedState(() => {
|
|
|
96
100
|
padding: paddingRight + verticalScrollbarWidth,
|
|
97
101
|
margin: Number.parseInt(bodyStyle.marginRight ?? "0", 10),
|
|
98
102
|
};
|
|
99
|
-
if
|
|
103
|
+
// only add padding compensation if stable gutter isn't handling it
|
|
104
|
+
if (verticalScrollbarWidth > 0 && !hasStableGutter) {
|
|
100
105
|
document.body.style.paddingRight = `${config.padding}px`;
|
|
101
106
|
document.body.style.marginRight = `${config.margin}px`;
|
|
102
107
|
document.body.style.setProperty("--scrollbar-width", `${verticalScrollbarWidth}px`);
|
|
103
|
-
document.body.style.overflow = "hidden";
|
|
104
108
|
}
|
|
109
|
+
document.body.style.overflow = "hidden";
|
|
105
110
|
if (isIOS) {
|
|
106
111
|
// IOS devices are special and require a touchmove listener to prevent scrolling
|
|
107
112
|
stopTouchMoveListener = on(document, "touchmove", (e) => {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type Getter } from "svelte-toolbelt";
|
|
2
|
+
export interface SafePolygonOptions {
|
|
3
|
+
enabled: Getter<boolean>;
|
|
4
|
+
triggerNode: Getter<HTMLElement | null>;
|
|
5
|
+
contentNode: Getter<HTMLElement | null>;
|
|
6
|
+
onPointerExit: () => void;
|
|
7
|
+
buffer?: number;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Creates a safe polygon area that allows users to move their cursor between
|
|
11
|
+
* the trigger and floating content without closing it.
|
|
12
|
+
*/
|
|
13
|
+
export declare class SafePolygon {
|
|
14
|
+
#private;
|
|
15
|
+
constructor(opts: SafePolygonOptions);
|
|
16
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { getDocument } from "svelte-toolbelt";
|
|
2
|
+
import { on } from "svelte/events";
|
|
3
|
+
import { watch } from "runed";
|
|
4
|
+
import { isElement } from "./is.js";
|
|
5
|
+
function isPointInPolygon(point, polygon) {
|
|
6
|
+
const [x, y] = point;
|
|
7
|
+
let isInside = false;
|
|
8
|
+
const length = polygon.length;
|
|
9
|
+
for (let i = 0, j = length - 1; i < length; j = i++) {
|
|
10
|
+
const [xi, yi] = polygon[i] ?? [0, 0];
|
|
11
|
+
const [xj, yj] = polygon[j] ?? [0, 0];
|
|
12
|
+
const intersect = yi >= y !== yj >= y && x <= ((xj - xi) * (y - yi)) / (yj - yi) + xi;
|
|
13
|
+
if (intersect) {
|
|
14
|
+
isInside = !isInside;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return isInside;
|
|
18
|
+
}
|
|
19
|
+
function isInsideRect(point, rect) {
|
|
20
|
+
return (point[0] >= rect.left &&
|
|
21
|
+
point[0] <= rect.right &&
|
|
22
|
+
point[1] >= rect.top &&
|
|
23
|
+
point[1] <= rect.bottom);
|
|
24
|
+
}
|
|
25
|
+
function getSide(triggerRect, contentRect) {
|
|
26
|
+
// determine which side the content is on relative to trigger
|
|
27
|
+
const triggerCenterX = triggerRect.left + triggerRect.width / 2;
|
|
28
|
+
const triggerCenterY = triggerRect.top + triggerRect.height / 2;
|
|
29
|
+
const contentCenterX = contentRect.left + contentRect.width / 2;
|
|
30
|
+
const contentCenterY = contentRect.top + contentRect.height / 2;
|
|
31
|
+
const deltaX = contentCenterX - triggerCenterX;
|
|
32
|
+
const deltaY = contentCenterY - triggerCenterY;
|
|
33
|
+
if (Math.abs(deltaX) > Math.abs(deltaY)) {
|
|
34
|
+
return deltaX > 0 ? "right" : "left";
|
|
35
|
+
}
|
|
36
|
+
return deltaY > 0 ? "bottom" : "top";
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Creates a safe polygon area that allows users to move their cursor between
|
|
40
|
+
* the trigger and floating content without closing it.
|
|
41
|
+
*/
|
|
42
|
+
export class SafePolygon {
|
|
43
|
+
#opts;
|
|
44
|
+
#buffer;
|
|
45
|
+
// tracks the cursor position when leaving trigger or content
|
|
46
|
+
#exitPoint = null;
|
|
47
|
+
// tracks what we're moving toward: "content" when leaving trigger, "trigger" when leaving content
|
|
48
|
+
#exitTarget = null;
|
|
49
|
+
constructor(opts) {
|
|
50
|
+
this.#opts = opts;
|
|
51
|
+
this.#buffer = opts.buffer ?? 1;
|
|
52
|
+
watch([opts.triggerNode, opts.contentNode, opts.enabled], ([triggerNode, contentNode, enabled]) => {
|
|
53
|
+
if (!triggerNode || !contentNode || !enabled) {
|
|
54
|
+
this.#exitPoint = null;
|
|
55
|
+
this.#exitTarget = null;
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const doc = getDocument(triggerNode);
|
|
59
|
+
const handlePointerMove = (e) => {
|
|
60
|
+
this.#onPointerMove(e, triggerNode, contentNode);
|
|
61
|
+
};
|
|
62
|
+
const handleTriggerLeave = (e) => {
|
|
63
|
+
// when leaving trigger toward content, record exit point
|
|
64
|
+
const target = e.relatedTarget;
|
|
65
|
+
// if going directly to content, no need for polygon tracking
|
|
66
|
+
if (isElement(target) && contentNode.contains(target)) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
this.#exitPoint = [e.clientX, e.clientY];
|
|
70
|
+
this.#exitTarget = "content";
|
|
71
|
+
};
|
|
72
|
+
const handleTriggerEnter = () => {
|
|
73
|
+
// reached trigger, clear tracking
|
|
74
|
+
this.#exitPoint = null;
|
|
75
|
+
this.#exitTarget = null;
|
|
76
|
+
};
|
|
77
|
+
const handleContentEnter = () => {
|
|
78
|
+
// reached content, clear tracking
|
|
79
|
+
this.#exitPoint = null;
|
|
80
|
+
this.#exitTarget = null;
|
|
81
|
+
};
|
|
82
|
+
const handleContentLeave = (e) => {
|
|
83
|
+
// when leaving content, check if going directly back to trigger
|
|
84
|
+
const target = e.relatedTarget;
|
|
85
|
+
if (isElement(target) && triggerNode.contains(target)) {
|
|
86
|
+
// going directly to trigger, no polygon tracking needed
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
// might be traversing gap back to trigger, set up polygon tracking
|
|
90
|
+
this.#exitPoint = [e.clientX, e.clientY];
|
|
91
|
+
this.#exitTarget = "trigger";
|
|
92
|
+
};
|
|
93
|
+
return [
|
|
94
|
+
on(doc, "pointermove", handlePointerMove),
|
|
95
|
+
on(triggerNode, "pointerleave", handleTriggerLeave),
|
|
96
|
+
on(triggerNode, "pointerenter", handleTriggerEnter),
|
|
97
|
+
on(contentNode, "pointerenter", handleContentEnter),
|
|
98
|
+
on(contentNode, "pointerleave", handleContentLeave),
|
|
99
|
+
].reduce((acc, cleanup) => () => {
|
|
100
|
+
acc();
|
|
101
|
+
cleanup();
|
|
102
|
+
}, () => { });
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
#onPointerMove(e, triggerNode, contentNode) {
|
|
106
|
+
// if no exit point recorded, nothing to check
|
|
107
|
+
if (!this.#exitPoint || !this.#exitTarget)
|
|
108
|
+
return;
|
|
109
|
+
const clientPoint = [e.clientX, e.clientY];
|
|
110
|
+
const triggerRect = triggerNode.getBoundingClientRect();
|
|
111
|
+
const contentRect = contentNode.getBoundingClientRect();
|
|
112
|
+
// check if pointer reached the target
|
|
113
|
+
if (this.#exitTarget === "content" && isInsideRect(clientPoint, contentRect)) {
|
|
114
|
+
this.#exitPoint = null;
|
|
115
|
+
this.#exitTarget = null;
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (this.#exitTarget === "trigger" && isInsideRect(clientPoint, triggerRect)) {
|
|
119
|
+
this.#exitPoint = null;
|
|
120
|
+
this.#exitTarget = null;
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
// check if pointer is in the rectangular corridor between trigger and content
|
|
124
|
+
const side = getSide(triggerRect, contentRect);
|
|
125
|
+
const corridorPoly = this.#getCorridorPolygon(triggerRect, contentRect, side);
|
|
126
|
+
if (corridorPoly && isPointInPolygon(clientPoint, corridorPoly)) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
// check if pointer is within the safe polygon from exit point to target
|
|
130
|
+
const targetRect = this.#exitTarget === "content" ? contentRect : triggerRect;
|
|
131
|
+
const safePoly = this.#getSafePolygon(this.#exitPoint, targetRect, side, this.#exitTarget);
|
|
132
|
+
if (isPointInPolygon(clientPoint, safePoly)) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
// pointer is outside all safe zones - close
|
|
136
|
+
this.#exitPoint = null;
|
|
137
|
+
this.#exitTarget = null;
|
|
138
|
+
this.#opts.onPointerExit();
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Creates a rectangular corridor between trigger and content
|
|
142
|
+
* This prevents closing when cursor is in the gap between them
|
|
143
|
+
*/
|
|
144
|
+
#getCorridorPolygon(triggerRect, contentRect, side) {
|
|
145
|
+
const buffer = this.#buffer;
|
|
146
|
+
switch (side) {
|
|
147
|
+
case "top":
|
|
148
|
+
return [
|
|
149
|
+
[Math.min(triggerRect.left, contentRect.left) - buffer, triggerRect.top],
|
|
150
|
+
[Math.min(triggerRect.left, contentRect.left) - buffer, contentRect.bottom],
|
|
151
|
+
[Math.max(triggerRect.right, contentRect.right) + buffer, contentRect.bottom],
|
|
152
|
+
[Math.max(triggerRect.right, contentRect.right) + buffer, triggerRect.top],
|
|
153
|
+
];
|
|
154
|
+
case "bottom":
|
|
155
|
+
return [
|
|
156
|
+
[Math.min(triggerRect.left, contentRect.left) - buffer, triggerRect.bottom],
|
|
157
|
+
[Math.min(triggerRect.left, contentRect.left) - buffer, contentRect.top],
|
|
158
|
+
[Math.max(triggerRect.right, contentRect.right) + buffer, contentRect.top],
|
|
159
|
+
[Math.max(triggerRect.right, contentRect.right) + buffer, triggerRect.bottom],
|
|
160
|
+
];
|
|
161
|
+
case "left":
|
|
162
|
+
return [
|
|
163
|
+
[triggerRect.left, Math.min(triggerRect.top, contentRect.top) - buffer],
|
|
164
|
+
[contentRect.right, Math.min(triggerRect.top, contentRect.top) - buffer],
|
|
165
|
+
[contentRect.right, Math.max(triggerRect.bottom, contentRect.bottom) + buffer],
|
|
166
|
+
[triggerRect.left, Math.max(triggerRect.bottom, contentRect.bottom) + buffer],
|
|
167
|
+
];
|
|
168
|
+
case "right":
|
|
169
|
+
return [
|
|
170
|
+
[triggerRect.right, Math.min(triggerRect.top, contentRect.top) - buffer],
|
|
171
|
+
[contentRect.left, Math.min(triggerRect.top, contentRect.top) - buffer],
|
|
172
|
+
[contentRect.left, Math.max(triggerRect.bottom, contentRect.bottom) + buffer],
|
|
173
|
+
[triggerRect.right, Math.max(triggerRect.bottom, contentRect.bottom) + buffer],
|
|
174
|
+
];
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Creates a triangular/trapezoidal safe zone from the exit point to the target
|
|
179
|
+
*/
|
|
180
|
+
#getSafePolygon(exitPoint, targetRect, side, exitTarget) {
|
|
181
|
+
const buffer = this.#buffer * 4;
|
|
182
|
+
const [x, y] = exitPoint;
|
|
183
|
+
// when going back to trigger, we need to flip the side
|
|
184
|
+
const effectiveSide = exitTarget === "trigger" ? this.#flipSide(side) : side;
|
|
185
|
+
// create polygon points from cursor to target edges
|
|
186
|
+
switch (effectiveSide) {
|
|
187
|
+
case "top":
|
|
188
|
+
return [
|
|
189
|
+
[x - buffer, y + buffer],
|
|
190
|
+
[x + buffer, y + buffer],
|
|
191
|
+
[targetRect.right + buffer, targetRect.bottom],
|
|
192
|
+
[targetRect.right + buffer, targetRect.top],
|
|
193
|
+
[targetRect.left - buffer, targetRect.top],
|
|
194
|
+
[targetRect.left - buffer, targetRect.bottom],
|
|
195
|
+
];
|
|
196
|
+
case "bottom":
|
|
197
|
+
return [
|
|
198
|
+
[x - buffer, y - buffer],
|
|
199
|
+
[x + buffer, y - buffer],
|
|
200
|
+
[targetRect.right + buffer, targetRect.top],
|
|
201
|
+
[targetRect.right + buffer, targetRect.bottom],
|
|
202
|
+
[targetRect.left - buffer, targetRect.bottom],
|
|
203
|
+
[targetRect.left - buffer, targetRect.top],
|
|
204
|
+
];
|
|
205
|
+
case "left":
|
|
206
|
+
return [
|
|
207
|
+
[x + buffer, y - buffer],
|
|
208
|
+
[x + buffer, y + buffer],
|
|
209
|
+
[targetRect.right, targetRect.bottom + buffer],
|
|
210
|
+
[targetRect.left, targetRect.bottom + buffer],
|
|
211
|
+
[targetRect.left, targetRect.top - buffer],
|
|
212
|
+
[targetRect.right, targetRect.top - buffer],
|
|
213
|
+
];
|
|
214
|
+
case "right":
|
|
215
|
+
return [
|
|
216
|
+
[x - buffer, y - buffer],
|
|
217
|
+
[x - buffer, y + buffer],
|
|
218
|
+
[targetRect.left, targetRect.bottom + buffer],
|
|
219
|
+
[targetRect.right, targetRect.bottom + buffer],
|
|
220
|
+
[targetRect.right, targetRect.top - buffer],
|
|
221
|
+
[targetRect.left, targetRect.top - buffer],
|
|
222
|
+
];
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
#flipSide(side) {
|
|
226
|
+
switch (side) {
|
|
227
|
+
case "top":
|
|
228
|
+
return "bottom";
|
|
229
|
+
case "bottom":
|
|
230
|
+
return "top";
|
|
231
|
+
case "left":
|
|
232
|
+
return "right";
|
|
233
|
+
case "right":
|
|
234
|
+
return "left";
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|