Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 180 additions & 0 deletions app/src/__tests__/component-tree-expand.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
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<string, unknown> = {
'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<unknown>) {
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<ComponentTree>) => fixture.nativeElement as HTMLElement;

function visible(fixture: ComponentFixture<ComponentTree>) {
return Array.from(host(fixture).querySelectorAll<HTMLElement>('.row')).map(
(row) => row.dataset['id'],
);
}

function button(fixture: ComponentFixture<ComponentTree>, label: string) {
const found = Array.from(host(fixture).querySelectorAll<HTMLButtonElement>('button')).find(
(b) => b.textContent?.trim() === label,
);
if (!found) throw new Error(`No ${label} button`);
return found;
}

function tabbable(fixture: ComponentFixture<ComponentTree>) {
return Array.from(host(fixture).querySelectorAll<HTMLElement>('.row[tabindex="0"]')).map(
(row) => row.dataset['id'],
);
}

function status(fixture: ComponentFixture<ComponentTree>) {
return host(fixture).querySelector('p.sr-only[role="status"]')?.textContent?.trim();
}

async function key(fixture: ComponentFixture<ComponentTree>, id: string, k: string) {
const row = host(fixture).querySelector<HTMLElement>(`.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('changes the status text when the same action repeats so it is read again', async () => {
const fixture = await setup();
button(fixture, 'Expand all').click();
await settle(fixture);
const raw = () => host(fixture).querySelector('p.sr-only[role="status"]')?.textContent;
const first = raw();
button(fixture, 'Expand all').click();
await settle(fixture);
const second = raw();
expect(second).toContain('Expanded every component');
expect(second).not.toBe(first);
});

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']);
});
});
73 changes: 73 additions & 0 deletions app/src/pages/component-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,30 @@ function bare(name: string): string {
/>
}
<span class="count">{{ countLabel() }}</span>
<button
type="button"
class="tree-action"
[disabled]="!!filter().trim()"
(click)="expandAll()"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="m7 15 5 5 5-5" />
<path d="m7 9 5-5 5 5" />
</svg>
Expand all
</button>
<button
type="button"
class="tree-action"
[disabled]="!!filter().trim()"
(click)="collapseAll()"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="m7 20 5-5 5 5" />
<path d="m7 4 5 5 5-5" />
</svg>
Collapse all
</button>
@if (!native()) {
<button
type="button"
Expand Down Expand Up @@ -797,6 +821,12 @@ function bare(name: string): string {
flex: 0 1 220px;
min-width: 0;
}
.tree-action:disabled,
.tree-action:disabled:hover {
background: var(--surface-2);
border-color: var(--border-strong);
opacity: 0.5;
}
.pick.on {
background: var(--accent-soft);
border-color: var(--accent-line);
Expand Down Expand Up @@ -1817,6 +1847,46 @@ export class ComponentTree {
});
}

expandAll() {
if (this.query()) return;
this.collapsed.set(new Set());
this.announce('Expanded every component.');
}

collapseAll() {
if (this.query()) return;
const { map, parents } = this.index();
const previous = this.rovingId();
const next = new Set<string>();
for (const node of map.values()) if (node.children.length) next.add(node.id);
this.collapsed.set(next);
if (previous) {
const visible = new Set(this.rows().map((row) => row.node.id));
let id: string | undefined = previous;
while (id && !visible.has(id)) id = parents.get(id);
if (id) this.focusId.set(id);
}
this.announce('Collapsed every component. Only the top level is shown.');
}

/** Sets the live message, changing it slightly when it repeats so screen readers say it again. */
private announce(message: string) {
this.announcement.update((current) => (current === message ? `${message}\u00a0` : message));
}

private expandSiblings(id: string) {
if (this.query()) return;
const parent = this.index().parents.get(id);
const siblings = parent
? (this.index().map.get(parent)?.children ?? [])
: (this.page()?.roots ?? []);
this.collapsed.update((set) => {
const next = new Set(set);
for (const sibling of siblings) next.delete(sibling.id);
return next;
});
}

highlight(id: string | null) {
const client = this.rpc();
if (!client) return;
Expand Down Expand Up @@ -1870,6 +1940,9 @@ export class ComponentTree {
case ' ':
this.select(row.node.id);
break;
case '*':
this.expandSiblings(row.node.id);
break;
default:
return;
}
Expand Down
7 changes: 5 additions & 2 deletions apps/docs/src/content/inspectors/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,10 @@ Each row shows the class name and the host tag. Routed components get a chip wit
- The toolbar counts the instances on the page. While you filter, it shows how many instances match, like **2 of 40**. Ancestors kept to show where a match sits don't count.
- **Hover or focus** a row to highlight its host element in the page.
- **Click** a row, or press Enter or Space, to select it. Click it again to clear the selection.
- **Expand all** opens every row and **Collapse all** closes them down to the top level. Both are off while you filter, because the filter already opens every row that leads to a match.
- **Pick component on page** selects the component behind an element you click in the app. See [Pick a component on the page](#pick-a-component-on-the-page).

Screen readers hear the match count when the filter changes. Components that the app adds or removes are not announced.
Screen readers hear the match count when the filter changes, and a short message after **Expand all** or **Collapse all**. Components that the app adds or removes are not announced.

### Pages

Expand Down Expand Up @@ -194,7 +195,9 @@ To go the other way, select a component and click **Reveal in Elements** in its

### Keyboard

Use the arrow keys, Home and End to move through the tree. The right arrow expands a row or moves to its first child. The left arrow collapses a row or moves to its parent.
Use the arrow keys, Home and End to move through the tree. The right arrow expands a row or moves to its first child. The left arrow collapses a row or moves to its parent. The `*` key expands the focused row and every row at the same level under the same parent.

After **Collapse all**, the row you return to with Tab is the closest visible parent of the row you were on.

## Agent tools

Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading