Skip to content

Commit b7d0ba7

Browse files
committed
♿️(frontend) fix sidebar resize handle for screen readers
Expose the handle as a slider so arrow keys work with NVDA
1 parent 0d09f76 commit b7d0ba7

2 files changed

Lines changed: 51 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to
99
### Changed
1010

1111
- 💄(frontend) improve comments highlights #1961
12+
♿️(frontend) fix sidebar resize handle for screen readers #2122
1213

1314
## [v4.8.3] - 2026-03-23
1415

src/frontend/apps/impress/src/features/left-panel/components/ResizableLeftPanel.tsx

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useEffect, useRef, useState } from 'react';
2+
import { useTranslation } from 'react-i18next';
23
import {
34
ImperativePanelHandle,
45
Panel,
@@ -22,12 +23,15 @@ type ResizableLeftPanelProps = {
2223
maxPanelSizePx?: number;
2324
};
2425

26+
const RESIZE_HANDLE_ID = 'left-panel-resize-handle';
27+
2528
export const ResizableLeftPanel = ({
2629
leftPanel,
2730
children,
2831
minPanelSizePx = 300,
2932
maxPanelSizePx = 450,
3033
}: ResizableLeftPanelProps) => {
34+
const { t } = useTranslation();
3135
const { isDesktop } = useResponsiveStore();
3236
const { isPanelOpen } = useLeftPanelStore();
3337
const ref = useRef<ImperativePanelHandle>(null);
@@ -96,14 +100,58 @@ export const ResizableLeftPanel = ({
96100
};
97101
}, [isDesktop]);
98102

103+
/**
104+
* Workaround: NVDA does not enter focus mode for role="separator"
105+
* intercepted by browse-mode navigation and never reach the handle.
106+
* Changing the role to "slider" makes NVDA reliably switch to focus
107+
* mode, restoring progressive keyboard resize with arrow keys.
108+
*/
109+
useEffect(() => {
110+
if (!isPanelOpen) {
111+
return;
112+
}
113+
const handle = document.getElementById(RESIZE_HANDLE_ID);
114+
if (!handle) {
115+
return;
116+
}
117+
118+
handle.setAttribute('role', 'slider');
119+
handle.setAttribute('aria-orientation', 'vertical');
120+
handle.setAttribute('aria-label', t('Resize sidebar'));
121+
122+
const updateValueText = () => {
123+
const value = handle.getAttribute('aria-valuenow');
124+
if (value) {
125+
const widthPx = Math.round(
126+
(parseFloat(value) / 100) * window.innerWidth,
127+
);
128+
handle.setAttribute(
129+
'aria-valuetext',
130+
t('Sidebar width: {{widthPx}} pixels', { widthPx }),
131+
);
132+
}
133+
};
134+
updateValueText();
135+
136+
const observer = new MutationObserver(updateValueText);
137+
observer.observe(handle, {
138+
attributes: true,
139+
attributeFilter: ['aria-valuenow'],
140+
});
141+
142+
return () => {
143+
observer.disconnect();
144+
};
145+
}, [isPanelOpen, t]);
146+
99147
const handleResize = (sizePercent: number) => {
100148
const widthPx = (sizePercent / 100) * window.innerWidth;
101149
savedWidthPxRef.current = widthPx;
102150
setPanelSizePercent(sizePercent);
103151
};
104152

105153
return (
106-
<PanelGroup direction="horizontal">
154+
<PanelGroup direction="horizontal" keyboardResizeBy={1}>
107155
<Panel
108156
ref={ref}
109157
className="--docs--resizable-left-panel"
@@ -132,6 +180,7 @@ export const ResizableLeftPanel = ({
132180
</Panel>
133181
{isPanelOpen && (
134182
<PanelResizeHandle
183+
id={RESIZE_HANDLE_ID}
135184
style={{
136185
borderRightWidth: '1px',
137186
borderRightStyle: 'solid',

0 commit comments

Comments
 (0)