Skip to content

feat(datepicker): set up input for date range picker #18159

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jan 17, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions src/dev-app/datepicker/datepicker-demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -169,3 +169,37 @@ <h2>Datepicker with custom header extending the default header</h2>
<mat-datepicker #customHeaderNgContentPicker [calendarHeaderComponent]="customHeaderNgContent"></mat-datepicker>
</mat-form-field>
</p>

<h2>Range picker</h2>
<p>
<mat-form-field>
<mat-label>Enter a date range</mat-label>
<mat-date-range-input>
<input matStartDate placeholder="Start date">
<input matEndDate placeholder="End date">
</mat-date-range-input>
<mat-datepicker-toggle matSuffix></mat-datepicker-toggle>
</mat-form-field>
</p>

<p>
<mat-form-field appearance="fill">
<mat-label>Enter a date range</mat-label>
<mat-date-range-input>
<input matStartDate placeholder="Start date">
<input matEndDate placeholder="End date">
</mat-date-range-input>
<mat-datepicker-toggle matSuffix></mat-datepicker-toggle>
</mat-form-field>
</p>

<p>
<mat-form-field appearance="outline">
<mat-label>Enter a date range</mat-label>
<mat-date-range-input>
<input matStartDate placeholder="Start date">
<input matEndDate placeholder="End date">
</mat-date-range-input>
<mat-datepicker-toggle matSuffix></mat-datepicker-toggle>
</mat-form-field>
</p>
7 changes: 7 additions & 0 deletions src/material/datepicker/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ ng_module(
assets = [
":datepicker-content.css",
":datepicker-toggle.css",
":date-range-input.css",
":calendar-body.css",
":calendar.css",
] + glob(["**/*.html"]),
Expand Down Expand Up @@ -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(
Expand Down
178 changes: 178 additions & 0 deletions src/material/datepicker/date-range-input-parts.ts
Original file line number Diff line number Diff line change
@@ -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<MatDateRangeInputParent>('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<D> extends _MatDateRangeInputMixinBase implements
ControlValueAccessor, Validator, CanUpdateErrorState, CanDisable, CanUpdateErrorState {

private _onTouched = () => {};

constructor(
protected _elementRef: ElementRef<HTMLInputElement>,
@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<D> extends MatDateRangeInputPartBase<D> {
/** 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<D> extends MatDateRangeInputPartBase<D> {
static ngAcceptInputType_disabled: BooleanInput;
}
18 changes: 18 additions & 0 deletions src/material/datepicker/date-range-input.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<div
class="mat-date-range-input-container"
cdkMonitorSubtreeFocus
(cdkFocusChange)="focused = $event !== null">
<div class="mat-date-range-input-start-wrapper">
<ng-content select="input[matStartDate]"></ng-content>
<span
class="mat-date-range-input-mirror"
aria-hidden="true">{{_getInputMirrorValue()}}</span>
</div>

<span class="mat-date-range-input-separator">{{separator}}</span>

<div class="mat-date-range-input-end-wrapper">
<ng-content select="input[matEndDate]"></ng-content>
</div>
</div>

115 changes: 115 additions & 0 deletions src/material/datepicker/date-range-input.scss
Original file line number Diff line number Diff line change
@@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does the input need to be flush against the separator?

Looking at the gif of your demo, I think it would be better if the separate and end-date input didn't shift as you were typing into the start date. Would it be better to give the input a min-width (equivalent to e.g. "77/77/7777") so that the end date input doesn't shift as the user is typing?

Copy link
Member Author

@crisbeto crisbeto Jan 13, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because it looks weird if you typed something shorter and then the space around the separator was uneven, e.g.

11/11     - 12/12

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if we right-align the the input?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Which one? The start date input or the date range input in general? I think we'd just the same problem but in a different place.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I pulled down the PR and tried tweaking a few things locally. I concluded that everything I suggested looks worse than what you ultimately did. I have one more idea, though, which is to hide the end-date input placeholder once you start typing into the start-date input. I tried this out locally and I think it eliminates the appearance of the input getting pushed.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please leave TODO's for anything that you will handle in a follow-up PR. I feel we've been getting a little lax with the "will handle in a follow-up PR" lately, and I'm worried that things might slip off our radar and never actually get done

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I ended up just doing it now. Can you take one more look @mmalerba? The way it behaves now is that it hides the placeholders when one of these criteria is true:

  • The inputs are empty and they have focus.
  • The start input is focused and the end input is empty.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The way I was imagining it, the dash doesn't disappear. @jelbourn should take a look too, since he felt more strongly about the placeholder stuff

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The way I was reading it the dash should disappear, otherwise you'll see it jump to the beginning when you start typing.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've updated it to keep the separator visible while the start has a value.

// 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;
}
Loading