diff --git a/frontend/src/lib/actions/clickOutside.svelte.spec.ts b/frontend/src/lib/actions/clickOutside.svelte.spec.ts
new file mode 100644
index 00000000..81917cb0
--- /dev/null
+++ b/frontend/src/lib/actions/clickOutside.svelte.spec.ts
@@ -0,0 +1,64 @@
+import { describe, it, expect, afterEach } from 'vitest';
+
+const { clickOutside } = await import('./clickOutside');
+
+describe('clickOutside action', () => {
+ const nodes: HTMLElement[] = [];
+
+ function makeNode(): HTMLElement {
+ const node = document.createElement('div');
+ document.body.appendChild(node);
+ nodes.push(node);
+ return node;
+ }
+
+ afterEach(() => {
+ nodes.forEach((n) => n.remove());
+ nodes.length = 0;
+ });
+
+ it('registers a capture-phase click listener on mount', () => {
+ const node = makeNode();
+ const original = document.addEventListener.bind(document);
+ let registered = false;
+ document.addEventListener = (type: string, _fn: unknown, opts: unknown) => {
+ if (type === 'click' && opts === true) registered = true;
+ original(type as string, _fn as EventListener, opts as boolean);
+ };
+ clickOutside(node);
+ expect(registered).toBe(true);
+ document.addEventListener = original;
+ });
+
+ it('dispatches clickoutside when clicking outside the node', () => {
+ const node = makeNode();
+ const outside = makeNode();
+ let fired = false;
+ node.addEventListener('clickoutside', () => (fired = true));
+ clickOutside(node);
+ outside.click();
+ expect(fired).toBe(true);
+ });
+
+ it('does not dispatch clickoutside when clicking inside the node', () => {
+ const node = makeNode();
+ const child = document.createElement('span');
+ node.appendChild(child);
+ let fired = false;
+ node.addEventListener('clickoutside', () => (fired = true));
+ clickOutside(node);
+ child.click();
+ expect(fired).toBe(false);
+ });
+
+ it('removes the listener on destroy', () => {
+ const node = makeNode();
+ const outside = makeNode();
+ let count = 0;
+ node.addEventListener('clickoutside', () => count++);
+ const { destroy } = clickOutside(node);
+ destroy();
+ outside.click();
+ expect(count).toBe(0);
+ });
+});
diff --git a/frontend/src/lib/actions/clickOutside.ts b/frontend/src/lib/actions/clickOutside.ts
new file mode 100644
index 00000000..ef7fc3a2
--- /dev/null
+++ b/frontend/src/lib/actions/clickOutside.ts
@@ -0,0 +1,15 @@
+export function clickOutside(node: HTMLElement): { destroy: () => void } {
+ function handleClick(event: MouseEvent) {
+ if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
+ node.dispatchEvent(new CustomEvent('clickoutside'));
+ }
+ }
+
+ document.addEventListener('click', handleClick, true);
+
+ return {
+ destroy() {
+ document.removeEventListener('click', handleClick, true);
+ }
+ };
+}
diff --git a/frontend/src/lib/components/PersonMultiSelect.svelte b/frontend/src/lib/components/PersonMultiSelect.svelte
index 5fc1171f..536ef97b 100644
--- a/frontend/src/lib/components/PersonMultiSelect.svelte
+++ b/frontend/src/lib/components/PersonMultiSelect.svelte
@@ -1,6 +1,7 @@