@@ -163,6 +163,7 @@ const ICON_FOLDER = '<svg viewBox="0 0 16 16"><path d="M1 4.5A1.5 1.5 0 0 1 2.5
163163const ICON_OPEN = '<svg viewBox="0 0 16 16" fill="currentColor"><path d="M8.5 1a.5.5 0 0 0-1 0v6.793L5.354 5.646a.5.5 0 1 0-.708.708l3 3a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 7.793V1zM3 10.5a.5.5 0 0 0-1 0v3a.5.5 0 0 0 .5.5h11a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 0-1 0V13H3v-2.5z"/></svg>' ;
164164
165165let selectedRow : HTMLElement | null = null ;
166+ let filteredFolderState : Map < string , boolean > | null = null ;
166167
167168function renderNode ( node : TreeNode , indent : number , container : HTMLElement ) : void {
168169 if ( node . type === 'folder' ) renderFolder ( node , indent , container ) ;
@@ -173,9 +174,14 @@ function renderFolder(node: TreeNode, indent: number, container: HTMLElement): v
173174 const wrapper = document . createElement ( 'div' ) ;
174175 wrapper . dataset [ 'type' ] = 'folder' ;
175176 wrapper . dataset [ 'name' ] = node . name . toLowerCase ( ) ;
177+ wrapper . dataset [ 'path' ] = node . fullPath ;
176178
177179 const row = document . createElement ( 'div' ) ;
178180 row . className = 'row' ;
181+ row . setAttribute ( 'role' , 'button' ) ;
182+ row . tabIndex = 0 ;
183+ row . setAttribute ( 'aria-expanded' , 'false' ) ;
184+ row . setAttribute ( 'aria-label' , 'Expand folder ' + node . name ) ;
179185 row . style . paddingLeft = ( indent * 16 + 6 ) + 'px' ;
180186 row . innerHTML =
181187 '<span class="chevron">' + ICON_CHEVRON + '</span>' +
@@ -188,9 +194,17 @@ function renderFolder(node: TreeNode, indent: number, container: HTMLElement): v
188194
189195 for ( const child of node . children ) renderNode ( child , indent + 1 , children ) ;
190196
191- row . addEventListener ( 'click' , ( ) => {
197+ const toggleFolder = ( ) => {
192198 const collapsed = wrapper . classList . toggle ( 'collapsed' ) ;
193199 children . classList . toggle ( 'collapsed' , collapsed ) ;
200+ row . setAttribute ( 'aria-expanded' , String ( ! collapsed ) ) ;
201+ row . setAttribute ( 'aria-label' , ( collapsed ? 'Expand' : 'Collapse' ) + ' folder ' + node . name ) ;
202+ } ;
203+ row . addEventListener ( 'click' , toggleFolder ) ;
204+ row . addEventListener ( 'keydown' , e => {
205+ if ( e . key !== 'Enter' && e . key !== ' ' ) return ;
206+ e . preventDefault ( ) ;
207+ toggleFolder ( ) ;
194208 } ) ;
195209
196210 wrapper . appendChild ( row ) ;
@@ -239,6 +253,12 @@ function renderFile(node: TreeNode, indent: number, container: HTMLElement): voi
239253 size . className = 'size' ;
240254 size . textContent = fmtSize ( node . entry ! . normalSize ) ;
241255
256+ const selectBtn = document . createElement ( 'button' ) ;
257+ selectBtn . className = 'row-select' ;
258+ selectBtn . type = 'button' ;
259+ selectBtn . setAttribute ( 'aria-label' , description ? `${ node . fullPath } - ${ description } ` : node . fullPath ) ;
260+ selectBtn . setAttribute ( 'aria-pressed' , 'false' ) ;
261+
242262 // Action button — only visible on hover/selection
243263 const openBtn = document . createElement ( 'button' ) ;
244264 openBtn . className = 'row-action' ;
@@ -250,22 +270,36 @@ function renderFile(node: TreeNode, indent: number, container: HTMLElement): voi
250270 vscode . postMessage ( { type : 'openFile' , name : node . fullPath } ) ;
251271 } ) ;
252272
253- row . appendChild ( badge ) ;
254- row . appendChild ( fileMain ) ;
273+ selectBtn . appendChild ( badge ) ;
274+ selectBtn . appendChild ( fileMain ) ;
275+ selectBtn . appendChild ( size ) ;
276+ row . appendChild ( selectBtn ) ;
255277 row . appendChild ( openBtn ) ;
256- row . appendChild ( size ) ;
257278
258279 // Click selects the row (does NOT immediately open)
259- row . addEventListener ( 'click' , ( ) => {
280+ const selectRow = ( ) => {
260281 if ( selectedRow ) selectedRow . classList . remove ( 'selected' ) ;
282+ if ( selectedRow ) selectedRow . querySelector < HTMLButtonElement > ( '.row-select' ) ?. setAttribute ( 'aria-pressed' , 'false' ) ;
261283 row . classList . add ( 'selected' ) ;
284+ selectBtn . setAttribute ( 'aria-pressed' , 'true' ) ;
262285 selectedRow = row ;
263- } ) ;
286+ } ;
287+ selectBtn . addEventListener ( 'click' , selectRow ) ;
264288
265289 // Double-click opens
266- row . addEventListener ( 'dblclick' , ( ) => {
290+ const openFile = ( ) => {
267291 if ( busy ) return ;
268292 vscode . postMessage ( { type : 'openFile' , name : node . fullPath } ) ;
293+ } ;
294+ selectBtn . addEventListener ( 'dblclick' , openFile ) ;
295+ selectBtn . addEventListener ( 'keydown' , e => {
296+ if ( e . key === 'Enter' ) {
297+ e . preventDefault ( ) ;
298+ openFile ( ) ;
299+ } else if ( e . key === ' ' ) {
300+ e . preventDefault ( ) ;
301+ selectRow ( ) ;
302+ }
269303 } ) ;
270304
271305 container . appendChild ( row ) ;
@@ -278,11 +312,28 @@ function applyFilter(query: string): void {
278312 const allFiles = treeWrap . querySelectorAll < HTMLElement > ( '[data-type="file"]' ) ;
279313 let shown = 0 ;
280314
315+ if ( q && ! filteredFolderState ) {
316+ filteredFolderState = new Map < string , boolean > ( ) ;
317+ treeWrap . querySelectorAll < HTMLElement > ( '[data-type="folder"]' ) . forEach ( folder => {
318+ filteredFolderState ! . set ( folder . dataset [ 'path' ] ?? '' , ! folder . classList . contains ( 'collapsed' ) ) ;
319+ } ) ;
320+ }
321+
281322 if ( ! q ) {
282323 allFiles . forEach ( r => r . classList . remove ( 'hidden' ) ) ;
283324 treeWrap . querySelectorAll < HTMLElement > ( '[data-type="folder"]' ) . forEach ( f => f . classList . remove ( 'hidden' ) ) ;
284- treeWrap . querySelectorAll < HTMLElement > ( '.children' ) . forEach ( c => c . classList . add ( 'collapsed' ) ) ;
285- treeWrap . querySelectorAll < HTMLElement > ( '[data-type="folder"]' ) . forEach ( f => f . classList . add ( 'collapsed' ) ) ;
325+ treeWrap . querySelectorAll < HTMLElement > ( '[data-type="folder"]' ) . forEach ( f => {
326+ const expanded = filteredFolderState ?. get ( f . dataset [ 'path' ] ?? '' ) ?? false ;
327+ f . classList . toggle ( 'collapsed' , ! expanded ) ;
328+ const children = f . querySelector < HTMLElement > ( ':scope > .children' ) ;
329+ if ( children ) children . classList . toggle ( 'collapsed' , ! expanded ) ;
330+ const row = f . querySelector < HTMLElement > ( ':scope > .row' ) ;
331+ if ( row ) {
332+ row . setAttribute ( 'aria-expanded' , String ( expanded ) ) ;
333+ row . setAttribute ( 'aria-label' , ( expanded ? 'Collapse' : 'Expand' ) + ' folder ' + ( f . dataset [ 'name' ] ?? '' ) ) ;
334+ }
335+ } ) ;
336+ filteredFolderState = null ;
286337 matchCount . textContent = '' ;
287338 return ;
288339 }
@@ -301,6 +352,11 @@ function applyFilter(query: string): void {
301352 el . classList . remove ( 'collapsed' ) ;
302353 const childrenEl = el . querySelector ( '.children' ) ;
303354 if ( childrenEl ) childrenEl . classList . remove ( 'collapsed' ) ;
355+ const row = el . querySelector < HTMLElement > ( ':scope > .row' ) ;
356+ if ( row ) {
357+ row . setAttribute ( 'aria-expanded' , 'true' ) ;
358+ row . setAttribute ( 'aria-label' , 'Collapse folder ' + ( el . dataset [ 'name' ] ?? '' ) ) ;
359+ }
304360 }
305361 }
306362
@@ -355,7 +411,7 @@ window.addEventListener('message', (e: MessageEvent) => {
355411 }
356412
357413 if ( msg . type === 'error' ) {
358- treeWrap . innerHTML = '<div class="state"><span>Failed to read archive</span><span class="err">' + esc ( msg . message ?? '' ) + '</span></div>' ;
414+ treeWrap . innerHTML = '<div class="state" role="alert" ><span>Failed to read archive</span><span class="err">' + esc ( msg . message ?? '' ) + '</span></div>' ;
359415 btnExtractAll . setAttribute ( 'disabled' , '' ) ;
360416 btnExportFolder . setAttribute ( 'disabled' , '' ) ;
361417 return ;
0 commit comments