@@ -16,15 +16,30 @@ const pxToPercent = (px: number) => {
1616 return ( px / window . innerWidth ) * 100 ;
1717} ;
1818
19+ const RESIZE_HANDLE_ID = 'left-panel-resize-handle' ;
20+
21+ const getSidebarWidthLabel = (
22+ current : number ,
23+ min : number ,
24+ max : number ,
25+ ) : 'narrow' | 'medium' | 'wide' => {
26+ const ratio = ( current - min ) / ( max - min ) ;
27+ if ( ratio < 1 / 3 ) {
28+ return 'narrow' ;
29+ }
30+ if ( ratio < 2 / 3 ) {
31+ return 'medium' ;
32+ }
33+ return 'wide' ;
34+ } ;
35+
1936type ResizableLeftPanelProps = {
2037 leftPanel : React . ReactNode ;
2138 children : React . ReactNode ;
2239 minPanelSizePx ?: number ;
2340 maxPanelSizePx ?: number ;
2441} ;
2542
26- const RESIZE_HANDLE_ID = 'left-panel-resize-handle' ;
27-
2843export const ResizableLeftPanel = ( {
2944 leftPanel,
3045 children,
@@ -102,47 +117,21 @@ export const ResizableLeftPanel = ({
102117
103118 /**
104119 * Workaround: NVDA does not enter focus mode for role="separator"
120+ * (https://github.com/nvaccess/nvda/issues/11403), so arrow keys are
105121 * intercepted by browse-mode navigation and never reach the handle.
106122 * Changing the role to "slider" makes NVDA reliably switch to focus
107123 * mode, restoring progressive keyboard resize with arrow keys.
124+ *
125+ * Note: PanelResizeHandle does not expose a ref (no RefAttributes in its
126+ * type definition), so we use id + getElementById as the only viable option.
127+ * Only role needs to be overridden here; aria-* props are passed directly.
108128 */
109129 useEffect ( ( ) => {
110130 if ( ! isPanelOpen ) {
111131 return ;
112132 }
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 ] ) ;
133+ document . getElementById ( RESIZE_HANDLE_ID ) ?. setAttribute ( 'role' , 'slider' ) ;
134+ } , [ isPanelOpen ] ) ;
146135
147136 const handleResize = ( sizePercent : number ) => {
148137 const widthPx = ( sizePercent / 100 ) * window . innerWidth ;
@@ -181,6 +170,20 @@ export const ResizableLeftPanel = ({
181170 { isPanelOpen && (
182171 < PanelResizeHandle
183172 id = { RESIZE_HANDLE_ID }
173+ aria-label = { t ( 'Resize sidebar' ) }
174+ aria-orientation = "vertical"
175+ aria-valuemin = { Math . round ( minPanelSizePercent ) }
176+ aria-valuemax = { Math . round ( maxPanelSizePercent ) }
177+ aria-valuenow = { Math . round ( panelSizePercent ) }
178+ aria-valuetext = { t ( `Sidebar width: {{label}}` , {
179+ label : t (
180+ getSidebarWidthLabel (
181+ panelSizePercent ,
182+ minPanelSizePercent ,
183+ maxPanelSizePercent ,
184+ ) ,
185+ ) ,
186+ } ) }
184187 style = { {
185188 borderRightWidth : '1px' ,
186189 borderRightStyle : 'solid' ,
0 commit comments