diff --git a/CHANGELOG.md b/CHANGELOG.md index c0ddb080..99ff5934 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to ### Changed - 💄(frontend) improve comments highlights #1961 + ♿️(frontend) fix sidebar resize handle for screen readers #2122 ## [v4.8.3] - 2026-03-23 diff --git a/src/frontend/apps/impress/src/features/left-panel/components/ResizableLeftPanel.tsx b/src/frontend/apps/impress/src/features/left-panel/components/ResizableLeftPanel.tsx index 03584d1d..e5e17669 100644 --- a/src/frontend/apps/impress/src/features/left-panel/components/ResizableLeftPanel.tsx +++ b/src/frontend/apps/impress/src/features/left-panel/components/ResizableLeftPanel.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { ImperativePanelHandle, Panel, @@ -22,12 +23,15 @@ type ResizableLeftPanelProps = { maxPanelSizePx?: number; }; +const RESIZE_HANDLE_ID = 'left-panel-resize-handle'; + export const ResizableLeftPanel = ({ leftPanel, children, minPanelSizePx = 300, maxPanelSizePx = 450, }: ResizableLeftPanelProps) => { + const { t } = useTranslation(); const { isDesktop } = useResponsiveStore(); const { isPanelOpen } = useLeftPanelStore(); const ref = useRef(null); @@ -96,6 +100,50 @@ export const ResizableLeftPanel = ({ }; }, [isDesktop]); + /** + * Workaround: NVDA does not enter focus mode for role="separator" + * intercepted by browse-mode navigation and never reach the handle. + * Changing the role to "slider" makes NVDA reliably switch to focus + * mode, restoring progressive keyboard resize with arrow keys. + */ + useEffect(() => { + if (!isPanelOpen) { + return; + } + const handle = document.getElementById(RESIZE_HANDLE_ID); + if (!handle) { + return; + } + + handle.setAttribute('role', 'slider'); + handle.setAttribute('aria-orientation', 'vertical'); + handle.setAttribute('aria-label', t('Resize sidebar')); + + const updateValueText = () => { + const value = handle.getAttribute('aria-valuenow'); + if (value) { + const widthPx = Math.round( + (parseFloat(value) / 100) * window.innerWidth, + ); + handle.setAttribute( + 'aria-valuetext', + t('Sidebar width: {{widthPx}} pixels', { widthPx }), + ); + } + }; + updateValueText(); + + const observer = new MutationObserver(updateValueText); + observer.observe(handle, { + attributes: true, + attributeFilter: ['aria-valuenow'], + }); + + return () => { + observer.disconnect(); + }; + }, [isPanelOpen, t]); + const handleResize = (sizePercent: number) => { const widthPx = (sizePercent / 100) * window.innerWidth; savedWidthPxRef.current = widthPx; @@ -103,7 +151,7 @@ export const ResizableLeftPanel = ({ }; return ( - + {isPanelOpen && (