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
+43 -17
View File
@@ -41,6 +41,7 @@ import { useTranslation } from 'react-i18next';
import { formatDownloadBytes } from './utils/downloadProgress';
import { synchronizeDocumentAppearance } from './utils/documentAppearance';
import { createMainWindowSizePersistence } from './utils/mainWindowState';
import { createSidebarResizeSession } from './utils/sidebarResize';
import type { MainWindowSize } from './bindings/MainWindowSize';
import { beginSchedulerControl, isSchedulerControlCurrent } from './utils/schedulerControl';
import { createSerialTaskQueue } from './utils/serialTaskQueue';
@@ -194,6 +195,9 @@ function App() {
const stored = Number(window.localStorage.getItem('firelink-sidebar-width'));
return Number.isFinite(stored) && stored >= 190 && stored <= 260 ? stored : 220;
});
const sidebarResizeCleanupRef = useRef<(() => void) | null>(null);
const sidebarRevealRef = useRef<HTMLButtonElement>(null);
const restoreSidebarFocusRef = useRef(false);
const theme = useSettingsStore(state => state.theme);
const windowControlStylePreference = useSettingsStore(state => state.windowControlStyle);
@@ -432,27 +436,45 @@ function App() {
}, [addToast, clearPendingPostActionTimer, removeToast, t]);
const startSidebarResize = (event: React.PointerEvent<HTMLDivElement>) => {
sidebarResizeCleanupRef.current?.();
event.preventDefault();
const startX = event.clientX;
const startWidth = sidebarWidth;
try {
event.currentTarget.setPointerCapture(event.pointerId);
} catch {
// Pointer capture is best-effort; the session still listens on window.
}
const cleanup = createSidebarResizeSession({
windowTarget: window,
body: document.body,
captureTarget: event.currentTarget,
pointerId: event.pointerId,
startX: event.clientX,
startWidth: sidebarWidth,
isRight: isSidebarOnRight,
onWidth: setSidebarWidth,
});
sidebarResizeCleanupRef.current = cleanup;
};
const handlePointerMove = (moveEvent: PointerEvent) => {
const delta = isSidebarOnRight
? startX - moveEvent.clientX
: moveEvent.clientX - startX;
const nextWidth = Math.min(260, Math.max(190, startWidth + delta));
setSidebarWidth(nextWidth);
};
useEffect(() => () => {
sidebarResizeCleanupRef.current?.();
}, []);
const handlePointerUp = () => {
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
document.body.classList.remove('is-resizing');
};
useEffect(() => {
if (isSidebarVisible) return;
if (restoreSidebarFocusRef.current) {
restoreSidebarFocusRef.current = false;
sidebarRevealRef.current?.focus({ preventScroll: true });
}
}, [isSidebarVisible]);
document.body.classList.add('is-resizing');
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp);
const handleSidebarToggle = () => {
if (isSidebarVisible) {
const activeElement = document.activeElement;
restoreSidebarFocusRef.current = activeElement instanceof HTMLElement
&& Boolean(activeElement.closest('.app-sidebar-shell'));
}
toggleSidebar();
};
useEffect(() => {
@@ -1161,6 +1183,8 @@ function App() {
} ${
isSidebarVisible ? 'opacity-100' : 'opacity-0 pointer-events-none'
}`}
aria-hidden={!isSidebarVisible}
inert={!isSidebarVisible}
style={{
width: sidebarWidth,
marginInlineStart: isSidebarVisible || isSidebarOnRight ? 0 : -sidebarWidth,
@@ -1173,6 +1197,7 @@ function App() {
>
<Sidebar
selectedFilter={filter}
onToggleSidebar={handleSidebarToggle}
onSelectFilter={(f) => {
setFilter(f);
useSettingsStore.getState().setActiveView('downloads');
@@ -1196,6 +1221,7 @@ function App() {
{!isSidebarVisible && (
<button
type="button"
ref={sidebarRevealRef}
onClick={toggleSidebar}
className="app-icon-button app-sidebar-reveal-button h-7 w-7"
title={t($ => $.actions.showSidebar)}
+38 -5
View File
@@ -19,11 +19,12 @@ export type SidebarFilter = 'all' | 'active' | 'completed' | 'unfinished' | Down
interface SidebarProps {
selectedFilter: SidebarFilter;
onToggleSidebar?: () => void;
onSelectFilter: (filter: SidebarFilter) => void;
}
export const Sidebar: React.FC<SidebarProps> = (props) => {
const { selectedFilter, onSelectFilter } = props;
const { selectedFilter, onToggleSidebar, onSelectFilter } = props;
const { downloads, queues, addQueue, renameQueue, removeQueue, startQueue, pauseQueue, setQueueConcurrency } = useDownloadStore();
const {
activeView,
@@ -44,6 +45,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null);
const foldersToggleRef = useRef<HTMLButtonElement>(null);
const foldersListRef = useRef<HTMLDivElement>(null);
const contextMenuTriggerRef = useRef<HTMLButtonElement | null>(null);
const addInputRef = useRef<HTMLInputElement>(null);
const renameInputRef = useRef<HTMLInputElement>(null);
@@ -96,6 +98,21 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
};
}, [contextMenu, queues, i18n.language]);
useEffect(() => {
if (!contextMenu) {
const trigger = contextMenuTriggerRef.current;
if (trigger?.isConnected && document.activeElement === document.body) {
trigger.focus({ preventScroll: true });
}
return;
}
const frame = window.requestAnimationFrame(() => {
contextMenuRef.current?.querySelector<HTMLButtonElement>('button')?.focus({ preventScroll: true });
});
return () => window.cancelAnimationFrame(frame);
}, [contextMenu]);
useEffect(() => {
const handleCloseMenu = () => setContextMenu(null);
const handleEscape = (event: KeyboardEvent) => {
@@ -196,11 +213,24 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
);
};
const handleQueueContextMenu = (e: React.MouseEvent, id: string) => {
const openQueueContextMenu = (id: string, x: number, y: number, trigger?: HTMLButtonElement) => {
if (trigger) contextMenuTriggerRef.current = trigger;
setContextMenuPosition(null);
setContextMenu({ x, y, id });
};
const handleQueueContextMenu = (e: React.MouseEvent<HTMLButtonElement>, id: string) => {
e.preventDefault();
e.stopPropagation();
setContextMenuPosition(null);
setContextMenu({ x: e.clientX, y: e.clientY, id });
openQueueContextMenu(id, e.clientX, e.clientY, e.currentTarget);
};
const handleQueueKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>, id: string) => {
if (e.key !== 'ContextMenu' && !(e.key === 'F10' && e.shiftKey)) return;
e.preventDefault();
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
openQueueContextMenu(id, rect.left, rect.bottom, e.currentTarget);
};
const handleAddQueueSubmit = (trigger: 'submit' | 'blur' = 'submit') => {
@@ -320,6 +350,8 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
data-active={isSelected}
data-sidebar-queue-id={queue.id}
onContextMenu={e => handleQueueContextMenu(e, queue.id)}
onKeyDown={e => handleQueueKeyDown(e, queue.id)}
aria-keyshortcuts="Shift+F10"
onClick={() => onSelectFilter(filterId)}
className="sidebar-nav-item group flex w-full items-center text-[13px] text-start cursor-default font-medium"
>
@@ -356,7 +388,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
<button
type="button"
onClick={toggleSidebar}
onClick={onToggleSidebar ?? toggleSidebar}
className="sidebar-toggle-button"
title={t($ => $.actions.hideSidebar)}
>
@@ -479,6 +511,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
{contextMenu && createPortal(
<div
role="menu"
id="sidebar-queue-context-menu"
ref={contextMenuRef}
className="fixed z-[70] w-48 py-1 rounded-xl shadow-lg border border-border-modal bg-bg-context-menu backdrop-blur-xl animate-fade-in text-[13px] text-text-primary overflow-hidden"
style={{
+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;
};