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 3941b79a..638ae5ed 100644 --- a/app/src/__tests__/di-inspector-pages.test.ts +++ b/app/src/__tests__/di-inspector-pages.test.ts @@ -290,6 +290,67 @@ describe('injector tree page selection', () => { }); }); +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"]'), + ); + }); +}); + describe('injector tree page picker', () => { afterEach(() => { scopeToPage(null); 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 5c9dd9f4..4e5ff0dd 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'; @@ -706,3 +707,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 bb065b8f..a75faa68 100644 --- a/app/src/pages/analog-inspector.ts +++ b/app/src/pages/analog-inspector.ts @@ -414,6 +414,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 958308dd..277c3a96 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, @@ -9,6 +10,7 @@ import { input, linkedSignal, signal, + viewChild, } from '@angular/core'; import { NgTemplateOutlet } from '@angular/common'; import type { DevframeRpcClient } from 'devframe/client'; @@ -147,6 +149,7 @@ function isTree(value: unknown): value is InjectorNode[] { Nothing here matches “{{ query().trim() }}”. - + } @@ -490,6 +493,7 @@ function isTree(value: unknown): value is InjectorNode[] {

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

    Try part of a token name or a file path.

    - + } @else {
    @@ -1411,6 +1415,7 @@ export class DiInspector { readonly loaded = signal(false); readonly view = signal('element'); readonly query = signal(''); + private readonly queryInput = viewChild>('queryInput'); readonly onlyProviding = signal(false); readonly componentsOnly = signal(true); readonly collapsed = this.perPage>(() => new Set()); @@ -1594,6 +1599,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';