docs(docs-infra): Add package filter to the API list

This commit also introduces the usage of signal forms on adev

(cherry picked from commit 15308149db)
This commit is contained in:
Matthieu Riegler 2026-01-04 23:27:51 +01:00 committed by kirjs
parent 834bae1331
commit 1748c8984c
10 changed files with 123 additions and 215 deletions

View file

@ -3,7 +3,7 @@
<docs-text-field
[autofocus]="true"
[hideIcon]="true"
[formControl]="searchControl"
[field]="searchForm"
[resetLabel]="'Clear the search'"
class="docs-search-input"
placeholder="Search docs"
@ -59,7 +59,7 @@
</li>
}
</ul>
} @else if (history.hasItems() && !this.searchControl.value.length) {
} @else if (history.hasItems() && !this.searchForm().value().length) {
<docs-search-history />
} @else {
<div class="docs-search-results docs-mini-scroll-track">

View file

@ -20,19 +20,19 @@ import {
viewChildren,
} from '@angular/core';
import {WINDOW} from '../../providers';
import {ClickOutside, SearchItem} from '../../directives';
import {WINDOW} from '../../providers';
import {Search, SearchHistory} from '../../services';
import {TextField} from '../text-field/text-field.component';
import {FormControl, ReactiveFormsModule} from '@angular/forms';
import {ActiveDescendantKeyManager} from '@angular/cdk/a11y';
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
import {Field, form} from '@angular/forms/signals';
import {Router, RouterLink} from '@angular/router';
import {fromEvent} from 'rxjs';
import {AlgoliaIcon} from '../algolia-icon/algolia-icon.component';
import {RelativeLink} from '../../pipes';
import {AlgoliaIcon} from '../algolia-icon/algolia-icon.component';
import {SearchHistoryComponent} from '../search-history/search-history.component';
import {TextField} from '../text-field/text-field.component';
@Component({
selector: 'docs-search-dialog',
@ -40,7 +40,7 @@ import {SearchHistoryComponent} from '../search-history/search-history.component
imports: [
ClickOutside,
TextField,
ReactiveFormsModule,
Field,
SearchItem,
AlgoliaIcon,
RelativeLink,
@ -67,22 +67,14 @@ export class SearchDialog {
this.injector,
).withWrap();
readonly searchQuery = this.search.searchQuery;
readonly resultsResource = this.search.resultsResource;
readonly searchResults = this.search.searchResults;
// We use a FormControl instead of relying on NgModel+signal to avoid
// the issue https://github.com/angular/angular/issues/13568
// TODO: Use signal forms when available
searchControl = new FormControl(this.searchQuery(), {nonNullable: true});
searchForm = form(this.search.searchQuery);
constructor() {
inject(DestroyRef).onDestroy(() => this.keyManager.destroy());
this.searchControl.valueChanges.pipe(takeUntilDestroyed()).subscribe((value) => {
this.searchQuery.set(value);
});
// Thinking about refactoring this to a single afterRenderEffect ?
// Answer: It won't have the same behavior
effect(() => {

View file

@ -1,10 +1,10 @@
<select
[attr.id]="id()"
[attr.name]="name()"
[ngModel]="selectedOption()"
(ngModelChange)="setOption($event)"
[value]="value()"
(change)="setOption($any($event.target).value)"
>
@for (item of options(); track item) {
<option [value]="item.value">{{ item.label }}</option>
<option [value]="item.value" [selected]="value() === item.value">{{ item.label }}</option>
}
</select>

View file

@ -6,8 +6,8 @@
* found in the LICENSE file at https://angular.dev/license
*/
import {ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule} from '@angular/forms';
import {ChangeDetectionStrategy, Component, model, forwardRef, input, signal} from '@angular/core';
import {ChangeDetectionStrategy, Component, input, model} from '@angular/core';
import {FormValueControl} from '@angular/forms/signals';
type SelectOptionValue = string | number | boolean;
@ -19,59 +19,25 @@ export interface SelectOption {
@Component({
selector: 'docs-select',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [FormsModule],
templateUrl: './select.component.html',
styleUrls: ['./select.component.scss'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => Select),
multi: true,
},
],
host: {
class: 'docs-form-element',
},
})
export class Select implements ControlValueAccessor {
export class Select implements FormValueControl<string | null> {
readonly value = model<string | null>(null);
readonly id = input.required<string>({alias: 'selectId'});
readonly name = input.required<string>();
readonly options = input.required<SelectOption[]>();
readonly disabled = model(false);
// Implemented as part of ControlValueAccessor.
private onChange: (value: SelectOptionValue) => void = (_: SelectOptionValue) => {};
private onTouched: () => void = () => {};
protected readonly selectedOption = signal<SelectOptionValue | null>(null);
// Implemented as part of ControlValueAccessor.
writeValue(value: SelectOptionValue): void {
this.selectedOption.set(value);
}
// Implemented as part of ControlValueAccessor.
registerOnChange(fn: any): void {
this.onChange = fn;
}
// Implemented as part of ControlValueAccessor.
registerOnTouched(fn: any): void {
this.onTouched = fn;
}
// Implemented as part of ControlValueAccessor.
setDisabledState?(isDisabled: boolean): void {
this.disabled.set(isDisabled);
}
readonly disabled = input(false);
setOption($event: SelectOptionValue): void {
if (this.disabled()) {
return;
}
this.selectedOption.set($event);
this.onChange($event);
this.onTouched();
this.value.set($event as string);
}
}

View file

@ -31,27 +31,14 @@ describe('SlideToggle', () => {
expect(component['checked']()).toBeTrue();
});
it('should call onChange and onTouched when toggled', () => {
const onChangeSpy = jasmine.createSpy('onChangeSpy');
const onTouchedSpy = jasmine.createSpy('onTouchedSpy');
component.registerOnChange(onChangeSpy);
component.registerOnTouched(onTouchedSpy);
component.toggle();
expect(onChangeSpy).toHaveBeenCalled();
expect(onChangeSpy).toHaveBeenCalledWith(true);
expect(onTouchedSpy).toHaveBeenCalled();
});
it('should set active class for button when is checked', async () => {
component.writeValue(true);
await fixture.whenStable();
it('should set active class for button when is checked', () => {
component.checked.set(true);
fixture.detectChanges();
const buttonElement: HTMLButtonElement = fixture.nativeElement.querySelector('input');
expect(buttonElement.classList.contains('docs-toggle-active')).toBeTrue();
component.writeValue(false);
await fixture.whenStable();
component.checked.set(false);
fixture.detectChanges();
expect(buttonElement.classList.contains('docs-toggle-active')).toBeFalse();
});
});

View file

@ -6,52 +6,21 @@
* found in the LICENSE file at https://angular.dev/license
*/
import {ChangeDetectionStrategy, Component, forwardRef, model, input, signal} from '@angular/core';
import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms';
import {ChangeDetectionStrategy, Component, input, model} from '@angular/core';
import {FormCheckboxControl} from '@angular/forms/signals';
@Component({
selector: 'docs-slide-toggle',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './slide-toggle.component.html',
styleUrls: ['./slide-toggle.component.scss'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => SlideToggle),
multi: true,
},
],
})
export class SlideToggle implements ControlValueAccessor {
export class SlideToggle implements FormCheckboxControl {
readonly buttonId = input.required<string>();
readonly label = input.required<string>();
readonly disabled = model(false);
// Implemented as part of ControlValueAccessor.
private onChange: (value: boolean) => void = (_: boolean) => {};
private onTouched: () => void = () => {};
protected readonly checked = signal(false);
// Implemented as part of ControlValueAccessor.
writeValue(value: boolean): void {
this.checked.set(value);
}
// Implemented as part of ControlValueAccessor.
registerOnChange(fn: any): void {
this.onChange = fn;
}
// Implemented as part of ControlValueAccessor.
registerOnTouched(fn: any): void {
this.onTouched = fn;
}
// Implemented as part of ControlValueAccessor.
setDisabledState(isDisabled: boolean): void {
this.disabled.set(isDisabled);
}
readonly checked = model(false);
// Toggles the checked state of the slide-toggle.
toggle(): void {
@ -60,7 +29,5 @@ export class SlideToggle implements ControlValueAccessor {
}
this.checked.update((checked) => !checked);
this.onChange(this.checked());
this.onTouched();
}
}

View file

@ -7,91 +7,58 @@
*/
import {
afterNextRender,
ChangeDetectionStrategy,
Component,
ElementRef,
input,
afterNextRender,
forwardRef,
signal,
model,
viewChild,
} from '@angular/core';
import {ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR} from '@angular/forms';
import {FormValueControl} from '@angular/forms/signals';
import {IconComponent} from '../icon/icon.component';
@Component({
selector: 'docs-text-field',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [FormsModule, IconComponent],
imports: [IconComponent],
templateUrl: './text-field.component.html',
styleUrls: ['./text-field.component.scss'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => TextField),
multi: true,
},
],
host: {
class: 'docs-form-element',
},
})
export class TextField implements ControlValueAccessor {
export class TextField implements FormValueControl<string | null> {
readonly input = viewChild.required<ElementRef<HTMLInputElement>>('inputRef');
readonly name = input<string | null>(null);
readonly name = input<string>('');
readonly value = model<string | null>(null);
readonly placeholder = input<string | null>(null);
readonly disabled = model<boolean>(false);
readonly hideIcon = input<boolean>(false);
readonly autofocus = input<boolean>(false);
readonly resetLabel = input<string | null>(null);
// Implemented as part of ControlValueAccessor.
private onChange: (value: string) => void = (_: string) => {};
private onTouched: () => void = () => {};
protected readonly value = signal<string | null>(null);
constructor() {
afterNextRender(() => {
if (this.autofocus()) {
this.input().nativeElement.focus();
this.focus();
}
});
}
// Implemented as part of ControlValueAccessor.
writeValue(value: unknown): void {
this.value.set(typeof value === 'string' ? value : null);
}
// Implemented as part of ControlValueAccessor.
registerOnChange(fn: any): void {
this.onChange = fn;
}
// Implemented as part of ControlValueAccessor.
registerOnTouched(fn: any): void {
this.onTouched = fn;
}
// Implemented as part of ControlValueAccessor.
setDisabledState?(isDisabled: boolean): void {
this.disabled.set(isDisabled);
}
setValue(value: string): void {
if (this.disabled()) {
return;
}
this.value.set(value);
this.onChange(value);
this.onTouched();
}
clearTextField() {
this.setValue('');
}
focus() {
this.input().nativeElement.focus();
}
}

View file

@ -4,7 +4,13 @@
<div class="adev-reference-list-filter">
<div class="adev-reference-list-query-filter">
<docs-text-field name="query" placeholder="Filter" [(ngModel)]="query" />
<docs-text-field placeholder="Filter" [field]="form.query" />
<docs-select
selectId="package-entry-filter"
[options]="packageOptions()"
[field]="form.selectedPackage"
/>
</div>
<div class="adev-reference-list-status">
@ -23,7 +29,7 @@
@for (itemType of itemTypes; track itemType) {
<li
class="adev-reference-list-type-filter-item"
[class.adev-reference-list-type-filter-item-active]="type() === itemType"
[class.adev-reference-list-type-filter-item-active]="form.type().value() === itemType"
(click)="setItemType(itemType)"
>
<docs-api-item-label [type]="itemType" mode="short" class="docs-api-item-label" />

View file

@ -8,16 +8,16 @@
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {Location} from '@angular/common';
import {signal} from '@angular/core';
import {TextField} from '@angular/docs';
import {By} from '@angular/platform-browser';
import {provideRouter} from '@angular/router';
import {RouterTestingHarness} from '@angular/router/testing';
import {ApiItem} from '../interfaces/api-item';
import {ApiItemType} from '../interfaces/api-item-type';
import ApiReferenceList, {ALL_TYPES_KEY, STATUSES} from './api-reference-list.component';
import {ApiReferenceManager} from './api-reference-manager.service';
import {signal} from '@angular/core';
import {ApiItemType} from '../interfaces/api-item-type';
import {RouterTestingHarness} from '@angular/router/testing';
import {provideRouter} from '@angular/router';
import {Location} from '@angular/common';
import {By} from '@angular/platform-browser';
import {TextField} from '@angular/docs';
import {ApiItem} from '../interfaces/api-item';
describe('ApiReferenceList', () => {
let component: ApiReferenceList;
@ -114,7 +114,7 @@ describe('ApiReferenceList', () => {
fixture.componentRef.setInput('query', 'Item1');
await fixture.whenStable();
expect(component.filteredGroups()![0].items).toEqual([fakeItem1]);
expect(component.filteredGroups()[0].items).toEqual([fakeItem1]);
});
it('should display items which match the query by group title', async () => {
@ -122,33 +122,33 @@ describe('ApiReferenceList', () => {
await fixture.whenStable();
// Should find all items whose group title contains the query
expect(component.filteredGroups()![0].items.length).toBeGreaterThan(0);
expect(component.filteredGroups()![0].items).toContain(fakeItem1);
expect(component.filteredGroups()[0].items.length).toBeGreaterThan(0);
expect(component.filteredGroups()[0].items).toContain(fakeItem1);
});
it('should display only class items when user selects Class in the Type select', async () => {
fixture.componentRef.setInput('type', ApiItemType.CLASS);
await fixture.whenStable();
expect(component.type()).toEqual(ApiItemType.CLASS);
expect(component.filteredGroups()![0].items).toEqual([fakeItem2]);
expect(component.form.type().value()).toEqual(ApiItemType.CLASS);
expect(component.filteredGroups()[0].items).toEqual([fakeItem2]);
});
it('should set selected type when provided type is different than selected', async () => {
expect(component.type()).toBe(ALL_TYPES_KEY);
expect(component.form.type().value()).toBe(ALL_TYPES_KEY);
component.setItemType(ApiItemType.BLOCK);
await RouterTestingHarness.create(`/api?type=${ApiItemType.BLOCK}`);
expect(component.type()).toBe(ApiItemType.BLOCK);
expect(component.form.type().value()).toBe(ApiItemType.BLOCK);
});
it('should reset selected type when provided type is equal to selected', async () => {
component.setItemType(ApiItemType.BLOCK);
const harness = await RouterTestingHarness.create(`/api?type=${ApiItemType.BLOCK}`);
expect(component.type()).toBe(ApiItemType.BLOCK);
expect(component.form.type().value()).toBe(ApiItemType.BLOCK);
component.setItemType(ApiItemType.BLOCK);
harness.navigateByUrl(`/api`);
expect(component.type()).toBe(ALL_TYPES_KEY);
expect(component.form.type().value()).toBe(ALL_TYPES_KEY);
});
it('should set the value of the queryParam equal to the query text field', async () => {
@ -164,15 +164,15 @@ describe('ApiReferenceList', () => {
const location = TestBed.inject(Location);
const textField = fixture.debugElement.query(By.directive(TextField));
(textField.componentInstance as TextField).setValue('item1');
fixture.componentInstance.form.query().value.set('item1');
await fixture.whenStable();
expect(location.path()).toBe(`?query=item1`);
component.setItemType(ApiItemType.BLOCK);
fixture.componentInstance.form.type().value.set(ApiItemType.BLOCK);
await fixture.whenStable();
expect(location.path()).toBe(`?query=item1&type=${ApiItemType.BLOCK}`);
fixture.componentRef.setInput('status', STATUSES.experimental);
fixture.componentInstance.form.status().value.set(STATUSES.experimental);
await fixture.whenStable();
expect(location.path()).toBe(
`?query=item1&type=${ApiItemType.BLOCK}&status=${STATUSES.experimental}`,

View file

@ -6,33 +6,33 @@
* found in the LICENSE file at https://angular.dev/license
*/
import {CdkMenuModule} from '@angular/cdk/menu';
import {KeyValuePipe} from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
ElementRef,
EnvironmentInjector,
afterNextRender,
computed,
effect,
inject,
input,
linkedSignal,
viewChild,
afterNextRender,
EnvironmentInjector,
effect,
input,
} from '@angular/core';
import ApiItemsSection from '../api-items-section/api-items-section.component';
import {FormsModule} from '@angular/forms';
import {TextField} from '@angular/docs';
import {KeyValuePipe} from '@angular/common';
import {Params, Router} from '@angular/router';
import {ApiItemType} from '../interfaces/api-item-type';
import {ApiReferenceManager} from './api-reference-manager.service';
import ApiItemLabel from '../api-item-label/api-item-label.component';
import {ApiLabel} from '../pipes/api-label.pipe';
import {ApiItemsGroup} from '../interfaces/api-items-group';
import {CdkMenuModule} from '@angular/cdk/menu';
import {Select, SelectOption, TextField} from '@angular/docs';
import {Field, form} from '@angular/forms/signals';
import {MatChipsModule} from '@angular/material/chips';
import {Params, Router} from '@angular/router';
import ApiItemLabel from '../api-item-label/api-item-label.component';
import ApiItemsSection from '../api-items-section/api-items-section.component';
import {ApiItemType} from '../interfaces/api-item-type';
import {ApiItemsGroup} from '../interfaces/api-items-group';
import {ApiLabel} from '../pipes/api-label.pipe';
import {ApiReferenceManager} from './api-reference-manager.service';
export const ALL_TYPES_KEY = 'All';
export const ALL_PACKAGES = 'All';
export const STATUSES = {
stable: 1,
developerPreview: 2,
@ -46,12 +46,13 @@ export const DEFAULT_STATUS = STATUSES.stable | STATUSES.developerPreview | STAT
imports: [
ApiItemsSection,
ApiItemLabel,
FormsModule,
TextField,
ApiLabel,
CdkMenuModule,
MatChipsModule,
KeyValuePipe,
Select,
Field,
],
templateUrl: './api-reference-list.component.html',
styleUrls: ['./api-reference-list.component.scss'],
@ -61,19 +62,33 @@ export default class ApiReferenceList {
// services
private readonly apiReferenceManager = inject(ApiReferenceManager);
private readonly router = inject(Router);
private readonly filterInput = viewChild.required(TextField, {read: ElementRef});
private readonly filterInput = viewChild.required(TextField);
private readonly injector = inject(EnvironmentInjector);
// inputs
readonly queryInput = input<string | undefined>('', {alias: 'query'});
readonly typeInput = input<string | undefined>(ALL_TYPES_KEY, {alias: 'type'});
readonly statusInput = input<number | undefined>(DEFAULT_STATUS, {alias: 'status'});
protected selectedPackageInput = input<string | undefined>(ALL_PACKAGES, {alias: 'package'});
// inputs are route binded, they can reset to undefined
// also we want a writable state, so we use a linked signal
protected query = linkedSignal(() => this.queryInput() ?? '');
public type = linkedSignal(() => this.typeInput() ?? ALL_TYPES_KEY);
private status = linkedSignal(() => this.statusInput() ?? DEFAULT_STATUS);
public form = form(
linkedSignal(() => ({
query: this.queryInput() ?? '',
status: this.statusInput() ?? DEFAULT_STATUS,
type: this.typeInput() ?? ALL_TYPES_KEY,
selectedPackage: this.selectedPackageInput() ?? ALL_PACKAGES,
})),
);
protected packageOptions = computed<SelectOption[]>(() => [
{label: 'All Packages', value: ALL_PACKAGES},
...this.apiReferenceManager.apiGroups().map((group) => ({
label: group.title,
value: group.id,
})),
]);
// const state
protected readonly itemTypes = Object.values(ApiItemType);
@ -87,9 +102,9 @@ export default class ApiReferenceList {
};
readonly filteredGroups = computed((): ApiItemsGroup[] => {
const query = this.query().toLocaleLowerCase();
const status = this.status();
const type = this.type();
const query = this.form.query().value().toLocaleLowerCase();
const status = this.form.status().value();
const type = this.form.type().value();
return this.apiReferenceManager
.apiGroups()
.map((group) => ({
@ -112,7 +127,12 @@ export default class ApiReferenceList {
);
}),
}))
.filter((group) => group.items.length > 0);
.filter(
(group) =>
group.items.length > 0 &&
(this.form.selectedPackage().value() === ALL_PACKAGES ||
group.id === this.form.selectedPackage().value()),
);
});
constructor() {
@ -121,10 +141,9 @@ export default class ApiReferenceList {
afterNextRender(
{
write: () => {
// Lord forgive me for I have sinned
// Use the CVA to focus when https://github.com/angular/angular/issues/31133 is implemented
// Why not improve this once https://github.com/orgs/angular/projects/60?pane=issue&itemId=143488813 is done
if (matchMedia('(hover: hover) and (pointer:fine)').matches) {
scheduleOnIdle(() => filterInput.nativeElement.querySelector('input').focus());
scheduleOnIdle(() => filterInput.focus());
}
},
},
@ -135,9 +154,13 @@ export default class ApiReferenceList {
effect(() => {
// We'll only set the params if we deviate from the default values
const params: Params = {
'query': this.query() || null,
'type': this.type() === ALL_TYPES_KEY ? null : this.type(),
'status': this.status() === DEFAULT_STATUS ? null : this.status(),
'query': this.form.query().value() || null,
'type': this.form.type().value() === ALL_TYPES_KEY ? null : this.form.type().value(),
'status': this.form.status().value() === DEFAULT_STATUS ? null : this.form.status().value(),
'package':
this.form.selectedPackage().value() === ALL_PACKAGES
? null
: this.form.selectedPackage().value(),
};
this.router.navigate([], {
@ -152,11 +175,11 @@ export default class ApiReferenceList {
}
setItemType(itemType: ApiItemType): void {
this.type.update((type) => (type === itemType ? ALL_TYPES_KEY : itemType));
this.form.type().value.update((type) => (type === itemType ? ALL_TYPES_KEY : itemType));
}
setStatus(status: number): void {
this.status.update((previousStatus) => {
this.form.status().value.update((previousStatus) => {
if (this.isStatusSelected(status)) {
return previousStatus & ~status; // Clear the bit
} else {
@ -166,7 +189,7 @@ export default class ApiReferenceList {
}
isStatusSelected(status: number): boolean {
return (this.status() & status) === status;
return (this.form.status().value() & status) === status;
}
}