refactor(actions): extract clickOutside to shared module, replace 5 inline copies
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
64
frontend/src/lib/actions/clickOutside.svelte.spec.ts
Normal file
64
frontend/src/lib/actions/clickOutside.svelte.spec.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
15
frontend/src/lib/actions/clickOutside.ts
Normal file
15
frontend/src/lib/actions/clickOutside.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import { clickOutside } from '$lib/actions/clickOutside';
|
||||||
type Person = components['schemas']['Person'];
|
type Person = components['schemas']['Person'];
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -56,20 +57,6 @@ function selectPerson(person: Person) {
|
|||||||
function removePerson(id: string | undefined) {
|
function removePerson(id: string | undefined) {
|
||||||
selectedPersons = selectedPersons.filter((p) => p.id !== id);
|
selectedPersons = selectedPersons.filter((p) => p.id !== id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clickOutside(node: HTMLElement) {
|
|
||||||
const handleClick = (e: MouseEvent) => {
|
|
||||||
if (node && !node.contains(e.target as Node) && !e.defaultPrevented) {
|
|
||||||
showDropdown = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener('click', handleClick, true);
|
|
||||||
return {
|
|
||||||
destroy() {
|
|
||||||
document.removeEventListener('click', handleClick, true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
|
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
|
||||||
@@ -78,7 +65,7 @@ function clickOutside(node: HTMLElement) {
|
|||||||
<input type="hidden" name="receiverIds" value={person.id} />
|
<input type="hidden" name="receiverIds" value={person.id} />
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
<div class="relative" use:clickOutside>
|
<div class="relative" use:clickOutside onclickoutside={() => (showDropdown = false)}>
|
||||||
<div
|
<div
|
||||||
class="flex min-h-[42px] flex-wrap gap-2 rounded border border-line bg-surface p-2 focus-within:border-ink focus-within:ring-1 focus-within:ring-ink"
|
class="flex min-h-[42px] flex-wrap gap-2 rounded border border-line bg-surface p-2 focus-within:border-ink focus-within:ring-1 focus-within:ring-ink"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import { clickOutside } from '$lib/actions/clickOutside';
|
||||||
type Person = components['schemas']['Person'];
|
type Person = components['schemas']['Person'];
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -118,23 +119,9 @@ function selectPerson(person: Person) {
|
|||||||
showDropdown = false;
|
showDropdown = false;
|
||||||
onchange?.(person.id!);
|
onchange?.(person.id!);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clickOutside(node: HTMLElement) {
|
|
||||||
const handleClick = (event: MouseEvent) => {
|
|
||||||
if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
|
|
||||||
showDropdown = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener('click', handleClick, true);
|
|
||||||
return {
|
|
||||||
destroy() {
|
|
||||||
document.removeEventListener('click', handleClick, true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="relative" use:clickOutside>
|
<div class="relative" use:clickOutside onclickoutside={() => (showDropdown = false)}>
|
||||||
<label
|
<label
|
||||||
for={name}
|
for={name}
|
||||||
class={compact
|
class={compact
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import { clickOutside } from '$lib/actions/clickOutside';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
@@ -66,23 +67,9 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
activeIndex = (activeIndex - 1 + suggestions.length) % suggestions.length;
|
activeIndex = (activeIndex - 1 + suggestions.length) % suggestions.length;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clickOutside(node: HTMLElement) {
|
|
||||||
const handleClick = (e: MouseEvent) => {
|
|
||||||
if (node && !node.contains(e.target as Node) && !e.defaultPrevented) {
|
|
||||||
showSuggestions = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener('click', handleClick, true);
|
|
||||||
return {
|
|
||||||
destroy() {
|
|
||||||
document.removeEventListener('click', handleClick, true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="w-full" use:clickOutside>
|
<div class="w-full" use:clickOutside onclickoutside={() => (showSuggestions = false)}>
|
||||||
<!-- Tag Container -->
|
<!-- Tag Container -->
|
||||||
<div
|
<div
|
||||||
class="flex min-h-[42px] flex-wrap gap-2 rounded border border-line bg-surface p-2 focus-within:border-ink focus-within:ring-1 focus-within:ring-ink"
|
class="flex min-h-[42px] flex-wrap gap-2 rounded border border-line bg-surface p-2 focus-within:border-ink focus-within:ring-1 focus-within:ring-ink"
|
||||||
|
|||||||
@@ -1,27 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import { clickOutside } from '$lib/actions/clickOutside';
|
||||||
|
|
||||||
let { userInitials }: { userInitials: string | null } = $props();
|
let { userInitials }: { userInitials: string | null } = $props();
|
||||||
|
|
||||||
let userMenuOpen = $state(false);
|
let userMenuOpen = $state(false);
|
||||||
|
|
||||||
function clickOutside(node: HTMLElement) {
|
|
||||||
const handleClick = (event: MouseEvent) => {
|
|
||||||
if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
|
|
||||||
userMenuOpen = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener('click', handleClick, true);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('click', handleClick, true);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="relative"
|
class="relative"
|
||||||
{@attach clickOutside}
|
use:clickOutside
|
||||||
|
onclickoutside={() => (userMenuOpen = false)}
|
||||||
onkeydown={(e) => {
|
onkeydown={(e) => {
|
||||||
if (e.key === 'Escape') userMenuOpen = false;
|
if (e.key === 'Escape') userMenuOpen = false;
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import { clickOutside } from '$lib/actions/clickOutside';
|
||||||
|
|
||||||
interface Correspondent {
|
interface Correspondent {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -17,20 +18,6 @@ interface Props {
|
|||||||
|
|
||||||
let { correspondents, loading, senderName, onselect, onclose }: Props = $props();
|
let { correspondents, loading, senderName, onselect, onclose }: Props = $props();
|
||||||
|
|
||||||
function clickOutside(node: HTMLElement) {
|
|
||||||
const handleClick = (event: MouseEvent) => {
|
|
||||||
if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
|
|
||||||
onclose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener('click', handleClick, true);
|
|
||||||
return {
|
|
||||||
destroy() {
|
|
||||||
document.removeEventListener('click', handleClick, true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getOptionElements(container: HTMLElement): HTMLElement[] {
|
function getOptionElements(container: HTMLElement): HTMLElement[] {
|
||||||
return Array.from(container.querySelectorAll<HTMLElement>('[role="option"]'));
|
return Array.from(container.querySelectorAll<HTMLElement>('[role="option"]'));
|
||||||
}
|
}
|
||||||
@@ -60,6 +47,7 @@ function getInitials(person: Correspondent): string {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
use:clickOutside
|
use:clickOutside
|
||||||
|
onclickoutside={onclose}
|
||||||
role="listbox"
|
role="listbox"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
aria-label={m.conv_suggestions_heading()}
|
aria-label={m.conv_suggestions_heading()}
|
||||||
|
|||||||
Reference in New Issue
Block a user