diff --git a/src/dev-app/datepicker/datepicker-demo.html b/src/dev-app/datepicker/datepicker-demo.html
index abd02e63e8d8..f41faec5e179 100644
--- a/src/dev-app/datepicker/datepicker-demo.html
+++ b/src/dev-app/datepicker/datepicker-demo.html
@@ -169,3 +169,37 @@
Datepicker with custom header extending the default header
+
+Range picker
+
+
+ Enter a date range
+
+
+
+
+
+
+
+
+
+
+ Enter a date range
+
+
+
+
+
+
+
+
+
+
+ Enter a date range
+
+
+
+
+
+
+
diff --git a/src/material/datepicker/BUILD.bazel b/src/material/datepicker/BUILD.bazel
index ec13f563dd6f..1d6b150938c0 100644
--- a/src/material/datepicker/BUILD.bazel
+++ b/src/material/datepicker/BUILD.bazel
@@ -19,6 +19,7 @@ ng_module(
assets = [
":datepicker-content.css",
":datepicker-toggle.css",
+ ":date-range-input.css",
":calendar-body.css",
":calendar.css",
] + glob(["**/*.html"]),
@@ -71,6 +72,12 @@ sass_binary(
deps = ["//src/cdk/a11y:a11y_scss_lib"],
)
+sass_binary(
+ name = "date_range_input_scss",
+ src = "date-range-input.scss",
+ deps = ["//src/material/core:core_scss_lib"],
+)
+
ng_test_library(
name = "unit_test_sources",
srcs = glob(
diff --git a/src/material/datepicker/date-range-input-parts.ts b/src/material/datepicker/date-range-input-parts.ts
new file mode 100644
index 000000000000..797c7ddbfa29
--- /dev/null
+++ b/src/material/datepicker/date-range-input-parts.ts
@@ -0,0 +1,178 @@
+/**
+ * @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.io/license
+ */
+
+import {Directive, ElementRef, Optional, Self, InjectionToken, Inject} from '@angular/core';
+import {
+ NG_VALUE_ACCESSOR,
+ NG_VALIDATORS,
+ ControlValueAccessor,
+ Validator,
+ AbstractControl,
+ ValidationErrors,
+ NgForm,
+ FormGroupDirective,
+ NgControl,
+} from '@angular/forms';
+import {
+ CanUpdateErrorState,
+ CanDisable,
+ ErrorStateMatcher,
+ CanDisableCtor,
+ CanUpdateErrorStateCtor,
+ mixinErrorState,
+ mixinDisabled,
+} from '@angular/material/core';
+import {BooleanInput} from '@angular/cdk/coercion';
+
+/** Parent component that should be wrapped around `MatStartDate` and `MatEndDate`. */
+export interface MatDateRangeInputParent {
+ id: string;
+ _ariaDescribedBy: string | null;
+ _ariaLabelledBy: string | null;
+ _handleChildValueChange: () => void;
+}
+
+/**
+ * Used to provide the date range input wrapper component
+ * to the parts without circular dependencies.
+ */
+export const MAT_DATE_RANGE_INPUT_PARENT =
+ new InjectionToken('MAT_DATE_RANGE_INPUT_PARENT');
+
+// Boilerplate for applying mixins to MatDateRangeInput.
+/** @docs-private */
+class MatDateRangeInputPartMixinBase {
+ constructor(public _defaultErrorStateMatcher: ErrorStateMatcher,
+ public _parentForm: NgForm,
+ public _parentFormGroup: FormGroupDirective,
+ /** @docs-private */
+ public ngControl: NgControl) {}
+}
+const _MatDateRangeInputMixinBase: CanDisableCtor &
+ CanUpdateErrorStateCtor & typeof MatDateRangeInputPartMixinBase =
+ mixinErrorState(mixinDisabled(MatDateRangeInputPartMixinBase));
+
+/**
+ * Base class for the individual inputs that can be projected inside a `mat-date-range-input`.
+ */
+@Directive()
+abstract class MatDateRangeInputPartBase extends _MatDateRangeInputMixinBase implements
+ ControlValueAccessor, Validator, CanUpdateErrorState, CanDisable, CanUpdateErrorState {
+
+ private _onTouched = () => {};
+
+ constructor(
+ protected _elementRef: ElementRef,
+ @Inject(MAT_DATE_RANGE_INPUT_PARENT) public _rangeInput: MatDateRangeInputParent,
+ defaultErrorStateMatcher: ErrorStateMatcher,
+ @Optional() parentForm: NgForm,
+ @Optional() parentFormGroup: FormGroupDirective,
+ @Optional() @Self() ngControl: NgControl) {
+ super(defaultErrorStateMatcher, parentForm, parentFormGroup, ngControl);
+ }
+
+ /** @docs-private */
+ writeValue(_value: D | null): void {
+ // TODO(crisbeto): implement
+ }
+
+ /** @docs-private */
+ registerOnChange(_fn: () => void): void {
+ // TODO(crisbeto): implement
+ }
+
+ /** @docs-private */
+ registerOnTouched(fn: () => void): void {
+ this._onTouched = fn;
+ }
+
+ /** @docs-private */
+ setDisabledState(isDisabled: boolean): void {
+ this.disabled = isDisabled;
+ }
+
+ /** @docs-private */
+ validate(_control: AbstractControl): ValidationErrors | null {
+ // TODO(crisbeto): implement
+ return null;
+ }
+
+ /** @docs-private */
+ registerOnValidatorChange(_fn: () => void): void {
+ // TODO(crisbeto): implement
+ }
+
+ /** Gets whether the input is empty. */
+ isEmpty(): boolean {
+ // TODO(crisbeto): should look at the CVA value.
+ return this._elementRef.nativeElement.value.length === 0;
+ }
+
+ /** Focuses the input. */
+ focus(): void {
+ this._elementRef.nativeElement.focus();
+ }
+
+ /** Handles blur events on the input. */
+ _handleBlur(): void {
+ this._onTouched();
+ }
+
+ static ngAcceptInputType_disabled: BooleanInput;
+}
+
+
+/** Input for entering the start date in a `mat-date-range-input`. */
+@Directive({
+ selector: 'input[matStartDate]',
+ inputs: ['disabled'],
+ host: {
+ '[id]': '_rangeInput.id',
+ '[attr.aria-labelledby]': '_rangeInput._ariaLabelledBy',
+ '[attr.aria-describedby]': '_rangeInput._ariaDescribedBy',
+ 'class': 'mat-date-range-input-inner',
+ 'type': 'text',
+ '(blur)': '_handleBlur()',
+ '(input)': '_rangeInput._handleChildValueChange()'
+ },
+ providers: [
+ {provide: NG_VALUE_ACCESSOR, useExisting: MatStartDate, multi: true},
+ {provide: NG_VALIDATORS, useExisting: MatStartDate, multi: true}
+ ]
+})
+export class MatStartDate extends MatDateRangeInputPartBase {
+ /** Gets the value that should be used when mirroring the input's size. */
+ getMirrorValue(): string {
+ const element = this._elementRef.nativeElement;
+ const value = element.value;
+ return value.length > 0 ? value : element.placeholder;
+ }
+
+ static ngAcceptInputType_disabled: BooleanInput;
+}
+
+
+/** Input for entering the end date in a `mat-date-range-input`. */
+@Directive({
+ selector: 'input[matEndDate]',
+ inputs: ['disabled'],
+ host: {
+ 'class': 'mat-date-range-input-inner',
+ '[attr.aria-labelledby]': '_rangeInput._ariaLabelledBy',
+ '[attr.aria-describedby]': '_rangeInput._ariaDescribedBy',
+ '(blur)': '_handleBlur',
+ 'type': 'text',
+ },
+ providers: [
+ {provide: NG_VALUE_ACCESSOR, useExisting: MatEndDate, multi: true},
+ {provide: NG_VALIDATORS, useExisting: MatEndDate, multi: true}
+ ]
+})
+export class MatEndDate extends MatDateRangeInputPartBase {
+ static ngAcceptInputType_disabled: BooleanInput;
+}
diff --git a/src/material/datepicker/date-range-input.html b/src/material/datepicker/date-range-input.html
new file mode 100644
index 000000000000..1187d2feeb90
--- /dev/null
+++ b/src/material/datepicker/date-range-input.html
@@ -0,0 +1,18 @@
+
+
diff --git a/src/material/datepicker/date-range-input.scss b/src/material/datepicker/date-range-input.scss
new file mode 100644
index 000000000000..b47f8edb4bdf
--- /dev/null
+++ b/src/material/datepicker/date-range-input.scss
@@ -0,0 +1,115 @@
+@import '../core/style/variables';
+@import '../core/style/vendor-prefixes';
+
+$mat-date-range-input-separator-spacing: 4px;
+$mat-date-range-input-part-max-width: calc(50% - #{$mat-date-range-input-separator-spacing});
+$mat-date-range-input-placeholder-transition:
+ color $swift-ease-out-duration $swift-ease-out-duration / 3 $swift-ease-out-timing-function;
+
+// Host of the date range input.
+.mat-date-range-input {
+ display: block;
+ width: 100%;
+}
+
+// Inner container that wraps around all the content.
+.mat-date-range-input-container {
+ display: flex;
+ align-items: center;
+}
+
+// Text shown between the two inputs.
+.mat-date-range-input-separator {
+ margin: 0 $mat-date-range-input-separator-spacing;
+ transition: $mat-date-range-input-placeholder-transition;
+
+ .mat-form-field-hide-placeholder & {
+ color: transparent;
+ transition: none;
+ }
+}
+
+// Underlying input inside the range input.
+.mat-date-range-input-inner {
+ // Reset the input so it's just a transparent rectangle.
+ font: inherit;
+ background: transparent;
+ color: currentColor;
+ border: none;
+ outline: none;
+ padding: 0;
+ margin: 0;
+ vertical-align: bottom;
+ text-align: inherit;
+ -webkit-appearance: none;
+ width: 100%;
+
+ // Remove IE's default clear and reveal icons.
+ &::-ms-clear,
+ &::-ms-reveal {
+ display: none;
+ }
+
+ @include input-placeholder {
+ transition: $mat-date-range-input-placeholder-transition;
+ }
+
+ .mat-form-field-hide-placeholder &,
+ .mat-date-range-input-hide-placeholders & {
+ @include input-placeholder {
+ // Needs to be !important, because the placeholder will end up inheriting the
+ // input color in IE, if the consumer overrides it with a higher specificity.
+ color: transparent !important;
+ -webkit-text-fill-color: transparent;
+ transition: none;
+ }
+ }
+}
+
+// We want the start input to be flush against the separator, no matter how much text it has, but
+// the problem is that inputs have a fixed width. We work around the issue by implementing an
+// auto-resizing input that stretches based on its text, up to a point. It works by having
+// a relatively-positioned wrapper (`.mat-date-range-input-start-wrapper` below) and an absolutely-
+// positioned `input`, as well as a `span` inside the wrapper which mirrors the input's value and
+// placeholder. As the user is typing, the value gets mirrored in the span which causes the wrapper
+// to stretch and the input with it.
+.mat-date-range-input-mirror {
+ // Disable user selection so users don't accidentally copy the text via ctrl + A.
+ @include user-select(none);
+
+ // Hide the element so it doesn't get read out by screen
+ // readers and it doesn't show up behind the input.
+ visibility: hidden;
+
+ // Text inside inputs never wraps so the one in the span shouldn't either.
+ white-space: nowrap;
+ display: inline-block;
+
+ // Prevent the container from collapsing. Make it more
+ // than 1px so the input caret doesn't get clipped.
+ min-width: 2px;
+}
+
+// Wrapper around the start input. Used to facilitate the auto-resizing input.
+.mat-date-range-input-start-wrapper {
+ position: relative;
+ overflow: hidden;
+ max-width: $mat-date-range-input-part-max-width;
+
+ .mat-date-range-input-inner {
+ position: absolute;
+ top: 0;
+ left: 0;
+ }
+}
+
+// Wrapper around the end input that makes sure that it has the proper size.
+.mat-date-range-input-end-wrapper {
+ flex-grow: 1;
+ max-width: $mat-date-range-input-part-max-width;
+}
+
+.mat-form-field-type-mat-date-range-input .mat-form-field-infix {
+ // Bump the default width slightly since it's somewhat cramped with two inputs and a separator.
+ width: 200px;
+}
diff --git a/src/material/datepicker/date-range-input.ts b/src/material/datepicker/date-range-input.ts
new file mode 100644
index 000000000000..6bdf95a18109
--- /dev/null
+++ b/src/material/datepicker/date-range-input.ts
@@ -0,0 +1,199 @@
+/**
+ * @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.io/license
+ */
+
+import {
+ Component,
+ ChangeDetectionStrategy,
+ ViewEncapsulation,
+ Input,
+ Optional,
+ OnDestroy,
+ ContentChild,
+ AfterContentInit,
+ ChangeDetectorRef,
+ Self,
+} from '@angular/core';
+import {MatFormFieldControl, MatFormField} from '@angular/material/form-field';
+import {DateRange} from '@angular/material/core';
+import {NgControl, ControlContainer} from '@angular/forms';
+import {Subject} from 'rxjs';
+import {coerceBooleanProperty, BooleanInput} from '@angular/cdk/coercion';
+import {
+ MatStartDate,
+ MatEndDate,
+ MatDateRangeInputParent,
+ MAT_DATE_RANGE_INPUT_PARENT,
+} from './date-range-input-parts';
+
+let nextUniqueId = 0;
+
+// TODO(crisbeto): when adding live examples, should how to use with `FormGroup`.
+
+@Component({
+ selector: 'mat-date-range-input',
+ templateUrl: 'date-range-input.html',
+ styleUrls: ['date-range-input.css'],
+ exportAs: 'matDateRangeInput',
+ host: {
+ 'class': 'mat-date-range-input',
+ '[class.mat-date-range-input-hide-placeholders]': '_shouldHidePlaceholders()',
+ '[attr.id]': 'null',
+ },
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ encapsulation: ViewEncapsulation.None,
+ providers: [
+ {provide: MatFormFieldControl, useExisting: MatDateRangeInput},
+ {provide: MAT_DATE_RANGE_INPUT_PARENT, useExisting: MatDateRangeInput},
+ ]
+})
+export class MatDateRangeInput implements MatFormFieldControl>,
+ MatDateRangeInputParent, AfterContentInit, OnDestroy {
+ /** Current value of the range input. */
+ value: DateRange | null = null;
+
+ /** Emits when the input's state has changed. */
+ stateChanges = new Subject();
+
+ /** Unique ID for the input. */
+ id = `mat-date-range-input-${nextUniqueId++}`;
+
+ /** 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`, but not used.
+ * Use `startPlaceholder` and `endPlaceholder` instead.
+ * @docs-private
+ */
+ placeholder: string;
+
+ /** Whether the input is required. */
+ @Input()
+ get required(): boolean { return !!this._required; }
+ set required(value: boolean) {
+ this._required = coerceBooleanProperty(value);
+ }
+ private _required: boolean;
+
+ /** Whether the input is disabled. */
+ get disabled(): boolean {
+ if (this._startInput && this._endInput) {
+ return this._startInput.disabled && this._endInput.disabled;
+ }
+
+ return 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;
+
+ /** Value for the `aria-labelledby` attribute of the inputs. */
+ _ariaLabelledBy: string | null = null;
+
+ /** Placeholder for the start input. */
+ @Input() startPlaceholder: string;
+
+ /** Placeholder for the end input. */
+ @Input() endPlaceholder: string;
+
+ /** Separator text to be shown between the inputs. */
+ @Input() separator = '–';
+
+ @ContentChild(MatStartDate) _startInput: MatStartDate;
+ @ContentChild(MatEndDate) _endInput: MatEndDate;
+
+ /**
+ * Implemented as a part of `MatFormFieldControl`.
+ * TODO(crisbeto): change type to `AbstractControlDirective` after #18206 lands.
+ * @docs-private
+ */
+ ngControl: NgControl | null;
+
+ constructor(
+ private _changeDetectorRef: ChangeDetectorRef,
+ @Optional() @Self() control: ControlContainer,
+ @Optional() formField?: MatFormField) {
+
+ // TODO(crisbeto): remove `as any` after #18206 lands.
+ this.ngControl = control as any;
+ this._ariaLabelledBy = formField ? formField._labelId : null;
+ }
+
+ /**
+ * 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._startInput.focus();
+ }
+ }
+
+ ngAfterContentInit() {
+ 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');
+ }
+ }
+
+ ngOnDestroy() {
+ this.stateChanges.complete();
+ }
+
+ /** Gets the value that is used to mirror the state input. */
+ _getInputMirrorValue() {
+ return this._startInput ? this._startInput.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._changeDetectorRef.markForCheck();
+ }
+
+ static ngAcceptInputType_required: BooleanInput;
+}
diff --git a/src/material/datepicker/datepicker-module.ts b/src/material/datepicker/datepicker-module.ts
index c0e08d6a75cf..ec5bea65bac5 100644
--- a/src/material/datepicker/datepicker-module.ts
+++ b/src/material/datepicker/datepicker-module.ts
@@ -26,6 +26,8 @@ import {MatDatepickerToggle, MatDatepickerToggleIcon} from './datepicker-toggle'
import {MatMonthView} from './month-view';
import {MatMultiYearView} from './multi-year-view';
import {MatYearView} from './year-view';
+import {MatDateRangeInput} from './date-range-input';
+import {MatStartDate, MatEndDate} from './date-range-input-parts';
@NgModule({
@@ -49,6 +51,9 @@ import {MatYearView} from './year-view';
MatYearView,
MatMultiYearView,
MatCalendarHeader,
+ MatDateRangeInput,
+ MatStartDate,
+ MatEndDate,
],
declarations: [
MatCalendar,
@@ -62,6 +67,9 @@ import {MatYearView} from './year-view';
MatYearView,
MatMultiYearView,
MatCalendarHeader,
+ MatDateRangeInput,
+ MatStartDate,
+ MatEndDate,
],
providers: [
MatDatepickerIntl,
diff --git a/src/material/datepicker/public-api.ts b/src/material/datepicker/public-api.ts
index 59cff4b77f88..3e248eefad70 100644
--- a/src/material/datepicker/public-api.ts
+++ b/src/material/datepicker/public-api.ts
@@ -16,4 +16,6 @@ export * from './datepicker-intl';
export * from './datepicker-toggle';
export * from './month-view';
export * from './year-view';
+export * from './date-range-input';
+export {MatStartDate, MatEndDate} from './date-range-input-parts';
export {MatMultiYearView, yearsPerPage, yearsPerRow} from './multi-year-view';
diff --git a/tools/public_api_guard/material/datepicker.d.ts b/tools/public_api_guard/material/datepicker.d.ts
index e76b22011d0b..9ca2db177d5f 100644
--- a/tools/public_api_guard/material/datepicker.d.ts
+++ b/tools/public_api_guard/material/datepicker.d.ts
@@ -220,7 +220,7 @@ export declare class MatDatepickerIntl {
export declare class MatDatepickerModule {
static ɵinj: i0.ɵɵInjectorDef;
- static ɵmod: i0.ɵɵNgModuleDefWithMeta;
+ static ɵmod: i0.ɵɵNgModuleDefWithMeta;
}
export declare class MatDatepickerToggle implements AfterContentInit, OnChanges, OnDestroy {
@@ -246,6 +246,45 @@ export declare class MatDatepickerToggleIcon {
static ɵfac: i0.ɵɵFactoryDef;
}
+export declare class MatDateRangeInput implements MatFormFieldControl>, MatDateRangeInputParent, AfterContentInit, OnDestroy {
+ _ariaDescribedBy: string | null;
+ _ariaLabelledBy: string | null;
+ _endInput: MatEndDate;
+ _startInput: MatStartDate;
+ controlType: string;
+ readonly disabled: boolean;
+ readonly empty: boolean;
+ endPlaceholder: string;
+ readonly errorState: boolean;
+ focused: boolean;
+ id: string;
+ ngControl: NgControl | null;
+ placeholder: string;
+ required: boolean;
+ separator: string;
+ readonly shouldLabelFloat: boolean;
+ startPlaceholder: string;
+ stateChanges: Subject;
+ value: DateRange | null;
+ constructor(_changeDetectorRef: ChangeDetectorRef, control: ControlContainer, formField?: MatFormField);
+ _getInputMirrorValue(): string;
+ _handleChildValueChange(): void;
+ _shouldHidePlaceholders(): boolean;
+ ngAfterContentInit(): void;
+ ngOnDestroy(): void;
+ onContainerClick(): void;
+ setDescribedByIds(ids: string[]): void;
+ static ngAcceptInputType_required: BooleanInput;
+ static ɵcmp: i0.ɵɵComponentDefWithMeta, "mat-date-range-input", ["matDateRangeInput"], { "required": "required"; "startPlaceholder": "startPlaceholder"; "endPlaceholder": "endPlaceholder"; "separator": "separator"; }, {}, ["_startInput", "_endInput"]>;
+ static ɵfac: i0.ɵɵFactoryDef>;
+}
+
+export declare class MatEndDate extends MatDateRangeInputPartBase {
+ static ngAcceptInputType_disabled: BooleanInput;
+ static ɵdir: i0.ɵɵDirectiveDefWithMeta, "input[matEndDate]", never, { "disabled": "disabled"; }, {}, never>;
+ static ɵfac: i0.ɵɵFactoryDef>;
+}
+
export declare class MatMonthView implements AfterContentInit, OnDestroy {
_dateAdapter: DateAdapter;
_firstWeekOffset: number;
@@ -304,6 +343,13 @@ export declare class MatMultiYearView implements AfterContentInit, OnDestroy
static ɵfac: i0.ɵɵFactoryDef>;
}
+export declare class MatStartDate extends MatDateRangeInputPartBase {
+ getMirrorValue(): string;
+ static ngAcceptInputType_disabled: BooleanInput;
+ static ɵdir: i0.ɵɵDirectiveDefWithMeta, "input[matStartDate]", never, { "disabled": "disabled"; }, {}, never>;
+ static ɵfac: i0.ɵɵFactoryDef>;
+}
+
export declare class MatYearView implements AfterContentInit, OnDestroy {
_dateAdapter: DateAdapter;
_matCalendarBody: MatCalendarBody;