/** * @license * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.dev/license */ import { booleanAttribute, computed, Directive, effect, ElementRef, inject, input, InputSignal, InputSignalWithTransform, model, ModelSignal, OnDestroy, OutputRefSubscription, Signal, signal, } from '@angular/core'; import {DateAdapter, MAT_DATE_FORMATS} from '@angular/material/core'; import { AbstractControl, ControlValueAccessor, NG_VALIDATORS, NG_VALUE_ACCESSOR, ValidationErrors, Validator, ValidatorFn, Validators, } from '@angular/forms'; import {MAT_FORM_FIELD} from '@angular/material/form-field'; import {MatTimepicker} from './timepicker'; import {MAT_INPUT_VALUE_ACCESSOR} from '@angular/material/input'; import {Subscription} from 'rxjs'; import {DOWN_ARROW, ESCAPE, hasModifierKey, UP_ARROW} from '@angular/cdk/keycodes'; import {validateAdapter} from './util'; import {DOCUMENT} from '@angular/common'; /** * Input that can be used to enter time and connect to a `mat-timepicker`. */ @Directive({ selector: 'input[matTimepicker]', exportAs: 'matTimepickerInput', host: { 'class': 'mat-timepicker-input', 'role': 'combobox', 'type': 'text', 'aria-haspopup': 'listbox', '[attr.aria-activedescendant]': '_ariaActiveDescendant()', '[attr.aria-expanded]': '_ariaExpanded()', '[attr.aria-controls]': '_ariaControls()', '[attr.mat-timepicker-id]': 'timepicker()?.panelId', '[disabled]': 'disabled()', '(blur)': '_handleBlur()', '(input)': '_handleInput($event.target.value)', '(keydown)': '_handleKeydown($event)', }, providers: [ { provide: NG_VALUE_ACCESSOR, useExisting: MatTimepickerInput, multi: true, }, { provide: NG_VALIDATORS, useExisting: MatTimepickerInput, multi: true, }, { provide: MAT_INPUT_VALUE_ACCESSOR, useExisting: MatTimepickerInput, }, ], }) export class MatTimepickerInput implements ControlValueAccessor, Validator, OnDestroy { private _elementRef = inject>(ElementRef); private _document = inject(DOCUMENT); private _dateAdapter = inject>(DateAdapter, {optional: true})!; private _dateFormats = inject(MAT_DATE_FORMATS, {optional: true})!; private _formField = inject(MAT_FORM_FIELD, {optional: true}); private _onChange: ((value: any) => void) | undefined; private _onTouched: (() => void) | undefined; private _validatorOnChange: (() => void) | undefined; private _accessorDisabled = signal(false); private _localeSubscription: Subscription; private _timepickerSubscription: OutputRefSubscription | undefined; private _validator: ValidatorFn; private _lastValueValid = true; private _lastValidDate: D | null = null; /** Value of the `aria-activedescendant` attribute. */ protected readonly _ariaActiveDescendant = computed(() => { const timepicker = this.timepicker(); const isOpen = timepicker.isOpen(); const activeDescendant = timepicker.activeDescendant(); return isOpen && activeDescendant ? activeDescendant : null; }); /** Value of the `aria-expanded` attribute. */ protected readonly _ariaExpanded = computed(() => this.timepicker().isOpen() + ''); /** Value of the `aria-controls` attribute. */ protected readonly _ariaControls = computed(() => { const timepicker = this.timepicker(); return timepicker.isOpen() ? timepicker.panelId : null; }); /** Current value of the input. */ readonly value: ModelSignal = model(null); /** Timepicker that the input is associated with. */ readonly timepicker: InputSignal> = input.required>({ alias: 'matTimepicker', }); /** * Minimum time that can be selected or typed in. Can be either * a date object (only time will be used) or a valid time string. */ readonly min: InputSignalWithTransform = input(null, { alias: 'matTimepickerMin', transform: (value: unknown) => this._transformDateInput(value), }); /** * Maximum time that can be selected or typed in. Can be either * a date object (only time will be used) or a valid time string. */ readonly max: InputSignalWithTransform = input(null, { alias: 'matTimepickerMax', transform: (value: unknown) => this._transformDateInput(value), }); /** Whether the input is disabled. */ readonly disabled: Signal = computed( () => this.disabledInput() || this._accessorDisabled(), ); /** * Whether the input should be disabled through the template. * @docs-private */ readonly disabledInput: InputSignalWithTransform = input(false, { transform: booleanAttribute, alias: 'disabled', }); constructor() { if (typeof ngDevMode === 'undefined' || ngDevMode) { validateAdapter(this._dateAdapter, this._dateFormats); } this._validator = this._getValidator(); this._respondToValueChanges(); this._respondToMinMaxChanges(); this._registerTimepicker(); this._localeSubscription = this._dateAdapter.localeChanges.subscribe(() => { if (!this._hasFocus()) { this._formatValue(this.value()); } }); // Bind the click listener manually to the overlay origin, because we want the entire // form field to be clickable, if the timepicker is used in `mat-form-field`. this.getOverlayOrigin().nativeElement.addEventListener('click', this._handleClick); } /** * Implemented as a part of `ControlValueAccessor`. * @docs-private */ writeValue(value: any): void { this.value.set(this._dateAdapter.getValidDateOrNull(value)); } /** * Implemented as a part of `ControlValueAccessor`. * @docs-private */ registerOnChange(fn: (value: any) => void): void { this._onChange = fn; } /** * Implemented as a part of `ControlValueAccessor`. * @docs-private */ registerOnTouched(fn: () => void): void { this._onTouched = fn; } /** * Implemented as a part of `ControlValueAccessor`. * @docs-private */ setDisabledState(isDisabled: boolean): void { this._accessorDisabled.set(isDisabled); } /** * Implemented as a part of `Validator`. * @docs-private */ validate(control: AbstractControl): ValidationErrors | null { return this._validator(control); } /** * Implemented as a part of `Validator`. * @docs-private */ registerOnValidatorChange(fn: () => void): void { this._validatorOnChange = fn; } /** Gets the element to which the timepicker popup should be attached. */ getOverlayOrigin(): ElementRef { return this._formField?.getConnectedOverlayOrigin() || this._elementRef; } /** Focuses the input. */ focus(): void { this._elementRef.nativeElement.focus(); } ngOnDestroy(): void { this.getOverlayOrigin().nativeElement.removeEventListener('click', this._handleClick); this._timepickerSubscription?.unsubscribe(); this._localeSubscription.unsubscribe(); } /** Gets the ID of the input's label. */ _getLabelId(): string | null { return this._formField?.getLabelId() || null; } /** Handles clicks on the input or the containing form field. */ private _handleClick = (): void => { this.timepicker().open(); }; /** Handles the `input` event. */ protected _handleInput(value: string) { const currentValue = this.value(); const date = this._dateAdapter.parseTime(value, this._dateFormats.parse.timeInput); const hasChanged = !this._dateAdapter.sameTime(date, currentValue); if (!date || hasChanged || !!(value && !currentValue)) { // We need to fire the CVA change event for all nulls, otherwise the validators won't run. this._assignUserSelection(date, true); } else { // Call the validator even if the value hasn't changed since // some fields change depending on what the user has entered. this._validatorOnChange?.(); } } /** Handles the `blur` event. */ protected _handleBlur() { const value = this.value(); // Only reformat on blur so the value doesn't change while the user is interacting. if (value && this._isValid(value)) { this._formatValue(value); } this._onTouched?.(); } /** Handles the `keydown` event. */ protected _handleKeydown(event: KeyboardEvent) { // All keyboard events while open are handled through the timepicker. if (this.timepicker().isOpen()) { return; } if (event.keyCode === ESCAPE && !hasModifierKey(event) && this.value() !== null) { event.preventDefault(); this.value.set(null); this._formatValue(null); } else if ((event.keyCode === DOWN_ARROW || event.keyCode === UP_ARROW) && !this.disabled()) { event.preventDefault(); this.timepicker().open(); } } /** Sets up the code that watches for changes in the value and adjusts the input. */ private _respondToValueChanges(): void { effect(() => { const value = this._dateAdapter.deserialize(this.value()); const wasValid = this._lastValueValid; this._lastValueValid = this._isValid(value); // Reformat the value if it changes while the user isn't interacting. if (!this._hasFocus()) { this._formatValue(value); } if (value && this._lastValueValid) { this._lastValidDate = value; } // Trigger the validator if the state changed. if (wasValid !== this._lastValueValid) { this._validatorOnChange?.(); } }); } /** Sets up the logic that registers the input with the timepicker. */ private _registerTimepicker(): void { effect(() => { const timepicker = this.timepicker(); timepicker.registerInput(this); timepicker.closed.subscribe(() => this._onTouched?.()); timepicker.selected.subscribe(({value}) => { if (!this._dateAdapter.sameTime(value, this.value())) { this._assignUserSelection(value, true); this._formatValue(value); } }); }); } /** Sets up the logic that adjusts the input if the min/max changes. */ private _respondToMinMaxChanges(): void { effect(() => { // Read the min/max so the effect knows when to fire. this.min(); this.max(); this._validatorOnChange?.(); }); } /** * Assigns a value set by the user to the input's model. * @param selection Time selected by the user that should be assigned. * @param propagateToAccessor Whether the value should be propagated to the ControlValueAccessor. */ private _assignUserSelection(selection: D | null, propagateToAccessor: boolean) { if (selection == null || !this._isValid(selection)) { this.value.set(selection); } else { // If a datepicker and timepicker are writing to the same object and the user enters an // invalid time into the timepicker, we may end up clearing their selection from the // datepicker. If the user enters a valid time afterwards, the datepicker's selection will // have been lost. This logic restores the previously-valid date and sets its time to // the newly-selected time. const adapter = this._dateAdapter; const target = adapter.getValidDateOrNull(this._lastValidDate || this.value()); const hours = adapter.getHours(selection); const minutes = adapter.getMinutes(selection); const seconds = adapter.getSeconds(selection); this.value.set(target ? adapter.setTime(target, hours, minutes, seconds) : selection); } if (propagateToAccessor) { this._onChange?.(this.value()); } } /** Formats the current value and assigns it to the input. */ private _formatValue(value: D | null): void { value = this._dateAdapter.getValidDateOrNull(value); this._elementRef.nativeElement.value = value == null ? '' : this._dateAdapter.format(value, this._dateFormats.display.timeInput); } /** Checks whether a value is valid. */ private _isValid(value: D | null): boolean { return !value || this._dateAdapter.isValid(value); } /** Transforms an arbitrary value into a value that can be assigned to a date-based input. */ private _transformDateInput(value: unknown): D | null { const date = typeof value === 'string' ? this._dateAdapter.parseTime(value, this._dateFormats.parse.timeInput) : this._dateAdapter.deserialize(value); return date && this._dateAdapter.isValid(date) ? (date as D) : null; } /** Whether the input is currently focused. */ private _hasFocus(): boolean { return this._document.activeElement === this._elementRef.nativeElement; } /** Gets a function that can be used to validate the input. */ private _getValidator(): ValidatorFn { return Validators.compose([ () => this._lastValueValid ? null : {'matTimepickerParse': {'text': this._elementRef.nativeElement.value}}, control => { const controlValue = this._dateAdapter.getValidDateOrNull( this._dateAdapter.deserialize(control.value), ); const min = this.min(); return !min || !controlValue || this._dateAdapter.compareTime(min, controlValue) <= 0 ? null : {'matTimepickerMin': {'min': min, 'actual': controlValue}}; }, control => { const controlValue = this._dateAdapter.getValidDateOrNull( this._dateAdapter.deserialize(control.value), ); const max = this.max(); return !max || !controlValue || this._dateAdapter.compareTime(max, controlValue) >= 0 ? null : {'matTimepickerMax': {'max': max, 'actual': controlValue}}; }, ])!; } }