mirror of
https://github.com/nimbold/Firelink.git
synced 2026-09-04 15:05:22 +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:
+43
-17
@@ -41,6 +41,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { formatDownloadBytes } from './utils/downloadProgress';
|
import { formatDownloadBytes } from './utils/downloadProgress';
|
||||||
import { synchronizeDocumentAppearance } from './utils/documentAppearance';
|
import { synchronizeDocumentAppearance } from './utils/documentAppearance';
|
||||||
import { createMainWindowSizePersistence } from './utils/mainWindowState';
|
import { createMainWindowSizePersistence } from './utils/mainWindowState';
|
||||||
|
import { createSidebarResizeSession } from './utils/sidebarResize';
|
||||||
import type { MainWindowSize } from './bindings/MainWindowSize';
|
import type { MainWindowSize } from './bindings/MainWindowSize';
|
||||||
import { beginSchedulerControl, isSchedulerControlCurrent } from './utils/schedulerControl';
|
import { beginSchedulerControl, isSchedulerControlCurrent } from './utils/schedulerControl';
|
||||||
import { createSerialTaskQueue } from './utils/serialTaskQueue';
|
import { createSerialTaskQueue } from './utils/serialTaskQueue';
|
||||||
@@ -194,6 +195,9 @@ function App() {
|
|||||||
const stored = Number(window.localStorage.getItem('firelink-sidebar-width'));
|
const stored = Number(window.localStorage.getItem('firelink-sidebar-width'));
|
||||||
return Number.isFinite(stored) && stored >= 190 && stored <= 260 ? stored : 220;
|
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 theme = useSettingsStore(state => state.theme);
|
||||||
const windowControlStylePreference = useSettingsStore(state => state.windowControlStyle);
|
const windowControlStylePreference = useSettingsStore(state => state.windowControlStyle);
|
||||||
@@ -432,27 +436,45 @@ function App() {
|
|||||||
}, [addToast, clearPendingPostActionTimer, removeToast, t]);
|
}, [addToast, clearPendingPostActionTimer, removeToast, t]);
|
||||||
|
|
||||||
const startSidebarResize = (event: React.PointerEvent<HTMLDivElement>) => {
|
const startSidebarResize = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
sidebarResizeCleanupRef.current?.();
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const startX = event.clientX;
|
try {
|
||||||
const startWidth = sidebarWidth;
|
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) => {
|
useEffect(() => () => {
|
||||||
const delta = isSidebarOnRight
|
sidebarResizeCleanupRef.current?.();
|
||||||
? startX - moveEvent.clientX
|
}, []);
|
||||||
: moveEvent.clientX - startX;
|
|
||||||
const nextWidth = Math.min(260, Math.max(190, startWidth + delta));
|
|
||||||
setSidebarWidth(nextWidth);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePointerUp = () => {
|
useEffect(() => {
|
||||||
window.removeEventListener('pointermove', handlePointerMove);
|
if (isSidebarVisible) return;
|
||||||
window.removeEventListener('pointerup', handlePointerUp);
|
if (restoreSidebarFocusRef.current) {
|
||||||
document.body.classList.remove('is-resizing');
|
restoreSidebarFocusRef.current = false;
|
||||||
};
|
sidebarRevealRef.current?.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
}, [isSidebarVisible]);
|
||||||
|
|
||||||
document.body.classList.add('is-resizing');
|
const handleSidebarToggle = () => {
|
||||||
window.addEventListener('pointermove', handlePointerMove);
|
if (isSidebarVisible) {
|
||||||
window.addEventListener('pointerup', handlePointerUp);
|
const activeElement = document.activeElement;
|
||||||
|
restoreSidebarFocusRef.current = activeElement instanceof HTMLElement
|
||||||
|
&& Boolean(activeElement.closest('.app-sidebar-shell'));
|
||||||
|
}
|
||||||
|
toggleSidebar();
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1161,6 +1183,8 @@ function App() {
|
|||||||
} ${
|
} ${
|
||||||
isSidebarVisible ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
isSidebarVisible ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||||
}`}
|
}`}
|
||||||
|
aria-hidden={!isSidebarVisible}
|
||||||
|
inert={!isSidebarVisible}
|
||||||
style={{
|
style={{
|
||||||
width: sidebarWidth,
|
width: sidebarWidth,
|
||||||
marginInlineStart: isSidebarVisible || isSidebarOnRight ? 0 : -sidebarWidth,
|
marginInlineStart: isSidebarVisible || isSidebarOnRight ? 0 : -sidebarWidth,
|
||||||
@@ -1173,6 +1197,7 @@ function App() {
|
|||||||
>
|
>
|
||||||
<Sidebar
|
<Sidebar
|
||||||
selectedFilter={filter}
|
selectedFilter={filter}
|
||||||
|
onToggleSidebar={handleSidebarToggle}
|
||||||
onSelectFilter={(f) => {
|
onSelectFilter={(f) => {
|
||||||
setFilter(f);
|
setFilter(f);
|
||||||
useSettingsStore.getState().setActiveView('downloads');
|
useSettingsStore.getState().setActiveView('downloads');
|
||||||
@@ -1196,6 +1221,7 @@ function App() {
|
|||||||
{!isSidebarVisible && (
|
{!isSidebarVisible && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
ref={sidebarRevealRef}
|
||||||
onClick={toggleSidebar}
|
onClick={toggleSidebar}
|
||||||
className="app-icon-button app-sidebar-reveal-button h-7 w-7"
|
className="app-icon-button app-sidebar-reveal-button h-7 w-7"
|
||||||
title={t($ => $.actions.showSidebar)}
|
title={t($ => $.actions.showSidebar)}
|
||||||
|
|||||||
@@ -19,11 +19,12 @@ export type SidebarFilter = 'all' | 'active' | 'completed' | 'unfinished' | Down
|
|||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
selectedFilter: SidebarFilter;
|
selectedFilter: SidebarFilter;
|
||||||
|
onToggleSidebar?: () => void;
|
||||||
onSelectFilter: (filter: SidebarFilter) => void;
|
onSelectFilter: (filter: SidebarFilter) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Sidebar: React.FC<SidebarProps> = (props) => {
|
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 { downloads, queues, addQueue, renameQueue, removeQueue, startQueue, pauseQueue, setQueueConcurrency } = useDownloadStore();
|
||||||
const {
|
const {
|
||||||
activeView,
|
activeView,
|
||||||
@@ -44,6 +45,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
|||||||
const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null);
|
const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null);
|
||||||
const foldersToggleRef = useRef<HTMLButtonElement>(null);
|
const foldersToggleRef = useRef<HTMLButtonElement>(null);
|
||||||
const foldersListRef = useRef<HTMLDivElement>(null);
|
const foldersListRef = useRef<HTMLDivElement>(null);
|
||||||
|
const contextMenuTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
|
||||||
const addInputRef = useRef<HTMLInputElement>(null);
|
const addInputRef = useRef<HTMLInputElement>(null);
|
||||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -96,6 +98,21 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
|||||||
};
|
};
|
||||||
}, [contextMenu, queues, i18n.language]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const handleCloseMenu = () => setContextMenu(null);
|
const handleCloseMenu = () => setContextMenu(null);
|
||||||
const handleEscape = (event: KeyboardEvent) => {
|
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.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setContextMenuPosition(null);
|
openQueueContextMenu(id, e.clientX, e.clientY, e.currentTarget);
|
||||||
setContextMenu({ x: e.clientX, y: e.clientY, id });
|
};
|
||||||
|
|
||||||
|
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') => {
|
const handleAddQueueSubmit = (trigger: 'submit' | 'blur' = 'submit') => {
|
||||||
@@ -320,6 +350,8 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
|||||||
data-active={isSelected}
|
data-active={isSelected}
|
||||||
data-sidebar-queue-id={queue.id}
|
data-sidebar-queue-id={queue.id}
|
||||||
onContextMenu={e => handleQueueContextMenu(e, queue.id)}
|
onContextMenu={e => handleQueueContextMenu(e, queue.id)}
|
||||||
|
onKeyDown={e => handleQueueKeyDown(e, queue.id)}
|
||||||
|
aria-keyshortcuts="Shift+F10"
|
||||||
onClick={() => onSelectFilter(filterId)}
|
onClick={() => onSelectFilter(filterId)}
|
||||||
className="sidebar-nav-item group flex w-full items-center text-[13px] text-start cursor-default font-medium"
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggleSidebar}
|
onClick={onToggleSidebar ?? toggleSidebar}
|
||||||
className="sidebar-toggle-button"
|
className="sidebar-toggle-button"
|
||||||
title={t($ => $.actions.hideSidebar)}
|
title={t($ => $.actions.hideSidebar)}
|
||||||
>
|
>
|
||||||
@@ -479,6 +511,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
|||||||
{contextMenu && createPortal(
|
{contextMenu && createPortal(
|
||||||
<div
|
<div
|
||||||
role="menu"
|
role="menu"
|
||||||
|
id="sidebar-queue-context-menu"
|
||||||
ref={contextMenuRef}
|
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"
|
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={{
|
style={{
|
||||||
|
|||||||
@@ -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