Skip to content

Commit 8bcddbd

Browse files
committed
Merge remote-tracking branch 'origin/main' into feat/ng-add-lan
2 parents 1bb729b + 5189ef5 commit 8bcddbd

87 files changed

Lines changed: 3657 additions & 701 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.claude/skills/devtools-ui/SKILL.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ Read `docs/contributing/ui-guidelines.md` first; it is the source of truth for t
1717
- Styles are SCSS in the component `styles` field, starting with `@use 'mixins' as m;`.
1818
- Colors only through CSS variables (`--surface`, `--text-2`, `--accent`, ...). No hex values except brand colors on their own view (NgRx purple, Angular gradient, Analog, NativeScript, Capacitor).
1919
- Controls are `var(--control-h)` tall. Use `app/src/ui/select.ts` for every dropdown; never a native `<select>`.
20-
- Page structure: intro line, sticky toolbar (search with icon and `Escape` to clear, filters, count, actions), content (list or tree plus sticky detail on wide screens), and loading, error with Retry, empty and no-match states.
20+
- Page structure: intro line, sticky toolbar (`app/src/ui/search-field.ts` named "Filter", filters, count, actions), content (list or tree plus sticky detail on wide screens), and loading, error with Retry, empty and no-match states.
2121
- Headings start at `h2` and never skip a level. Section labels use `m.label`.
2222
- Rows are keyboard reachable (buttons or ARIA tree items with arrow keys). Selection is keyed by stable ids from the page.
2323
- Rows that map to an element in the app call `request-page-highlight` on hover and focus, and clear it on leave and blur.

‎app/src/__tests__/app-shell.test.ts‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,3 +320,38 @@ describe('App tab bar', () => {
320320
fixture.destroy();
321321
});
322322
});
323+
324+
describe('App counts launcher setting', () => {
325+
it('sends the stored counts launcher to the popup once the panel connects', async () => {
326+
localStorage.setItem('pangular-pref:pill', 'true');
327+
const fixture = TestBed.createComponent(App);
328+
document.body.appendChild(host(fixture));
329+
await settle(fixture);
330+
const app = fixture.componentInstance;
331+
const posted = vi.spyOn(app as unknown as { postToPopup(m: unknown): void }, 'postToPopup');
332+
app.connected.set(true);
333+
await settle(fixture);
334+
expect(posted).toHaveBeenCalledWith({ type: 'pangular:popup-pill', pill: true });
335+
localStorage.removeItem('pangular-pref:pill');
336+
fixture.destroy();
337+
});
338+
339+
it('saves the counts launcher and sends it to the popup on the page', async () => {
340+
localStorage.removeItem('pangular-pref:pill');
341+
const fixture = TestBed.createComponent(App);
342+
document.body.appendChild(host(fixture));
343+
await settle(fixture);
344+
const app = fixture.componentInstance;
345+
expect(app.pill()).toBe(false);
346+
const sent = vi.spyOn(window.parent, 'postMessage');
347+
app.setPill(true);
348+
expect(app.pill()).toBe(true);
349+
expect(localStorage.getItem('pangular-pref:pill')).toBe('true');
350+
app.setPill(false);
351+
expect(localStorage.getItem('pangular-pref:pill')).toBeNull();
352+
// A top-level panel has no popup around it, so nothing is posted.
353+
expect(sent).not.toHaveBeenCalled();
354+
sent.mockRestore();
355+
fixture.destroy();
356+
});
357+
});

‎app/src/__tests__/component-value-tree.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -384,7 +384,7 @@ describe('ComponentTree value expansion', () => {
384384
const call = vi.fn<Call>(async () => []);
385385
const fixture = await open(call);
386386
const filter = el(fixture).querySelector<HTMLInputElement>(
387-
'input[aria-label^="Filter inputs, outputs and properties"]',
387+
'input[aria-label="Filter values"]',
388388
)!;
389389
type(filter, 'coun');
390390
await settle(fixture);

‎app/src/__tests__/di-inspector-pages.test.ts‎

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,28 @@ describe('injector tree page selection', () => {
296296
});
297297
});
298298

299+
describe('injector detail splitter', () => {
300+
afterEach(() => {
301+
document.body.innerHTML = '';
302+
localStorage.clear();
303+
});
304+
305+
it('puts a keyboard splitter between the tree and the detail that sizes the detail', async () => {
306+
localStorage.setItem('pangular-pref:pane:injectors', '500');
307+
const fixture = await render({ roots: [node('shared')], environment: [] });
308+
const host = fixture.nativeElement as HTMLElement;
309+
const bar = host.querySelector<HTMLElement>('.layout > app-splitter')!;
310+
expect(bar.getAttribute('role')).toBe('separator');
311+
expect(bar.getAttribute('aria-label')).toBe('Resize the injector detail');
312+
expect(bar.getAttribute('aria-valuenow')).toBe('500');
313+
const layout = host.querySelector<HTMLElement>('.layout')!;
314+
expect(layout.style.getPropertyValue('--detail-size')).toBe('500px');
315+
bar.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
316+
await fixture.whenStable();
317+
expect(layout.style.getPropertyValue('--detail-size')).toBe('516px');
318+
});
319+
});
320+
299321
describe('injector search box focus', () => {
300322
afterEach(() => {
301323
document.body.innerHTML = '';
@@ -328,9 +350,7 @@ describe('injector search box focus', () => {
328350
clickNamed(host, 'Clear filters');
329351
await fixture.whenStable();
330352
expect(fixture.componentInstance.query()).toBe('');
331-
expect(document.activeElement).toBe(
332-
host.querySelector('input[aria-label="Find a token, component or injector"]'),
333-
);
353+
expect(document.activeElement).toBe(host.querySelector('.toolbar input[type="search"]'));
334354
});
335355

336356
it('keeps focus on the search box after Clear filter in the source scan', async () => {
@@ -351,9 +371,7 @@ describe('injector search box focus', () => {
351371
clickNamed(host, 'Clear filters');
352372
await fixture.whenStable();
353373
expect(host.textContent).toContain('CartService');
354-
expect(document.activeElement).toBe(
355-
host.querySelector('input[aria-label="Filter by token or file"]'),
356-
);
374+
expect(document.activeElement).toBe(host.querySelector('.toolbar input[type="search"]'));
357375
});
358376
});
359377

‎app/src/__tests__/forms-panels.test.ts‎

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -340,7 +340,7 @@ describe('FormsInspector fields', () => {
340340
fixture.componentRef.setInput('rpc', fakeClient(fail, [form]));
341341
await settle(fixture);
342342
const box = (fixture.nativeElement as HTMLElement).querySelector<HTMLInputElement>(
343-
'input[aria-label="Filter fields by path"]',
343+
'.filters input[type="search"]',
344344
)!;
345345
box.value = 'email';
346346
box.dispatchEvent(new Event('input'));
@@ -367,7 +367,7 @@ describe('FormsInspector field filters', () => {
367367

368368
function search(fixture: ComponentFixture<FormsInspector>): HTMLInputElement {
369369
return (fixture.nativeElement as HTMLElement).querySelector<HTMLInputElement>(
370-
'input.filter[type="search"]',
370+
'.filters input[type="search"]',
371371
)!;
372372
}
373373

@@ -1023,9 +1023,7 @@ describe('FormsInspector timeline', () => {
10231023
expect(text(fixture)).toContain('Showing the latest 100 of 300 events');
10241024

10251025
const host = fixture.nativeElement as HTMLElement;
1026-
const search = host.querySelector<HTMLInputElement>(
1027-
'input[aria-label="Filter by field path"]',
1028-
)!;
1026+
const search = host.querySelector<HTMLInputElement>('app-forms-timeline input[type="search"]')!;
10291027
search.value = 'name';
10301028
search.dispatchEvent(new Event('input'));
10311029
await settle(fixture);

‎app/src/__tests__/forms-timeline.test.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,8 @@ describe('FormsTimeline filters', () => {
5454
it('labels the path filter and the event type select', async () => {
5555
const fixture = await setup(EVENTS);
5656
const input = root(fixture).querySelector('input[type="search"]')!;
57-
expect(input.getAttribute('aria-label')).toBe('Filter by field path');
57+
expect(input.getAttribute('aria-label')).toBe('Filter');
58+
expect(input.getAttribute('placeholder')).toBe('Filter by field path');
5859
const select = root(fixture).querySelector('button[role="combobox"]')!;
5960
expect(select.getAttribute('aria-label')).toBe('Event type');
6061
expect(total(fixture)).toBe('6 events');
@@ -114,7 +115,10 @@ describe('FormsTimeline filters', () => {
114115

115116
it('announces the count when a filter changes, not on every new event', async () => {
116117
const fixture = await setup(EVENTS);
117-
const status = () => root(fixture).querySelector('[role="status"]')?.textContent?.trim();
118+
const status = () =>
119+
[...root(fixture).querySelectorAll('[role="status"]')]
120+
.find((e) => !e.closest('app-search-field'))
121+
?.textContent?.trim();
118122
expect(root(fixture).querySelector('.total')?.hasAttribute('aria-live')).toBe(false);
119123
await typePath(fixture, 'name');
120124
const filtered = status();

‎app/src/__tests__/network-timeline-filter.test.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ const host = () => fixture.nativeElement as HTMLElement;
2121
const rows = () => [...host().querySelectorAll<HTMLElement>('button[data-call-id]')];
2222
const rowIds = () => rows().map((b) => b.dataset['callId']);
2323
const total = () => host().querySelector('.filters .total')!.textContent!.trim();
24+
/** The filter count status, not the saved filter status of the search field. */
25+
const pageStatus = () =>
26+
[...host().querySelectorAll<HTMLElement>('.filters [role="status"]')].find(
27+
(e) => !e.closest('app-search-field'),
28+
)!;
2429
const search = () => host().querySelector<HTMLInputElement>('#timeline-search')!;
2530

2631
async function type(value: string) {
@@ -63,7 +68,7 @@ describe('NetworkInspector timeline filters', () => {
6368
it('labels every filter control and counts all calls', () => {
6469
expect(rows()).toHaveLength(6);
6570
expect(total()).toBe('6 of 6');
66-
expect(search().getAttribute('aria-label')).toBe('Find a URL or method');
71+
expect(search().getAttribute('aria-label')).toBe('Filter');
6772
const failed = [...host().querySelectorAll<HTMLLabelElement>('.filters label')].find((l) =>
6873
l.textContent?.includes('Failed only'),
6974
);
@@ -152,7 +157,7 @@ describe('NetworkInspector timeline filters', () => {
152157
});
153158

154159
it('announces the count when a filter changes, not when calls arrive', async () => {
155-
const status = () => host().querySelector('.filters [role="status"]')!.textContent!.trim();
160+
const status = () => pageStatus().textContent!.trim();
156161
expect(host().querySelector('.filters .total')!.closest('[aria-live], [role="status"]')).toBe(
157162
null,
158163
);
@@ -173,7 +178,7 @@ describe('NetworkInspector timeline filters', () => {
173178
});
174179

175180
it('announces a filter change again when the counts stay the same', async () => {
176-
const raw = () => host().querySelector('.filters [role="status"]')!.textContent!;
181+
const raw = () => pageStatus().textContent!;
177182
await type('products');
178183
const first = raw();
179184
expect(first.trim()).toBe('2 of 6');
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// @vitest-environment node
2+
// Checks over the page sources: every page uses the shared search field, and the pages with a
3+
// list and a detail use the splitter. Kept apart from the component tests, which the Angular
4+
// compiler transforms.
5+
import { describe, expect, it } from 'vitest';
6+
7+
const PAGES = new URL('../pages/', import.meta.url).pathname;
8+
9+
/** The page sources by file name. The panel tsconfig has no Node types, so fs is a loose import. */
10+
async function pageSources(): Promise<Map<string, string>> {
11+
const fsName = 'node:fs';
12+
const fs = (await import(/* @vite-ignore */ fsName)) as {
13+
readFileSync(path: string, encoding: 'utf8'): string;
14+
readdirSync(path: string): string[];
15+
};
16+
const files = fs.readdirSync(PAGES).filter((file) => file.endsWith('.ts'));
17+
return new Map(files.map((file) => [file, fs.readFileSync(`${PAGES}${file}`, 'utf8')]));
18+
}
19+
20+
describe('every page uses the shared search field', () => {
21+
it('has no hand-written search input left in a page', async () => {
22+
const raw = [...(await pageSources())]
23+
.filter(([, source]) => /type="search"/.test(source))
24+
.map(([file]) => file);
25+
expect(raw).toEqual([]);
26+
});
27+
28+
it('names no field anything other than Filter, except a second field on the same page', async () => {
29+
const labels = [...(await pageSources())].flatMap(([file, source]) =>
30+
[...source.matchAll(/<app-search-field[^>]*?\blabel="([^"]+)"/gs)].map(
31+
(match) => `${file}: ${match[1]}`,
32+
),
33+
);
34+
// The values filter sits under the component filter in the Components detail panel.
35+
expect(labels).toEqual(['component-tree.ts: Filter values']);
36+
});
37+
38+
it('uses Filter, not Find, in every placeholder', async () => {
39+
const placeholders = [...(await pageSources()).values()].flatMap((source) =>
40+
[...source.matchAll(/<app-search-field[^>]*?\bplaceholder="([^"]+)"/gs)].map(
41+
(match) => match[1]!,
42+
),
43+
);
44+
expect(placeholders.length).toBeGreaterThan(15);
45+
for (const placeholder of placeholders) {
46+
expect(placeholder).toMatch(/^Filter\b/);
47+
expect(placeholder).not.toMatch(/…$/);
48+
}
49+
});
50+
});
51+
52+
describe('pages with a splitter', () => {
53+
it('size the injector detail and the store change list from a saved width', async () => {
54+
const sources = await pageSources();
55+
const page = (name: string) => sources.get(name) ?? '';
56+
expect(page('di-inspector.ts')).toMatch(/<app-splitter[^>]*view="injectors"/s);
57+
expect(page('di-inspector.ts')).toContain("storedPaneSize('injectors', 420)");
58+
expect(page('store-inspector.ts')).toMatch(
59+
/<app-splitter[^>]*view="store-log"[^>]*pane="before"/s,
60+
);
61+
expect(page('store-inspector.ts')).toContain("storedPaneSize('store-log', 300)");
62+
});
63+
});

‎app/src/__tests__/route-timeline-clear.test.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,11 @@ function clearButton(fixture: ComponentFixture<unknown>): HTMLButtonElement {
5555
}
5656

5757
function status(fixture: ComponentFixture<unknown>): string {
58-
return el(fixture).querySelector('[role="status"]')?.textContent?.trim() ?? '';
58+
return (
59+
[...el(fixture).querySelectorAll('[role="status"]')]
60+
.find((e) => !e.closest('app-search-field'))
61+
?.textContent?.trim() ?? ''
62+
);
5963
}
6064

6165
afterEach(() => {

‎app/src/__tests__/router-panels.test.ts‎

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ describe('RouteTree row actions', () => {
9696
first.value = '42';
9797
first.dispatchEvent(new Event('input'));
9898

99-
const filter = el(fixture).querySelector<HTMLInputElement>('input.filter')!;
99+
const filter = el(fixture).querySelector<HTMLInputElement>('.filter input[type="search"]')!;
100100
filter.value = 'lazy';
101101
filter.dispatchEvent(new Event('input'));
102102
await settle(fixture);
@@ -202,8 +202,9 @@ describe('RouteTree page switch', () => {
202202
await settle(fixture);
203203
expect(liveRegion(fixture).textContent?.trim()).toBe('');
204204
const regions = el(fixture).querySelectorAll('[role="status"]');
205-
expect(regions).toHaveLength(2);
206-
expect(Array.from(regions).map((r) => r.textContent?.trim())).toEqual(['', '']);
205+
// The row status, the filter count and the saved filter status of the search field.
206+
expect(regions).toHaveLength(3);
207+
expect(Array.from(regions).map((r) => r.textContent?.trim())).toEqual(['', '', '']);
207208
});
208209

209210
it("drops page A's results and typed params after switching to page B", async () => {
@@ -268,7 +269,7 @@ describe('RouteTree filter count', () => {
268269
];
269270

270271
function filterInput(fixture: ComponentFixture<unknown>): HTMLInputElement {
271-
return el(fixture).querySelector<HTMLInputElement>('input.filter')!;
272+
return el(fixture).querySelector<HTMLInputElement>('.filter input[type="search"]')!;
272273
}
273274

274275
function type(fixture: ComponentFixture<unknown>, value: string) {
@@ -897,15 +898,18 @@ describe('Router filter boxes clear on Escape', () => {
897898
offline,
898899
);
899900
await settle(fixture);
900-
await typeThenEscape(fixture, el(fixture).querySelector<HTMLInputElement>('input.filter')!);
901+
await typeThenEscape(
902+
fixture,
903+
el(fixture).querySelector<HTMLInputElement>('.filter input[type="search"]')!,
904+
);
901905
});
902906

903907
it('clears the navigation URL filter', async () => {
904908
const fixture = mount(RouteTimeline, page({ navigations: [nav(1, '/users')] }), offline);
905909
await settle(fixture);
906910
await typeThenEscape(
907911
fixture,
908-
el(fixture).querySelector<HTMLInputElement>('input[aria-label="Filter navigations by URL"]')!,
912+
el(fixture).querySelector<HTMLInputElement>('.toolbar input[type="search"]')!,
909913
);
910914
});
911915

@@ -924,7 +928,7 @@ describe('Router filter boxes clear on Escape', () => {
924928
await settle(fixture);
925929
await typeThenEscape(
926930
fixture,
927-
el(fixture).querySelector<HTMLInputElement>('input[aria-label="Filter source routes"]')!,
931+
el(fixture).querySelector<HTMLInputElement>('.toolbar input[type="search"]')!,
928932
);
929933
});
930934
});

0 commit comments

Comments
 (0)