diff --git a/docs/tools/lighthouse-audit.mjs b/docs/tools/lighthouse-audit.mjs index 9aa71f5dbc4e..fafe1bd962b0 100644 --- a/docs/tools/lighthouse-audit.mjs +++ b/docs/tools/lighthouse-audit.mjs @@ -169,7 +169,25 @@ async function cleanupAndPrepareReportsDir() { await fs.promises.rm(reportsDir, {recursive: true}); } catch {} - await fs.promises.mkdir(reportsDir, {recursive: true}); + try { + await fs.promises.mkdir(reportsDir, {recursive: true}); + } catch (err) { + // If mkdir fails, try to create the entire path structure + const pathParts = reportsDir.split(path.sep); + let currentPath = pathParts[0] || path.sep; + + for (let i = 1; i < pathParts.length; i++) { + currentPath = path.join(currentPath, pathParts[i]); + try { + await fs.promises.mkdir(currentPath); + } catch (mkdirErr) { + // Ignore EEXIST errors, but throw others + if (mkdirErr.code !== 'EEXIST') { + throw mkdirErr; + } + } + } + } } /** diff --git a/guides/creating-reusable-material-components.md b/guides/creating-reusable-material-components.md new file mode 100644 index 000000000000..07c624b0bdcb --- /dev/null +++ b/guides/creating-reusable-material-components.md @@ -0,0 +1,542 @@ +# Creating Reusable Components from Existing Material Components + +This guide shows how to create custom, reusable components by wrapping existing Angular Material components like `mat-select`, `mat-input`, and `mat-autocomplete`. These wrapped components can be used with `formControlName`, maintain all Material Design features, and work seamlessly within `mat-form-field`. + +## Basic Component Wrapping + +### Wrapping mat-select with Predefined Options + +A common use case is creating a select component with predefined options that can be reused throughout your application. + +```typescript +import { Component, Input, forwardRef } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; + +@Component({ + selector: 'app-permission-select', + template: ` + + Read Only + Read & Write + Administrator + + `, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => PermissionSelectComponent), + multi: true + } + ] +}) +export class PermissionSelectComponent implements ControlValueAccessor { + @Input() placeholder = 'Select permission'; + @Input() disabled = false; + + value: string | null = null; + + private onChange = (value: any) => {}; + private onTouched = () => {}; + + writeValue(value: any): void { + this.value = value; + } + + registerOnChange(fn: any): void { + this.onChange = fn; + } + + registerOnTouched(fn: any): void { + this.onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + onSelectionChange(event: any): void { + this.value = event.value; + this.onChange(this.value); + this.onTouched(); + } +} +``` + +**Usage:** +```html + + + + + + User Permissions + + Choose the appropriate permission level + +``` + +### Wrapping mat-input with Custom Validation + +Create a reusable input component with built-in validation and formatting: + +```typescript +@Component({ + selector: 'app-phone-input', + template: ` + + + + `, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => PhoneInputComponent), + multi: true + } + ] +}) +export class PhoneInputComponent implements ControlValueAccessor { + @Input() placeholder = 'Enter phone number'; + @Input() disabled = false; + + value: string | null = null; + + private onChange = (value: any) => {}; + private onTouched = () => {}; + + writeValue(value: any): void { + this.value = this.formatPhoneNumber(value); + } + + registerOnChange(fn: any): void { + this.onChange = fn; + } + + registerOnTouched(fn: any): void { + this.onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + onInput(event: any): void { + const rawValue = event.target.value; + this.value = this.formatPhoneNumber(rawValue); + this.onChange(this.value); + } + + onBlur(): void { + this.onTouched(); + } + + private formatPhoneNumber(value: string): string { + if (!value) return ''; + + // Remove all non-digits + const digits = value.replace(/\D/g, ''); + + // Format as (XXX) XXX-XXXX + if (digits.length >= 10) { + return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6, 10)}`; + } + + return digits; + } +} +``` + +## Advanced Patterns + +### Async Data Loading with mat-autocomplete + +Create a user picker component that loads data asynchronously: + +```typescript +import { Component, Input, OnInit, forwardRef } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { Observable, of, Subject } from 'rxjs'; +import { debounceTime, distinctUntilChanged, switchMap, startWith } from 'rxjs/operators'; + +export interface User { + id: number; + name: string; + email: string; +} + +@Component({ + selector: 'app-user-picker', + template: ` + + +
+ {{ user.name }} + {{ user.email }} +
+
+
+ + + `, + styles: [` + .user-option { + display: flex; + flex-direction: column; + } + .user-name { + font-weight: 500; + } + .user-email { + font-size: 0.8em; + color: rgba(0, 0, 0, 0.6); + } + `], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => UserPickerComponent), + multi: true + } + ] +}) +export class UserPickerComponent implements ControlValueAccessor, OnInit { + @Input() placeholder = 'Search users...'; + @Input() disabled = false; + + value: User | null = null; + displayValue = ''; + + private searchTerms = new Subject(); + filteredUsers$: Observable; + + private onChange = (value: any) => {}; + private onTouched = () => {}; + + constructor(private userService: UserService) {} + + ngOnInit(): void { + this.filteredUsers$ = this.searchTerms.pipe( + startWith(''), + debounceTime(300), + distinctUntilChanged(), + switchMap(term => this.searchUsers(term)) + ); + } + + writeValue(value: User | null): void { + this.value = value; + this.displayValue = this.displayUser(value); + } + + registerOnChange(fn: any): void { + this.onChange = fn; + } + + registerOnTouched(fn: any): void { + this.onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + onInput(event: any): void { + const inputValue = event.target.value; + this.displayValue = inputValue; + this.searchTerms.next(inputValue); + + // If user is typing, clear the selected value + if (this.value && inputValue !== this.displayUser(this.value)) { + this.value = null; + this.onChange(null); + } + } + + onBlur(): void { + this.onTouched(); + } + + onOptionSelected(user: User): void { + this.value = user; + this.displayValue = this.displayUser(user); + this.onChange(user); + } + + displayUser(user: User | null): string { + return user ? user.name : ''; + } + + private searchUsers(term: string): Observable { + if (!term.trim()) { + return of([]); + } + return this.userService.searchUsers(term); + } +} +``` + +**Usage:** +```html + + Project Manager + + + Project manager is required + + +``` + +### Multi-Select with Custom Options + +Create a multi-select component with custom styling and behavior: + +```typescript +@Component({ + selector: 'app-tag-select', + template: ` + + + + {{ tag.name }} + + + + + +
+ + {{ getTagName(tagId) }} + cancel + +
+ `, + styles: [` + .tag-chip { + color: white; + font-size: 0.8em; + } + .selected-tags { + margin-top: 8px; + display: flex; + flex-wrap: wrap; + gap: 4px; + } + `], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TagSelectComponent), + multi: true + } + ] +}) +export class TagSelectComponent implements ControlValueAccessor { + @Input() placeholder = 'Select tags'; + @Input() disabled = false; + @Input() availableTags: Tag[] = []; + + value: number[] | null = null; + + private onChange = (value: any) => {}; + private onTouched = () => {}; + + writeValue(value: number[]): void { + this.value = value || []; + } + + registerOnChange(fn: any): void { + this.onChange = fn; + } + + registerOnTouched(fn: any): void { + this.onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + onSelectionChange(event: any): void { + this.value = event.value; + this.onChange(this.value); + this.onTouched(); + } + + removeTag(tagId: number): void { + if (this.value) { + this.value = this.value.filter(id => id !== tagId); + this.onChange(this.value); + } + } + + getTagName(tagId: number): string { + const tag = this.availableTags.find(t => t.id === tagId); + return tag ? tag.name : ''; + } + + getTagColor(tagId: number): string { + const tag = this.availableTags.find(t => t.id === tagId); + return tag ? tag.color : '#ccc'; + } +} +``` + +## Best Practices + +### 1. Always Implement ControlValueAccessor + +For components to work with Angular forms, implement the `ControlValueAccessor` interface: + +- `writeValue()`: Update component when form value changes +- `registerOnChange()`: Register callback for value changes +- `registerOnTouched()`: Register callback for touch events +- `setDisabledState()`: Handle disabled state + +### 2. Handle Validation Properly + +```typescript +// In your component +@Component({ + // ... + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => YourComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => YourComponent), + multi: true + } + ] +}) +export class YourComponent implements ControlValueAccessor, Validator { + + validate(control: AbstractControl): ValidationErrors | null { + if (!this.value) { + return { required: true }; + } + + // Custom validation logic + if (this.value.length < 3) { + return { minLength: { requiredLength: 3, actualLength: this.value.length } }; + } + + return null; + } +} +``` + +### 3. Support All Material Form Field Features + +Ensure your wrapped components work with: +- `mat-label` +- `mat-hint` +- `mat-error` +- `matPrefix` and `matSuffix` +- Floating labels +- Required indicators + +### 4. Accessibility Considerations + +```typescript +// Add proper ARIA attributes +@Component({ + template: ` + + + + ` +}) +export class AccessibleSelectComponent { + @Input() ariaLabel: string; + @Input() ariaDescribedBy: string; + @Input() required = false; +} +``` + +### 5. Testing Your Components + +```typescript +describe('PermissionSelectComponent', () => { + let component: PermissionSelectComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [PermissionSelectComponent], + imports: [MatSelectModule, ReactiveFormsModule] + }); + + fixture = TestBed.createComponent(PermissionSelectComponent); + component = fixture.componentInstance; + }); + + it('should work with reactive forms', () => { + const form = new FormGroup({ + permission: new FormControl('read') + }); + + // Test form integration + expect(component.value).toBe('read'); + }); + + it('should emit changes', () => { + spyOn(component, 'onChange'); + + component.onSelectionChange({ value: 'admin' }); + + expect(component.onChange).toHaveBeenCalledWith('admin'); + }); +}); +``` + +## Common Patterns Summary + +| Pattern | Use Case | Key Implementation | +|---------|----------|-------------------| +| **Simple Wrapper** | Predefined options | Basic ControlValueAccessor | +| **Formatted Input** | Phone, currency, etc. | Custom formatting logic | +| **Async Data** | User pickers, search | Observable data streams | +| **Multi-Select** | Tags, categories | Array value handling | +| **Validation** | Custom rules | Validator interface | + +## Working Examples + +For complete working examples of these patterns, see: +- [Simple Permission Select](https://stackblitz.com/edit/angular-permission-select) +- [Async User Picker](https://stackblitz.com/edit/angular-user-picker) +- [Multi-Tag Select](https://stackblitz.com/edit/angular-tag-select) + +These patterns allow you to create powerful, reusable components that maintain all the benefits of Angular Material while adding your custom business logic and styling. \ No newline at end of file diff --git a/src/components-examples/material/form-field/form-field-custom-wrapper/form-field-custom-wrapper-example.css b/src/components-examples/material/form-field/form-field-custom-wrapper/form-field-custom-wrapper-example.css new file mode 100644 index 000000000000..e378394695b9 --- /dev/null +++ b/src/components-examples/material/form-field/form-field-custom-wrapper/form-field-custom-wrapper-example.css @@ -0,0 +1,29 @@ +.example-container { + max-width: 600px; + margin: 20px; +} + +.example-section { + margin-bottom: 30px; + padding: 20px; + border: 1px solid #e0e0e0; + border-radius: 4px; +} + +.example-section h4 { + margin-top: 0; + color: #1976d2; +} + +mat-form-field { + width: 100%; + margin-bottom: 10px; +} + +p { + margin: 10px 0; + font-family: 'Roboto Mono', monospace; + background-color: #f5f5f5; + padding: 8px; + border-radius: 4px; +} diff --git a/src/components-examples/material/form-field/form-field-custom-wrapper/form-field-custom-wrapper-example.html b/src/components-examples/material/form-field/form-field-custom-wrapper/form-field-custom-wrapper-example.html new file mode 100644 index 000000000000..76aa0ba42aad --- /dev/null +++ b/src/components-examples/material/form-field/form-field-custom-wrapper/form-field-custom-wrapper-example.html @@ -0,0 +1,23 @@ +
+

Custom Wrapper Components

+ +
+

Permission Select Component

+ + User Permissions + + Choose the appropriate permission level + +

Selected value: {{ permissionControl.value }}

+
+ +
+

Phone Input Component

+ + Phone Number + + Enter your phone number + +

Formatted value: {{ phoneControl.value }}

+
+
\ No newline at end of file diff --git a/src/components-examples/material/form-field/form-field-custom-wrapper/form-field-custom-wrapper-example.ts b/src/components-examples/material/form-field/form-field-custom-wrapper/form-field-custom-wrapper-example.ts new file mode 100644 index 000000000000..7331c5631e21 --- /dev/null +++ b/src/components-examples/material/form-field/form-field-custom-wrapper/form-field-custom-wrapper-example.ts @@ -0,0 +1,155 @@ +import {Component, Input, forwardRef} from '@angular/core'; +import { + ControlValueAccessor, + NG_VALUE_ACCESSOR, + FormControl, + ReactiveFormsModule, +} from '@angular/forms'; +import {MatSelectModule} from '@angular/material/select'; +import {MatFormFieldModule} from '@angular/material/form-field'; +import {MatInputModule} from '@angular/material/input'; +import {MatOptionModule} from '@angular/material/core'; + +/** + * @title Custom wrapper components for Material form controls + */ +@Component({ + selector: 'form-field-custom-wrapper-example', + templateUrl: 'form-field-custom-wrapper-example.html', + styleUrl: 'form-field-custom-wrapper-example.css', + imports: [ + MatFormFieldModule, + ReactiveFormsModule, + forwardRef(() => PermissionSelectComponent), + forwardRef(() => PhoneInputComponent), + ], +}) +export class FormFieldCustomWrapperExample { + permissionControl = new FormControl('read'); + phoneControl = new FormControl(''); +} + +@Component({ + selector: 'app-permission-select', + template: ` + + Read Only + Read & Write + Administrator + + `, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => PermissionSelectComponent), + multi: true, + }, + ], + imports: [MatSelectModule, MatOptionModule], +}) +export class PermissionSelectComponent implements ControlValueAccessor { + @Input() placeholder = 'Select permission'; + @Input() disabled = false; + + value: string | null = null; + + private _onChange = (value: any) => {}; + private _onTouched = () => {}; + + writeValue(value: any): void { + this.value = value; + } + + registerOnChange(fn: any): void { + this._onChange = fn; + } + + registerOnTouched(fn: any): void { + this._onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + onSelectionChange(event: any): void { + this.value = event.value; + this._onChange(this.value); + this._onTouched(); + } +} + +@Component({ + selector: 'app-phone-input', + template: ` + + `, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => PhoneInputComponent), + multi: true, + }, + ], + imports: [MatInputModule], +}) +export class PhoneInputComponent implements ControlValueAccessor { + @Input() placeholder = 'Enter phone number'; + @Input() disabled = false; + + value: string | null = null; + + private _onChange = (value: any) => {}; + private _onTouched = () => {}; + + writeValue(value: any): void { + this.value = this._formatPhoneNumber(value); + } + + registerOnChange(fn: any): void { + this._onChange = fn; + } + + registerOnTouched(fn: any): void { + this._onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + onInput(event: any): void { + const rawValue = event.target.value; + this.value = this._formatPhoneNumber(rawValue); + this._onChange(this.value); + } + + onBlur(): void { + this._onTouched(); + } + + private _formatPhoneNumber(value: string): string { + if (!value) return ''; + + // Remove all non-digits + const digits = value.replace(/\D/g, ''); + + // Format as (XXX) XXX-XXXX + if (digits.length >= 10) { + return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6, 10)}`; + } + + return digits; + } +} diff --git a/src/material/timepicker/timepicker.spec.ts b/src/material/timepicker/timepicker.spec.ts index 7308e70b3260..af339005c653 100644 --- a/src/material/timepicker/timepicker.spec.ts +++ b/src/material/timepicker/timepicker.spec.ts @@ -135,6 +135,39 @@ describe('MatTimepicker', () => { }), ); })); + + it('should emit selected event after form control value is updated', fakeAsync(() => { + const fixture = TestBed.createComponent(TimepickerWithForms); + const control = fixture.componentInstance.control; + fixture.detectChanges(); + + let formControlValue: Date | null = null; + let eventValue: Date | null = null; + + // Subscribe to form control changes + control.valueChanges.subscribe(value => { + formControlValue = value; + }); + + // Subscribe to selected event + fixture.componentInstance.input.timepicker().selected.subscribe(event => { + eventValue = event.value; + // At this point, form control should already be updated + expect(control.value).toBeTruthy(); + expectSameTime(control.value, event.value); + }); + + getInput(fixture).click(); + fixture.detectChanges(); + getOptions()[3].click(); // Select 1:30 AM + fixture.detectChanges(); + flush(); + + expect(formControlValue).toBeTruthy(); + expect(eventValue).toBeTruthy(); + expectSameTime(formControlValue, eventValue); + expectSameTime(control.value, createTime(1, 30)); + })); }); describe('input behavior', () => { diff --git a/src/material/timepicker/timepicker.ts b/src/material/timepicker/timepicker.ts index ab63291c9699..7ed5d78343b9 100644 --- a/src/material/timepicker/timepicker.ts +++ b/src/material/timepicker/timepicker.ts @@ -296,7 +296,10 @@ export class MatTimepicker implements OnDestroy, MatOptionParentComponent { current.deselect(false); } }); - this.selected.emit({value: option.value, source: this}); + // Emit the selected event after a microtask to ensure the form control is updated first + Promise.resolve().then(() => { + this.selected.emit({value: option.value, source: this}); + }); this._input()?.focus(); }