mirror of
https://github.com/nimbold/Firelink.git
synced 2026-09-02 22:17:56 +00:00
fix(shell): harden sidebar accessibility and resize lifecycle
- keep the collapsed sidebar out of the accessibility tree and restore reveal focus - add keyboard queue context actions and correct the queue accessibility contract - fence sidebar resize pointers and clean up every interruption path with regressions
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { createSidebarResizeSession } from './sidebarResize';
|
||||
|
||||
const createEventTarget = () => {
|
||||
const listeners = new Map<string, Set<EventListener>>();
|
||||
const target = {
|
||||
addEventListener: vi.fn((type: string, listener: EventListener) => {
|
||||
const current = listeners.get(type) ?? new Set<EventListener>();
|
||||
current.add(listener);
|
||||
listeners.set(type, current);
|
||||
}),
|
||||
removeEventListener: vi.fn((type: string, listener: EventListener) => {
|
||||
listeners.get(type)?.delete(listener);
|
||||
}),
|
||||
};
|
||||
return {
|
||||
target,
|
||||
dispatch: (type: string, event: Partial<PointerEvent> = {}) => {
|
||||
listeners.get(type)?.forEach(listener => listener(event as Event));
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
describe('sidebar resize session', () => {
|
||||
it('ignores other pointers and clamps the active pointer width', () => {
|
||||
const eventTarget = createEventTarget();
|
||||
const classes = new Set<string>();
|
||||
const onWidth = vi.fn();
|
||||
const classList = {
|
||||
add: (value: string) => classes.add(value),
|
||||
remove: (value: string) => classes.delete(value),
|
||||
} as unknown as DOMTokenList;
|
||||
const cleanup = createSidebarResizeSession({
|
||||
windowTarget: eventTarget.target,
|
||||
body: { classList },
|
||||
pointerId: 7,
|
||||
startX: 100,
|
||||
startWidth: 220,
|
||||
isRight: false,
|
||||
onWidth,
|
||||
});
|
||||
|
||||
expect(classes.has('is-resizing')).toBe(true);
|
||||
eventTarget.dispatch('pointermove', { pointerId: 8, clientX: 1 });
|
||||
eventTarget.dispatch('pointermove', { pointerId: 7, clientX: 1 });
|
||||
expect(onWidth).toHaveBeenLastCalledWith(190);
|
||||
|
||||
eventTarget.dispatch('pointermove', { pointerId: 7, clientX: 1000 });
|
||||
expect(onWidth).toHaveBeenLastCalledWith(260);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('cleans global state on cancellation and makes cleanup idempotent', () => {
|
||||
const eventTarget = createEventTarget();
|
||||
const classes = new Set<string>();
|
||||
const classList = {
|
||||
add: (value: string) => classes.add(value),
|
||||
remove: (value: string) => classes.delete(value),
|
||||
} as unknown as DOMTokenList;
|
||||
const cleanup = createSidebarResizeSession({
|
||||
windowTarget: eventTarget.target,
|
||||
body: { classList },
|
||||
pointerId: 3,
|
||||
startX: 100,
|
||||
startWidth: 220,
|
||||
isRight: true,
|
||||
onWidth: vi.fn(),
|
||||
});
|
||||
|
||||
eventTarget.dispatch('pointercancel', { pointerId: 3 });
|
||||
expect(classes.has('is-resizing')).toBe(false);
|
||||
expect(eventTarget.target.removeEventListener).toHaveBeenCalledTimes(5);
|
||||
cleanup();
|
||||
expect(eventTarget.target.removeEventListener).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
type ResizeEventTarget = Pick<Window, 'addEventListener' | 'removeEventListener'>;
|
||||
type ResizeBody = Pick<HTMLElement, 'classList'>;
|
||||
type PointerCaptureTarget = Pick<HTMLElement, 'addEventListener' | 'removeEventListener' | 'hasPointerCapture' | 'releasePointerCapture'>;
|
||||
|
||||
export const createSidebarResizeSession = ({
|
||||
windowTarget,
|
||||
body,
|
||||
captureTarget,
|
||||
pointerId,
|
||||
startX,
|
||||
startWidth,
|
||||
isRight,
|
||||
onWidth,
|
||||
}: {
|
||||
windowTarget: ResizeEventTarget;
|
||||
body: ResizeBody;
|
||||
captureTarget?: PointerCaptureTarget;
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
startWidth: number;
|
||||
isRight: boolean;
|
||||
onWidth: (width: number) => void;
|
||||
}): (() => void) => {
|
||||
let active = true;
|
||||
|
||||
const handlePointerMove = (event: Event) => {
|
||||
const pointerEvent = event as PointerEvent;
|
||||
if (!active || pointerEvent.pointerId !== pointerId) return;
|
||||
const delta = isRight
|
||||
? startX - pointerEvent.clientX
|
||||
: pointerEvent.clientX - startX;
|
||||
onWidth(Math.min(260, Math.max(190, startWidth + delta)));
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
windowTarget.removeEventListener('pointermove', handlePointerMove);
|
||||
windowTarget.removeEventListener('pointerup', handlePointerEnd);
|
||||
windowTarget.removeEventListener('pointercancel', handlePointerEnd);
|
||||
windowTarget.removeEventListener('blur', handleResizeInterrupted);
|
||||
windowTarget.removeEventListener('resize', handleResizeInterrupted);
|
||||
captureTarget?.removeEventListener('lostpointercapture', handleLostPointerCapture);
|
||||
try {
|
||||
if (captureTarget?.hasPointerCapture(pointerId)) {
|
||||
captureTarget.releasePointerCapture(pointerId);
|
||||
}
|
||||
} catch {
|
||||
// The handle may already be detached or have released the pointer.
|
||||
}
|
||||
body.classList.remove('is-resizing');
|
||||
};
|
||||
|
||||
const handlePointerEnd = (event: Event) => {
|
||||
if ((event as PointerEvent).pointerId === pointerId) cleanup();
|
||||
};
|
||||
|
||||
const handleResizeInterrupted = () => cleanup();
|
||||
const handleLostPointerCapture = () => cleanup();
|
||||
|
||||
body.classList.add('is-resizing');
|
||||
windowTarget.addEventListener('pointermove', handlePointerMove);
|
||||
windowTarget.addEventListener('pointerup', handlePointerEnd);
|
||||
windowTarget.addEventListener('pointercancel', handlePointerEnd);
|
||||
windowTarget.addEventListener('blur', handleResizeInterrupted);
|
||||
windowTarget.addEventListener('resize', handleResizeInterrupted);
|
||||
captureTarget?.addEventListener('lostpointercapture', handleLostPointerCapture);
|
||||
|
||||
return cleanup;
|
||||
};
|
||||
Reference in New Issue
Block a user