From 5780d04a28a7a1fa34a1b83de4dd70169a57c411 Mon Sep 17 00:00:00 2001 From: crisbeto Date: Fri, 17 Jan 2020 22:16:40 +0100 Subject: [PATCH] feat(datepicker): set up input for date range picker Sets up the UI and most of the boilerplate that we'll need for the input that is associated with a date range picker. Doesn't include any interactions with the datepicker itself yet. --- src/dev-app/datepicker/datepicker-demo.html | 34 +++ src/material/datepicker/BUILD.bazel | 7 + .../datepicker/date-range-input-parts.ts | 178 ++++++++++++++++ src/material/datepicker/date-range-input.html | 18 ++ src/material/datepicker/date-range-input.scss | 115 ++++++++++ src/material/datepicker/date-range-input.ts | 199 ++++++++++++++++++ src/material/datepicker/datepicker-module.ts | 8 + src/material/datepicker/public-api.ts | 2 + .../public_api_guard/material/datepicker.d.ts | 48 ++++- 9 files changed, 608 insertions(+), 1 deletion(-) create mode 100644 src/material/datepicker/date-range-input-parts.ts create mode 100644 src/material/datepicker/date-range-input.html create mode 100644 src/material/datepicker/date-range-input.scss create mode 100644 src/material/datepicker/date-range-input.ts 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 @@ +
+
+ + +
+ + {{separator}} + +
+ +
+
+ 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;