refactor: move shared utilities to lib/shared/ sub-packages
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
76
frontend/src/lib/shared/actions/clickOutside.svelte.spec.ts
Normal file
76
frontend/src/lib/shared/actions/clickOutside.svelte.spec.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
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('does not dispatch clickoutside when event.defaultPrevented is true', () => {
|
||||
const node = makeNode();
|
||||
const outside = makeNode();
|
||||
let fired = false;
|
||||
node.addEventListener('clickoutside', () => (fired = true));
|
||||
clickOutside(node);
|
||||
const event = new MouseEvent('click', { bubbles: true, cancelable: true });
|
||||
event.preventDefault();
|
||||
outside.dispatchEvent(event);
|
||||
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);
|
||||
});
|
||||
});
|
||||
16
frontend/src/lib/shared/actions/clickOutside.ts
Normal file
16
frontend/src/lib/shared/actions/clickOutside.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
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'));
|
||||
}
|
||||
}
|
||||
|
||||
// Capture phase (true) ensures this fires before any child stopPropagation() calls.
|
||||
document.addEventListener('click', handleClick, true);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
document.removeEventListener('click', handleClick, true);
|
||||
}
|
||||
};
|
||||
}
|
||||
87
frontend/src/lib/shared/actions/radioGroupNav.svelte.spec.ts
Normal file
87
frontend/src/lib/shared/actions/radioGroupNav.svelte.spec.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
|
||||
const { radioGroupNav } = await import('./radioGroupNav');
|
||||
|
||||
describe('radioGroupNav action', () => {
|
||||
const nodes: HTMLElement[] = [];
|
||||
|
||||
function makeGroup(count: number): { container: HTMLElement; buttons: HTMLElement[] } {
|
||||
const container = document.createElement('div');
|
||||
container.setAttribute('role', 'radiogroup');
|
||||
const buttons: HTMLElement[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const btn = document.createElement('button');
|
||||
btn.setAttribute('role', 'radio');
|
||||
btn.setAttribute('aria-checked', i === 0 ? 'true' : 'false');
|
||||
btn.setAttribute('tabindex', i === 0 ? '0' : '-1');
|
||||
container.appendChild(btn);
|
||||
buttons.push(btn);
|
||||
}
|
||||
document.body.appendChild(container);
|
||||
nodes.push(container);
|
||||
return { container, buttons };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
nodes.forEach((n) => n.remove());
|
||||
nodes.length = 0;
|
||||
});
|
||||
|
||||
it('ArrowRight moves focus to next button', () => {
|
||||
const { container, buttons } = makeGroup(4);
|
||||
radioGroupNav(container);
|
||||
buttons[0].focus();
|
||||
buttons[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
|
||||
expect(document.activeElement).toBe(buttons[1]);
|
||||
});
|
||||
|
||||
it('ArrowRight wraps from last to first', () => {
|
||||
const { container, buttons } = makeGroup(4);
|
||||
radioGroupNav(container);
|
||||
buttons[3].focus();
|
||||
buttons[3].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
|
||||
expect(document.activeElement).toBe(buttons[0]);
|
||||
});
|
||||
|
||||
it('ArrowLeft moves focus to previous button', () => {
|
||||
const { container, buttons } = makeGroup(4);
|
||||
radioGroupNav(container);
|
||||
buttons[2].focus();
|
||||
buttons[2].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
|
||||
expect(document.activeElement).toBe(buttons[1]);
|
||||
});
|
||||
|
||||
it('ArrowLeft wraps from first to last', () => {
|
||||
const { container, buttons } = makeGroup(4);
|
||||
radioGroupNav(container);
|
||||
buttons[0].focus();
|
||||
buttons[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
|
||||
expect(document.activeElement).toBe(buttons[3]);
|
||||
});
|
||||
|
||||
it('ArrowRight updates aria-checked on new button and removes it from old', () => {
|
||||
const { container, buttons } = makeGroup(4);
|
||||
radioGroupNav(container);
|
||||
buttons[0].focus();
|
||||
buttons[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
|
||||
expect(buttons[1].getAttribute('aria-checked')).toBe('true');
|
||||
expect(buttons[0].getAttribute('aria-checked')).toBe('false');
|
||||
});
|
||||
|
||||
it('destroy removes keydown listener', () => {
|
||||
const { container, buttons } = makeGroup(4);
|
||||
const { destroy } = radioGroupNav(container);
|
||||
destroy();
|
||||
buttons[0].focus();
|
||||
buttons[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
|
||||
expect(document.activeElement).toBe(buttons[0]);
|
||||
});
|
||||
|
||||
it('ignores non-arrow keys', () => {
|
||||
const { container, buttons } = makeGroup(4);
|
||||
radioGroupNav(container);
|
||||
buttons[0].focus();
|
||||
buttons[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||
expect(document.activeElement).toBe(buttons[0]);
|
||||
});
|
||||
});
|
||||
37
frontend/src/lib/shared/actions/radioGroupNav.ts
Normal file
37
frontend/src/lib/shared/actions/radioGroupNav.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export function radioGroupNav(
|
||||
node: HTMLElement,
|
||||
onChange?: (value: string) => void
|
||||
): { destroy: () => void; update: (onChange?: (value: string) => void) => void } {
|
||||
let onChangeFn = onChange;
|
||||
|
||||
function getRadios(): HTMLElement[] {
|
||||
return Array.from(node.querySelectorAll<HTMLElement>('[role="radio"]'));
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'ArrowRight' && event.key !== 'ArrowLeft') return;
|
||||
|
||||
const radios = getRadios();
|
||||
const current = radios.indexOf(document.activeElement as HTMLElement);
|
||||
if (current === -1) return;
|
||||
|
||||
const delta = event.key === 'ArrowRight' ? 1 : -1;
|
||||
const next = (current + delta + radios.length) % radios.length;
|
||||
|
||||
radios[current].setAttribute('aria-checked', 'false');
|
||||
radios[next].setAttribute('aria-checked', 'true');
|
||||
radios[next].focus();
|
||||
onChangeFn?.(radios[next].getAttribute('value') ?? '');
|
||||
}
|
||||
|
||||
node.addEventListener('keydown', handleKeydown);
|
||||
|
||||
return {
|
||||
update(newOnChange) {
|
||||
onChangeFn = newOnChange;
|
||||
},
|
||||
destroy() {
|
||||
node.removeEventListener('keydown', handleKeydown);
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user