Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions src/lib/holocene/outside-click.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { afterEach, describe, expect, it, vi } from 'vitest';

import { clickoutside } from './outside-click';

const dispatch = (target: Element, type: string, init: MouseEventInit = {}) => {
target.dispatchEvent(
new MouseEvent(type, {
bubbles: true,
cancelable: true,
detail: 1,
...init,
}),
);
};

const getElement = (id: string): HTMLElement => {
const element = document.getElementById(id);
if (!element) throw new Error(`Test fixture is missing an element: #${id}`);
return element;
};

describe('clickoutside', () => {
afterEach(() => {
document.body.innerHTML = '';
});

const setup = async () => {
document.body.innerHTML = `
<div id="container"><input id="inside" /></div>
<div id="outside"></div>
`;

const node = getElement('container');
const handler = vi.fn();
const action = clickoutside(node, handler);

// svelte/events defers attaching pointer listeners to a microtask
await Promise.resolve();

return {
node,
handler,
action,
inside: getElement('inside'),
outside: getElement('outside'),
};
};

it('calls the handler when a click starts and ends outside the node', async () => {
const { handler, outside, action } = await setup();

dispatch(outside, 'pointerdown');
dispatch(outside, 'click');

expect(handler).toHaveBeenCalledTimes(1);
action.destroy();
});

it('does not call the handler when a click starts and ends inside the node', async () => {
const { handler, inside, action } = await setup();

dispatch(inside, 'pointerdown');
dispatch(inside, 'click');

expect(handler).not.toHaveBeenCalled();
action.destroy();
});

it('does not call the handler when a drag starts inside the node and releases outside', async () => {
const { handler, inside, action } = await setup();

dispatch(inside, 'pointerdown');
dispatch(document.body, 'click');

expect(handler).not.toHaveBeenCalled();
action.destroy();
});

it('still closes on the next click that originates outside the node', async () => {
const { handler, inside, outside, action } = await setup();

dispatch(inside, 'pointerdown');
dispatch(document.body, 'click');

dispatch(outside, 'pointerdown');
dispatch(outside, 'click');

expect(handler).toHaveBeenCalledTimes(1);
action.destroy();
});

it('closes on a keyboard-activated click outside even after a press inside', async () => {
const { handler, inside, outside, action } = await setup();

dispatch(inside, 'pointerdown');
dispatch(outside, 'click', { detail: 0 });

expect(handler).toHaveBeenCalledTimes(1);
action.destroy();
});

it('does not let a non-primary press inside suppress a later outside click', async () => {
const { handler, inside, outside, action } = await setup();

dispatch(inside, 'pointerdown', { button: 2 });
dispatch(outside, 'click');

expect(handler).toHaveBeenCalledTimes(1);
action.destroy();
});

it('removes both listeners on destroy', async () => {
const { handler, outside, action } = await setup();

action.destroy();

dispatch(outside, 'pointerdown');
dispatch(outside, 'click');

expect(handler).not.toHaveBeenCalled();
});
});
31 changes: 26 additions & 5 deletions src/lib/holocene/outside-click.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,24 @@
import type { Action } from 'svelte/action';
import { on } from 'svelte/events';

export const clickoutside: Action<Element, (event: MouseEvent) => void> = (
export const clickoutside = ((
node: Element,
handler: (event: MouseEvent) => void,
): { destroy: () => void } => {
) => {
let pointerDownInside = false;

const handlePointerDown = (event: PointerEvent) => {
pointerDownInside =
event.button === 0 &&
Boolean(node?.contains(event.target as HTMLElement));
};

const handleClick = (event: MouseEvent) => {
if (event.detail > 0 && pointerDownInside) {
pointerDownInside = false;
return;
}

if (
node &&
!node.contains(event.target as HTMLElement) &&
Expand All @@ -15,7 +28,15 @@ export const clickoutside: Action<Element, (event: MouseEvent) => void> = (
}
};

const destroy = on(document, 'click', handleClick, { capture: true });
const destroyPointerDown = on(document, 'pointerdown', handlePointerDown, {
capture: true,
});
const destroyClick = on(document, 'click', handleClick, { capture: true });

return { destroy };
};
return {
destroy: () => {
destroyPointerDown();
destroyClick();
},
};
}) satisfies Action<Element, (event: MouseEvent) => void>;
Loading