453 lines
14 KiB
TypeScript
453 lines
14 KiB
TypeScript
|
|
/**
|
|||
|
|
* @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 {_IdGenerator, CdkMonitorFocus, FocusOrigin} from '@angular/cdk/a11y';
|
|||
|
|
import {
|
|||
|
|
AfterContentInit,
|
|||
|
|
ChangeDetectionStrategy,
|
|||
|
|
ChangeDetectorRef,
|
|||
|
|
Component,
|
|||
|
|
ContentChild,
|
|||
|
|
ElementRef,
|
|||
|
|
Input,
|
|||
|
|
OnChanges,
|
|||
|
|
OnDestroy,
|
|||
|
|
SimpleChanges,
|
|||
|
|
ViewEncapsulation,
|
|||
|
|
booleanAttribute,
|
|||
|
|
signal,
|
|||
|
|
inject,
|
|||
|
|
} from '@angular/core';
|
|||
|
|
import {ControlContainer, NgControl, Validators} from '@angular/forms';
|
|||
|
|
import {DateAdapter, ThemePalette} from '@angular/material/core';
|
|||
|
|
import {MAT_FORM_FIELD, MatFormFieldControl} from '@angular/material/form-field';
|
|||
|
|
import {Subject, Subscription, merge} from 'rxjs';
|
|||
|
|
import {
|
|||
|
|
MAT_DATE_RANGE_INPUT_PARENT,
|
|||
|
|
MatDateRangeInputParent,
|
|||
|
|
MatEndDate,
|
|||
|
|
MatStartDate,
|
|||
|
|
} from './date-range-input-parts';
|
|||
|
|
import {MatDateRangePickerInput} from './date-range-picker';
|
|||
|
|
import {DateRange, MatDateSelectionModel} from './date-selection-model';
|
|||
|
|
import {MatDatepickerControl, MatDatepickerPanel} from './datepicker-base';
|
|||
|
|
import {createMissingDateImplError} from './datepicker-errors';
|
|||
|
|
import {DateFilterFn, _MatFormFieldPartial, dateInputsHaveChanged} from './datepicker-input-base';
|
|||
|
|
|
|||
|
|
@Component({
|
|||
|
|
selector: 'mat-date-range-input',
|
|||
|
|
templateUrl: 'date-range-input.html',
|
|||
|
|
styleUrl: 'date-range-input.css',
|
|||
|
|
exportAs: 'matDateRangeInput',
|
|||
|
|
host: {
|
|||
|
|
'class': 'mat-date-range-input',
|
|||
|
|
'[class.mat-date-range-input-hide-placeholders]': '_shouldHidePlaceholders()',
|
|||
|
|
'[class.mat-date-range-input-required]': 'required',
|
|||
|
|
'[attr.id]': 'id',
|
|||
|
|
'role': 'group',
|
|||
|
|
'[attr.aria-labelledby]': '_getAriaLabelledby()',
|
|||
|
|
'[attr.aria-describedby]': '_ariaDescribedBy',
|
|||
|
|
// Used by the test harness to tie this input to its calendar. We can't depend on
|
|||
|
|
// `aria-owns` for this, because it's only defined while the calendar is open.
|
|||
|
|
'[attr.data-mat-calendar]': 'rangePicker ? rangePicker.id : null',
|
|||
|
|
},
|
|||
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|||
|
|
encapsulation: ViewEncapsulation.None,
|
|||
|
|
providers: [
|
|||
|
|
{provide: MatFormFieldControl, useExisting: MatDateRangeInput},
|
|||
|
|
{provide: MAT_DATE_RANGE_INPUT_PARENT, useExisting: MatDateRangeInput},
|
|||
|
|
],
|
|||
|
|
imports: [CdkMonitorFocus],
|
|||
|
|
})
|
|||
|
|
export class MatDateRangeInput<D>
|
|||
|
|
implements
|
|||
|
|
MatFormFieldControl<DateRange<D>>,
|
|||
|
|
MatDatepickerControl<D>,
|
|||
|
|
MatDateRangeInputParent<D>,
|
|||
|
|
MatDateRangePickerInput<D>,
|
|||
|
|
AfterContentInit,
|
|||
|
|
OnChanges,
|
|||
|
|
OnDestroy
|
|||
|
|
{
|
|||
|
|
private _changeDetectorRef = inject(ChangeDetectorRef);
|
|||
|
|
private _elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
|
|||
|
|
private _dateAdapter = inject<DateAdapter<D>>(DateAdapter, {optional: true})!;
|
|||
|
|
private _formField = inject<_MatFormFieldPartial>(MAT_FORM_FIELD, {optional: true});
|
|||
|
|
|
|||
|
|
private _closedSubscription = Subscription.EMPTY;
|
|||
|
|
private _openedSubscription = Subscription.EMPTY;
|
|||
|
|
|
|||
|
|
/** Current value of the range input. */
|
|||
|
|
get value() {
|
|||
|
|
return this._model ? this._model.selection : null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Unique ID for the group. */
|
|||
|
|
id: string = inject(_IdGenerator).getId('mat-date-range-input-');
|
|||
|
|
|
|||
|
|
/** Whether the control is focused. */
|
|||
|
|
focused = false;
|
|||
|
|
|
|||
|
|
/** Whether the control's label should float. */
|
|||
|
|
get shouldLabelFloat(): boolean {
|
|||
|
|
return this.focused || !this.empty;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Name of the form control. */
|
|||
|
|
controlType = 'mat-date-range-input';
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Implemented as a part of `MatFormFieldControl`.
|
|||
|
|
* Set the placeholder attribute on `matStartDate` and `matEndDate`.
|
|||
|
|
* @docs-private
|
|||
|
|
*/
|
|||
|
|
get placeholder() {
|
|||
|
|
const start = this._startInput?._getPlaceholder() || '';
|
|||
|
|
const end = this._endInput?._getPlaceholder() || '';
|
|||
|
|
return start || end ? `${start} ${this.separator} ${end}` : '';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** The range picker that this input is associated with. */
|
|||
|
|
@Input()
|
|||
|
|
get rangePicker() {
|
|||
|
|
return this._rangePicker;
|
|||
|
|
}
|
|||
|
|
set rangePicker(rangePicker: MatDatepickerPanel<MatDatepickerControl<D>, DateRange<D>, D>) {
|
|||
|
|
if (rangePicker) {
|
|||
|
|
this._model = rangePicker.registerInput(this);
|
|||
|
|
this._rangePicker = rangePicker;
|
|||
|
|
this._closedSubscription.unsubscribe();
|
|||
|
|
this._openedSubscription.unsubscribe();
|
|||
|
|
this._ariaOwns.set(this.rangePicker.opened ? rangePicker.id : null);
|
|||
|
|
this._closedSubscription = rangePicker.closedStream.subscribe(() => {
|
|||
|
|
this._startInput?._onTouched();
|
|||
|
|
this._endInput?._onTouched();
|
|||
|
|
this._ariaOwns.set(null);
|
|||
|
|
});
|
|||
|
|
this._openedSubscription = rangePicker.openedStream.subscribe(() => {
|
|||
|
|
this._ariaOwns.set(rangePicker.id);
|
|||
|
|
});
|
|||
|
|
this._registerModel(this._model!);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
private _rangePicker: MatDatepickerPanel<MatDatepickerControl<D>, DateRange<D>, D>;
|
|||
|
|
|
|||
|
|
/** The id of the panel owned by this input. */
|
|||
|
|
_ariaOwns = signal<string | null>(null);
|
|||
|
|
|
|||
|
|
/** Whether the input is required. */
|
|||
|
|
@Input({transform: booleanAttribute})
|
|||
|
|
get required(): boolean {
|
|||
|
|
return (
|
|||
|
|
this._required ??
|
|||
|
|
(this._isTargetRequired(this) ||
|
|||
|
|
this._isTargetRequired(this._startInput) ||
|
|||
|
|
this._isTargetRequired(this._endInput)) ??
|
|||
|
|
false
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
set required(value: boolean) {
|
|||
|
|
this._required = value;
|
|||
|
|
}
|
|||
|
|
private _required: boolean | undefined;
|
|||
|
|
|
|||
|
|
/** Function that can be used to filter out dates within the date range picker. */
|
|||
|
|
@Input()
|
|||
|
|
get dateFilter() {
|
|||
|
|
return this._dateFilter;
|
|||
|
|
}
|
|||
|
|
set dateFilter(value: DateFilterFn<D>) {
|
|||
|
|
const start = this._startInput;
|
|||
|
|
const end = this._endInput;
|
|||
|
|
const wasMatchingStart = start && start._matchesFilter(start.value);
|
|||
|
|
const wasMatchingEnd = end && end._matchesFilter(start.value);
|
|||
|
|
this._dateFilter = value;
|
|||
|
|
|
|||
|
|
if (start && start._matchesFilter(start.value) !== wasMatchingStart) {
|
|||
|
|
start._validatorOnChange();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (end && end._matchesFilter(end.value) !== wasMatchingEnd) {
|
|||
|
|
end._validatorOnChange();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
private _dateFilter: DateFilterFn<D>;
|
|||
|
|
|
|||
|
|
/** The minimum valid date. */
|
|||
|
|
@Input()
|
|||
|
|
get min(): D | null {
|
|||
|
|
return this._min;
|
|||
|
|
}
|
|||
|
|
set min(value: D | null) {
|
|||
|
|
const validValue = this._dateAdapter.getValidDateOrNull(this._dateAdapter.deserialize(value));
|
|||
|
|
|
|||
|
|
if (!this._dateAdapter.sameDate(validValue, this._min)) {
|
|||
|
|
this._min = validValue;
|
|||
|
|
this._revalidate();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
private _min: D | null;
|
|||
|
|
|
|||
|
|
/** The maximum valid date. */
|
|||
|
|
@Input()
|
|||
|
|
get max(): D | null {
|
|||
|
|
return this._max;
|
|||
|
|
}
|
|||
|
|
set max(value: D | null) {
|
|||
|
|
const validValue = this._dateAdapter.getValidDateOrNull(this._dateAdapter.deserialize(value));
|
|||
|
|
|
|||
|
|
if (!this._dateAdapter.sameDate(validValue, this._max)) {
|
|||
|
|
this._max = validValue;
|
|||
|
|
this._revalidate();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
private _max: D | null;
|
|||
|
|
|
|||
|
|
/** Whether the input is disabled. */
|
|||
|
|
@Input({transform: booleanAttribute})
|
|||
|
|
get disabled(): boolean {
|
|||
|
|
return this._startInput && this._endInput
|
|||
|
|
? this._startInput.disabled && this._endInput.disabled
|
|||
|
|
: this._groupDisabled;
|
|||
|
|
}
|
|||
|
|
set disabled(value: boolean) {
|
|||
|
|
if (value !== this._groupDisabled) {
|
|||
|
|
this._groupDisabled = value;
|
|||
|
|
this.stateChanges.next(undefined);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
_groupDisabled = false;
|
|||
|
|
|
|||
|
|
/** Whether the input is in an error state. */
|
|||
|
|
get errorState(): boolean {
|
|||
|
|
if (this._startInput && this._endInput) {
|
|||
|
|
return this._startInput.errorState || this._endInput.errorState;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Whether the datepicker input is empty. */
|
|||
|
|
get empty(): boolean {
|
|||
|
|
const startEmpty = this._startInput ? this._startInput.isEmpty() : false;
|
|||
|
|
const endEmpty = this._endInput ? this._endInput.isEmpty() : false;
|
|||
|
|
return startEmpty && endEmpty;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Value for the `aria-describedby` attribute of the inputs. */
|
|||
|
|
_ariaDescribedBy: string | null = null;
|
|||
|
|
|
|||
|
|
/** Date selection model currently registered with the input. */
|
|||
|
|
private _model: MatDateSelectionModel<DateRange<D>> | undefined;
|
|||
|
|
|
|||
|
|
/** Separator text to be shown between the inputs. */
|
|||
|
|
@Input() separator = '–';
|
|||
|
|
|
|||
|
|
/** Start of the comparison range that should be shown in the calendar. */
|
|||
|
|
@Input() comparisonStart: D | null = null;
|
|||
|
|
|
|||
|
|
/** End of the comparison range that should be shown in the calendar. */
|
|||
|
|
@Input() comparisonEnd: D | null = null;
|
|||
|
|
|
|||
|
|
@ContentChild(MatStartDate) _startInput: MatStartDate<D>;
|
|||
|
|
@ContentChild(MatEndDate) _endInput: MatEndDate<D>;
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Implemented as a part of `MatFormFieldControl`.
|
|||
|
|
* TODO(crisbeto): change type to `AbstractControlDirective` after #18206 lands.
|
|||
|
|
* @docs-private
|
|||
|
|
*/
|
|||
|
|
ngControl: NgControl | null;
|
|||
|
|
|
|||
|
|
/** Emits when the input's state has changed. */
|
|||
|
|
readonly stateChanges = new Subject<void>();
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Disable the automatic labeling to avoid issues like #27241.
|
|||
|
|
* @docs-private
|
|||
|
|
*/
|
|||
|
|
readonly disableAutomaticLabeling = true;
|
|||
|
|
|
|||
|
|
constructor(...args: unknown[]);
|
|||
|
|
|
|||
|
|
constructor() {
|
|||
|
|
if (!this._dateAdapter && (typeof ngDevMode === 'undefined' || ngDevMode)) {
|
|||
|
|
throw createMissingDateImplError('DateAdapter');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// The datepicker module can be used both with MDC and non-MDC form fields. We have
|
|||
|
|
// to conditionally add the MDC input class so that the range picker looks correctly.
|
|||
|
|
if (this._formField?._elementRef.nativeElement.classList.contains('mat-mdc-form-field')) {
|
|||
|
|
this._elementRef.nativeElement.classList.add(
|
|||
|
|
'mat-mdc-input-element',
|
|||
|
|
'mat-mdc-form-field-input-control',
|
|||
|
|
'mdc-text-field__input',
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// TODO(crisbeto): remove `as any` after #18206 lands.
|
|||
|
|
this.ngControl = inject(ControlContainer, {optional: true, self: true}) as any;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Implemented as a part of `MatFormFieldControl`.
|
|||
|
|
* @docs-private
|
|||
|
|
*/
|
|||
|
|
setDescribedByIds(ids: string[]): void {
|
|||
|
|
this._ariaDescribedBy = ids.length ? ids.join(' ') : null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Implemented as a part of `MatFormFieldControl`.
|
|||
|
|
* @docs-private
|
|||
|
|
*/
|
|||
|
|
onContainerClick(): void {
|
|||
|
|
if (!this.focused && !this.disabled) {
|
|||
|
|
if (!this._model || !this._model.selection.start) {
|
|||
|
|
this._startInput.focus();
|
|||
|
|
} else {
|
|||
|
|
this._endInput.focus();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
ngAfterContentInit() {
|
|||
|
|
if (typeof ngDevMode === 'undefined' || ngDevMode) {
|
|||
|
|
if (!this._startInput) {
|
|||
|
|
throw Error('mat-date-range-input must contain a matStartDate input');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!this._endInput) {
|
|||
|
|
throw Error('mat-date-range-input must contain a matEndDate input');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (this._model) {
|
|||
|
|
this._registerModel(this._model);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// We don't need to unsubscribe from this, because we
|
|||
|
|
// know that the input streams will be completed on destroy.
|
|||
|
|
merge(this._startInput.stateChanges, this._endInput.stateChanges).subscribe(() => {
|
|||
|
|
this.stateChanges.next(undefined);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
ngOnChanges(changes: SimpleChanges) {
|
|||
|
|
if (dateInputsHaveChanged(changes, this._dateAdapter)) {
|
|||
|
|
this.stateChanges.next(undefined);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
ngOnDestroy() {
|
|||
|
|
this._closedSubscription.unsubscribe();
|
|||
|
|
this._openedSubscription.unsubscribe();
|
|||
|
|
this.stateChanges.complete();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Gets the date at which the calendar should start. */
|
|||
|
|
getStartValue(): D | null {
|
|||
|
|
return this.value ? this.value.start : null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Gets the input's theme palette. */
|
|||
|
|
getThemePalette(): ThemePalette {
|
|||
|
|
return this._formField ? this._formField.color : undefined;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Gets the element to which the calendar overlay should be attached. */
|
|||
|
|
getConnectedOverlayOrigin(): ElementRef {
|
|||
|
|
return this._formField ? this._formField.getConnectedOverlayOrigin() : this._elementRef;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Gets the ID of an element that should be used a description for the calendar overlay. */
|
|||
|
|
getOverlayLabelId(): string | null {
|
|||
|
|
return this._formField ? this._formField.getLabelId() : null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Gets the value that is used to mirror the state input. */
|
|||
|
|
_getInputMirrorValue(part: 'start' | 'end') {
|
|||
|
|
const input = part === 'start' ? this._startInput : this._endInput;
|
|||
|
|
return input ? input.getMirrorValue() : '';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Whether the input placeholders should be hidden. */
|
|||
|
|
_shouldHidePlaceholders() {
|
|||
|
|
return this._startInput ? !this._startInput.isEmpty() : false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Handles the value in one of the child inputs changing. */
|
|||
|
|
_handleChildValueChange() {
|
|||
|
|
this.stateChanges.next(undefined);
|
|||
|
|
this._changeDetectorRef.markForCheck();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Opens the date range picker associated with the input. */
|
|||
|
|
_openDatepicker() {
|
|||
|
|
if (this._rangePicker) {
|
|||
|
|
this._rangePicker.open();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Whether the separate text should be hidden. */
|
|||
|
|
_shouldHideSeparator() {
|
|||
|
|
return (
|
|||
|
|
(!this._formField ||
|
|||
|
|
(this._formField.getLabelId() && !this._formField._shouldLabelFloat())) &&
|
|||
|
|
this.empty
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Gets the value for the `aria-labelledby` attribute of the inputs. */
|
|||
|
|
_getAriaLabelledby() {
|
|||
|
|
const formField = this._formField;
|
|||
|
|
return formField && formField._hasFloatingLabel() ? formField._labelId : null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
_getStartDateAccessibleName(): string {
|
|||
|
|
return this._startInput._getAccessibleName();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
_getEndDateAccessibleName(): string {
|
|||
|
|
return this._endInput._getAccessibleName();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Updates the focused state of the range input. */
|
|||
|
|
_updateFocus(origin: FocusOrigin) {
|
|||
|
|
this.focused = origin !== null;
|
|||
|
|
this.stateChanges.next();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Re-runs the validators on the start/end inputs. */
|
|||
|
|
private _revalidate() {
|
|||
|
|
if (this._startInput) {
|
|||
|
|
this._startInput._validatorOnChange();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (this._endInput) {
|
|||
|
|
this._endInput._validatorOnChange();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Registers the current date selection model with the start/end inputs. */
|
|||
|
|
private _registerModel(model: MatDateSelectionModel<DateRange<D>>) {
|
|||
|
|
if (this._startInput) {
|
|||
|
|
this._startInput._registerModel(model);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (this._endInput) {
|
|||
|
|
this._endInput._registerModel(model);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Checks whether a specific range input directive is required. */
|
|||
|
|
private _isTargetRequired(target: {ngControl: NgControl | null} | null): boolean | undefined {
|
|||
|
|
return target?.ngControl?.control?.hasValidator(Validators.required);
|
|||
|
|
}
|
|||
|
|
}
|