Skip to content

Commit 024cd65

Browse files
author
Fabien MARIE-LOUISE
committed
feat(interactions): add createInteractOutside primitive
1 parent 90c7e39 commit 024cd65

File tree

3 files changed

+372
-0
lines changed

3 files changed

+372
-0
lines changed
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { access, MaybeAccessor } from "@solid-primitives/utils";
2+
import { Accessor, createEffect, createSignal, onCleanup } from "solid-js";
3+
4+
interface CreateInteractOutsideProps {
5+
/**
6+
* Whether the interact outside events should be disabled.
7+
*/
8+
isDisabled?: MaybeAccessor<boolean | undefined>;
9+
10+
/**
11+
* Handler that is called when an interaction outside of the `ref` element start.
12+
*/
13+
onInteractOutsideStart?: (e: Event) => void;
14+
15+
/**
16+
* Handler that is called when interaction outside of the `ref` element end.
17+
*/
18+
onInteractOutside?: (e: Event) => void;
19+
}
20+
21+
/**
22+
* Handles interaction outside a given element.
23+
* Used in components like Dialogs and Popovers so they can close
24+
* when a user clicks outside them.
25+
* @param props - Props for the interact outside primitive.
26+
* @param ref - A ref for the HTML element.
27+
*/
28+
export function createInteractOutside(
29+
props: CreateInteractOutsideProps,
30+
ref: Accessor<Element | undefined>
31+
) {
32+
const [isPointerDown, setIsPointerDown] = createSignal(false);
33+
const [ignoreEmulatedMouseEvents, setIgnoreEmulatedMouseEvents] = createSignal(false);
34+
35+
createEffect(() => {
36+
if (access(props.isDisabled)) {
37+
return;
38+
}
39+
40+
// Same handler logic used for pointer, mouse and touch down/start events.
41+
const onPointerDown = (e: PointerEvent | MouseEvent | TouchEvent) => {
42+
if (isValidEvent(e, ref())) {
43+
props.onInteractOutsideStart?.(e);
44+
setIsPointerDown(true);
45+
}
46+
};
47+
48+
// Use pointer events if available. Otherwise, fall back to mouse and touch events.
49+
if (typeof PointerEvent !== "undefined") {
50+
const onPointerUp = (e: PointerEvent) => {
51+
if (isPointerDown() && isValidEvent(e, ref())) {
52+
setIsPointerDown(false);
53+
props.onInteractOutside?.(e);
54+
}
55+
};
56+
57+
// changing these to capture phase fixed combobox
58+
document.addEventListener("pointerdown", onPointerDown, true);
59+
document.addEventListener("pointerup", onPointerUp, true);
60+
61+
onCleanup(() => {
62+
document.removeEventListener("pointerdown", onPointerDown, true);
63+
document.removeEventListener("pointerup", onPointerUp, true);
64+
});
65+
} else {
66+
const onMouseUp = (e: MouseEvent) => {
67+
if (ignoreEmulatedMouseEvents()) {
68+
setIgnoreEmulatedMouseEvents(false);
69+
} else if (isPointerDown() && isValidEvent(e, ref())) {
70+
setIsPointerDown(false);
71+
props.onInteractOutside?.(e);
72+
}
73+
};
74+
75+
const onTouchEnd = (e: TouchEvent) => {
76+
setIgnoreEmulatedMouseEvents(true);
77+
78+
if (isPointerDown() && isValidEvent(e, ref())) {
79+
setIsPointerDown(false);
80+
props.onInteractOutside?.(e);
81+
}
82+
};
83+
84+
document.addEventListener("mousedown", onPointerDown, true);
85+
document.addEventListener("mouseup", onMouseUp, true);
86+
document.addEventListener("touchstart", onPointerDown, true);
87+
document.addEventListener("touchend", onTouchEnd, true);
88+
89+
onCleanup(() => {
90+
document.removeEventListener("mousedown", onPointerDown, true);
91+
document.removeEventListener("mouseup", onMouseUp, true);
92+
document.removeEventListener("touchstart", onPointerDown, true);
93+
document.removeEventListener("touchend", onTouchEnd, true);
94+
});
95+
}
96+
});
97+
}
98+
99+
/**
100+
* Returns whether the event is a valid interact outside event
101+
* (e.g. the event target is outside the `ref` element).
102+
*/
103+
function isValidEvent(event: any, ref: Element | undefined) {
104+
if (event.button > 0) {
105+
return false;
106+
}
107+
108+
// if the event target is no longer in the document
109+
if (event.target) {
110+
const ownerDocument = event.target.ownerDocument;
111+
112+
if (!ownerDocument || !ownerDocument.documentElement.contains(event.target)) {
113+
return false;
114+
}
115+
}
116+
117+
return ref && !ref.contains(event.target);
118+
}

packages/interactions/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export * from "./createFocus";
22
export * from "./createFocusVisible";
33
export * from "./createFocusWithin";
44
export * from "./createHover";
5+
export * from "./createInteractOutside";
56
export * from "./createKeyboard";
67
export * from "./createPress";
78
export * from "./textSelection";
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
import { fireEvent, render, screen } from "solid-testing-library";
2+
3+
import { createInteractOutside } from "../src";
4+
import { installPointerEvent } from "../src/test-utils";
5+
6+
function Example(props: any) {
7+
let ref: any;
8+
9+
createInteractOutside(props, () => ref);
10+
11+
return <div ref={ref}>test</div>;
12+
}
13+
14+
function pointerEvent(type: any, opts?: any) {
15+
const evt = new Event(type, { bubbles: true, cancelable: true });
16+
Object.assign(evt, opts);
17+
return evt;
18+
}
19+
20+
describe("createInteractOutside", () => {
21+
// TODO: JSDOM doesn't yet support pointer events. Once they do, convert these tests.
22+
// https://github.com/jsdom/jsdom/issues/2527
23+
describe("pointer events", () => {
24+
installPointerEvent();
25+
26+
it("should fire interact outside events based on pointer events", async () => {
27+
const onInteractOutside = jest.fn();
28+
render(() => <Example onInteractOutside={onInteractOutside} />);
29+
30+
const el = screen.getByText("test");
31+
32+
fireEvent(el, pointerEvent("pointerdown"));
33+
await Promise.resolve();
34+
35+
fireEvent(el, pointerEvent("pointerup"));
36+
await Promise.resolve();
37+
38+
expect(onInteractOutside).not.toHaveBeenCalled();
39+
40+
fireEvent(document.body, pointerEvent("pointerdown"));
41+
await Promise.resolve();
42+
43+
fireEvent(document.body, pointerEvent("pointerup"));
44+
await Promise.resolve();
45+
46+
expect(onInteractOutside).toHaveBeenCalledTimes(1);
47+
});
48+
49+
it("should only listen for the left mouse button", async () => {
50+
const onInteractOutside = jest.fn();
51+
render(() => <Example onInteractOutside={onInteractOutside} />);
52+
53+
fireEvent(document.body, pointerEvent("pointerdown", { button: 1 }));
54+
await Promise.resolve();
55+
56+
fireEvent(document.body, pointerEvent("pointerup", { button: 1 }));
57+
await Promise.resolve();
58+
59+
expect(onInteractOutside).not.toHaveBeenCalled();
60+
61+
fireEvent(document.body, pointerEvent("pointerdown", { button: 0 }));
62+
await Promise.resolve();
63+
64+
fireEvent(document.body, pointerEvent("pointerup", { button: 0 }));
65+
await Promise.resolve();
66+
67+
expect(onInteractOutside).toHaveBeenCalledTimes(1);
68+
});
69+
70+
it("should not fire interact outside if there is a pointer up event without a pointer down first", async () => {
71+
// Fire pointer down before component with useInteractOutside is mounted
72+
fireEvent(document.body, pointerEvent("pointerdown"));
73+
await Promise.resolve();
74+
75+
const onInteractOutside = jest.fn();
76+
render(() => <Example onInteractOutside={onInteractOutside} />);
77+
78+
fireEvent(document.body, pointerEvent("pointerup"));
79+
await Promise.resolve();
80+
81+
expect(onInteractOutside).not.toHaveBeenCalled();
82+
});
83+
});
84+
85+
describe("mouse events", () => {
86+
it("should fire interact outside events based on mouse events", async () => {
87+
const onInteractOutside = jest.fn();
88+
render(() => <Example onInteractOutside={onInteractOutside} />);
89+
90+
const el = screen.getByText("test");
91+
92+
fireEvent.mouseDown(el);
93+
await Promise.resolve();
94+
95+
fireEvent.mouseUp(el);
96+
await Promise.resolve();
97+
98+
expect(onInteractOutside).not.toHaveBeenCalled();
99+
100+
fireEvent.mouseDown(document.body);
101+
await Promise.resolve();
102+
103+
fireEvent.mouseUp(document.body);
104+
await Promise.resolve();
105+
106+
expect(onInteractOutside).toHaveBeenCalledTimes(1);
107+
});
108+
109+
it("should only listen for the left mouse button", async () => {
110+
const onInteractOutside = jest.fn();
111+
render(() => <Example onInteractOutside={onInteractOutside} />);
112+
113+
fireEvent.mouseDown(document.body, { button: 1 });
114+
await Promise.resolve();
115+
116+
fireEvent.mouseUp(document.body, { button: 1 });
117+
await Promise.resolve();
118+
119+
expect(onInteractOutside).not.toHaveBeenCalled();
120+
121+
fireEvent.mouseDown(document.body, { button: 0 });
122+
await Promise.resolve();
123+
124+
fireEvent.mouseUp(document.body, { button: 0 });
125+
await Promise.resolve();
126+
127+
expect(onInteractOutside).toHaveBeenCalledTimes(1);
128+
});
129+
130+
it("should not fire interact outside if there is a mouse up event without a mouse down first", async () => {
131+
// Fire mouse down before component with useInteractOutside is mounted
132+
fireEvent.mouseDown(document.body);
133+
await Promise.resolve();
134+
135+
const onInteractOutside = jest.fn();
136+
render(() => <Example onInteractOutside={onInteractOutside} />);
137+
138+
fireEvent.mouseUp(document.body);
139+
await Promise.resolve();
140+
141+
expect(onInteractOutside).not.toHaveBeenCalled();
142+
});
143+
});
144+
145+
describe("touch events", () => {
146+
it("should fire interact outside events based on mouse events", async () => {
147+
const onInteractOutside = jest.fn();
148+
render(() => <Example onInteractOutside={onInteractOutside} />);
149+
150+
const el = screen.getByText("test");
151+
152+
fireEvent.touchStart(el);
153+
await Promise.resolve();
154+
155+
fireEvent.touchEnd(el);
156+
await Promise.resolve();
157+
158+
expect(onInteractOutside).not.toHaveBeenCalled();
159+
160+
fireEvent.touchStart(document.body);
161+
await Promise.resolve();
162+
163+
fireEvent.touchEnd(document.body);
164+
await Promise.resolve();
165+
166+
expect(onInteractOutside).toHaveBeenCalledTimes(1);
167+
});
168+
169+
it("should ignore emulated mouse events", async () => {
170+
const onInteractOutside = jest.fn();
171+
render(() => <Example onInteractOutside={onInteractOutside} />);
172+
173+
const el = screen.getByText("test");
174+
175+
fireEvent.touchStart(el);
176+
await Promise.resolve();
177+
178+
fireEvent.touchEnd(el);
179+
await Promise.resolve();
180+
181+
fireEvent.mouseUp(el);
182+
await Promise.resolve();
183+
184+
expect(onInteractOutside).not.toHaveBeenCalled();
185+
186+
fireEvent.touchStart(document.body);
187+
await Promise.resolve();
188+
189+
fireEvent.touchEnd(document.body);
190+
await Promise.resolve();
191+
192+
fireEvent.mouseUp(document.body);
193+
await Promise.resolve();
194+
195+
expect(onInteractOutside).toHaveBeenCalledTimes(1);
196+
});
197+
198+
it("should not fire interact outside if there is a touch end event without a touch start first", async () => {
199+
// Fire mouse down before component with useInteractOutside is mounted
200+
fireEvent.touchStart(document.body);
201+
await Promise.resolve();
202+
203+
const onInteractOutside = jest.fn();
204+
render(() => <Example onInteractOutside={onInteractOutside} />);
205+
206+
fireEvent.touchEnd(document.body);
207+
await Promise.resolve();
208+
209+
expect(onInteractOutside).not.toHaveBeenCalled();
210+
});
211+
});
212+
213+
describe("disable interact outside events", () => {
214+
it("does not handle pointer events if disabled", async () => {
215+
const onInteractOutside = jest.fn();
216+
render(() => <Example isDisabled onInteractOutside={onInteractOutside} />);
217+
218+
fireEvent(document.body, pointerEvent("mousedown"));
219+
await Promise.resolve();
220+
221+
fireEvent(document.body, pointerEvent("mouseup"));
222+
await Promise.resolve();
223+
224+
expect(onInteractOutside).not.toHaveBeenCalled();
225+
});
226+
227+
it("does not handle touch events if disabled", async () => {
228+
const onInteractOutside = jest.fn();
229+
render(() => <Example isDisabled onInteractOutside={onInteractOutside} />);
230+
231+
fireEvent.touchStart(document.body);
232+
await Promise.resolve();
233+
234+
fireEvent.touchEnd(document.body);
235+
await Promise.resolve();
236+
237+
expect(onInteractOutside).not.toHaveBeenCalled();
238+
});
239+
240+
it("does not handle mouse events if disabled", async () => {
241+
const onInteractOutside = jest.fn();
242+
render(() => <Example isDisabled onInteractOutside={onInteractOutside} />);
243+
244+
fireEvent.mouseDown(document.body);
245+
await Promise.resolve();
246+
247+
fireEvent.mouseUp(document.body);
248+
await Promise.resolve();
249+
250+
expect(onInteractOutside).not.toHaveBeenCalled();
251+
});
252+
});
253+
});

0 commit comments

Comments
 (0)