From 3c104e9486711ff09940217212f6013c14b9e811 Mon Sep 17 00:00:00 2001 From: erKam Date: Sat, 10 Oct 2026 22:47:38 +0000 Subject: [PATCH 1/2] feat(components): add expand all and collapse all to the component tree The live tree can list up to 2000 instances, and the only way to open or close it was one row at a time. Add Expand all and Collapse all buttons to the toolbar, and support the * key from the ARIA treeview pattern, which expands the focused row and all of its siblings. Collapse all moves the roving focus to the nearest visible ancestor of the row that had it, so Tab always lands on a row that is shown. Both buttons are disabled while a text filter is active, because filtering already opens every row that leads to a match. The result is announced through the existing polite status region. Update the Components docs page and rebuild extension/ui. --- .../__tests__/component-tree-expand.test.ts | 167 ++++++++++++++++++ app/src/pages/component-tree.ts | 68 +++++++ .../docs/src/content/inspectors/components.md | 7 +- ...=> browser-agent-rpc-BXhoSh1z-B4Ch2vG5.js} | 2 +- .../{index-BEfrZ0QX.js => index-BO4_iMQ3.js} | 11 +- extension/ui/index.html | 2 +- 6 files changed, 251 insertions(+), 6 deletions(-) create mode 100644 app/src/__tests__/component-tree-expand.test.ts rename extension/ui/assets/{browser-agent-rpc-BXhoSh1z-C_twCiPD.js => browser-agent-rpc-BXhoSh1z-B4Ch2vG5.js} (93%) rename extension/ui/assets/{index-BEfrZ0QX.js => index-BO4_iMQ3.js} (96%) diff --git a/app/src/__tests__/component-tree-expand.test.ts b/app/src/__tests__/component-tree-expand.test.ts new file mode 100644 index 00000000..095bd338 --- /dev/null +++ b/app/src/__tests__/component-tree-expand.test.ts @@ -0,0 +1,167 @@ +import { TestBed, type ComponentFixture } from '@angular/core/testing'; +import type { DevframeRpcClient } from 'devframe/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ComponentTree } from '../pages/component-tree'; + +function node(id: string, name: string, children: unknown[] = []) { + return { id, name, tag: `app-${name.toLowerCase()}`, children }; +} + +// App +// Header +// Nav +// Main +// List +// Item +// Footer +const roots = [ + node('app', 'App', [ + node('header', 'Header', [node('nav', 'Nav')]), + node('main', 'Main', [node('list', 'List', [node('item', 'Item')])]), + node('footer', 'Footer'), + ]), +]; + +function client(): DevframeRpcClient { + const state: Record = { + 'component-tree': { + pages: { + a: { pageId: 'a', title: 'a', url: '/', roots, count: 7, detail: null, reportedAt: 1 }, + }, + }, + }; + const rpc = { + call: () => Promise.resolve([]), + callEvent: () => Promise.resolve(), + sharedState: (name: string) => + Promise.resolve({ value: () => state[name] ?? null, on: () => () => {} }), + }; + return { connectionMeta: {}, scope: () => ({ rpc }) } as unknown as DevframeRpcClient; +} + +async function settle(fixture: ComponentFixture) { + for (let i = 0; i < 3; i++) { + await new Promise((resolve) => setTimeout(resolve)); + await fixture.whenStable(); + } +} + +async function setup() { + const fixture = TestBed.createComponent(ComponentTree); + fixture.componentRef.setInput('rpc', client()); + document.body.appendChild(fixture.nativeElement); + await settle(fixture); + return fixture; +} + +const host = (fixture: ComponentFixture) => fixture.nativeElement as HTMLElement; + +function visible(fixture: ComponentFixture) { + return Array.from(host(fixture).querySelectorAll('.row')).map( + (row) => row.dataset['id'], + ); +} + +function button(fixture: ComponentFixture, label: string) { + const found = Array.from(host(fixture).querySelectorAll('button')).find( + (b) => b.textContent?.trim() === label, + ); + if (!found) throw new Error(`No ${label} button`); + return found; +} + +function tabbable(fixture: ComponentFixture) { + return Array.from(host(fixture).querySelectorAll('.row[tabindex="0"]')).map( + (row) => row.dataset['id'], + ); +} + +function status(fixture: ComponentFixture) { + return host(fixture).querySelector('p.sr-only[role="status"]')?.textContent?.trim(); +} + +async function key(fixture: ComponentFixture, id: string, k: string) { + const row = host(fixture).querySelector(`.row[data-id="${id}"]`)!; + row.focus(); + row.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true })); + await settle(fixture); +} + +describe('ComponentTree expand and collapse all', () => { + beforeEach(() => { + vi.stubGlobal('CSS', { escape: (value: string) => value }); + Element.prototype.scrollIntoView ??= () => {}; + }); + + afterEach(() => { + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + }); + + it('collapses to the top level and expands every row again', async () => { + const fixture = await setup(); + expect(visible(fixture)).toEqual(['app', 'header', 'nav', 'main', 'list', 'item', 'footer']); + + button(fixture, 'Collapse all').click(); + await settle(fixture); + expect(visible(fixture)).toEqual(['app']); + expect(status(fixture)).toContain('Collapsed every component'); + expect(host(fixture).querySelector('.row[data-id="app"]')?.getAttribute('aria-expanded')).toBe( + 'false', + ); + + button(fixture, 'Expand all').click(); + await settle(fixture); + expect(visible(fixture)).toEqual(['app', 'header', 'nav', 'main', 'list', 'item', 'footer']); + expect(status(fixture)).toContain('Expanded every component'); + }); + + it('keeps the tabbable row visible by moving it to the nearest visible ancestor', async () => { + const fixture = await setup(); + await key(fixture, 'list', 'ArrowDown'); + expect(tabbable(fixture)).toEqual(['item']); + expect(document.activeElement?.getAttribute('data-id')).toBe('item'); + + button(fixture, 'Collapse all').click(); + await settle(fixture); + expect(visible(fixture)).not.toContain('item'); + expect(tabbable(fixture)).toEqual(['app']); + expect(fixture.componentInstance.focusId()).toBe('app'); + + button(fixture, 'Expand all').click(); + await settle(fixture); + expect(tabbable(fixture)).toEqual(['app']); + }); + + it('expands every sibling of the focused row with the * key', async () => { + const fixture = await setup(); + fixture.componentInstance.collapsed.set(new Set(['header', 'main', 'list'])); + await settle(fixture); + expect(visible(fixture)).toEqual(['app', 'header', 'main', 'footer']); + + await key(fixture, 'header', '*'); + expect(visible(fixture)).toEqual(['app', 'header', 'nav', 'main', 'list', 'footer']); + expect(tabbable(fixture)).toEqual(['header']); + expect(document.activeElement?.getAttribute('data-id')).toBe('header'); + + // Only siblings open, not their descendants. + expect(fixture.componentInstance.collapsed().has('list')).toBe(true); + }); + + it('turns the buttons off while a text filter is active', async () => { + const fixture = await setup(); + expect(button(fixture, 'Expand all').disabled).toBe(false); + expect(button(fixture, 'Collapse all').disabled).toBe(false); + + fixture.componentInstance.setFilter('item'); + await settle(fixture); + expect(button(fixture, 'Expand all').disabled).toBe(true); + expect(button(fixture, 'Collapse all').disabled).toBe(true); + + fixture.componentInstance.collapseAll(); + fixture.componentInstance.setFilter(''); + await settle(fixture); + expect(button(fixture, 'Collapse all').disabled).toBe(false); + expect(visible(fixture)).toEqual(['app', 'header', 'nav', 'main', 'list', 'item', 'footer']); + }); +}); diff --git a/app/src/pages/component-tree.ts b/app/src/pages/component-tree.ts index 19fd53b0..3a82945a 100644 --- a/app/src/pages/component-tree.ts +++ b/app/src/pages/component-tree.ts @@ -179,6 +179,30 @@ function bare(name: string): string { /> } {{ countLabel() }} + + @if (!native()) {