From 0312e3389cfb697dbf8811249e1bc7066ab4f45d Mon Sep 17 00:00:00 2001 From: erKam Date: Sun, 11 Oct 2026 03:03:15 +0000 Subject: [PATCH 1/2] fix(ui): keep focus on the filter box after Clear filters Clear filter and Clear filters sit inside the no-match state, so clearing removed the button and focus fell to the page body (WCAG 2.4.3). Components (live and source), Injectors (Clear search and the source Clear filter), Pipes, Signals (live and source) and Store now move focus back to their filter box, the way Routes, Forms and SSR & HTTP already do. Jumping to a hidden signal node still clears the filters without taking focus. Every filter box now clears on Escape: Signals, Store, the route tree, navigations, source routes, Forms fields and Analog routes gain the handler. The text boxes among them become search boxes, like Components and Pipes, so the popup sees a search box with text and keeps the panel open on that Escape. The Components and Injectors keyboard notes mention Escape and where focus goes after clearing. --- .../__tests__/analog-inspector-pages.test.ts | 17 +++++ .../__tests__/component-tree-expand.test.ts | 66 +++++++++++++++++++ app/src/__tests__/di-inspector-pages.test.ts | 61 +++++++++++++++++ app/src/__tests__/forms-panels.test.ts | 17 +++++ app/src/__tests__/pipes-record-page.test.ts | 49 ++++++++++++++ app/src/__tests__/router-panels.test.ts | 56 ++++++++++++++++ app/src/__tests__/signal-inspector.test.ts | 64 ++++++++++++++++++ app/src/__tests__/store-inspector.test.ts | 26 ++++++++ app/src/pages/analog-inspector.ts | 1 + app/src/pages/component-tree.ts | 15 ++++- app/src/pages/di-inspector.ts | 15 ++++- app/src/pages/forms-inspector.ts | 1 + app/src/pages/pipes-inspector.ts | 4 ++ app/src/pages/route-inspector.ts | 3 +- app/src/pages/route-timeline.ts | 3 +- app/src/pages/route-tree.ts | 3 +- app/src/pages/signal-inspector.ts | 14 +++- app/src/pages/store-inspector.ts | 5 ++ .../docs/src/content/inspectors/components.md | 2 + apps/docs/src/content/inspectors/injectors.md | 2 + ...=> browser-agent-rpc-BXhoSh1z-BT0bXXtD.js} | 2 +- .../{index-CgvJVwtz.js => index-DqV1vqN9.js} | 56 ++++++++-------- extension/ui/index.html | 2 +- 23 files changed, 445 insertions(+), 39 deletions(-) rename extension/ui/assets/{browser-agent-rpc-BXhoSh1z-CQUbrXfP.js => browser-agent-rpc-BXhoSh1z-BT0bXXtD.js} (93%) rename extension/ui/assets/{index-CgvJVwtz.js => index-DqV1vqN9.js} (76%) diff --git a/app/src/__tests__/analog-inspector-pages.test.ts b/app/src/__tests__/analog-inspector-pages.test.ts index 3b6ec999..925e7b9c 100644 --- a/app/src/__tests__/analog-inspector-pages.test.ts +++ b/app/src/__tests__/analog-inspector-pages.test.ts @@ -93,3 +93,20 @@ describe('Analog project load', () => { expect(fixture.componentInstance.project()).not.toBeNull(); }); }); + +describe('Analog route filter', () => { + it('clears on Escape', async () => { + const fixture = await render(client()); + const box = (fixture.nativeElement as HTMLElement).querySelector( + '#route-filter', + )!; + box.value = 'products'; + box.dispatchEvent(new Event('input')); + await settle(fixture); + expect(fixture.componentInstance.filter()).toBe('products'); + box.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + await settle(fixture); + expect(fixture.componentInstance.filter()).toBe(''); + expect(box.value).toBe(''); + }); +}); diff --git a/app/src/__tests__/component-tree-expand.test.ts b/app/src/__tests__/component-tree-expand.test.ts index dce15188..1eb4d9cc 100644 --- a/app/src/__tests__/component-tree-expand.test.ts +++ b/app/src/__tests__/component-tree-expand.test.ts @@ -178,3 +178,69 @@ describe('ComponentTree expand and collapse all', () => { expect(visible(fixture)).toEqual(['app', 'header', 'nav', 'main', 'list', 'item', 'footer']); }); }); + +describe('ComponentTree Clear filter focus', () => { + beforeEach(() => { + vi.stubGlobal('CSS', { escape: (value: string) => value }); + Element.prototype.scrollIntoView ??= () => {}; + }); + + afterEach(() => { + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + }); + + const search = (fixture: ComponentFixture) => + host(fixture).querySelector('input[type="search"]'); + + it('keeps focus on the search box after Clear filter in the live tree', async () => { + const fixture = await setup(); + fixture.componentInstance.setFilter('zzz'); + await settle(fixture); + const clear = button(fixture, 'Clear filter'); + clear.focus(); + clear.click(); + await settle(fixture); + expect(visible(fixture)).toContain('app'); + expect(document.activeElement).toBe(search(fixture)); + }); + + it('keeps focus on the search box after Clear filter in the source scan', async () => { + const rpc = { + call: (name: string) => + Promise.resolve( + name === 'get-components' + ? [ + { + selector: 'app-cart', + className: 'Cart', + kind: 'component', + file: 'src/cart.ts', + line: 1, + inputs: [], + outputs: [], + isStandalone: true, + }, + ] + : [], + ), + callEvent: () => Promise.resolve(), + sharedState: () => Promise.resolve({ value: () => null, on: () => () => {} }), + }; + const fixture = TestBed.createComponent(ComponentTree); + fixture.componentRef.setInput('rpc', { + connectionMeta: {}, + scope: () => ({ rpc }), + } as unknown as DevframeRpcClient); + document.body.appendChild(fixture.nativeElement); + await settle(fixture); + fixture.componentInstance.filter.set('zzz'); + await settle(fixture); + const clear = button(fixture, 'Clear filter'); + clear.focus(); + clear.click(); + await settle(fixture); + expect(host(fixture).textContent).toContain('app-cart'); + expect(document.activeElement).toBe(search(fixture)); + }); +}); diff --git a/app/src/__tests__/di-inspector-pages.test.ts b/app/src/__tests__/di-inspector-pages.test.ts index 1b076c89..f799cb8d 100644 --- a/app/src/__tests__/di-inspector-pages.test.ts +++ b/app/src/__tests__/di-inspector-pages.test.ts @@ -289,3 +289,64 @@ describe('injector tree page selection', () => { expect(fixture.componentInstance.roots().map((n) => n.injector.name)).toEqual(['shared']); }); }); + +describe('injector search box focus', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + function clickNamed(host: HTMLElement, name: string) { + const button = Array.from(host.querySelectorAll('button')).find( + (b) => b.textContent?.trim() === name, + ); + if (!button) throw new Error(`No button named ${name}`); + button.focus(); + button.click(); + } + + it('keeps focus on the search box after Clear search in the live tree', async () => { + const fixture = await render({ + roots: [ + { + injector: { id: 'a', type: 'element', name: 'A', providerCount: 0, component: 'A' }, + providers: [], + children: [], + }, + ], + environment: [], + }); + const host = fixture.nativeElement as HTMLElement; + document.body.append(host); + fixture.componentInstance.query.set('zzz'); + await fixture.whenStable(); + clickNamed(host, 'Clear search'); + await fixture.whenStable(); + expect(fixture.componentInstance.query()).toBe(''); + expect(document.activeElement).toBe( + host.querySelector('input[aria-label="Find a token, component or injector"]'), + ); + }); + + it('keeps focus on the search box after Clear filter in the source scan', async () => { + const fixture = await render({}); + const host = fixture.nativeElement as HTMLElement; + document.body.append(host); + fixture.componentInstance.sourceProviders.set([ + { + token: 'CartService', + source: 'CartService', + file: 'src/cart.ts', + line: 1, + type: 'injectable', + }, + ]); + fixture.componentInstance.query.set('zzz'); + await fixture.whenStable(); + clickNamed(host, 'Clear filter'); + await fixture.whenStable(); + expect(host.textContent).toContain('CartService'); + expect(document.activeElement).toBe( + host.querySelector('input[aria-label="Filter by token or file"]'), + ); + }); +}); diff --git a/app/src/__tests__/forms-panels.test.ts b/app/src/__tests__/forms-panels.test.ts index c81fa2ca..d3cafd44 100644 --- a/app/src/__tests__/forms-panels.test.ts +++ b/app/src/__tests__/forms-panels.test.ts @@ -283,6 +283,23 @@ describe('FormsInspector fields', () => { expect(host.querySelector('app-forms-field-detail')).toBeNull(); expect(document.activeElement).toBe(row); }); + + it('clears the field path filter on Escape', async () => { + const fixture = TestBed.createComponent(FormsInspector); + fixture.componentRef.setInput('rpc', fakeClient(fail, [form])); + await settle(fixture); + const box = (fixture.nativeElement as HTMLElement).querySelector( + 'input[aria-label="Filter fields by path"]', + )!; + box.value = 'email'; + box.dispatchEvent(new Event('input')); + await settle(fixture); + expect(fixture.componentInstance.filter()).toBe('email'); + box.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + await settle(fixture); + expect(fixture.componentInstance.filter()).toBe(''); + expect(box.value).toBe(''); + }); }); describe('FormsInspector status pills', () => { diff --git a/app/src/__tests__/pipes-record-page.test.ts b/app/src/__tests__/pipes-record-page.test.ts index bf12fdc2..ecc16be2 100644 --- a/app/src/__tests__/pipes-record-page.test.ts +++ b/app/src/__tests__/pipes-record-page.test.ts @@ -56,3 +56,52 @@ describe('pipe recording request', () => { expect(requests).toEqual([{ on: false }]); }); }); + +describe('pipes filter box', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + function withPipes() { + const rpc = { + call: (name: string) => + Promise.resolve( + name === 'get-pipes' + ? [ + { + name: 'price', + className: 'PricePipe', + file: 'src/price.pipe.ts', + line: 4, + isStandalone: true, + isPure: true, + }, + ] + : [], + ), + callEvent: () => Promise.resolve(), + sharedState: () => + Promise.resolve({ + value: () => ({ pipes: [], async: [], reportedAt: 0, instrumented: [] }), + on: () => () => {}, + }), + }; + return { connectionMeta: {}, scope: () => ({ rpc }) } as unknown as DevframeRpcClient; + } + + it('keeps focus on the search box after Clear filters', async () => { + const fixture = await render(withPipes()); + const host = fixture.nativeElement as HTMLElement; + document.body.append(host); + fixture.componentInstance.filter.set('zzz'); + await fixture.whenStable(); + const clear = Array.from(host.querySelectorAll('button')).find( + (b) => b.textContent?.trim() === 'Clear filters', + )!; + clear.focus(); + clear.click(); + await fixture.whenStable(); + expect(host.textContent).not.toContain('No pipes match'); + expect(document.activeElement).toBe(host.querySelector('input[type="search"]')); + }); +}); diff --git a/app/src/__tests__/router-panels.test.ts b/app/src/__tests__/router-panels.test.ts index cbf5ba40..f63aeedd 100644 --- a/app/src/__tests__/router-panels.test.ts +++ b/app/src/__tests__/router-panels.test.ts @@ -3,6 +3,7 @@ import { TestBed, type ComponentFixture } from '@angular/core/testing'; import type { DevframeRpcClient } from 'devframe/client'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { RouteCurrent } from '../pages/route-current'; +import { RouteInspector } from '../pages/route-inspector'; import { RouteLint } from '../pages/route-lint'; import { RouteTimeline } from '../pages/route-timeline'; import { RouteTree } from '../pages/route-tree'; @@ -524,3 +525,58 @@ describe('RouteCurrent', () => { expect(showButtons(fixture)).toHaveLength(0); }); }); + +describe('Router filter boxes clear on Escape', () => { + async function typeThenEscape( + fixture: ComponentFixture<{ filter: () => string }>, + box: HTMLInputElement, + ) { + expect(box.type).toBe('search'); + box.value = 'users'; + box.dispatchEvent(new Event('input')); + await settle(fixture); + expect(fixture.componentInstance.filter()).toBe('users'); + box.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + await settle(fixture); + expect(fixture.componentInstance.filter()).toBe(''); + expect(box.value).toBe(''); + } + + it('clears the Routes filter', async () => { + const fixture = mount( + RouteTree, + page({ config: [{ id: 'r1', path: 'users', fullPath: 'users', kind: 'component' }] }), + offline, + ); + await settle(fixture); + await typeThenEscape(fixture, el(fixture).querySelector('input.filter')!); + }); + + it('clears the navigation URL filter', async () => { + const fixture = mount(RouteTimeline, page({ navigations: [nav(1, '/users')] }), offline); + await settle(fixture); + await typeThenEscape( + fixture, + el(fixture).querySelector('input[aria-label="Filter navigations by URL"]')!, + ); + }); + + it('clears the source routes filter', async () => { + const rpc = { + call: () => Promise.resolve([]), + callEvent: () => Promise.resolve(), + sharedState: () => Promise.resolve({ value: () => null, on: () => () => {} }), + }; + const fixture = TestBed.createComponent(RouteInspector); + fixture.componentRef.setInput('rpc', { + connectionMeta: {}, + scope: () => ({ rpc }), + } as unknown as DevframeRpcClient); + document.body.append(el(fixture)); + await settle(fixture); + await typeThenEscape( + fixture, + el(fixture).querySelector('input[aria-label="Filter source routes"]')!, + ); + }); +}); diff --git a/app/src/__tests__/signal-inspector.test.ts b/app/src/__tests__/signal-inspector.test.ts index aeec4fcb..b9ed8b1b 100644 --- a/app/src/__tests__/signal-inspector.test.ts +++ b/app/src/__tests__/signal-inspector.test.ts @@ -241,4 +241,68 @@ describe('SignalInspector', () => { await fixture.whenStable(); expect(host.textContent).toContain('user'); }); + + describe('filter box focus and Escape', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + function filterBox(host: HTMLElement): HTMLInputElement { + const box = host.querySelector( + 'input[aria-label="Filter signals by name or kind"]', + ); + if (!box) throw new Error('No filter box'); + return box; + } + + function clearButton(host: HTMLElement): HTMLButtonElement { + const clear = Array.from(host.querySelectorAll('button')).find( + (b) => b.textContent?.trim() === 'Clear filters', + ); + if (!clear) throw new Error('No Clear filters button'); + return clear; + } + + it('keeps focus on the filter box after Clear filters in the live graph', async () => { + const fixture = await render(graph); + const host = fixture.nativeElement as HTMLElement; + document.body.append(host); + fixture.componentInstance.filter.set('zzz'); + await fixture.whenStable(); + clearButton(host).focus(); + clearButton(host).click(); + await fixture.whenStable(); + expect(host.textContent).not.toContain('No signals match.'); + expect(document.activeElement).toBe(filterBox(host)); + }); + + it('keeps focus on the filter box after Clear filters in the source scan', async () => { + const fixture = await render(null as unknown as Record); + const host = fixture.nativeElement as HTMLElement; + document.body.append(host); + fixture.componentInstance.sourceSignals.set([ + { name: 'count', kind: 'signal', file: 'src/app.ts', line: 3 }, + ]); + fixture.componentInstance.filter.set('zzz'); + await fixture.whenStable(); + clearButton(host).focus(); + clearButton(host).click(); + await fixture.whenStable(); + expect(host.textContent).toContain('count'); + expect(document.activeElement).toBe(filterBox(host)); + }); + + it('clears the filter box on Escape', async () => { + const fixture = await render(graph); + const host = fixture.nativeElement as HTMLElement; + const box = filterBox(host); + expect(box.type).toBe('search'); + fixture.componentInstance.filter.set('query'); + await fixture.whenStable(); + box.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + await fixture.whenStable(); + expect(fixture.componentInstance.filter()).toBe(''); + expect(box.value).toBe(''); + }); + }); }); diff --git a/app/src/__tests__/store-inspector.test.ts b/app/src/__tests__/store-inspector.test.ts index 3591f186..26ce6ef9 100644 --- a/app/src/__tests__/store-inspector.test.ts +++ b/app/src/__tests__/store-inspector.test.ts @@ -376,4 +376,30 @@ describe('StoreInspector source declarations', () => { } expect(nodes.querySelector('[role="status"]')).not.toBeNull(); }); + + it('keeps focus on the filter box after Clear filters', async () => { + const { fixture } = await mount([entry(1)]); + document.body.append(root(fixture)); + const box = root(fixture).querySelector('input[type="search"]')!; + type(box, 'zzz'); + await settle(fixture); + const clear = button(fixture, 'Clear filters')!; + clear.focus(); + clear.click(); + await settle(fixture); + expect(button(fixture, 'Clear filters')).toBeUndefined(); + expect(fixture.componentInstance.filter()).toBe(''); + expect(document.activeElement).toBe(box); + }); + + it('clears the filter box on Escape', async () => { + const { fixture } = await mount([entry(1)]); + const box = root(fixture).querySelector('input[type="search"]')!; + type(box, 'cart'); + await settle(fixture); + box.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + await settle(fixture); + expect(fixture.componentInstance.filter()).toBe(''); + expect(box.value).toBe(''); + }); }); diff --git a/app/src/pages/analog-inspector.ts b/app/src/pages/analog-inspector.ts index afe562c8..ab8773c2 100644 --- a/app/src/pages/analog-inspector.ts +++ b/app/src/pages/analog-inspector.ts @@ -408,6 +408,7 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth placeholder="Filter by path or file" [value]="filter()" (input)="filter.set($any($event.target).value)" + (keydown.escape)="filter.set('')" />
diff --git a/app/src/pages/component-tree.ts b/app/src/pages/component-tree.ts index 200f61fc..0ee7cbc7 100644 --- a/app/src/pages/component-tree.ts +++ b/app/src/pages/component-tree.ts @@ -13,6 +13,7 @@ import { output, signal, untracked, + viewChild, } from '@angular/core'; import type { DevframeRpcClient } from 'devframe/client'; import { ExtensionBridge, type SourceLocation } from '../extension-bridge'; @@ -159,6 +160,7 @@ function bare(name: string): string { The filter only searches the components the tree lists.

} - +
}

No components match “{{ filter().trim() }}”

- +
} @else {
    @@ -1403,6 +1406,7 @@ export class ComponentTree { private readonly destroyRef = inject(DestroyRef); private readonly injector = inject(Injector); private readonly host = inject>(ElementRef); + private readonly filterInput = viewChild>('filterInput'); private readonly cleanups: (() => void)[] = []; private selection = { pageId: null as string | null, detailId: null as string | null }; @@ -1762,6 +1766,13 @@ export class ComponentTree { ); } + /** Clears the filter from the no-match state and keeps focus on the search box. */ + clearFilter() { + if (this.live()) this.setFilter(''); + else this.filter.set(''); + this.filterInput()?.nativeElement.focus(); + } + async pick() { const client = this.rpc(); if (!client || this.picking()) return; diff --git a/app/src/pages/di-inspector.ts b/app/src/pages/di-inspector.ts index 0f9a3d66..731156cb 100644 --- a/app/src/pages/di-inspector.ts +++ b/app/src/pages/di-inspector.ts @@ -1,6 +1,7 @@ import { Component, DestroyRef, + ElementRef, Injector, afterNextRender, computed, @@ -8,6 +9,7 @@ import { inject, input, signal, + viewChild, } from '@angular/core'; import type { DevframeRpcClient } from 'devframe/client'; import { hostPageId } from '../page-id'; @@ -118,6 +120,7 @@ function isTree(value: unknown): value is InjectorNode[] { Nothing here matches “{{ query().trim() }}”. - + } @@ -455,6 +458,7 @@ function isTree(value: unknown): value is InjectorNode[] {

    No providers match “{{ query() }}”

    Try part of a token name or a file path.

    - + } @else {
    @@ -1285,6 +1289,7 @@ export class DiInspector { readonly truncated = signal(false); readonly view = signal('element'); readonly query = signal(''); + private readonly queryInput = viewChild>('queryInput'); readonly onlyProviding = signal(false); readonly componentsOnly = signal(true); readonly collapsed = signal>(new Set()); @@ -1440,6 +1445,12 @@ export class DiInspector { }); } + /** Clears the search from a no-match message and keeps focus on the search box. */ + clearQuery() { + this.query.set(''); + this.queryInput()?.nativeElement.focus(); + } + label(node: InjectorNode) { return labelOf(node); } diff --git a/app/src/pages/forms-inspector.ts b/app/src/pages/forms-inspector.ts index cd2c272c..ead5ab86 100644 --- a/app/src/pages/forms-inspector.ts +++ b/app/src/pages/forms-inspector.ts @@ -360,6 +360,7 @@ function countFields(node: FormFieldNode): number { spellcheck="false" [value]="filter()" (input)="onFilter($event)" + (keydown.escape)="filter.set('')" />
    Show only fields that are diff --git a/app/src/pages/pipes-inspector.ts b/app/src/pages/pipes-inspector.ts index 100c716d..18c8b7a3 100644 --- a/app/src/pages/pipes-inspector.ts +++ b/app/src/pages/pipes-inspector.ts @@ -7,6 +7,7 @@ import { inject, input, signal, + viewChild, } from '@angular/core'; import type { DevframeRpcClient } from 'devframe/client'; import { hostPageId } from '../page-id'; @@ -123,6 +124,7 @@ const KIND_OPTIONS: readonly SelectOption[] = [ [] = [ export class PipesInspector { private readonly destroyRef = inject(DestroyRef); private readonly host = inject>(ElementRef); + private readonly filterInput = viewChild>('filterInput'); rpc = input(null); @@ -1239,6 +1242,7 @@ export class PipesInspector { clearFilters() { this.filter.set(''); this.kind.set('all'); + this.filterInput()?.nativeElement.focus(); } highlightPipe(live: LivePipeInfo | undefined) { diff --git a/app/src/pages/route-inspector.ts b/app/src/pages/route-inspector.ts index 05042265..bd850ac5 100644 --- a/app/src/pages/route-inspector.ts +++ b/app/src/pages/route-inspector.ts @@ -50,11 +50,12 @@ import type { ComponentFocus } from '../types/component-focus.types';