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:
NimBold
2026-08-22 00:48:22 +03:30
parent cc74bec9b0
commit 1fe12b1fea
4 changed files with 227 additions and 22 deletions
+76
View File
@@ -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);
});
});
+70
View File
@@ -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;
};