Skip to content

Commit

Permalink
feat(material/timepicker): add timepicker component
Browse files Browse the repository at this point in the history
Addresses a long-time feature request by adding a component that allows users to select a time.
The new component uses a combination of an `input` and a dropdown to allow users to either type
a time or select it from a pre-defined list. Example usage:

```html
<mat-form-field>
  <mat-label>Pick a time</mat-label>
  <input matInput [matTimepicker]="picker"/>
  <mat-timepicker #picker/>
  <mat-timepicker-toggle [for]="picker"/>
</mat-form-field>
```

Features of the new component include:
* Automatically parses the typed-in value to a date object using the current `DateAdapter`. Existing date adapters have been updated to add support for parsing times.
* Time values can be generated either using the `interval` input (e.g. `interval="45min"`) or provided directly through the `options` input.
* Integrated into `@angular/forms` by providing itself as a `ControlValueAccessor` and `Validator`.
* Offers built-in validation for minimum, maximum and time formatting.
* Offers keyboard navigation support.
* Accessibility implemented using the combobox + listbox pattern.
* Can be used either with `mat-form-field` or on its own.
* Can be combined with `mat-datepicker` (docs to follow, see the dev app for now).
* Includes test harnesses for all directives.
* Works with Material's theming system.
* Can be configured globally through an injection token.
* Can be used either as an `NgModule` or by importing the standalone directives.

One of the main reasons why we hadn't provided a timepicker component until now was that there's no
universally-accepted design for what a timepicker should look like.

Material Design has had a [specification for a timepicker](https://m3.material.io/components/time-pickers/overview) for years, but we didn't want to implement it because:
1. This design is primarily geared towards mobile users on Android. It would look out of place in the desktop-focused enterprise UIs that a lot of Angular developers build.
2. The time dial UI is complicated and can be overwhelming, especially in the 24h variant.
3. The accessibility pattern is unclear, users may have to fall back to using the inputs.
4. It's unclear how the time selection would work on non-Westernized locales whose time formatting isn't some variation of `HH:MM`.
5. The time dial requires very precise movements if the user wants to select a specific time between others (e.g. 6:52). This can be unusable for users with some disabilities.
6. The non-dial functionality (inputs in a dropdown) don't add much to the user experience.

There are [community implementations](https://dhutaryan.github.io/ngx-mat-timepicker) of the dial design that you can install if you want it for your app.

Some libraries like [Kendo UI](https://www.telerik.com/kendo-angular-ui/components/dateinputs/timepicker), [Ignite UI](https://www.infragistics.com/products/ignite-ui-angular/angular/components/time-picker) or [MUI](https://mui.com/x/react-date-pickers/time-picker/), as well as Chrome's implementation of `<input type="time"/>` appear to have settled on a multi-column design for the dropdown. We didn't want to do something similar because:
1. The selected state is only shown using one sensory characteristic (color) which is problematic for accessibility. While we could either add a second one (e.g. a checkbox) or adjust the design somehow, we felt that this would make it look sub-optimal.
2. The UI only looks good on smaller sizes and when each column has roughly the same amount of text. Changing either for a particular column can throw off the whole UI's appearance.
3. It requires the user to tab through several controls within the dialog.
4. It's unclear how the time selection would work on non-Westernized locales whose time formatting isn't some variation of `HH:MM`.
5. Each column requires a lot of filler whitespace in order to be able to align the selected states to each other which can look off on some selections.

We chose the current design, because:
1. Users are familiar with it, e.g. Google Calendar uses something similar for their time selection.
2. It reuses the design from existing Material Design components.
3. It uses an established accessibility pattern (combobox + listbox) and it doesn't have the same concerns as the multi-column design around indicating the selected state.
4. It allows us to support a wide range of locales.
5. It's compact, allowing us to do some sort of unified control with `mat-datepicker` in the future.

Fixes #5648.
  • Loading branch information
crisbeto committed Oct 4, 2024
1 parent a8c41b9 commit 2646e08
Show file tree
Hide file tree
Showing 20 changed files with 2,776 additions and 38 deletions.
5 changes: 5 additions & 0 deletions src/dev-app/timepicker/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@ ng_module(
deps = [
"//src/material/button",
"//src/material/card",
"//src/material/core",
"//src/material/datepicker",
"//src/material/form-field",
"//src/material/icon",
"//src/material/input",
"//src/material/select",
"//src/material/timepicker",
],
)
Expand Down
100 changes: 99 additions & 1 deletion src/dev-app/timepicker/timepicker-demo.html
Original file line number Diff line number Diff line change
@@ -1 +1,99 @@
<mat-timepicker/>
<div class="demo-row">
<div>
<div>
<h2>Basic timepicker</h2>
<mat-form-field>
<mat-label>Pick a time</mat-label>
<input
matInput
[matTimepicker]="basicPicker"
[matTimepickerMin]="minControl.value"
[matTimepickerMax]="maxControl.value"
[formControl]="control">
<mat-timepicker [interval]="intervalControl.value" #basicPicker/>
<mat-timepicker-toggle [for]="basicPicker" matSuffix/>
</mat-form-field>

<p>Value: {{control.value}}</p>
<p>Dirty: {{control.dirty}}</p>
<p>Touched: {{control.touched}}</p>
<p>Errors: {{control.errors | json}}</p>
<button mat-button (click)="randomizeValue()">Assign a random value</button>
</div>

<div>
<h2>Timepicker and datepicker</h2>
<mat-form-field>
<mat-label>Pick a date</mat-label>
<input
matInput
[matDatepicker]="combinedDatepicker"
[(ngModel)]="combinedValue">
<mat-datepicker #combinedDatepicker/>
<mat-datepicker-toggle [for]="combinedDatepicker" matSuffix/>
</mat-form-field>

<div>
<mat-form-field>
<mat-label>Pick a time</mat-label>
<input
matInput
[matTimepicker]="combinedTimepicker"
[matTimepickerMin]="minControl.value"
[matTimepickerMax]="maxControl.value"
[(ngModel)]="combinedValue"
[ngModelOptions]="{updateOn: 'blur'}">
<mat-timepicker [interval]="intervalControl.value" #combinedTimepicker/>
<mat-timepicker-toggle [for]="combinedTimepicker" matSuffix/>
</mat-form-field>
</div>

<p>Value: {{combinedValue}}</p>
</div>

<div>
<h2>Timepicker without form field</h2>
<input [matTimepicker]="nonFormFieldPicker">
<mat-timepicker aria-label="Standalone timepicker" #nonFormFieldPicker/>
</div>
</div>

<mat-card appearance="outlined" class="demo-card">
<mat-card-header>
<mat-card-title>State</mat-card-title>
</mat-card-header>

<mat-card-content>
<div class="demo-form-fields">
<mat-form-field>
<mat-label>Locale</mat-label>
<mat-select [formControl]="localeControl">
@for (locale of locales; track $index) {
<mat-option [value]="locale">{{locale}}</mat-option>
}
</mat-select>
</mat-form-field>

<mat-form-field>
<mat-label>Interval</mat-label>
<input matInput [formControl]="intervalControl"/>
</mat-form-field>

<mat-form-field>
<mat-label>Min time</mat-label>
<input matInput [matTimepicker]="minPicker" [formControl]="minControl">
<mat-timepicker #minPicker/>
<mat-timepicker-toggle [for]="minPicker" matSuffix/>
</mat-form-field>

<mat-form-field>
<mat-label>Max time</mat-label>
<input matInput [matTimepicker]="maxPicker" [formControl]="maxControl">
<mat-timepicker #maxPicker/>
<mat-timepicker-toggle [for]="maxPicker" matSuffix/>
</mat-form-field>
</div>
</mat-card-content>
</mat-card>
</div>

23 changes: 22 additions & 1 deletion src/dev-app/timepicker/timepicker-demo.scss
Original file line number Diff line number Diff line change
@@ -1 +1,22 @@
// TODO
.demo-row {
display: flex;
align-items: flex-start;
gap: 100px;
}

.demo-card {
width: 600px;
max-width: 100%;
flex-shrink: 0;
}

.demo-form-fields {
display: flex;
flex-wrap: wrap;
gap: 0 2%;
margin-top: 16px;

mat-form-field {
flex-basis: 49%;
}
}
61 changes: 57 additions & 4 deletions src/dev-app/timepicker/timepicker-demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,68 @@
* found in the LICENSE file at https://angular.dev/license
*/

import {ChangeDetectionStrategy, Component} from '@angular/core';
import {MatTimepicker} from '@angular/material/timepicker';
import {ChangeDetectionStrategy, Component, inject, OnDestroy} from '@angular/core';
import {DateAdapter} from '@angular/material/core';
import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms';
import {MatTimepickerModule} from '@angular/material/timepicker';
import {MatFormFieldModule} from '@angular/material/form-field';
import {MatInputModule} from '@angular/material/input';
import {JsonPipe} from '@angular/common';
import {MatButtonModule} from '@angular/material/button';
import {MatSelectModule} from '@angular/material/select';
import {Subscription} from 'rxjs';
import {MatCardModule} from '@angular/material/card';
import {MatDatepickerModule} from '@angular/material/datepicker';

@Component({
selector: 'timepicker-demo',
templateUrl: 'timepicker-demo.html',
styleUrl: 'timepicker-demo.css',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [MatTimepicker],
imports: [
MatTimepickerModule,
MatDatepickerModule,
MatFormFieldModule,
MatInputModule,
ReactiveFormsModule,
FormsModule,
JsonPipe,
MatButtonModule,
MatSelectModule,
MatCardModule,
],
})
export class TimepickerDemo {}
export class TimepickerDemo implements OnDestroy {
private _dateAdapter = inject(DateAdapter);
private _localeSubscription: Subscription;
locales = ['en-US', 'da-DK', 'bg-BG', 'zh-TW'];
control: FormControl<Date | null>;
localeControl = new FormControl('en-US', {nonNullable: true});
intervalControl = new FormControl('1h', {nonNullable: true});
minControl = new FormControl<Date | null>(null);
maxControl = new FormControl<Date | null>(null);
combinedValue: Date | null = null;

constructor() {
const value = new Date();
value.setHours(15, 0, 0);
this.control = new FormControl(value);

this._localeSubscription = this.localeControl.valueChanges.subscribe(locale => {
if (locale) {
this._dateAdapter.setLocale(locale);
}
});
}

randomizeValue() {
const value = new Date();
value.setHours(Math.floor(Math.random() * 23), Math.floor(Math.random() * 59), 0);
this.control.setValue(value);
}

ngOnDestroy(): void {
this._localeSubscription.unsubscribe();
}
}
3 changes: 1 addition & 2 deletions src/material/core/tokens/_density.scss
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ $_density-tokens: (
(mat, slider): (),
(mat, snack-bar): (),
(mat, sort): (),
(mat, timepicker): (),
(mat, standard-button-toggle): (
height: (40px, 40px, 40px, 36px, 24px),
),
Expand All @@ -157,8 +158,6 @@ $_density-tokens: (
(mat, tree): (
node-min-height: (48px, 44px, 40px, 36px, 28px),
),
// TODO: timepicker
(mat, timepicker): (),
);

/// Gets the value for the given density scale from the given set of density values.
Expand Down
13 changes: 8 additions & 5 deletions src/material/core/tokens/m2/mat/_timepicker.scss
Original file line number Diff line number Diff line change
@@ -1,27 +1,30 @@
@use '../../token-definition';
@use '../../../theming/inspection';
@use '../../../style/sass-utils';
@use '../../../style/elevation';

// The prefix used to generate the fully qualified name for tokens in this file.
$prefix: (mat, timepicker);

// Tokens that can't be configured through Angular Material's current theming API,
// but may be in a future version of the theming API.
@function get-unthemable-tokens() {
@return ();
@return (
container-shape: 4px,
container-elevation-shadow: elevation.get-box-shadow(8),
);
}

// Tokens that can be configured through Angular Material's color theming API.
@function get-color-tokens($theme) {
@return (
enabled-trigger-text-color: hotpink,
container-background-color: inspection.get-theme-color($theme, background, card)
);
}

// Tokens that can be configured through Angular Material's typography theming API.
@function get-typography-tokens($theme) {
@return (
trigger-text-font: fantasy,
);
@return ();
}

// Tokens that can be configured through Angular Material's density theming API.
Expand Down
8 changes: 6 additions & 2 deletions src/material/core/tokens/m3/mat/_timepicker.scss
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
@use 'sass:map';
@use '../../../style/elevation';
@use '../../token-definition';

// The prefix used to generate the fully qualified name for tokens in this file.
Expand All @@ -10,8 +12,10 @@ $prefix: (mat, timepicker);
/// @return {Map} A set of custom tokens for the mat-timepicker
@function get-tokens($systems, $exclude-hardcoded, $token-slots) {
$tokens: (
enabled-trigger-text-color: hotpink,
trigger-text-font: fantasy,
container-background-color: map.get($systems, md-sys-color, surface-container),
container-shape: map.get($systems, md-sys-shape, corner-extra-small),
container-elevation-shadow:
token-definition.hardcode(elevation.get-box-shadow(2), $exclude-hardcoded),
);

@return token-definition.namespace-tokens($prefix, $tokens, $token-slots);
Expand Down
16 changes: 14 additions & 2 deletions src/material/timepicker/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,16 @@ ng_module(
deps = [
"//src:dev_mode_types",
"//src/cdk/bidi",
"//src/cdk/coercion",
"//src/cdk/keycodes",
"//src/cdk/overlay",
"//src/cdk/platform",
"//src/cdk/portal",
"//src/cdk/scrolling",
"//src/material/button",
"//src/material/core",
"//src/material/input",
"@npm//@angular/core",
"@npm//@angular/forms",
],
)

Expand All @@ -45,7 +52,12 @@ ng_test_library(
),
deps = [
":timepicker",
"//src/cdk/bidi",
"//src/cdk/keycodes",
"//src/cdk/testing/private",
"//src/material/core",
"//src/material/form-field",
"//src/material/input",
"@npm//@angular/forms",
"@npm//@angular/platform-browser",
],
)
Expand Down
25 changes: 22 additions & 3 deletions src/material/timepicker/_timepicker-theme.scss
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
@use '../core/tokens/m2/mat/timepicker' as tokens-mat-timepicker;
@use '../core/tokens/token-utils';

/// Outputs base theme styles (styles not dependent on the color, typography, or density settings)
/// for the mat-timepicker.
/// @param {Map} $theme The theme to generate base styles for.
@mixin base($theme) {
@if inspection.get-theme-version($theme) == 1 {
@include _theme-from-tokens(inspection.get-theme-tokens($theme, base));
Expand All @@ -19,7 +22,12 @@
}
}

@mixin color($theme) {
/// Outputs color theme styles for the mat-timepicker.
/// @param {Map} $theme The theme to generate color styles for.
/// @param {ArgList} Additional optional arguments (only supported for M3 themes):
/// $color-variant: The color variant to use for the main selection: primary, secondary, tertiary,
/// or error (If not specified, default primary color will be used).
@mixin color($theme, $options...) {
@if inspection.get-theme-version($theme) == 1 {
@include _theme-from-tokens(inspection.get-theme-tokens($theme, color), $options...);
}
Expand All @@ -31,9 +39,11 @@
}
}

/// Outputs typography theme styles for the mat-timepicker.
/// @param {Map} $theme The theme to generate typography styles for.
@mixin typography($theme) {
@if inspection.get-theme-version($theme) == 1 {
@include _theme-from-tokens(inspection.get-theme-tokens($theme, typography), $options...);
@include _theme-from-tokens(inspection.get-theme-tokens($theme, typography));
}
@else {
@include sass-utils.current-selector-or-root() {
Expand All @@ -43,9 +53,11 @@
}
}

/// Outputs density theme styles for the mat-timepicker.
/// @param {Map} $theme The theme to generate density styles for.
@mixin density($theme) {
@if inspection.get-theme-version($theme) == 1 {
@include _theme-from-tokens(inspection.get-theme-tokens($theme, density), $options...);
@include _theme-from-tokens(inspection.get-theme-tokens($theme, density));
}
@else {
@include sass-utils.current-selector-or-root() {
Expand All @@ -55,13 +67,20 @@
}
}

/// Outputs the CSS variable values for the given tokens.
/// @param {Map} $tokens The token values to emit.
@mixin overrides($tokens: ()) {
@include token-utils.batch-create-token-values(
$tokens,
(prefix: tokens-mat-timepicker.$prefix, tokens: tokens-mat-timepicker.get-token-slots()),
);
}

/// Outputs all (base, color, typography, and density) theme styles for the mat-timepicker.
/// @param {Map} $theme The theme to generate styles for.
/// @param {ArgList} Additional optional arguments (only supported for M3 themes):
/// $color-variant: The color variant to use for the main selection: primary, secondary, tertiary,
/// or error (If not specified, default primary color will be used).
@mixin theme($theme) {
@include theming.private-check-duplicate-theme-styles($theme, 'mat-timepicker') {
@if inspection.get-theme-version($theme) == 1 {
Expand Down
5 changes: 4 additions & 1 deletion src/material/timepicker/public-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,8 @@
* found in the LICENSE file at https://angular.dev/license
*/

export * from './timepicker-module';
export * from './timepicker';
export * from './timepicker-input';
export * from './timepicker-toggle';
export * from './timepicker-module';
export {MatTimepickerOption, MAT_TIMEPICKER_CONFIG, MatTimepickerConfig} from './util';
Loading

0 comments on commit 2646e08

Please sign in to comment.