From 408e4274ae52c1099f20920bfb6d5d3c120fc40e Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Wed, 18 Sep 2024 12:38:30 +0200 Subject: [PATCH 01/13] build: initial setup for timepicker module Sets up the boilerplate for the timepicker module. --- .ng-dev/commit-message.mts | 1 + src/components-examples/config.bzl | 1 + .../material/timepicker/BUILD.bazel | 49 ++++++++++ .../material/timepicker/index.ts | 1 + src/dev-app/BUILD.bazel | 1 + src/dev-app/dev-app/dev-app-layout.ts | 1 + src/dev-app/routes.ts | 4 + src/dev-app/timepicker/BUILD.bazel | 23 +++++ src/dev-app/timepicker/timepicker-demo.html | 1 + src/dev-app/timepicker/timepicker-demo.scss | 1 + src/dev-app/timepicker/timepicker-demo.ts | 20 ++++ src/material/_index.scss | 2 + src/material/config.bzl | 2 + src/material/core/theming/_all-theme.scss | 3 + .../theming/tests/test-theming-bundle.scss | 5 + src/material/core/tokens/_density.scss | 2 + src/material/core/tokens/m2/_index.scss | 2 + .../core/tokens/m2/mat/_timepicker.scss | 41 +++++++++ src/material/core/tokens/m3/_index.scss | 2 + .../core/tokens/m3/mat/_timepicker.scss | 18 ++++ .../core/typography/_all-typography.scss | 2 + src/material/timepicker/BUILD.bazel | 66 +++++++++++++ src/material/timepicker/README.md | 1 + .../timepicker/_timepicker-theme.scss | 92 +++++++++++++++++++ src/material/timepicker/index.ts | 9 ++ src/material/timepicker/public-api.ts | 10 ++ src/material/timepicker/testing/BUILD.bazel | 38 ++++++++ src/material/timepicker/testing/index.ts | 9 ++ src/material/timepicker/testing/public-api.ts | 10 ++ .../testing/timepicker-harness-filters.ts | 12 +++ .../testing/timepicker-harness.spec.ts | 37 ++++++++ .../timepicker/testing/timepicker-harness.ts | 26 ++++++ src/material/timepicker/timepicker-module.ts | 16 ++++ src/material/timepicker/timepicker.html | 1 + src/material/timepicker/timepicker.md | 1 + src/material/timepicker/timepicker.scss | 10 ++ src/material/timepicker/timepicker.spec.ts | 17 ++++ src/material/timepicker/timepicker.ts | 23 +++++ 38 files changed, 560 insertions(+) create mode 100644 src/components-examples/material/timepicker/BUILD.bazel create mode 100644 src/components-examples/material/timepicker/index.ts create mode 100644 src/dev-app/timepicker/BUILD.bazel create mode 100644 src/dev-app/timepicker/timepicker-demo.html create mode 100644 src/dev-app/timepicker/timepicker-demo.scss create mode 100644 src/dev-app/timepicker/timepicker-demo.ts create mode 100644 src/material/core/tokens/m2/mat/_timepicker.scss create mode 100644 src/material/core/tokens/m3/mat/_timepicker.scss create mode 100644 src/material/timepicker/BUILD.bazel create mode 100644 src/material/timepicker/README.md create mode 100644 src/material/timepicker/_timepicker-theme.scss create mode 100644 src/material/timepicker/index.ts create mode 100644 src/material/timepicker/public-api.ts create mode 100644 src/material/timepicker/testing/BUILD.bazel create mode 100644 src/material/timepicker/testing/index.ts create mode 100644 src/material/timepicker/testing/public-api.ts create mode 100644 src/material/timepicker/testing/timepicker-harness-filters.ts create mode 100644 src/material/timepicker/testing/timepicker-harness.spec.ts create mode 100644 src/material/timepicker/testing/timepicker-harness.ts create mode 100644 src/material/timepicker/timepicker-module.ts create mode 100644 src/material/timepicker/timepicker.html create mode 100644 src/material/timepicker/timepicker.md create mode 100644 src/material/timepicker/timepicker.scss create mode 100644 src/material/timepicker/timepicker.spec.ts create mode 100644 src/material/timepicker/timepicker.ts diff --git a/.ng-dev/commit-message.mts b/.ng-dev/commit-message.mts index d0c30cf117a3..561a80958992 100644 --- a/.ng-dev/commit-message.mts +++ b/.ng-dev/commit-message.mts @@ -81,6 +81,7 @@ export const commitMessage: CommitMessageConfig = { 'material/sort', 'material/stepper', 'material/testing', + 'material/timepicker', 'material/theming', 'material/toolbar', 'material/tooltip', diff --git a/src/components-examples/config.bzl b/src/components-examples/config.bzl index 4a00c01c18db..36a1e59c850c 100644 --- a/src/components-examples/config.bzl +++ b/src/components-examples/config.bzl @@ -37,6 +37,7 @@ ALL_EXAMPLES = [ "//src/components-examples/material/bottom-sheet", "//src/components-examples/material/badge", "//src/components-examples/material/autocomplete", + "//src/components-examples/material/timepicker", "//src/components-examples/material-experimental/column-resize", "//src/components-examples/material-experimental/popover-edit", "//src/components-examples/material-experimental/selection", diff --git a/src/components-examples/material/timepicker/BUILD.bazel b/src/components-examples/material/timepicker/BUILD.bazel new file mode 100644 index 000000000000..050f921bcad1 --- /dev/null +++ b/src/components-examples/material/timepicker/BUILD.bazel @@ -0,0 +1,49 @@ +load("//tools:defaults.bzl", "ng_module", "ng_test_library", "ng_web_test_suite") + +package(default_visibility = ["//visibility:public"]) + +ng_module( + name = "timepicker", + srcs = glob( + ["**/*.ts"], + exclude = ["**/*.spec.ts"], + ), + assets = glob([ + "**/*.html", + "**/*.css", + ]), + deps = [ + "//src/cdk/testing", + "//src/cdk/testing/testbed", + "//src/material/timepicker", + "//src/material/timepicker/testing", + "@npm//@angular/platform-browser", + "@npm//@types/jasmine", + ], +) + +filegroup( + name = "source-files", + srcs = glob([ + "**/*.html", + "**/*.css", + "**/*.ts", + ]), +) + +ng_test_library( + name = "unit_tests_lib", + srcs = glob(["**/*.spec.ts"]), + deps = [ + ":timepicker", + "//src/cdk/testing", + "//src/cdk/testing/testbed", + "//src/material/timepicker", + "//src/material/timepicker/testing", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":unit_tests_lib"], +) diff --git a/src/components-examples/material/timepicker/index.ts b/src/components-examples/material/timepicker/index.ts new file mode 100644 index 000000000000..f7d88401b1c3 --- /dev/null +++ b/src/components-examples/material/timepicker/index.ts @@ -0,0 +1 @@ +export const TEMP = true; diff --git a/src/dev-app/BUILD.bazel b/src/dev-app/BUILD.bazel index c2948f0cdb0d..05ec303b4eab 100644 --- a/src/dev-app/BUILD.bazel +++ b/src/dev-app/BUILD.bazel @@ -72,6 +72,7 @@ ng_module( "//src/dev-app/table-scroll-container", "//src/dev-app/tabs", "//src/dev-app/theme", + "//src/dev-app/timepicker", "//src/dev-app/toolbar", "//src/dev-app/tooltip", "//src/dev-app/tree", diff --git a/src/dev-app/dev-app/dev-app-layout.ts b/src/dev-app/dev-app/dev-app-layout.ts index a58139aa9bda..35051cb8c19c 100644 --- a/src/dev-app/dev-app/dev-app-layout.ts +++ b/src/dev-app/dev-app/dev-app-layout.ts @@ -111,6 +111,7 @@ export class DevAppLayout { {name: 'Table', route: '/table'}, {name: 'Tabs', route: '/tabs'}, {name: 'Theme', route: '/theme'}, + {name: 'Timepicker', route: '/timepicker'}, {name: 'Toolbar', route: '/toolbar'}, {name: 'Tooltip', route: '/tooltip'}, {name: 'Tree', route: '/tree'}, diff --git a/src/dev-app/routes.ts b/src/dev-app/routes.ts index 91bbac481ac8..427b490a99b1 100644 --- a/src/dev-app/routes.ts +++ b/src/dev-app/routes.ts @@ -225,6 +225,10 @@ export const DEV_APP_ROUTES: Routes = [ path: 'theme', loadComponent: () => import('./theme/theme-demo').then(m => m.ThemeDemo), }, + { + path: 'timepicker', + loadComponent: () => import('./timepicker/timepicker-demo').then(m => m.TimepickerDemo), + }, { path: 'toolbar', loadComponent: () => import('./toolbar/toolbar-demo').then(m => m.ToolbarDemo), diff --git a/src/dev-app/timepicker/BUILD.bazel b/src/dev-app/timepicker/BUILD.bazel new file mode 100644 index 000000000000..018e5c9d62c4 --- /dev/null +++ b/src/dev-app/timepicker/BUILD.bazel @@ -0,0 +1,23 @@ +load("//tools:defaults.bzl", "ng_module", "sass_binary") + +package(default_visibility = ["//visibility:public"]) + +ng_module( + name = "timepicker", + srcs = glob(["**/*.ts"]), + assets = [ + "timepicker-demo.html", + ":timepicker_demo_scss", + ], + deps = [ + "//src/material/button", + "//src/material/card", + "//src/material/icon", + "//src/material/timepicker", + ], +) + +sass_binary( + name = "timepicker_demo_scss", + src = "timepicker-demo.scss", +) diff --git a/src/dev-app/timepicker/timepicker-demo.html b/src/dev-app/timepicker/timepicker-demo.html new file mode 100644 index 000000000000..21a44db81d86 --- /dev/null +++ b/src/dev-app/timepicker/timepicker-demo.html @@ -0,0 +1 @@ + diff --git a/src/dev-app/timepicker/timepicker-demo.scss b/src/dev-app/timepicker/timepicker-demo.scss new file mode 100644 index 000000000000..70b786d12ed0 --- /dev/null +++ b/src/dev-app/timepicker/timepicker-demo.scss @@ -0,0 +1 @@ +// TODO diff --git a/src/dev-app/timepicker/timepicker-demo.ts b/src/dev-app/timepicker/timepicker-demo.ts new file mode 100644 index 000000000000..e925a300f1f2 --- /dev/null +++ b/src/dev-app/timepicker/timepicker-demo.ts @@ -0,0 +1,20 @@ +/** + * @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 {ChangeDetectionStrategy, Component} from '@angular/core'; +import {MatTimepicker} from '@angular/material/timepicker'; + +@Component({ + selector: 'timepicker-demo', + templateUrl: 'timepicker-demo.html', + styleUrl: 'timepicker-demo.css', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [MatTimepicker], +}) +export class TimepickerDemo {} diff --git a/src/material/_index.scss b/src/material/_index.scss index 36f1d45b193a..7a6e4c634274 100644 --- a/src/material/_index.scss +++ b/src/material/_index.scss @@ -143,3 +143,5 @@ tooltip-typography, tooltip-density, tooltip-base, tooltip-overrides; @forward './tree/tree-theme' as tree-* show tree-theme, tree-color, tree-typography, tree-density, tree-base, tree-overrides; +@forward './timepicker/timepicker-theme' as timepicker-* show timepicker-theme, timepicker-color, + timepicker-typography, timepicker-density, timepicker-base, timepicker-overrides; diff --git a/src/material/config.bzl b/src/material/config.bzl index ff33a5267675..ea40e8b7ab82 100644 --- a/src/material/config.bzl +++ b/src/material/config.bzl @@ -64,6 +64,8 @@ entryPoints = [ "table/testing", "tabs", "tabs/testing", + "timepicker", + "timepicker/testing", "toolbar", "toolbar/testing", "tooltip", diff --git a/src/material/core/theming/_all-theme.scss b/src/material/core/theming/_all-theme.scss index 12d7eefc8607..66823927319b 100644 --- a/src/material/core/theming/_all-theme.scss +++ b/src/material/core/theming/_all-theme.scss @@ -36,6 +36,7 @@ @use '../../tree/tree-theme'; @use '../../snack-bar/snack-bar-theme'; @use '../../form-field/form-field-theme'; +@use '../../timepicker/timepicker-theme'; @use './theming'; // Create a theme. @@ -79,6 +80,7 @@ @include sort-theme.theme($theme); @include toolbar-theme.theme($theme); @include tree-theme.theme($theme); + @include timepicker-theme.theme($theme); } } @@ -120,6 +122,7 @@ @include sort-theme.base($theme); @include toolbar-theme.base($theme); @include tree-theme.base($theme); + @include timepicker-theme.base($theme); } // @deprecated Use `all-component-themes`. diff --git a/src/material/core/theming/tests/test-theming-bundle.scss b/src/material/core/theming/tests/test-theming-bundle.scss index 3d47c82212c1..83dbd551269a 100644 --- a/src/material/core/theming/tests/test-theming-bundle.scss +++ b/src/material/core/theming/tests/test-theming-bundle.scss @@ -224,3 +224,8 @@ $rem-typography: mat.m2-define-rem-typography-config(); @include mat.tree-color($theme); @include mat.tree-typography($theme); @include mat.tree-density($theme); + +@include mat.timepicker-theme($theme); +@include mat.timepicker-color($theme); +@include mat.timepicker-typography($theme); +@include mat.timepicker-density($theme); diff --git a/src/material/core/tokens/_density.scss b/src/material/core/tokens/_density.scss index 7b3101c7665c..eb6afa573c3c 100644 --- a/src/material/core/tokens/_density.scss +++ b/src/material/core/tokens/_density.scss @@ -157,6 +157,8 @@ $_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. diff --git a/src/material/core/tokens/m2/_index.scss b/src/material/core/tokens/m2/_index.scss index ea6fadb54442..85c8c7195b50 100644 --- a/src/material/core/tokens/m2/_index.scss +++ b/src/material/core/tokens/m2/_index.scss @@ -44,6 +44,7 @@ @use './mat/table' as tokens-mat-table; @use './mat/toolbar' as tokens-mat-toolbar; @use './mat/tree' as tokens-mat-tree; +@use './mat/timepicker' as tokens-mat-timepicker; @use './mdc/checkbox' as tokens-mdc-checkbox; @use './mdc/text-button' as tokens-mdc-text-button; @use './mdc/protected-button' as tokens-mdc-protected-button; @@ -156,6 +157,7 @@ _get-tokens-for-module($theme, tokens-mat-text-button), _get-tokens-for-module($theme, tokens-mat-toolbar), _get-tokens-for-module($theme, tokens-mat-tree), + _get-tokens-for-module($theme, tokens-mat-timepicker), _get-tokens-for-module($theme, tokens-mdc-checkbox), _get-tokens-for-module($theme, tokens-mdc-chip), _get-tokens-for-module($theme, tokens-mdc-circular-progress), diff --git a/src/material/core/tokens/m2/mat/_timepicker.scss b/src/material/core/tokens/m2/mat/_timepicker.scss new file mode 100644 index 000000000000..6bfaaef8da93 --- /dev/null +++ b/src/material/core/tokens/m2/mat/_timepicker.scss @@ -0,0 +1,41 @@ +@use '../../token-definition'; +@use '../../../style/sass-utils'; + +// 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 (); +} + +// Tokens that can be configured through Angular Material's color theming API. +@function get-color-tokens($theme) { + @return ( + enabled-trigger-text-color: hotpink, + ); +} + +// Tokens that can be configured through Angular Material's typography theming API. +@function get-typography-tokens($theme) { + @return ( + trigger-text-font: fantasy, + ); +} + +// Tokens that can be configured through Angular Material's density theming API. +@function get-density-tokens($theme) { + @return (); +} + +// Combines the tokens generated by the above functions into a single map with placeholder values. +// This is used to create token slots. +@function get-token-slots() { + @return sass-utils.deep-merge-all( + get-unthemable-tokens(), + get-color-tokens(token-definition.$placeholder-color-config), + get-typography-tokens(token-definition.$placeholder-typography-config), + get-density-tokens(token-definition.$placeholder-density-config) + ); +} diff --git a/src/material/core/tokens/m3/_index.scss b/src/material/core/tokens/m3/_index.scss index 2fa5ff4691c9..4d85b20f002a 100644 --- a/src/material/core/tokens/m3/_index.scss +++ b/src/material/core/tokens/m3/_index.scss @@ -42,6 +42,7 @@ @use './mat/table' as tokens-mat-table; @use './mat/toolbar' as tokens-mat-toolbar; @use './mat/tree' as tokens-mat-tree; +@use './mat/timepicker' as tokens-mat-timepicker; @use './mdc/checkbox' as tokens-mdc-checkbox; @use './mdc/text-button' as tokens-mdc-text-button; @use './mdc/protected-button' as tokens-mdc-protected-button; @@ -112,6 +113,7 @@ $_module-names: ( tokens-mat-text-button, tokens-mat-toolbar, tokens-mat-tree, + tokens-mat-timepicker, // MDC tokens tokens-mdc-checkbox, tokens-mdc-chip, diff --git a/src/material/core/tokens/m3/mat/_timepicker.scss b/src/material/core/tokens/m3/mat/_timepicker.scss new file mode 100644 index 000000000000..6334553a8915 --- /dev/null +++ b/src/material/core/tokens/m3/mat/_timepicker.scss @@ -0,0 +1,18 @@ +@use '../../token-definition'; + +// The prefix used to generate the fully qualified name for tokens in this file. +$prefix: (mat, timepicker); + +/// Generates custom tokens for the mat-timepicker. +/// @param {Map} $systems The MDC system tokens +/// @param {Boolean} $exclude-hardcoded Whether to exclude hardcoded token values +/// @param {Map} $token-slots Possible token slots +/// @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, + ); + + @return token-definition.namespace-tokens($prefix, $tokens, $token-slots); +} diff --git a/src/material/core/typography/_all-typography.scss b/src/material/core/typography/_all-typography.scss index 294b6d35613b..c20e532a5f14 100644 --- a/src/material/core/typography/_all-typography.scss +++ b/src/material/core/typography/_all-typography.scss @@ -35,6 +35,7 @@ @use '../../tooltip/tooltip-theme'; @use '../../snack-bar/snack-bar-theme'; @use '../../form-field/form-field-theme'; +@use '../../timepicker/timepicker-theme'; @use '../../tree/tree-theme'; @use '../theming/inspection'; @use '../core-theme'; @@ -94,6 +95,7 @@ @include fab-theme.typography($theme); @include snack-bar-theme.typography($theme); @include table-theme.typography($theme); + @include timepicker-theme.typography($theme); } // @deprecated Use `all-component-typographies`. diff --git a/src/material/timepicker/BUILD.bazel b/src/material/timepicker/BUILD.bazel new file mode 100644 index 000000000000..6aabcad13896 --- /dev/null +++ b/src/material/timepicker/BUILD.bazel @@ -0,0 +1,66 @@ +load( + "//tools:defaults.bzl", + "markdown_to_html", + "ng_module", + "ng_test_library", + "ng_web_test_suite", + "sass_binary", + "sass_library", +) + +package(default_visibility = ["//visibility:public"]) + +ng_module( + name = "timepicker", + srcs = glob( + ["**/*.ts"], + exclude = ["**/*.spec.ts"], + ), + assets = [":timepicker.css"] + glob(["**/*.html"]), + deps = [ + "//src:dev_mode_types", + "//src/cdk/bidi", + "//src/cdk/coercion", + "//src/material/core", + "@npm//@angular/core", + ], +) + +sass_library( + name = "timepicker_scss_lib", + srcs = glob(["**/_*.scss"]), + deps = ["//src/material/core:core_scss_lib"], +) + +sass_binary( + name = "timepicker_scss", + src = "timepicker.scss", + deps = ["//src/material/core:core_scss_lib"], +) + +ng_test_library( + name = "unit_test_sources", + srcs = glob( + ["**/*.spec.ts"], + ), + deps = [ + ":timepicker", + "//src/cdk/bidi", + "@npm//@angular/platform-browser", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":unit_test_sources"], +) + +markdown_to_html( + name = "overview", + srcs = [":timepicker.md"], +) + +filegroup( + name = "source-files", + srcs = glob(["**/*.ts"]), +) diff --git a/src/material/timepicker/README.md b/src/material/timepicker/README.md new file mode 100644 index 000000000000..7e82b09e5892 --- /dev/null +++ b/src/material/timepicker/README.md @@ -0,0 +1 @@ +Please see the official documentation at https://material.angular.dev/components/component/timepicker diff --git a/src/material/timepicker/_timepicker-theme.scss b/src/material/timepicker/_timepicker-theme.scss new file mode 100644 index 000000000000..0103dc408eb0 --- /dev/null +++ b/src/material/timepicker/_timepicker-theme.scss @@ -0,0 +1,92 @@ +@use 'sass:map'; +@use '../core/theming/theming'; +@use '../core/theming/inspection'; +@use '../core/theming/validation'; +@use '../core/typography/typography'; +@use '../core/style/sass-utils'; +@use '../core/tokens/m2/mat/timepicker' as tokens-mat-timepicker; +@use '../core/tokens/token-utils'; + +@mixin base($theme) { + @if inspection.get-theme-version($theme) == 1 { + @include _theme-from-tokens(inspection.get-theme-tokens($theme, base)); + } + @else { + @include sass-utils.current-selector-or-root() { + @include token-utils.create-token-values(tokens-mat-timepicker.$prefix, + tokens-mat-timepicker.get-unthemable-tokens()); + } + } +} + +@mixin color($theme) { + @if inspection.get-theme-version($theme) == 1 { + @include _theme-from-tokens(inspection.get-theme-tokens($theme, color), $options...); + } + @else { + @include sass-utils.current-selector-or-root() { + @include token-utils.create-token-values(tokens-mat-timepicker.$prefix, + tokens-mat-timepicker.get-color-tokens($theme)); + } + } +} + +@mixin typography($theme) { + @if inspection.get-theme-version($theme) == 1 { + @include _theme-from-tokens(inspection.get-theme-tokens($theme, typography), $options...); + } + @else { + @include sass-utils.current-selector-or-root() { + @include token-utils.create-token-values(tokens-mat-timepicker.$prefix, + tokens-mat-timepicker.get-typography-tokens($theme)); + } + } +} + +@mixin density($theme) { + @if inspection.get-theme-version($theme) == 1 { + @include _theme-from-tokens(inspection.get-theme-tokens($theme, density), $options...); + } + @else { + @include sass-utils.current-selector-or-root() { + @include token-utils.create-token-values(tokens-mat-timepicker.$prefix, + tokens-mat-timepicker.get-density-tokens($theme)); + } + } +} + +@mixin overrides($tokens: ()) { + @include token-utils.batch-create-token-values( + $tokens, + (prefix: tokens-mat-timepicker.$prefix, tokens: tokens-mat-timepicker.get-token-slots()), + ); +} + +@mixin theme($theme) { + @include theming.private-check-duplicate-theme-styles($theme, 'mat-timepicker') { + @if inspection.get-theme-version($theme) == 1 { + @include _theme-from-tokens(inspection.get-theme-tokens($theme)); + } + @else { + @include base($theme); + @if inspection.theme-has($theme, color) { + @include color($theme); + } + @if inspection.theme-has($theme, density) { + @include density($theme); + } + @if inspection.theme-has($theme, typography) { + @include typography($theme); + } + } + } +} + +@mixin _theme-from-tokens($tokens) { + @include validation.selector-defined( + 'Calls to Angular Material theme mixins with an M3 theme must be wrapped in a selector'); + @if ($tokens != ()) { + @include token-utils.create-token-values( + tokens-mat-timepicker.$prefix, map.get($tokens, tokens-mat-timepicker.$prefix)); + } +} diff --git a/src/material/timepicker/index.ts b/src/material/timepicker/index.ts new file mode 100644 index 000000000000..52b3c7a5156f --- /dev/null +++ b/src/material/timepicker/index.ts @@ -0,0 +1,9 @@ +/** + * @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 + */ + +export * from './public-api'; diff --git a/src/material/timepicker/public-api.ts b/src/material/timepicker/public-api.ts new file mode 100644 index 000000000000..573ccabeb79d --- /dev/null +++ b/src/material/timepicker/public-api.ts @@ -0,0 +1,10 @@ +/** + * @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 + */ + +export * from './timepicker-module'; +export * from './timepicker'; diff --git a/src/material/timepicker/testing/BUILD.bazel b/src/material/timepicker/testing/BUILD.bazel new file mode 100644 index 000000000000..bd293e9cb214 --- /dev/null +++ b/src/material/timepicker/testing/BUILD.bazel @@ -0,0 +1,38 @@ +load("//tools:defaults.bzl", "ng_test_library", "ng_web_test_suite", "ts_library") + +package(default_visibility = ["//visibility:public"]) + +ts_library( + name = "testing", + srcs = glob( + ["**/*.ts"], + exclude = ["**/*.spec.ts"], + ), + deps = [ + "//src/cdk/testing", + "//src/material/timepicker", + ], +) + +filegroup( + name = "source-files", + srcs = glob(["**/*.ts"]), +) + +ng_test_library( + name = "unit_tests_lib", + srcs = glob(["**/*.spec.ts"]), + deps = [ + ":testing", + "//src/cdk/testing", + "//src/cdk/testing/private", + "//src/cdk/testing/testbed", + "//src/material/timepicker", + "@npm//@angular/platform-browser", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":unit_tests_lib"], +) diff --git a/src/material/timepicker/testing/index.ts b/src/material/timepicker/testing/index.ts new file mode 100644 index 000000000000..52b3c7a5156f --- /dev/null +++ b/src/material/timepicker/testing/index.ts @@ -0,0 +1,9 @@ +/** + * @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 + */ + +export * from './public-api'; diff --git a/src/material/timepicker/testing/public-api.ts b/src/material/timepicker/testing/public-api.ts new file mode 100644 index 000000000000..466ff114decc --- /dev/null +++ b/src/material/timepicker/testing/public-api.ts @@ -0,0 +1,10 @@ +/** + * @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 + */ + +export * from './timepicker-harness'; +export * from './timepicker-harness-filters'; diff --git a/src/material/timepicker/testing/timepicker-harness-filters.ts b/src/material/timepicker/testing/timepicker-harness-filters.ts new file mode 100644 index 000000000000..32f174cec78f --- /dev/null +++ b/src/material/timepicker/testing/timepicker-harness-filters.ts @@ -0,0 +1,12 @@ +/** + * @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 {BaseHarnessFilters} from '@angular/cdk/testing'; + +/** A set of criteria that can be used to filter a list of `MatTimepickerHarness` instances. */ +export interface TimepickerHarnessFilters extends BaseHarnessFilters {} diff --git a/src/material/timepicker/testing/timepicker-harness.spec.ts b/src/material/timepicker/testing/timepicker-harness.spec.ts new file mode 100644 index 000000000000..5e66176b5b68 --- /dev/null +++ b/src/material/timepicker/testing/timepicker-harness.spec.ts @@ -0,0 +1,37 @@ +import {Component} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {HarnessLoader} from '@angular/cdk/testing'; +import {TestbedHarnessEnvironment} from '@angular/cdk/testing/testbed'; +import {NoopAnimationsModule} from '@angular/platform-browser/animations'; +import {MatTimepicker} from '@angular/material/timepicker'; +import {MatTimepickerHarness} from './timepicker-harness'; + +describe('MatTimepicker', () => { + let fixture: ComponentFixture; + let loader: HarnessLoader; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, TimepickerHarnessTest], + }); + + fixture = TestBed.createComponent(TimepickerHarnessTest); + fixture.detectChanges(); + loader = TestbedHarnessEnvironment.documentRootLoader(fixture); + }); + + it('should be able to load timepicker harnesses', async () => { + const harnesses = await loader.getAllHarnesses(MatTimepickerHarness); + expect(harnesses.length).toBe(2); + }); +}); + +@Component({ + template: ` + + + `, + standalone: true, + imports: [MatTimepicker], +}) +class TimepickerHarnessTest {} diff --git a/src/material/timepicker/testing/timepicker-harness.ts b/src/material/timepicker/testing/timepicker-harness.ts new file mode 100644 index 000000000000..3e127a497033 --- /dev/null +++ b/src/material/timepicker/testing/timepicker-harness.ts @@ -0,0 +1,26 @@ +/** + * @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 {ComponentHarness, HarnessPredicate} from '@angular/cdk/testing'; +import {TimepickerHarnessFilters} from './timepicker-harness-filters'; + +/** Harness for interacting with a standard `MatTimepicker` in tests. */ +export class MatTimepickerHarness extends ComponentHarness { + /** The selector for the host element of a `MatTimepicker` instance. */ + static hostSelector = '.mat-timepicker'; + + /** + * Gets a `HarnessPredicate` that can be used to search for a `MatTimepicker` + * that meets certain criteria. + * @param options Options for filtering which dialog instances are considered a match. + * @return a `HarnessPredicate` configured with the given options. + */ + static with(options: TimepickerHarnessFilters = {}): HarnessPredicate { + return new HarnessPredicate(MatTimepickerHarness, options); + } +} diff --git a/src/material/timepicker/timepicker-module.ts b/src/material/timepicker/timepicker-module.ts new file mode 100644 index 000000000000..93d9196bc135 --- /dev/null +++ b/src/material/timepicker/timepicker-module.ts @@ -0,0 +1,16 @@ +/** + * @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 {NgModule} from '@angular/core'; +import {MatTimepicker} from './timepicker'; + +@NgModule({ + imports: [MatTimepicker], + exports: [MatTimepicker], +}) +export class MatTimepickerModule {} diff --git a/src/material/timepicker/timepicker.html b/src/material/timepicker/timepicker.html new file mode 100644 index 000000000000..e965047ad7c5 --- /dev/null +++ b/src/material/timepicker/timepicker.html @@ -0,0 +1 @@ +Hello diff --git a/src/material/timepicker/timepicker.md b/src/material/timepicker/timepicker.md new file mode 100644 index 000000000000..1333ed77b7e1 --- /dev/null +++ b/src/material/timepicker/timepicker.md @@ -0,0 +1 @@ +TODO diff --git a/src/material/timepicker/timepicker.scss b/src/material/timepicker/timepicker.scss new file mode 100644 index 000000000000..08a47c4f870f --- /dev/null +++ b/src/material/timepicker/timepicker.scss @@ -0,0 +1,10 @@ +@use '../core/tokens/token-utils'; +@use '../core/tokens/m2/mat/timepicker' as tokens-mat-timepicker; + +.mat-timepicker { + @include token-utils.use-tokens( + tokens-mat-timepicker.$prefix, tokens-mat-timepicker.get-token-slots()) { + @include token-utils.create-token-slot(color, enabled-trigger-text-color); + @include token-utils.create-token-slot(font-family, trigger-text-font); + } +} diff --git a/src/material/timepicker/timepicker.spec.ts b/src/material/timepicker/timepicker.spec.ts new file mode 100644 index 000000000000..45150bd4e3e3 --- /dev/null +++ b/src/material/timepicker/timepicker.spec.ts @@ -0,0 +1,17 @@ +import {Component} from '@angular/core'; +import {TestBed} from '@angular/core/testing'; +import {MatTimepicker} from './timepicker'; + +describe('MatTimepicker', () => { + it('TODO', () => { + const fixture = TestBed.createComponent(BasicTimepicker); + expect(fixture).toBeTruthy(); + }); +}); + +@Component({ + template: '', + standalone: true, + imports: [MatTimepicker], +}) +class BasicTimepicker {} diff --git a/src/material/timepicker/timepicker.ts b/src/material/timepicker/timepicker.ts new file mode 100644 index 000000000000..4b115bcaac95 --- /dev/null +++ b/src/material/timepicker/timepicker.ts @@ -0,0 +1,23 @@ +/** + * @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 {ChangeDetectionStrategy, Component, ViewEncapsulation} from '@angular/core'; + +@Component({ + selector: 'mat-timepicker', + exportAs: 'matTimepicker', + templateUrl: 'timepicker.html', + styleUrl: 'timepicker.css', + host: { + 'class': 'mat-timepicker', + }, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + standalone: true, +}) +export class MatTimepicker {} From 7a9418a1279cefb1f29228b2e6ce8153d63268da Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Tue, 1 Oct 2024 09:15:28 +0200 Subject: [PATCH 02/13] test(multiple): add more time parsing tests Adds some more tests for the logic that compares times to ensure that they don't consider the date. --- .../adapter/date-fns-adapter.spec.ts | 16 +++++++++------- .../adapter/luxon-date-adapter.spec.ts | 19 ++++++++++++------- .../adapter/moment-date-adapter.spec.ts | 16 +++++++++------- .../core/datetime/native-date-adapter.spec.ts | 16 +++++++++------- 4 files changed, 39 insertions(+), 28 deletions(-) diff --git a/src/material-date-fns-adapter/adapter/date-fns-adapter.spec.ts b/src/material-date-fns-adapter/adapter/date-fns-adapter.spec.ts index f1f684f64b78..f31457cbbce7 100644 --- a/src/material-date-fns-adapter/adapter/date-fns-adapter.spec.ts +++ b/src/material-date-fns-adapter/adapter/date-fns-adapter.spec.ts @@ -549,23 +549,25 @@ describe('DateFnsAdapter', () => { }); it('should compare times', () => { - const base = [2024, JAN, 1] as const; + // Use different dates to guarantee that we only compare the times. + const aDate = [2024, JAN, 1] as const; + const bDate = [2024, FEB, 7] as const; expect( - adapter.compareTime(new Date(...base, 12, 0, 0), new Date(...base, 13, 0, 0)), + adapter.compareTime(new Date(...aDate, 12, 0, 0), new Date(...bDate, 13, 0, 0)), ).toBeLessThan(0); expect( - adapter.compareTime(new Date(...base, 12, 50, 0), new Date(...base, 12, 51, 0)), + adapter.compareTime(new Date(...aDate, 12, 50, 0), new Date(...bDate, 12, 51, 0)), ).toBeLessThan(0); - expect(adapter.compareTime(new Date(...base, 1, 2, 3), new Date(...base, 1, 2, 3))).toBe(0); + expect(adapter.compareTime(new Date(...aDate, 1, 2, 3), new Date(...bDate, 1, 2, 3))).toBe(0); expect( - adapter.compareTime(new Date(...base, 13, 0, 0), new Date(...base, 12, 0, 0)), + adapter.compareTime(new Date(...aDate, 13, 0, 0), new Date(...bDate, 12, 0, 0)), ).toBeGreaterThan(0); expect( - adapter.compareTime(new Date(...base, 12, 50, 11), new Date(...base, 12, 50, 10)), + adapter.compareTime(new Date(...aDate, 12, 50, 11), new Date(...bDate, 12, 50, 10)), ).toBeGreaterThan(0); expect( - adapter.compareTime(new Date(...base, 13, 0, 0), new Date(...base, 10, 59, 59)), + adapter.compareTime(new Date(...aDate, 13, 0, 0), new Date(...bDate, 10, 59, 59)), ).toBeGreaterThan(0); }); diff --git a/src/material-luxon-adapter/adapter/luxon-date-adapter.spec.ts b/src/material-luxon-adapter/adapter/luxon-date-adapter.spec.ts index 8ee0701ece03..4cbcf3655a37 100644 --- a/src/material-luxon-adapter/adapter/luxon-date-adapter.spec.ts +++ b/src/material-luxon-adapter/adapter/luxon-date-adapter.spec.ts @@ -657,25 +657,30 @@ describe('LuxonDateAdapter', () => { }); it('should compare times', () => { - const base = [2024, JAN, 1] as const; + // Use different dates to guarantee that we only compare the times. + const aDate = [2024, JAN, 1] as const; + const bDate = [2024, FEB, 7] as const; expect( - adapter.compareTime(DateTime.local(...base, 12, 0, 0), DateTime.local(...base, 13, 0, 0)), + adapter.compareTime(DateTime.local(...aDate, 12, 0, 0), DateTime.local(...bDate, 13, 0, 0)), ).toBeLessThan(0); expect( - adapter.compareTime(DateTime.local(...base, 12, 50, 0), DateTime.local(...base, 12, 51, 0)), + adapter.compareTime(DateTime.local(...aDate, 12, 50, 0), DateTime.local(...bDate, 12, 51, 0)), ).toBeLessThan(0); expect( - adapter.compareTime(DateTime.local(...base, 1, 2, 3), DateTime.local(...base, 1, 2, 3)), + adapter.compareTime(DateTime.local(...aDate, 1, 2, 3), DateTime.local(...bDate, 1, 2, 3)), ).toBe(0); expect( - adapter.compareTime(DateTime.local(...base, 13, 0, 0), DateTime.local(...base, 12, 0, 0)), + adapter.compareTime(DateTime.local(...aDate, 13, 0, 0), DateTime.local(...bDate, 12, 0, 0)), ).toBeGreaterThan(0); expect( - adapter.compareTime(DateTime.local(...base, 12, 50, 11), DateTime.local(...base, 12, 50, 10)), + adapter.compareTime( + DateTime.local(...aDate, 12, 50, 11), + DateTime.local(...bDate, 12, 50, 10), + ), ).toBeGreaterThan(0); expect( - adapter.compareTime(DateTime.local(...base, 13, 0, 0), DateTime.local(...base, 10, 59, 59)), + adapter.compareTime(DateTime.local(...aDate, 13, 0, 0), DateTime.local(...bDate, 10, 59, 59)), ).toBeGreaterThan(0); }); diff --git a/src/material-moment-adapter/adapter/moment-date-adapter.spec.ts b/src/material-moment-adapter/adapter/moment-date-adapter.spec.ts index 86cb41c12f59..dffc542d3c26 100644 --- a/src/material-moment-adapter/adapter/moment-date-adapter.spec.ts +++ b/src/material-moment-adapter/adapter/moment-date-adapter.spec.ts @@ -639,23 +639,25 @@ describe('MomentDateAdapter', () => { }); it('should compare times', () => { - const base = [2024, JAN, 1] as const; + // Use different dates to guarantee that we only compare the times. + const aDate = [2024, JAN, 1] as const; + const bDate = [2024, FEB, 7] as const; expect( - adapter.compareTime(moment([...base, 12, 0, 0]), moment([...base, 13, 0, 0])), + adapter.compareTime(moment([...aDate, 12, 0, 0]), moment([...bDate, 13, 0, 0])), ).toBeLessThan(0); expect( - adapter.compareTime(moment([...base, 12, 50, 0]), moment([...base, 12, 51, 0])), + adapter.compareTime(moment([...aDate, 12, 50, 0]), moment([...bDate, 12, 51, 0])), ).toBeLessThan(0); - expect(adapter.compareTime(moment([...base, 1, 2, 3]), moment([...base, 1, 2, 3]))).toBe(0); + expect(adapter.compareTime(moment([...aDate, 1, 2, 3]), moment([...bDate, 1, 2, 3]))).toBe(0); expect( - adapter.compareTime(moment([...base, 13, 0, 0]), moment([...base, 12, 0, 0])), + adapter.compareTime(moment([...aDate, 13, 0, 0]), moment([...bDate, 12, 0, 0])), ).toBeGreaterThan(0); expect( - adapter.compareTime(moment([...base, 12, 50, 11]), moment([...base, 12, 50, 10])), + adapter.compareTime(moment([...aDate, 12, 50, 11]), moment([...bDate, 12, 50, 10])), ).toBeGreaterThan(0); expect( - adapter.compareTime(moment([...base, 13, 0, 0]), moment([...base, 10, 59, 59])), + adapter.compareTime(moment([...aDate, 13, 0, 0]), moment([...bDate, 10, 59, 59])), ).toBeGreaterThan(0); }); diff --git a/src/material/core/datetime/native-date-adapter.spec.ts b/src/material/core/datetime/native-date-adapter.spec.ts index cd1979274c2e..5a2df4672f5b 100644 --- a/src/material/core/datetime/native-date-adapter.spec.ts +++ b/src/material/core/datetime/native-date-adapter.spec.ts @@ -605,23 +605,25 @@ describe('NativeDateAdapter', () => { }); it('should compare times', () => { - const base = [2024, JAN, 1] as const; + // Use different dates to guarantee that we only compare the times. + const aDate = [2024, JAN, 1] as const; + const bDate = [2024, FEB, 7] as const; expect( - adapter.compareTime(new Date(...base, 12, 0, 0), new Date(...base, 13, 0, 0)), + adapter.compareTime(new Date(...aDate, 12, 0, 0), new Date(...bDate, 13, 0, 0)), ).toBeLessThan(0); expect( - adapter.compareTime(new Date(...base, 12, 50, 0), new Date(...base, 12, 51, 0)), + adapter.compareTime(new Date(...aDate, 12, 50, 0), new Date(...bDate, 12, 51, 0)), ).toBeLessThan(0); - expect(adapter.compareTime(new Date(...base, 1, 2, 3), new Date(...base, 1, 2, 3))).toBe(0); + expect(adapter.compareTime(new Date(...aDate, 1, 2, 3), new Date(...bDate, 1, 2, 3))).toBe(0); expect( - adapter.compareTime(new Date(...base, 13, 0, 0), new Date(...base, 12, 0, 0)), + adapter.compareTime(new Date(...aDate, 13, 0, 0), new Date(...bDate, 12, 0, 0)), ).toBeGreaterThan(0); expect( - adapter.compareTime(new Date(...base, 12, 50, 11), new Date(...base, 12, 50, 10)), + adapter.compareTime(new Date(...aDate, 12, 50, 11), new Date(...bDate, 12, 50, 10)), ).toBeGreaterThan(0); expect( - adapter.compareTime(new Date(...base, 13, 0, 0), new Date(...base, 10, 59, 59)), + adapter.compareTime(new Date(...aDate, 13, 0, 0), new Date(...bDate, 10, 59, 59)), ).toBeGreaterThan(0); }); From 60281edc6e2280534c104db769fcc87af8210630 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Tue, 1 Oct 2024 09:18:58 +0200 Subject: [PATCH 03/13] build: fix up sidenav spacing in M3 dev app Fixes that there wasn't any side padding in the sidenav when using M3 in the dev app. --- src/dev-app/dev-app/dev-app-layout.html | 2 +- src/dev-app/theme-m3.scss | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/dev-app/dev-app/dev-app-layout.html b/src/dev-app/dev-app/dev-app-layout.html index 1fb910623869..2d390d2ac39f 100644 --- a/src/dev-app/dev-app/dev-app-layout.html +++ b/src/dev-app/dev-app/dev-app-layout.html @@ -1,6 +1,6 @@ - + @for (navItem of navItems; track navItem) { Date: Tue, 1 Oct 2024 09:33:56 +0200 Subject: [PATCH 04/13] refactor(material/timepicker): add logic to parse intervals and generate options Adds the following utilities to the timpicker: * `parseInterval` - turns an interval value into a number of seconds. * `generateOptions` - generates a list of timepicker options between a minimum and maximum, and with a specific interval. --- src/material/timepicker/util.spec.ts | 187 +++++++++++++++++++++++++++ src/material/timepicker/util.ts | 86 ++++++++++++ 2 files changed, 273 insertions(+) create mode 100644 src/material/timepicker/util.spec.ts create mode 100644 src/material/timepicker/util.ts diff --git a/src/material/timepicker/util.spec.ts b/src/material/timepicker/util.spec.ts new file mode 100644 index 000000000000..aa21a94bddaf --- /dev/null +++ b/src/material/timepicker/util.spec.ts @@ -0,0 +1,187 @@ +import {TestBed} from '@angular/core/testing'; +import { + DateAdapter, + MAT_DATE_FORMATS, + MatDateFormats, + provideNativeDateAdapter, +} from '@angular/material/core'; +import {generateOptions, parseInterval} from './util'; + +describe('timepicker utilities', () => { + describe('parseInterval', () => { + it('should parse null', () => { + expect(parseInterval(null)).toBe(null); + }); + + it('should parse a number', () => { + expect(parseInterval(75)).toBe(75); + }); + + it('should parse a number in a string', () => { + expect(parseInterval('75')).toBe(75); + expect(parseInterval('75.50')).toBe(75.5); + }); + + it('should handle invalid strings', () => { + expect(parseInterval('')).toBe(null); + expect(parseInterval(' ')).toBe(null); + expect(parseInterval('abc')).toBe(null); + expect(parseInterval('1a')).toBe(null); + expect(parseInterval('m1')).toBe(null); + expect(parseInterval('10.')).toBe(null); + }); + + it('should parse hours', () => { + expect(parseInterval('3h')).toBe(10_800); + expect(parseInterval('4.5h')).toBe(16_200); + expect(parseInterval('11h')).toBe(39_600); + }); + + it('should parse minutes', () => { + expect(parseInterval('3m')).toBe(180); + expect(parseInterval('7.5m')).toBe(450); + expect(parseInterval('90m')).toBe(5_400); + expect(parseInterval('100.5m')).toBe(6_030); + }); + + it('should parse seconds', () => { + expect(parseInterval('3s')).toBe(3); + expect(parseInterval('7.5s')).toBe(7.5); + expect(parseInterval('90s')).toBe(90); + expect(parseInterval('100.5s')).toBe(100.5); + }); + + it('should parse uppercase units', () => { + expect(parseInterval('3H')).toBe(10_800); + expect(parseInterval('3M')).toBe(180); + expect(parseInterval('3S')).toBe(3); + }); + }); + + describe('generateOptions', () => { + let adapter: DateAdapter; + let formats: MatDateFormats; + + beforeEach(() => { + TestBed.configureTestingModule({providers: [provideNativeDateAdapter()]}); + adapter = TestBed.inject(DateAdapter); + formats = TestBed.inject(MAT_DATE_FORMATS); + adapter.setLocale('en-US'); + }); + + it('should generate a list of options', () => { + const min = new Date(2024, 0, 1, 9, 0, 0, 0); + const max = new Date(2024, 0, 1, 22, 0, 0, 0); + const options = generateOptions(adapter, formats, min, max, 3600).map(o => o.label); + expect(options).toEqual([ + '9:00 AM', + '10:00 AM', + '11:00 AM', + '12:00 PM', + '1:00 PM', + '2:00 PM', + '3:00 PM', + '4:00 PM', + '5:00 PM', + '6:00 PM', + '7:00 PM', + '8:00 PM', + '9:00 PM', + '10:00 PM', + ]); + }); + + it('should generate a list of options with a sub-hour interval', () => { + const min = new Date(2024, 0, 1, 9, 0, 0, 0); + const max = new Date(2024, 0, 1, 22, 0, 0, 0); + const options = generateOptions(adapter, formats, min, max, 43 * 60).map(o => o.label); + expect(options).toEqual([ + '9:00 AM', + '9:43 AM', + '10:26 AM', + '11:09 AM', + '11:52 AM', + '12:35 PM', + '1:18 PM', + '2:01 PM', + '2:44 PM', + '3:27 PM', + '4:10 PM', + '4:53 PM', + '5:36 PM', + '6:19 PM', + '7:02 PM', + '7:45 PM', + '8:28 PM', + '9:11 PM', + '9:54 PM', + ]); + }); + + it('should generate a list of options with a minute interval', () => { + const min = new Date(2024, 0, 1, 9, 0, 0, 0); + const max = new Date(2024, 0, 1, 9, 16, 0, 0); + const options = generateOptions(adapter, formats, min, max, 60).map(o => o.label); + expect(options).toEqual([ + '9:00 AM', + '9:01 AM', + '9:02 AM', + '9:03 AM', + '9:04 AM', + '9:05 AM', + '9:06 AM', + '9:07 AM', + '9:08 AM', + '9:09 AM', + '9:10 AM', + '9:11 AM', + '9:12 AM', + '9:13 AM', + '9:14 AM', + '9:15 AM', + '9:16 AM', + ]); + }); + + it('should generate a list of options with a sub-minute interval', () => { + const previousFormat = formats.display.timeOptionLabel; + formats.display.timeOptionLabel = {hour: 'numeric', minute: 'numeric', second: 'numeric'}; + const min = new Date(2024, 0, 1, 9, 0, 0, 0); + const max = new Date(2024, 0, 1, 9, 3, 0, 0); + const options = generateOptions(adapter, formats, min, max, 12).map(o => o.label); + expect(options).toEqual([ + '9:00:00 AM', + '9:00:12 AM', + '9:00:24 AM', + '9:00:36 AM', + '9:00:48 AM', + '9:01:00 AM', + '9:01:12 AM', + '9:01:24 AM', + '9:01:36 AM', + '9:01:48 AM', + '9:02:00 AM', + '9:02:12 AM', + '9:02:24 AM', + '9:02:36 AM', + '9:02:48 AM', + '9:03:00 AM', + ]); + formats.display.timeOptionLabel = previousFormat; + }); + + it('should generate at least one option if the interval is too large', () => { + const min = new Date(2024, 0, 1, 0, 0, 0, 0); + const max = new Date(2024, 0, 1, 23, 59, 0, 0); + const options = generateOptions(adapter, formats, min, max, 60 * 60 * 24).map(o => o.label); + expect(options).toEqual(['12:00 AM']); + }); + + it('should generate at least one option if the max is later than the min', () => { + const min = new Date(2024, 0, 1, 23, 0, 0, 0); + const max = new Date(2024, 0, 1, 13, 0, 0, 0); + const options = generateOptions(adapter, formats, min, max, 3600).map(o => o.label); + expect(options).toEqual(['1:00 PM']); + }); + }); +}); diff --git a/src/material/timepicker/util.ts b/src/material/timepicker/util.ts new file mode 100644 index 000000000000..26bbca1b7621 --- /dev/null +++ b/src/material/timepicker/util.ts @@ -0,0 +1,86 @@ +/** + * @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 {DateAdapter, MatDateFormats} from '@angular/material/core'; + +/** Pattern that interval strings have to match. */ +const INTERVAL_PATTERN = /^(\d*\.?\d+)(h|m|s)?$/i; + +/** + * Time selection option that can be displayed within a `mat-timepicker`. + */ +export interface MatTimepickerOption { + /** Date value of the option. */ + value: D; + + /** Label to show to the user. */ + label: string; +} + +/** Parses an interval value into seconds. */ +export function parseInterval(value: number | string | null): number | null { + let result: number; + + if (value === null) { + return null; + } else if (typeof value === 'number') { + result = value; + } else { + if (value.trim().length === 0) { + return null; + } + + const parsed = value.match(INTERVAL_PATTERN); + const amount = parsed ? parseFloat(parsed[1]) : null; + const unit = parsed?.[2]?.toLowerCase() || null; + + if (!parsed || amount === null || isNaN(amount)) { + return null; + } + + if (unit === 'h') { + result = amount * 3600; + } else if (unit === 'm') { + result = amount * 60; + } else { + result = amount; + } + } + + return result; +} + +/** + * Generates the options to show in a timepicker. + * @param adapter Date adapter to be used to generate the options. + * @param formats Formatting config to use when displaying the options. + * @param min Time from which to start generating the options. + * @param max Time at which to stop generating the options. + * @param interval Amount of seconds between each option. + */ +export function generateOptions( + adapter: DateAdapter, + formats: MatDateFormats, + min: D, + max: D, + interval: number, +): MatTimepickerOption[] { + const options: MatTimepickerOption[] = []; + let current = adapter.compareTime(min, max) < 1 ? min : max; + + while ( + adapter.sameDate(current, min) && + adapter.compareTime(current, max) < 1 && + adapter.isValid(current) + ) { + options.push({value: current, label: adapter.format(current, formats.display.timeOptionLabel)}); + current = adapter.addSeconds(current, interval); + } + + return options; +} From aef19ba7adf80f96e07fd9f1520ef49a5ff74948 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Tue, 1 Oct 2024 10:30:34 +0200 Subject: [PATCH 05/13] feat(material/timepicker): add timepicker component 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 Pick a time ``` 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 `` 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. --- src/dev-app/timepicker/BUILD.bazel | 5 + src/dev-app/timepicker/timepicker-demo.html | 100 +- src/dev-app/timepicker/timepicker-demo.scss | 23 +- src/dev-app/timepicker/timepicker-demo.ts | 61 +- src/material/core/tokens/_density.scss | 3 +- .../core/tokens/m2/mat/_timepicker.scss | 13 +- .../core/tokens/m3/mat/_timepicker.scss | 8 +- src/material/timepicker/BUILD.bazel | 16 +- .../timepicker/_timepicker-theme.scss | 25 +- src/material/timepicker/public-api.ts | 5 +- src/material/timepicker/timepicker-input.ts | 415 +++++ src/material/timepicker/timepicker-module.ts | 7 +- .../timepicker/timepicker-toggle.html | 21 + src/material/timepicker/timepicker-toggle.ts | 82 + src/material/timepicker/timepicker.html | 16 +- src/material/timepicker/timepicker.scss | 49 +- src/material/timepicker/timepicker.spec.ts | 1335 ++++++++++++++++- src/material/timepicker/timepicker.ts | 438 +++++- src/material/timepicker/util.ts | 53 + tools/public_api_guard/material/timepicker.md | 139 ++ 20 files changed, 2776 insertions(+), 38 deletions(-) create mode 100644 src/material/timepicker/timepicker-input.ts create mode 100644 src/material/timepicker/timepicker-toggle.html create mode 100644 src/material/timepicker/timepicker-toggle.ts create mode 100644 tools/public_api_guard/material/timepicker.md diff --git a/src/dev-app/timepicker/BUILD.bazel b/src/dev-app/timepicker/BUILD.bazel index 018e5c9d62c4..7b8dabbbe47a 100644 --- a/src/dev-app/timepicker/BUILD.bazel +++ b/src/dev-app/timepicker/BUILD.bazel @@ -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", ], ) diff --git a/src/dev-app/timepicker/timepicker-demo.html b/src/dev-app/timepicker/timepicker-demo.html index 21a44db81d86..52abf423e2d6 100644 --- a/src/dev-app/timepicker/timepicker-demo.html +++ b/src/dev-app/timepicker/timepicker-demo.html @@ -1 +1,99 @@ - +
+
+
+

Basic timepicker

+ + Pick a time + + + + + +

Value: {{control.value}}

+

Dirty: {{control.dirty}}

+

Touched: {{control.touched}}

+

Errors: {{control.errors | json}}

+ +
+ +
+

Timepicker and datepicker

+ + Pick a date + + + + + +
+ + Pick a time + + + + +
+ +

Value: {{combinedValue}}

+
+ +
+

Timepicker without form field

+ + +
+
+ + + + State + + + +
+ + Locale + + @for (locale of locales; track $index) { + {{locale}} + } + + + + + Interval + + + + + Min time + + + + + + + Max time + + + + +
+
+
+
+ diff --git a/src/dev-app/timepicker/timepicker-demo.scss b/src/dev-app/timepicker/timepicker-demo.scss index 70b786d12ed0..ae671762a74a 100644 --- a/src/dev-app/timepicker/timepicker-demo.scss +++ b/src/dev-app/timepicker/timepicker-demo.scss @@ -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%; + } +} diff --git a/src/dev-app/timepicker/timepicker-demo.ts b/src/dev-app/timepicker/timepicker-demo.ts index e925a300f1f2..96e4e1fe317a 100644 --- a/src/dev-app/timepicker/timepicker-demo.ts +++ b/src/dev-app/timepicker/timepicker-demo.ts @@ -6,8 +6,18 @@ * 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', @@ -15,6 +25,49 @@ import {MatTimepicker} from '@angular/material/timepicker'; 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; + localeControl = new FormControl('en-US', {nonNullable: true}); + intervalControl = new FormControl('1h', {nonNullable: true}); + minControl = new FormControl(null); + maxControl = new FormControl(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(); + } +} diff --git a/src/material/core/tokens/_density.scss b/src/material/core/tokens/_density.scss index eb6afa573c3c..5bc573138c0c 100644 --- a/src/material/core/tokens/_density.scss +++ b/src/material/core/tokens/_density.scss @@ -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), ), @@ -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. diff --git a/src/material/core/tokens/m2/mat/_timepicker.scss b/src/material/core/tokens/m2/mat/_timepicker.scss index 6bfaaef8da93..c40620529cf9 100644 --- a/src/material/core/tokens/m2/mat/_timepicker.scss +++ b/src/material/core/tokens/m2/mat/_timepicker.scss @@ -1,5 +1,7 @@ @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); @@ -7,21 +9,22 @@ $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. diff --git a/src/material/core/tokens/m3/mat/_timepicker.scss b/src/material/core/tokens/m3/mat/_timepicker.scss index 6334553a8915..8e9388d3ac6c 100644 --- a/src/material/core/tokens/m3/mat/_timepicker.scss +++ b/src/material/core/tokens/m3/mat/_timepicker.scss @@ -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. @@ -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); diff --git a/src/material/timepicker/BUILD.bazel b/src/material/timepicker/BUILD.bazel index 6aabcad13896..3de8ce63c87d 100644 --- a/src/material/timepicker/BUILD.bazel +++ b/src/material/timepicker/BUILD.bazel @@ -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", ], ) @@ -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", ], ) diff --git a/src/material/timepicker/_timepicker-theme.scss b/src/material/timepicker/_timepicker-theme.scss index 0103dc408eb0..769ffa9eb0d4 100644 --- a/src/material/timepicker/_timepicker-theme.scss +++ b/src/material/timepicker/_timepicker-theme.scss @@ -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)); @@ -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...); } @@ -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() { @@ -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() { @@ -55,6 +67,8 @@ } } +/// 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, @@ -62,6 +76,11 @@ ); } +/// 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 { diff --git a/src/material/timepicker/public-api.ts b/src/material/timepicker/public-api.ts index 573ccabeb79d..d544ec4520a5 100644 --- a/src/material/timepicker/public-api.ts +++ b/src/material/timepicker/public-api.ts @@ -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'; diff --git a/src/material/timepicker/timepicker-input.ts b/src/material/timepicker/timepicker-input.ts new file mode 100644 index 000000000000..9cc958f0b50e --- /dev/null +++ b/src/material/timepicker/timepicker-input.ts @@ -0,0 +1,415 @@ +/** + * @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 { + booleanAttribute, + computed, + Directive, + effect, + ElementRef, + inject, + input, + InputSignal, + InputSignalWithTransform, + model, + ModelSignal, + OnDestroy, + OutputRefSubscription, + Signal, + signal, +} from '@angular/core'; +import {DateAdapter, MAT_DATE_FORMATS} from '@angular/material/core'; +import { + AbstractControl, + ControlValueAccessor, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator, + ValidatorFn, + Validators, +} from '@angular/forms'; +import {MAT_FORM_FIELD} from '@angular/material/form-field'; +import {MatTimepicker} from './timepicker'; +import {MAT_INPUT_VALUE_ACCESSOR} from '@angular/material/input'; +import {Subscription} from 'rxjs'; +import {DOWN_ARROW, ESCAPE, hasModifierKey, UP_ARROW} from '@angular/cdk/keycodes'; +import {validateAdapter} from './util'; +import {DOCUMENT} from '@angular/common'; + +/** + * Input that can be used to enter time and connect to a `mat-timepicker`. + */ +@Directive({ + standalone: true, + selector: 'input[matTimepicker]', + exportAs: 'matTimepickerInput', + host: { + 'class': 'mat-timepicker-input', + 'role': 'combobox', + 'type': 'text', + 'aria-haspopup': 'listbox', + '[attr.aria-activedescendant]': '_ariaActiveDescendant()', + '[attr.aria-expanded]': '_ariaExpanded()', + '[attr.aria-controls]': '_ariaControls()', + '[attr.mat-timepicker-id]': 'timepicker()?.panelId', + '[disabled]': 'disabled()', + '(blur)': '_handleBlur()', + '(input)': '_handleInput($event.target.value)', + '(keydown)': '_handleKeydown($event)', + }, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: MatTimepickerInput, + multi: true, + }, + { + provide: NG_VALIDATORS, + useExisting: MatTimepickerInput, + multi: true, + }, + { + provide: MAT_INPUT_VALUE_ACCESSOR, + useExisting: MatTimepickerInput, + }, + ], +}) +export class MatTimepickerInput implements ControlValueAccessor, Validator, OnDestroy { + private _elementRef = inject>(ElementRef); + private _document = inject(DOCUMENT); + private _dateAdapter = inject>(DateAdapter, {optional: true})!; + private _dateFormats = inject(MAT_DATE_FORMATS, {optional: true})!; + private _formField = inject(MAT_FORM_FIELD, {optional: true}); + + private _onChange: ((value: any) => void) | undefined; + private _onTouched: (() => void) | undefined; + private _validatorOnChange: (() => void) | undefined; + private _accessorDisabled = signal(false); + private _localeSubscription: Subscription; + private _timepickerSubscription: OutputRefSubscription | undefined; + private _validator: ValidatorFn; + private _lastValueValid = false; + private _lastValidDate: D | null = null; + + /** Value of the `aria-activedescendant` attribute. */ + protected readonly _ariaActiveDescendant = computed(() => { + const timepicker = this.timepicker(); + const isOpen = timepicker.isOpen(); + const activeDescendant = timepicker.activeDescendant(); + return isOpen && activeDescendant ? activeDescendant : null; + }); + + /** Value of the `aria-expanded` attribute. */ + protected readonly _ariaExpanded = computed(() => this.timepicker().isOpen() + ''); + + /** Value of the `aria-controls` attribute. */ + protected readonly _ariaControls = computed(() => { + const timepicker = this.timepicker(); + return timepicker.isOpen() ? timepicker.panelId : null; + }); + + /** Current value of the input. */ + readonly value: ModelSignal = model(null); + + /** Timepicker that the input is associated with. */ + readonly timepicker: InputSignal> = input.required>({ + alias: 'matTimepicker', + }); + + /** + * Minimum time that can be selected or typed in. Can be either + * a date object (only time will be used) or a valid time string. + */ + readonly min: InputSignalWithTransform = input(null, { + alias: 'matTimepickerMin', + transform: (value: unknown) => this._transformDateInput(value), + }); + + /** + * Maximum time that can be selected or typed in. Can be either + * a date object (only time will be used) or a valid time string. + */ + readonly max: InputSignalWithTransform = input(null, { + alias: 'matTimepickerMax', + transform: (value: unknown) => this._transformDateInput(value), + }); + + /** Whether the input is disabled. */ + readonly disabled: Signal = computed( + () => this.disabledInput() || this._accessorDisabled(), + ); + + /** Whether the input should be disabled through the template. */ + protected readonly disabledInput: InputSignalWithTransform = input(false, { + transform: booleanAttribute, + alias: 'disabled', + }); + + constructor() { + if (typeof ngDevMode === 'undefined' || ngDevMode) { + validateAdapter(this._dateAdapter, this._dateFormats); + } + + this._validator = this._getValidator(); + this._respondToValueChanges(); + this._respondToMinMaxChanges(); + this._registerTimepicker(); + this._localeSubscription = this._dateAdapter.localeChanges.subscribe(() => { + if (!this._hasFocus()) { + this._formatValue(this.value()); + } + }); + + // Bind the click listener manually to the overlay origin, because we want the entire + // form field to be clickable, if the timepicker is used in `mat-form-field`. + this.getOverlayOrigin().nativeElement.addEventListener('click', this._handleClick); + } + + /** + * Implemented as a part of `ControlValueAccessor`. + * @docs-private + */ + writeValue(value: any): void { + this.value.set(this._dateAdapter.getValidDateOrNull(value)); + } + + /** + * Implemented as a part of `ControlValueAccessor`. + * @docs-private + */ + registerOnChange(fn: (value: any) => void): void { + this._onChange = fn; + } + + /** + * Implemented as a part of `ControlValueAccessor`. + * @docs-private + */ + registerOnTouched(fn: () => void): void { + this._onTouched = fn; + } + + /** + * Implemented as a part of `ControlValueAccessor`. + * @docs-private + */ + setDisabledState(isDisabled: boolean): void { + this._accessorDisabled.set(isDisabled); + } + + /** + * Implemented as a part of `Validator`. + * @docs-private + */ + validate(control: AbstractControl): ValidationErrors | null { + return this._validator(control); + } + + /** + * Implemented as a part of `Validator`. + * @docs-private + */ + registerOnValidatorChange(fn: () => void): void { + this._validatorOnChange = fn; + } + + /** Gets the element to which the timepicker popup should be attached. */ + getOverlayOrigin(): ElementRef { + return this._formField?.getConnectedOverlayOrigin() || this._elementRef; + } + + /** Focuses the input. */ + focus(): void { + this._elementRef.nativeElement.focus(); + } + + ngOnDestroy(): void { + this.getOverlayOrigin().nativeElement.removeEventListener('click', this._handleClick); + this._timepickerSubscription?.unsubscribe(); + this._localeSubscription.unsubscribe(); + } + + /** Gets the ID of the input's label. */ + _getLabelId(): string | null { + return this._formField?.getLabelId() || null; + } + + /** Handles clicks on the input or the containing form field. */ + private _handleClick = (): void => { + this.timepicker().open(); + }; + + /** Handles the `input` event. */ + protected _handleInput(value: string) { + const currentValue = this.value(); + const date = this._dateAdapter.parseTime(value, this._dateFormats.parse.timeInput); + const hasChanged = !this._dateAdapter.sameTime(date, currentValue); + + // We need to fire the CVA change event for all nulls, otherwise the validators won't run. + this._assignUserSelection(date, !date || hasChanged || !!(value && !currentValue)); + } + + /** Handles the `blur` event. */ + protected _handleBlur() { + const value = this.value(); + + // Only reformat on blur so the value doesn't change while the user is interacting. + if (value && this._isValid(value)) { + this._formatValue(value); + } + + this._onTouched?.(); + } + + /** Handles the `keydown` event. */ + protected _handleKeydown(event: KeyboardEvent) { + // All keyboard events while open are handled through the timepicker. + if (this.timepicker().isOpen()) { + return; + } + + if (event.keyCode === ESCAPE && !hasModifierKey(event) && this.value() !== null) { + event.preventDefault(); + this.value.set(null); + this._formatValue(null); + } else if ((event.keyCode === DOWN_ARROW || event.keyCode === UP_ARROW) && !this.disabled()) { + event.preventDefault(); + this.timepicker().open(); + } + } + + /** Sets up the code that watches for changes in the value and adjusts the input. */ + private _respondToValueChanges(): void { + effect(() => { + const value = this._dateAdapter.deserialize(this.value()); + const wasValid = this._lastValueValid; + this._lastValueValid = this._isValid(value); + + // Reformat the value if it changes while the user isn't interacting. + if (!this._hasFocus()) { + this._formatValue(value); + } + + if (value && this._lastValueValid) { + this._lastValidDate = value; + } + + // Trigger the validator if the state changed. + if (wasValid !== this._lastValueValid) { + this._validatorOnChange?.(); + } + }); + } + + /** Sets up the logic that registers the input with the timepicker. */ + private _registerTimepicker(): void { + effect(() => { + const timepicker = this.timepicker(); + timepicker.registerInput(this); + timepicker.closed.subscribe(() => this._onTouched?.()); + timepicker.selected.subscribe(({value}) => { + if (!this._dateAdapter.sameTime(value, this.value())) { + this._assignUserSelection(value, true); + this._formatValue(value); + } + }); + }); + } + + /** Sets up the logic that adjusts the input if the min/max changes. */ + private _respondToMinMaxChanges(): void { + effect(() => { + // Read the min/max so the effect knows when to fire. + this.min(); + this.max(); + this._validatorOnChange?.(); + }); + } + + /** + * Assigns a value set by the user to the input's model. + * @param selection Time selected by the user that should be assigned. + * @param propagateToAccessor Whether the value should be propagated to the ControlValueAccessor. + */ + private _assignUserSelection(selection: D | null, propagateToAccessor: boolean) { + if (selection == null || !this._isValid(selection)) { + this.value.set(selection); + } else { + // If a datepicker and timepicker are writing to the same object and the user enters an + // invalid time into the timepicker, we may end up clearing their selection from the + // datepicker. If the user enters a valid time afterwards, the datepicker's selection will + // have been lost. This logic restores the previously-valid date and sets its time to + // the newly-selected time. + const adapter = this._dateAdapter; + const target = adapter.getValidDateOrNull(this._lastValidDate || this.value()); + const hours = adapter.getHours(selection); + const minutes = adapter.getMinutes(selection); + const seconds = adapter.getSeconds(selection); + this.value.set(target ? adapter.setTime(target, hours, minutes, seconds) : selection); + } + + if (propagateToAccessor) { + this._onChange?.(this.value()); + } + } + + /** Formats the current value and assigns it to the input. */ + private _formatValue(value: D | null): void { + value = this._dateAdapter.getValidDateOrNull(value); + this._elementRef.nativeElement.value = + value == null ? '' : this._dateAdapter.format(value, this._dateFormats.display.timeInput); + } + + /** Checks whether a value is valid. */ + private _isValid(value: D | null): boolean { + return !value || this._dateAdapter.isValid(value); + } + + /** Transforms an arbitrary value into a value that can be assigned to a date-based input. */ + private _transformDateInput(value: unknown): D | null { + const date = + typeof value === 'string' + ? this._dateAdapter.parseTime(value, this._dateFormats.parse.timeInput) + : this._dateAdapter.deserialize(value); + return date && this._dateAdapter.isValid(date) ? (date as D) : null; + } + + /** Whether the input is currently focused. */ + private _hasFocus(): boolean { + return this._document.activeElement === this._elementRef.nativeElement; + } + + /** Gets a function that can be used to validate the input. */ + private _getValidator(): ValidatorFn { + return Validators.compose([ + () => + this._lastValueValid + ? null + : {'matTimepickerParse': {'text': this._elementRef.nativeElement.value}}, + control => { + const controlValue = this._dateAdapter.getValidDateOrNull( + this._dateAdapter.deserialize(control.value), + ); + const min = this.min(); + return !min || !controlValue || this._dateAdapter.compareTime(min, controlValue) <= 0 + ? null + : {'matTimepickerMin': {'min': min, 'actual': controlValue}}; + }, + control => { + const controlValue = this._dateAdapter.getValidDateOrNull( + this._dateAdapter.deserialize(control.value), + ); + const max = this.max(); + return !max || !controlValue || this._dateAdapter.compareTime(max, controlValue) >= 0 + ? null + : {'matTimepickerMax': {'max': max, 'actual': controlValue}}; + }, + ])!; + } +} diff --git a/src/material/timepicker/timepicker-module.ts b/src/material/timepicker/timepicker-module.ts index 93d9196bc135..a6850da97066 100644 --- a/src/material/timepicker/timepicker-module.ts +++ b/src/material/timepicker/timepicker-module.ts @@ -7,10 +7,13 @@ */ import {NgModule} from '@angular/core'; +import {CdkScrollableModule} from '@angular/cdk/scrolling'; import {MatTimepicker} from './timepicker'; +import {MatTimepickerInput} from './timepicker-input'; +import {MatTimepickerToggle} from './timepicker-toggle'; @NgModule({ - imports: [MatTimepicker], - exports: [MatTimepicker], + imports: [MatTimepicker, MatTimepickerInput, MatTimepickerToggle], + exports: [CdkScrollableModule, MatTimepicker, MatTimepickerInput, MatTimepickerToggle], }) export class MatTimepickerModule {} diff --git a/src/material/timepicker/timepicker-toggle.html b/src/material/timepicker/timepicker-toggle.html new file mode 100644 index 000000000000..310469073cd2 --- /dev/null +++ b/src/material/timepicker/timepicker-toggle.html @@ -0,0 +1,21 @@ + diff --git a/src/material/timepicker/timepicker-toggle.ts b/src/material/timepicker/timepicker-toggle.ts new file mode 100644 index 000000000000..6170a2bab3f9 --- /dev/null +++ b/src/material/timepicker/timepicker-toggle.ts @@ -0,0 +1,82 @@ +/** + * @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 { + booleanAttribute, + ChangeDetectionStrategy, + Component, + HostAttributeToken, + inject, + input, + InputSignal, + InputSignalWithTransform, + ViewEncapsulation, +} from '@angular/core'; +import {MatIconButton} from '@angular/material/button'; +import {MAT_TIMEPICKER_CONFIG} from './util'; +import type {MatTimepicker} from './timepicker'; + +/** Button that can be used to open a `mat-timepicker`. */ +@Component({ + selector: 'mat-timepicker-toggle', + templateUrl: 'timepicker-toggle.html', + host: { + 'class': 'mat-timepicker-toggle', + '[attr.tabindex]': 'null', + // Bind the `click` on the host, rather than the inner `button`, so that we can call + // `stopPropagation` on it without affecting the user's `click` handlers. We need to stop + // it so that the input doesn't get focused automatically by the form field (See #21836). + '(click)': '_open($event)', + }, + exportAs: 'matTimepickerToggle', + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [MatIconButton], +}) +export class MatTimepickerToggle { + private _defaultConfig = inject(MAT_TIMEPICKER_CONFIG, {optional: true}); + private _defaultTabIndex = (() => { + const value = inject(new HostAttributeToken('tabindex'), {optional: true}); + const parsed = Number(value); + return isNaN(parsed) ? null : parsed; + })(); + + /** Timepicker instance that the button will toggle. */ + readonly timepicker: InputSignal> = input.required>({ + alias: 'for', + }); + + /** Screen-reader label for the button. */ + readonly ariaLabel = input(undefined, { + alias: 'aria-label', + }); + + /** Whether the toggle button is disabled. */ + readonly disabled: InputSignalWithTransform = input(false, { + transform: booleanAttribute, + alias: 'disabled', + }); + + /** Tabindex for the toggle. */ + readonly tabIndex: InputSignal = input(this._defaultTabIndex); + + /** Whether ripples on the toggle should be disabled. */ + readonly disableRipple: InputSignalWithTransform = input( + this._defaultConfig?.disableRipple ?? false, + {transform: booleanAttribute}, + ); + + /** Opens the connected timepicker. */ + protected _open(event: Event): void { + if (this.timepicker() && !this.disabled()) { + this.timepicker().open(); + event.stopPropagation(); + } + } +} diff --git a/src/material/timepicker/timepicker.html b/src/material/timepicker/timepicker.html index e965047ad7c5..86d86a41a89e 100644 --- a/src/material/timepicker/timepicker.html +++ b/src/material/timepicker/timepicker.html @@ -1 +1,15 @@ -Hello + +
+ @for (option of _timeOptions; track option.value) { + {{option.label}} + } +
+
diff --git a/src/material/timepicker/timepicker.scss b/src/material/timepicker/timepicker.scss index 08a47c4f870f..ae8cfc84fff8 100644 --- a/src/material/timepicker/timepicker.scss +++ b/src/material/timepicker/timepicker.scss @@ -1,10 +1,53 @@ +@use '@angular/cdk'; @use '../core/tokens/token-utils'; @use '../core/tokens/m2/mat/timepicker' as tokens-mat-timepicker; -.mat-timepicker { +mat-timepicker { + display: none; +} + +.mat-timepicker-panel { + width: 100%; + max-height: 256px; + transform-origin: center top; + overflow: auto; + padding: 8px 0; + box-sizing: border-box; + @include token-utils.use-tokens( tokens-mat-timepicker.$prefix, tokens-mat-timepicker.get-token-slots()) { - @include token-utils.create-token-slot(color, enabled-trigger-text-color); - @include token-utils.create-token-slot(font-family, trigger-text-font); + @include token-utils.create-token-slot(border-bottom-left-radius, container-shape); + @include token-utils.create-token-slot(border-bottom-right-radius, container-shape); + @include token-utils.create-token-slot(box-shadow, container-elevation-shadow); + @include token-utils.create-token-slot(background-color, container-background-color); + } + + @include cdk.high-contrast { + outline: solid 1px; + } + + .mat-timepicker-above & { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + + @include token-utils.use-tokens( + tokens-mat-timepicker.$prefix, tokens-mat-timepicker.get-token-slots()) { + @include token-utils.create-token-slot(border-top-left-radius, container-shape); + @include token-utils.create-token-slot(border-top-right-radius, container-shape); + } + } +} + +// stylelint-disable material/no-prefixes +.mat-timepicker-input:read-only { + cursor: pointer; +} +// stylelint-enable material/no-prefixes + +@include cdk.high-contrast { + .mat-timepicker-toggle-default-icon { + // On Chromium-based browsers the icon doesn't appear to inherit the text color in high + // contrast mode so we have to set it explicitly. This is a no-op on IE and Firefox. + color: CanvasText; } } diff --git a/src/material/timepicker/timepicker.spec.ts b/src/material/timepicker/timepicker.spec.ts index 45150bd4e3e3..ce461ff3a818 100644 --- a/src/material/timepicker/timepicker.spec.ts +++ b/src/material/timepicker/timepicker.spec.ts @@ -1,17 +1,1340 @@ -import {Component} from '@angular/core'; -import {TestBed} from '@angular/core/testing'; +import {Component, Provider, signal, ViewChild} from '@angular/core'; +import {ComponentFixture, fakeAsync, flush, TestBed} from '@angular/core/testing'; +import {NoopAnimationsModule} from '@angular/platform-browser/animations'; +import {DateAdapter, provideNativeDateAdapter} from '@angular/material/core'; +import { + clearElement, + dispatchFakeEvent, + dispatchKeyboardEvent, + typeInElement, +} from '@angular/cdk/testing/private'; +import { + DOWN_ARROW, + END, + ENTER, + ESCAPE, + HOME, + LEFT_ARROW, + PAGE_DOWN, + PAGE_UP, + RIGHT_ARROW, + TAB, + UP_ARROW, +} from '@angular/cdk/keycodes'; +import {MatInput} from '@angular/material/input'; +import {MatFormField, MatLabel, MatSuffix} from '@angular/material/form-field'; +import {MatTimepickerInput} from './timepicker-input'; import {MatTimepicker} from './timepicker'; +import {MatTimepickerToggle} from './timepicker-toggle'; +import {MAT_TIMEPICKER_CONFIG, MatTimepickerOption} from './util'; +import {FormControl, ReactiveFormsModule, Validators} from '@angular/forms'; describe('MatTimepicker', () => { - it('TODO', () => { - const fixture = TestBed.createComponent(BasicTimepicker); - expect(fixture).toBeTruthy(); + let adapter: DateAdapter; + + beforeEach(() => configureTestingModule()); + + describe('value selection', () => { + it('should only change the time part of the selected date', fakeAsync(() => { + const fixture = TestBed.createComponent(StandaloneTimepicker); + fixture.componentInstance.value.set(new Date(2024, 0, 15, 0, 0, 0)); + fixture.detectChanges(); + getInput(fixture).click(); + fixture.detectChanges(); + + getOptions()[3].click(); + fixture.detectChanges(); + flush(); + + const value = fixture.componentInstance.input.value()!; + expect(value).toBeTruthy(); + expect(adapter.getYear(value)).toBe(2024); + expect(adapter.getMonth(value)).toBe(0); + expect(adapter.getDate(value)).toBe(15); + expect(adapter.getHours(value)).toBe(1); + expect(adapter.getMinutes(value)).toBe(30); + expect(adapter.getSeconds(value)).toBe(0); + })); + + it('should accept the selected value and close the panel when clicking an option', fakeAsync(() => { + const fixture = TestBed.createComponent(StandaloneTimepicker); + const input = getInput(fixture); + fixture.detectChanges(); + expect(input.value).toBe(''); + expect(fixture.componentInstance.input.value()).toBe(null); + expect(fixture.componentInstance.selectedSpy).not.toHaveBeenCalled(); + + input.click(); + fixture.detectChanges(); + + getOptions()[1].click(); + fixture.detectChanges(); + flush(); + + expect(getPanel()).toBeFalsy(); + expect(input.value).toBe('12:30 AM'); + expect(fixture.componentInstance.input.value()).toEqual(createTime(0, 30)); + expect(fixture.componentInstance.selectedSpy).toHaveBeenCalledTimes(1); + expect(fixture.componentInstance.selectedSpy).toHaveBeenCalledWith( + jasmine.objectContaining({ + source: fixture.componentInstance.timepicker, + value: jasmine.any(Date), + }), + ); + })); + + it('should support two-way binding on the `value` input', fakeAsync(() => { + const fixture = TestBed.createComponent(TimepickerTwoWayBinding); + const input = getInput(fixture); + fixture.detectChanges(); + const inputInstance = fixture.componentInstance.input; + + // Initial value + expect(fixture.componentInstance.value).toBeTruthy(); + expect(inputInstance.value()).toEqual(fixture.componentInstance.value()); + + // Propagation from input back to host + clearElement(input); + typeInElement(input, '11:15 AM'); + fixture.detectChanges(); + let value = inputInstance.value()!; + expect(adapter.getHours(value)).toBe(11); + expect(adapter.getMinutes(value)).toBe(15); + expect(fixture.componentInstance.value()).toEqual(value); + + // Propagation from host down to input + fixture.componentInstance.value.set(createTime(13, 37)); + fixture.detectChanges(); + flush(); + value = inputInstance.value()!; + expect(adapter.getHours(value)).toBe(13); + expect(adapter.getMinutes(value)).toBe(37); + expect(value).toEqual(fixture.componentInstance.value()); + })); + + it('should emit the `selected` event if the option being clicked was selected already', fakeAsync(() => { + const fixture = TestBed.createComponent(StandaloneTimepicker); + fixture.componentInstance.value.set(new Date(2024, 0, 15, 2, 30, 0)); + fixture.detectChanges(); + getInput(fixture).click(); + fixture.detectChanges(); + expect(fixture.componentInstance.selectedSpy).not.toHaveBeenCalled(); + + getOptions()[getActiveOptionIndex()].click(); + fixture.detectChanges(); + flush(); + + expect(getPanel()).toBeFalsy(); + expect(fixture.componentInstance.selectedSpy).toHaveBeenCalledTimes(1); + expect(fixture.componentInstance.selectedSpy).toHaveBeenCalledWith( + jasmine.objectContaining({ + source: fixture.componentInstance.timepicker, + value: jasmine.any(Date), + }), + ); + })); + }); + + describe('input behavior', () => { + it('should reformat the input value when the model changes', () => { + const fixture = TestBed.createComponent(StandaloneTimepicker); + const input = getInput(fixture); + fixture.componentInstance.value.set(createTime(13, 45)); + fixture.detectChanges(); + expect(input.value).toBe('1:45 PM'); + fixture.componentInstance.value.set(createTime(9, 31)); + fixture.detectChanges(); + expect(input.value).toBe('9:31 AM'); + }); + + it('should reformat the input value when the locale changes', () => { + const fixture = TestBed.createComponent(StandaloneTimepicker); + const input = getInput(fixture); + fixture.componentInstance.value.set(createTime(13, 45)); + fixture.detectChanges(); + expect(input.value).toBe('1:45 PM'); + adapter.setLocale('da-DK'); + fixture.detectChanges(); + expect(input.value).toBe('13.45'); + }); + + it('should parse a valid time value entered by the user', () => { + const fixture = TestBed.createComponent(StandaloneTimepicker); + const input = getInput(fixture); + fixture.detectChanges(); + expect(fixture.componentInstance.input.value()).toBe(null); + + typeInElement(input, '13:37'); + fixture.detectChanges(); + + // The user's value shouldn't be overwritten. + expect(input.value).toBe('13:37'); + expect(fixture.componentInstance.input.value()).toEqual(createTime(13, 37)); + }); + + it('should parse invalid time string', () => { + const fixture = TestBed.createComponent(StandaloneTimepicker); + fixture.detectChanges(); + const input = getInput(fixture); + fixture.componentInstance.input.value.set(createTime(10, 55)); + + typeInElement(input, 'not a valid time'); + fixture.detectChanges(); + + expect(input.value).toBe('not a valid time'); + expect(adapter.isValid(fixture.componentInstance.input.value()!)).toBe(false); + }); + + it('should format the entered value on blur', () => { + const fixture = TestBed.createComponent(StandaloneTimepicker); + const input = getInput(fixture); + fixture.detectChanges(); + + typeInElement(input, '13:37'); + fixture.detectChanges(); + expect(input.value).toBe('13:37'); + + dispatchFakeEvent(input, 'blur'); + fixture.detectChanges(); + expect(input.value).toBe('1:37 PM'); + }); + + it('should not format invalid time string entered by the user', () => { + const fixture = TestBed.createComponent(StandaloneTimepicker); + const input = getInput(fixture); + fixture.detectChanges(); + + typeInElement(input, 'not a valid time'); + fixture.detectChanges(); + expect(input.value).toBe('not a valid time'); + expect(adapter.isValid(fixture.componentInstance.input.value()!)).toBe(false); + + dispatchFakeEvent(input, 'blur'); + fixture.detectChanges(); + expect(input.value).toBe('not a valid time'); + expect(adapter.isValid(fixture.componentInstance.input.value()!)).toBe(false); + }); + + it('should not format invalid time set programmatically', () => { + const fixture = TestBed.createComponent(StandaloneTimepicker); + fixture.componentInstance.value.set(adapter.invalid()); + fixture.detectChanges(); + expect(getInput(fixture).value).toBe(''); + }); + + it('should set the disabled state of the input', () => { + const fixture = TestBed.createComponent(StandaloneTimepicker); + const input = getInput(fixture); + fixture.detectChanges(); + expect(input.disabled).toBe(false); + fixture.componentInstance.disabled.set(true); + fixture.detectChanges(); + expect(input.disabled).toBe(true); + }); + + it('should assign the last valid date with a new time if the user clears the time and re-enters it', () => { + const dateParts = [2024, 0, 15] as const; + const fixture = TestBed.createComponent(StandaloneTimepicker); + const input = getInput(fixture); + fixture.detectChanges(); + const inputInstance = fixture.componentInstance.input; + + inputInstance.value.set(new Date(...dateParts, 8, 15, 0)); + fixture.detectChanges(); + expect(input.value).toBe('8:15 AM'); + + clearElement(input); + fixture.detectChanges(); + expect(input.value).toBe(''); + expect(inputInstance.value()).toBe(null); + + typeInElement(input, '2:10 PM'); + fixture.detectChanges(); + expect(input.value).toBe('2:10 PM'); + expect(inputInstance.value()).toEqual(new Date(...dateParts, 14, 10, 0)); + }); + + it('should not accept an invalid `min` value', () => { + const fixture = TestBed.createComponent(StandaloneTimepicker); + fixture.componentInstance.min.set(createTime(13, 45)); + fixture.detectChanges(); + expect(fixture.componentInstance.input.min()).toEqual(createTime(13, 45)); + + fixture.componentInstance.min.set(adapter.invalid()); + fixture.detectChanges(); + expect(fixture.componentInstance.input.min()).toBe(null); + }); + + it('should not accept an invalid `max` value', () => { + const fixture = TestBed.createComponent(StandaloneTimepicker); + fixture.componentInstance.max.set(createTime(13, 45)); + fixture.detectChanges(); + expect(fixture.componentInstance.input.max()).toEqual(createTime(13, 45)); + + fixture.componentInstance.max.set(adapter.invalid()); + fixture.detectChanges(); + expect(fixture.componentInstance.input.max()).toBe(null); + }); + + it('should accept a valid time string as the `min`', () => { + const fixture = TestBed.createComponent(StandaloneTimepicker); + fixture.componentInstance.min.set('1:45 PM'); + fixture.detectChanges(); + expect(fixture.componentInstance.input.min()).toEqual(createTime(13, 45)); + }); + + it('should accept a valid time string as the `max`', () => { + const fixture = TestBed.createComponent(StandaloneTimepicker); + fixture.componentInstance.max.set('1:45 PM'); + fixture.detectChanges(); + expect(fixture.componentInstance.input.max()).toEqual(createTime(13, 45)); + }); + + it('should throw if multiple inputs are associated with a timepicker', () => { + expect(() => { + const fixture = TestBed.createComponent(TimepickerWithMultipleInputs); + fixture.detectChanges(); + }).toThrowError(/MatTimepicker can only be registered with one input at a time/); + }); + }); + + describe('opening and closing', () => { + it('should open the timepicker on click', () => { + const fixture = TestBed.createComponent(StandaloneTimepicker); + fixture.detectChanges(); + getInput(fixture).click(); + fixture.detectChanges(); + expect(getPanel()).toBeTruthy(); + }); + + it('should open the timepicker on arrow press', () => { + const fixture = TestBed.createComponent(StandaloneTimepicker); + fixture.detectChanges(); + const event = dispatchKeyboardEvent(getInput(fixture), 'keydown', DOWN_ARROW); + fixture.detectChanges(); + expect(getPanel()).toBeTruthy(); + expect(event.defaultPrevented).toBe(true); + }); + + it('should not open the timepicker on focus', () => { + const fixture = TestBed.createComponent(StandaloneTimepicker); + fixture.detectChanges(); + getInput(fixture).focus(); + fixture.detectChanges(); + expect(getPanel()).toBeFalsy(); + }); + + it('should close the timepicker when clicking outside', fakeAsync(() => { + const fixture = TestBed.createComponent(StandaloneTimepicker); + fixture.detectChanges(); + getInput(fixture).click(); + fixture.detectChanges(); + expect(getPanel()).toBeTruthy(); + document.body.click(); + fixture.detectChanges(); + flush(); + expect(getPanel()).toBeFalsy(); + })); + + it('should close the timepicker when tabbing away from the input', fakeAsync(() => { + const fixture = TestBed.createComponent(StandaloneTimepicker); + fixture.detectChanges(); + getInput(fixture).click(); + fixture.detectChanges(); + expect(getPanel()).toBeTruthy(); + dispatchKeyboardEvent(getInput(fixture), 'keydown', TAB); + fixture.detectChanges(); + flush(); + expect(getPanel()).toBeFalsy(); + })); + + it('should close the timepicker when pressing escape', fakeAsync(() => { + const fixture = TestBed.createComponent(StandaloneTimepicker); + fixture.detectChanges(); + getInput(fixture).click(); + fixture.detectChanges(); + expect(getPanel()).toBeTruthy(); + const event = dispatchKeyboardEvent(document.body, 'keydown', ESCAPE); + fixture.detectChanges(); + flush(); + expect(getPanel()).toBeFalsy(); + expect(event.defaultPrevented).toBe(true); + })); + + it('should emit events on open/close', fakeAsync(() => { + const fixture = TestBed.createComponent(StandaloneTimepicker); + fixture.detectChanges(); + const {openedSpy, closedSpy} = fixture.componentInstance; + expect(openedSpy).not.toHaveBeenCalled(); + expect(closedSpy).not.toHaveBeenCalled(); + + getInput(fixture).click(); + fixture.detectChanges(); + expect(openedSpy).toHaveBeenCalledTimes(1); + expect(closedSpy).not.toHaveBeenCalled(); + + document.body.click(); + fixture.detectChanges(); + flush(); + expect(openedSpy).toHaveBeenCalledTimes(1); + expect(closedSpy).toHaveBeenCalledTimes(1); + })); + + it('should clean up the overlay if it is open on destroy', () => { + const fixture = TestBed.createComponent(StandaloneTimepicker); + fixture.detectChanges(); + getInput(fixture).click(); + fixture.detectChanges(); + expect(getPanel()).toBeTruthy(); + fixture.destroy(); + expect(getPanel()).toBeFalsy(); + }); + + it('should be able to open and close the panel programmatically', fakeAsync(() => { + const fixture = TestBed.createComponent(StandaloneTimepicker); + fixture.detectChanges(); + fixture.componentInstance.timepicker.open(); + fixture.detectChanges(); + expect(getPanel()).toBeTruthy(); + fixture.componentInstance.timepicker.close(); + fixture.detectChanges(); + flush(); + expect(getPanel()).toBeFalsy(); + })); + + it('should focus the input when opened programmatically', () => { + const fixture = TestBed.createComponent(StandaloneTimepicker); + const input = getInput(fixture); + fixture.detectChanges(); + fixture.componentInstance.timepicker.open(); + fixture.detectChanges(); + expect(input).toBeTruthy(); + expect(document.activeElement).toBe(input); + }); + + it('should expose the current open state', fakeAsync(() => { + const fixture = TestBed.createComponent(StandaloneTimepicker); + fixture.detectChanges(); + const timepicker = fixture.componentInstance.timepicker; + expect(timepicker.isOpen()).toBe(false); + timepicker.open(); + fixture.detectChanges(); + expect(timepicker.isOpen()).toBe(true); + timepicker.close(); + fixture.detectChanges(); + flush(); + expect(timepicker.isOpen()).toBe(false); + })); + + // Note: this will be a type checking error, but we check it just in case for JIT mode. + it('should do nothing if trying to open a timepicker without an input', fakeAsync(() => { + const fixture = TestBed.createComponent(TimepickerWithoutInput); + fixture.detectChanges(); + fixture.componentInstance.timepicker.open(); + fixture.detectChanges(); + expect(getPanel()).toBeFalsy(); + + expect(() => { + fixture.componentInstance.timepicker.close(); + fixture.detectChanges(); + flush(); + }).not.toThrow(); + })); + }); + + // Note: these tests intentionally don't cover the full option generation logic + // and interval parsing, because they are tested already in `util.spec.ts`. + describe('panel options behavior', () => { + it('should set the selected state of the options based on the input value', () => { + const getStates = () => { + return getOptions().map( + o => `${o.textContent} - ${o.classList.contains('mdc-list-item--selected')}`, + ); + }; + const fixture = TestBed.createComponent(StandaloneTimepicker); + const input = getInput(fixture); + fixture.componentInstance.min.set(createTime(12, 0)); + fixture.componentInstance.max.set(createTime(14, 0)); + fixture.detectChanges(); + + // Initial open with pre-entereted value. + typeInElement(input, '1:30 PM'); + fixture.detectChanges(); + input.click(); + fixture.detectChanges(); + expect(getStates()).toEqual([ + '12:00 PM - false', + '12:30 PM - false', + '1:00 PM - false', + '1:30 PM - true', + '2:00 PM - false', + ]); + + // Clear the input while open. + clearElement(input); + fixture.detectChanges(); + expect(getStates()).toEqual([ + '12:00 PM - false', + '12:30 PM - false', + '1:00 PM - false', + '1:30 PM - false', + '2:00 PM - false', + ]); + + // Type new value while open. + typeInElement(input, '12:30 PM'); + fixture.detectChanges(); + expect(getStates()).toEqual([ + '12:00 PM - false', + '12:30 PM - true', + '1:00 PM - false', + '1:30 PM - false', + '2:00 PM - false', + ]); + + // Type value that doesn't match anything. + clearElement(input); + typeInElement(input, '12:34 PM'); + fixture.detectChanges(); + expect(getStates()).toEqual([ + '12:00 PM - false', + '12:30 PM - false', + '1:00 PM - false', + '1:30 PM - false', + '2:00 PM - false', + ]); + }); + + it('should take the input min value into account when generating the options', () => { + const fixture = TestBed.createComponent(StandaloneTimepicker); + fixture.componentInstance.min.set(createTime(18, 0)); + fixture.detectChanges(); + + getInput(fixture).click(); + fixture.detectChanges(); + expect(getOptions().map(o => o.textContent)).toEqual([ + '6:00 PM', + '6:30 PM', + '7:00 PM', + '7:30 PM', + '8:00 PM', + '8:30 PM', + '9:00 PM', + '9:30 PM', + '10:00 PM', + '10:30 PM', + '11:00 PM', + '11:30 PM', + ]); + }); + + it('should take the input max value into account when generating the options', () => { + const fixture = TestBed.createComponent(StandaloneTimepicker); + fixture.componentInstance.max.set(createTime(4, 0)); + fixture.detectChanges(); + + getInput(fixture).click(); + fixture.detectChanges(); + expect(getOptions().map(o => o.textContent)).toEqual([ + '12:00 AM', + '12:30 AM', + '1:00 AM', + '1:30 AM', + '2:00 AM', + '2:30 AM', + '3:00 AM', + '3:30 AM', + '4:00 AM', + ]); + }); + + it('should take the interval into account when generating the options', () => { + const fixture = TestBed.createComponent(StandaloneTimepicker); + fixture.componentInstance.interval.set('3.5h'); + fixture.detectChanges(); + + getInput(fixture).click(); + fixture.detectChanges(); + expect(getOptions().map(o => o.textContent)).toEqual([ + '12:00 AM', + '3:30 AM', + '7:00 AM', + '10:30 AM', + '2:00 PM', + '5:30 PM', + '9:00 PM', + ]); + }); + + it('should be able to pass a custom array of options', () => { + const fixture = TestBed.createComponent(StandaloneTimepicker); + fixture.componentInstance.customOptions.set([ + {label: 'Breakfast', value: createTime(8, 0)}, + {label: 'Lunch', value: createTime(12, 0)}, + {label: 'Dinner', value: createTime(20, 0)}, + ]); + fixture.detectChanges(); + + getInput(fixture).click(); + fixture.detectChanges(); + expect(getOptions().map(o => o.textContent)).toEqual(['Breakfast', 'Lunch', 'Dinner']); + }); + + it('should throw if both an interval and custom options are passed in', () => { + const fixture = TestBed.createComponent(StandaloneTimepicker); + expect(() => { + fixture.componentInstance.interval.set('3h'); + fixture.componentInstance.customOptions.set([{label: 'Noon', value: createTime(12, 0)}]); + fixture.detectChanges(); + }).toThrowError(/Cannot specify both the `options` and `interval` inputs at the same time/); + }); + + it('should throw if an empty array of custom options is passed in', () => { + const fixture = TestBed.createComponent(StandaloneTimepicker); + expect(() => { + fixture.componentInstance.customOptions.set([]); + fixture.detectChanges(); + }).toThrowError(/Value of `options` input cannot be an empty array/); + }); + + it('should interpret an invalid interval as null', () => { + const fixture = TestBed.createComponent(StandaloneTimepicker); + fixture.componentInstance.interval.set('not a valid interval'); + fixture.detectChanges(); + expect(fixture.componentInstance.timepicker.interval()).toBe(null); + }); + }); + + describe('mat-form-field integration', () => { + it('should open when clicking on the form field', () => { + const fixture = TestBed.createComponent(TimepickerInFormField); + fixture.detectChanges(); + fixture.nativeElement.querySelector('mat-form-field').click(); + fixture.detectChanges(); + expect(getPanel()).toBeTruthy(); + }); + + it('should default the aria-labelledby of the panel to the form field label', () => { + const fixture = TestBed.createComponent(TimepickerInFormField); + fixture.detectChanges(); + getInput(fixture).click(); + fixture.detectChanges(); + + const panel = getPanel(); + const labelId = fixture.nativeElement.querySelector('label').getAttribute('id'); + expect(labelId).toBeTruthy(); + expect(panel.getAttribute('aria-labelledby')).toBe(labelId); + }); + }); + + describe('accessibility', () => { + it('should set the correct roles', () => { + const fixture = TestBed.createComponent(StandaloneTimepicker); + const input = getInput(fixture); + fixture.detectChanges(); + input.click(); + fixture.detectChanges(); + const panel = getPanel(); + const option = panel.querySelector('mat-option') as HTMLElement; + + expect(input.getAttribute('role')).toBe('combobox'); + expect(input.getAttribute('aria-haspopup')).toBe('listbox'); + expect(panel.getAttribute('role')).toBe('listbox'); + expect(option.getAttribute('role')).toBe('option'); + }); + + it('should point the aria-controls attribute to the panel while open', () => { + const fixture = TestBed.createComponent(StandaloneTimepicker); + const input = getInput(fixture); + fixture.detectChanges(); + expect(input.hasAttribute('aria-controls')).toBe(false); + + input.click(); + fixture.detectChanges(); + const panelId = getPanel().getAttribute('id'); + expect(panelId).toBeTruthy(); + expect(input.getAttribute('aria-controls')).toBe(panelId); + }); + + it('should set aria-expanded based on whether the panel is open', () => { + const fixture = TestBed.createComponent(StandaloneTimepicker); + const input = getInput(fixture); + fixture.detectChanges(); + expect(input.getAttribute('aria-expanded')).toBe('false'); + + input.click(); + fixture.detectChanges(); + expect(input.getAttribute('aria-expanded')).toBe('true'); + + document.body.click(); + fixture.detectChanges(); + expect(input.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should be able to set aria-label of the panel', () => { + const fixture = TestBed.createComponent(StandaloneTimepicker); + fixture.componentInstance.ariaLabel.set('Pick a time'); + fixture.detectChanges(); + getInput(fixture).click(); + fixture.detectChanges(); + expect(getPanel().getAttribute('aria-label')).toBe('Pick a time'); + }); + + it('should be able to set aria-labelledby of the panel', () => { + const fixture = TestBed.createComponent(StandaloneTimepicker); + fixture.componentInstance.ariaLabelledby.set('some-label'); + fixture.detectChanges(); + getInput(fixture).click(); + fixture.detectChanges(); + expect(getPanel().getAttribute('aria-labelledby')).toBe('some-label'); + }); + + it('should give precedence to aria-label over aria-labelledby', () => { + const fixture = TestBed.createComponent(StandaloneTimepicker); + fixture.componentInstance.ariaLabel.set('Pick a time'); + fixture.componentInstance.ariaLabelledby.set('some-label'); + fixture.detectChanges(); + getInput(fixture).click(); + fixture.detectChanges(); + + const panel = getPanel(); + expect(panel.getAttribute('aria-label')).toBe('Pick a time'); + expect(panel.hasAttribute('aria-labelledby')).toBe(false); + }); + + it('should navigate up/down the list when pressing the arrow keys', () => { + const fixture = TestBed.createComponent(StandaloneTimepicker); + const input = getInput(fixture); + fixture.detectChanges(); + input.click(); + fixture.detectChanges(); + expect(getActiveOptionIndex()).toBe(0); + + // Navigate down + for (let i = 1; i < 6; i++) { + const event = dispatchKeyboardEvent(input, 'keydown', DOWN_ARROW); + fixture.detectChanges(); + expect(getActiveOptionIndex()).toBe(i); + expect(event.defaultPrevented).toBe(true); + } + + // Navigate back up + for (let i = 4; i > -1; i--) { + const event = dispatchKeyboardEvent(input, 'keydown', UP_ARROW); + fixture.detectChanges(); + expect(getActiveOptionIndex()).toBe(i); + expect(event.defaultPrevented).toBe(true); + } + }); + + it('should navigate to the first/last options when pressing home/end', () => { + const fixture = TestBed.createComponent(StandaloneTimepicker); + const input = getInput(fixture); + fixture.detectChanges(); + input.click(); + fixture.detectChanges(); + expect(getActiveOptionIndex()).toBe(0); + + let event = dispatchKeyboardEvent(input, 'keydown', END); + fixture.detectChanges(); + expect(getActiveOptionIndex()).toBe(getOptions().length - 1); + expect(event.defaultPrevented).toBe(true); + + event = dispatchKeyboardEvent(input, 'keydown', HOME); + fixture.detectChanges(); + expect(getActiveOptionIndex()).toBe(0); + expect(event.defaultPrevented).toBe(true); + }); + + it('should navigate up/down the list using page up/down', () => { + const fixture = TestBed.createComponent(StandaloneTimepicker); + const input = getInput(fixture); + fixture.detectChanges(); + input.click(); + fixture.detectChanges(); + expect(getActiveOptionIndex()).toBe(0); + + let event = dispatchKeyboardEvent(input, 'keydown', PAGE_DOWN); + fixture.detectChanges(); + expect(getActiveOptionIndex()).toBe(10); + expect(event.defaultPrevented).toBe(true); + + event = dispatchKeyboardEvent(input, 'keydown', PAGE_UP); + fixture.detectChanges(); + expect(getActiveOptionIndex()).toBe(0); + expect(event.defaultPrevented).toBe(true); + }); + + it('should select the active option and close when pressing enter', fakeAsync(() => { + const fixture = TestBed.createComponent(StandaloneTimepicker); + const input = getInput(fixture); + fixture.detectChanges(); + input.click(); + fixture.detectChanges(); + + for (let i = 0; i < 3; i++) { + dispatchKeyboardEvent(input, 'keydown', DOWN_ARROW); + fixture.detectChanges(); + } + + expect(input.value).toBe(''); + expect(fixture.componentInstance.input.value()).toBe(null); + expect(getPanel()).toBeTruthy(); + expect(getActiveOptionIndex()).toBe(3); + expect(fixture.componentInstance.selectedSpy).not.toHaveBeenCalled(); + + const event = dispatchKeyboardEvent(input, 'keydown', ENTER); + fixture.detectChanges(); + flush(); + + expect(input.value).toBe('1:30 AM'); + expect(fixture.componentInstance.input.value()).toEqual(createTime(1, 30)); + expect(getPanel()).toBeFalsy(); + expect(event.defaultPrevented).toBeTrue(); + expect(fixture.componentInstance.selectedSpy).toHaveBeenCalledTimes(1); + expect(fixture.componentInstance.selectedSpy).toHaveBeenCalledWith( + jasmine.objectContaining({ + source: fixture.componentInstance.timepicker, + value: jasmine.any(Date), + }), + ); + })); + + it('should not navigate using the left/right arrow keys', () => { + const fixture = TestBed.createComponent(StandaloneTimepicker); + const input = getInput(fixture); + fixture.detectChanges(); + input.click(); + fixture.detectChanges(); + expect(getActiveOptionIndex()).toBe(0); + + let event = dispatchKeyboardEvent(input, 'keydown', RIGHT_ARROW); + fixture.detectChanges(); + expect(event.defaultPrevented).toBe(false); + expect(getActiveOptionIndex()).toBe(0); + + event = dispatchKeyboardEvent(input, 'keydown', LEFT_ARROW); + fixture.detectChanges(); + expect(event.defaultPrevented).toBe(false); + expect(getActiveOptionIndex()).toBe(0); + }); + + it('should set aria-activedescendant to the currently-active option', () => { + const fixture = TestBed.createComponent(StandaloneTimepicker); + const input = getInput(fixture); + fixture.detectChanges(); + + // Initial state + expect(input.hasAttribute('aria-activedescendant')).toBe(false); + + // Once the panel is opened + input.click(); + fixture.detectChanges(); + const optionIds = getOptions().map(o => o.getAttribute('id')); + expect(optionIds.length).toBeGreaterThan(0); + expect(optionIds.every(o => o != null)).toBe(true); + expect(input.getAttribute('aria-activedescendant')).toBe(optionIds[0]); + + // Navigate down once + dispatchKeyboardEvent(input, 'keydown', DOWN_ARROW); + fixture.detectChanges(); + expect(input.getAttribute('aria-activedescendant')).toBe(optionIds[1]); + + // Navigate down again + dispatchKeyboardEvent(input, 'keydown', DOWN_ARROW); + fixture.detectChanges(); + expect(input.getAttribute('aria-activedescendant')).toBe(optionIds[2]); + + // Navigate up once + dispatchKeyboardEvent(input, 'keydown', UP_ARROW); + fixture.detectChanges(); + expect(input.getAttribute('aria-activedescendant')).toBe(optionIds[1]); + + // Close + document.body.click(); + fixture.detectChanges(); + expect(input.hasAttribute('aria-activedescendant')).toBe(false); + }); }); + + describe('forms integration', () => { + it('should propagate value typed into the input to the form control', () => { + const fixture = TestBed.createComponent(TimepickerWithForms); + const input = getInput(fixture); + const control = fixture.componentInstance.control; + fixture.detectChanges(); + expect(control.value).toBe(null); + expect(control.dirty).toBe(false); + + typeInElement(input, '1:37 PM'); + fixture.detectChanges(); + expect(control.value).toEqual(createTime(13, 37)); + expect(control.dirty).toBe(true); + expect(control.touched).toBe(false); + + clearElement(input); + fixture.detectChanges(); + expect(control.value).toBe(null); + expect(control.dirty).toBe(true); + }); + + it('should propagate value selected from the panel to the form control', () => { + const fixture = TestBed.createComponent(TimepickerWithForms); + const control = fixture.componentInstance.control; + fixture.detectChanges(); + expect(control.value).toBe(null); + expect(control.dirty).toBe(false); + + getInput(fixture).click(); + fixture.detectChanges(); + getOptions()[5].click(); + fixture.detectChanges(); + + expect(control.value).toEqual(createTime(2, 30)); + expect(control.dirty).toBe(true); + }); + + it('should format values assigned to the input through the form control', () => { + const fixture = TestBed.createComponent(TimepickerWithForms); + const input = getInput(fixture); + const control = fixture.componentInstance.control; + control.setValue(createTime(13, 37)); + fixture.detectChanges(); + expect(input.value).toBe('1:37 PM'); + + control.setValue(createTime(12, 15)); + fixture.detectChanges(); + expect(input.value).toBe('12:15 PM'); + + control.reset(); + fixture.detectChanges(); + expect(input.value).toBe(''); + + control.setValue(createTime(10, 10)); + fixture.detectChanges(); + expect(input.value).toBe('10:10 AM'); + }); + + it('should not change the control if the same value is selected from the dropdown', () => { + const fixture = TestBed.createComponent(TimepickerWithForms); + const control = fixture.componentInstance.control; + control.setValue(createTime(2, 30)); + fixture.detectChanges(); + const spy = jasmine.createSpy('valueChanges'); + const subscription = control.valueChanges.subscribe(spy); + expect(control.dirty).toBe(false); + expect(spy).not.toHaveBeenCalled(); + + getInput(fixture).click(); + fixture.detectChanges(); + getOptions()[5].click(); + fixture.detectChanges(); + + expect(control.value).toEqual(createTime(2, 30)); + expect(control.dirty).toBe(false); + expect(spy).not.toHaveBeenCalled(); + subscription.unsubscribe(); + }); + + it('should not propagate programmatic changes to the form control', () => { + const fixture = TestBed.createComponent(TimepickerWithForms); + const control = fixture.componentInstance.control; + control.setValue(createTime(13, 37)); + fixture.detectChanges(); + expect(control.dirty).toBe(false); + + fixture.componentInstance.input.value.set(createTime(12, 0)); + fixture.detectChanges(); + + expect(control.value).toEqual(createTime(13, 37)); + expect(control.dirty).toBe(false); + }); + + it('should mark the control as touched on blur', () => { + const fixture = TestBed.createComponent(TimepickerWithForms); + fixture.detectChanges(); + expect(fixture.componentInstance.control.touched).toBe(false); + + dispatchFakeEvent(getInput(fixture), 'blur'); + fixture.detectChanges(); + expect(fixture.componentInstance.control.touched).toBe(true); + }); + + it('should mark the control as touched when the panel is closed', fakeAsync(() => { + const fixture = TestBed.createComponent(TimepickerWithForms); + fixture.detectChanges(); + expect(fixture.componentInstance.control.touched).toBe(false); + + getInput(fixture).click(); + fixture.detectChanges(); + expect(fixture.componentInstance.control.touched).toBe(false); + + document.body.click(); + fixture.detectChanges(); + flush(); + expect(fixture.componentInstance.control.touched).toBe(true); + })); + + it('should not set the `required` error if there is no valid value in the input', () => { + const fixture = TestBed.createComponent(TimepickerWithForms); + const control = fixture.componentInstance.control; + const input = getInput(fixture); + fixture.detectChanges(); + expect(control.errors?.['required']).toBeTruthy(); + + typeInElement(input, '10:10 AM'); + fixture.detectChanges(); + expect(control.errors?.['required']).toBeFalsy(); + + typeInElement(input, 'not a valid date'); + fixture.detectChanges(); + expect(control.errors?.['required']).toBeFalsy(); + }); + + it('should set an error if the user enters an invalid time string', fakeAsync(() => { + const fixture = TestBed.createComponent(TimepickerWithForms); + const control = fixture.componentInstance.control; + const input = getInput(fixture); + fixture.detectChanges(); + expect(control.errors?.['matTimepickerParse']).toBeFalsy(); + expect(control.value).toBe(null); + + typeInElement(input, '10:10 AM'); + fixture.detectChanges(); + expect(control.errors?.['matTimepickerParse']).toBeFalsy(); + expect(control.value).toEqual(createTime(10, 10)); + + clearElement(input); + typeInElement(input, 'not a valid date'); + fixture.detectChanges(); + expect(control.errors?.['matTimepickerParse']).toBeTruthy(); + expect(control.value).toBeTruthy(); + expect(adapter.isValid(control.value!)).toBe(false); + + clearElement(input); + fixture.detectChanges(); + expect(control.errors?.['matTimepickerParse']).toBeFalsy(); + expect(control.value).toBe(null); + + typeInElement(input, '12:10 PM'); + fixture.detectChanges(); + expect(control.errors?.['matTimepickerParse']).toBeFalsy(); + expect(control.value).toEqual(createTime(12, 10)); + })); + + it('should set an error if the user enters a time earlier than the minimum', fakeAsync(() => { + const fixture = TestBed.createComponent(TimepickerWithForms); + const control = fixture.componentInstance.control; + const input = getInput(fixture); + fixture.componentInstance.min.set(createTime(12, 0)); + fixture.detectChanges(); + + // No value initially so no error either. + expect(control.errors?.['matTimepickerMin']).toBeFalsy(); + expect(control.value).toBe(null); + + // Entire a value that is before the minimum. + typeInElement(input, '11:59 AM'); + fixture.detectChanges(); + expect(control.errors?.['matTimepickerMin']).toBeTruthy(); + expect(control.value).toEqual(createTime(11, 59)); + + // Change the minimum so the value becomes valid. + fixture.componentInstance.min.set(createTime(11, 0)); + fixture.detectChanges(); + expect(control.errors?.['matTimepickerMin']).toBeFalsy(); + })); + + it('should set an error if the user enters a time later than the maximum', fakeAsync(() => { + const fixture = TestBed.createComponent(TimepickerWithForms); + const control = fixture.componentInstance.control; + const input = getInput(fixture); + fixture.componentInstance.max.set(createTime(12, 0)); + fixture.detectChanges(); + + // No value initially so no error either. + expect(control.errors?.['matTimepickerMax']).toBeFalsy(); + expect(control.value).toBe(null); + + // Entire a value that is after the maximum. + typeInElement(input, '12:01 PM'); + fixture.detectChanges(); + expect(control.errors?.['matTimepickerMax']).toBeTruthy(); + expect(control.value).toEqual(createTime(12, 1)); + + // Change the maximum so the value becomes valid. + fixture.componentInstance.max.set(createTime(13, 0)); + fixture.detectChanges(); + expect(control.errors?.['matTimepickerMax']).toBeFalsy(); + })); + + it('should mark the input as disabled when the form control is disabled', () => { + const fixture = TestBed.createComponent(TimepickerWithForms); + const input = getInput(fixture); + fixture.detectChanges(); + expect(input.disabled).toBe(false); + expect(fixture.componentInstance.input.disabled()).toBe(false); + + fixture.componentInstance.control.disable(); + fixture.detectChanges(); + expect(input.disabled).toBe(true); + expect(fixture.componentInstance.input.disabled()).toBe(true); + }); + }); + + describe('timepicker toggle', () => { + it('should open the timepicker when clicking the toggle', () => { + const fixture = TestBed.createComponent(StandaloneTimepicker); + fixture.detectChanges(); + expect(getPanel()).toBeFalsy(); + + getToggle(fixture).click(); + fixture.detectChanges(); + expect(getPanel()).toBeTruthy(); + }); + + it('should set the correct ARIA attributes on the toggle', () => { + const fixture = TestBed.createComponent(StandaloneTimepicker); + const toggle = getToggle(fixture); + fixture.detectChanges(); + + expect(toggle.getAttribute('aria-haspopup')).toBe('listbox'); + expect(toggle.getAttribute('aria-expanded')).toBe('false'); + + toggle.click(); + fixture.detectChanges(); + expect(toggle.getAttribute('aria-expanded')).toBe('true'); + }); + + it('should be able to set aria-label on the button', () => { + const fixture = TestBed.createComponent(StandaloneTimepicker); + const toggle = getToggle(fixture); + fixture.detectChanges(); + expect(toggle.hasAttribute('aria-label')).toBe(false); + + fixture.componentInstance.toggleAriaLabel.set('Toggle the timepicker'); + fixture.detectChanges(); + expect(toggle.getAttribute('aria-label')).toBe('Toggle the timepicker'); + }); + + it('should be able to set the tabindex on the toggle', () => { + const fixture = TestBed.createComponent(StandaloneTimepicker); + const toggle = getToggle(fixture); + fixture.detectChanges(); + expect(toggle.getAttribute('tabindex')).toBe('0'); + + fixture.componentInstance.toggleTabIndex.set(1); + fixture.detectChanges(); + expect(toggle.getAttribute('tabindex')).toBe('1'); + }); + + it('should be able to set the disabled state on the toggle', () => { + const fixture = TestBed.createComponent(StandaloneTimepicker); + const toggle = getToggle(fixture); + fixture.detectChanges(); + expect(toggle.disabled).toBe(false); + expect(toggle.getAttribute('tabindex')).toBe('0'); + + fixture.componentInstance.toggleDisabled.set(true); + fixture.detectChanges(); + expect(toggle.disabled).toBe(true); + expect(toggle.getAttribute('tabindex')).toBe('-1'); + }); + + it('should not open the timepicker on click if the toggle is disabled', () => { + const fixture = TestBed.createComponent(StandaloneTimepicker); + fixture.componentInstance.toggleDisabled.set(true); + fixture.detectChanges(); + getToggle(fixture).click(); + fixture.detectChanges(); + expect(getPanel()).toBeFalsy(); + }); + }); + + describe('global defaults', () => { + beforeEach(() => TestBed.resetTestingModule()); + + it('should be able to set the default inverval through DI', () => { + configureTestingModule([ + { + provide: MAT_TIMEPICKER_CONFIG, + useValue: {interval: '9h'}, + }, + ]); + + const fixture = TestBed.createComponent(TimepickerInFormField); + fixture.detectChanges(); + expect(fixture.componentInstance.timepicker.interval()).toBe(9 * 60 * 60); + }); + + it('should be able to set the default disableRipple value through DI', () => { + configureTestingModule([ + { + provide: MAT_TIMEPICKER_CONFIG, + useValue: {disableRipple: true}, + }, + ]); + + const fixture = TestBed.createComponent(TimepickerInFormField); + fixture.detectChanges(); + expect(fixture.componentInstance.timepicker.disableRipple()).toBe(true); + expect(fixture.componentInstance.toggle.disableRipple()).toBe(true); + }); + }); + + function configureTestingModule(additionalProviders: Provider[] = []): void { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule], + providers: [provideNativeDateAdapter(), ...additionalProviders], + }); + adapter = TestBed.inject(DateAdapter); + adapter.setLocale('en-US'); + } + + function getInput(fixture: ComponentFixture): HTMLInputElement { + return fixture.nativeElement.querySelector('.mat-timepicker-input'); + } + + function getPanel(): HTMLElement { + return document.querySelector('.mat-timepicker-panel')!; + } + + function getOptions(): HTMLElement[] { + const panel = getPanel(); + return panel ? Array.from(panel.querySelectorAll('mat-option')) : []; + } + + function createTime(hours: number, minutes: number): Date { + return adapter.setTime(adapter.today(), hours, minutes, 0); + } + + function getActiveOptionIndex(): number { + return getOptions().findIndex(o => o.classList.contains('mat-mdc-option-active')); + } + + function getToggle(fixture: ComponentFixture): HTMLButtonElement { + return fixture.nativeElement.querySelector('mat-timepicker-toggle button'); + } }); +@Component({ + template: ` + + + + `, + standalone: true, + imports: [MatTimepicker, MatTimepickerInput, MatTimepickerToggle], +}) +class StandaloneTimepicker { + @ViewChild(MatTimepickerInput) input: MatTimepickerInput; + @ViewChild(MatTimepicker) timepicker: MatTimepicker; + readonly value = signal(null); + readonly disabled = signal(false); + readonly interval = signal(null); + readonly min = signal(null); + readonly max = signal(null); + readonly ariaLabel = signal(null); + readonly ariaLabelledby = signal(null); + readonly toggleAriaLabel = signal(null); + readonly toggleDisabled = signal(false); + readonly toggleTabIndex = signal(0); + readonly customOptions = signal[] | null>(null); + readonly openedSpy = jasmine.createSpy('opened'); + readonly closedSpy = jasmine.createSpy('closed'); + readonly selectedSpy = jasmine.createSpy('selected'); +} + +@Component({ + template: ` + + Pick a time + + + + + `, + standalone: true, + imports: [ + MatTimepicker, + MatTimepickerInput, + MatTimepickerToggle, + MatInput, + MatLabel, + MatFormField, + MatSuffix, + ], +}) +class TimepickerInFormField { + @ViewChild(MatTimepicker) timepicker: MatTimepicker; + @ViewChild(MatTimepickerToggle) toggle: MatTimepickerToggle; +} + +@Component({ + template: ` + + + `, + standalone: true, + imports: [MatTimepicker, MatTimepickerInput], +}) +class TimepickerTwoWayBinding { + @ViewChild(MatTimepickerInput) input: MatTimepickerInput; + readonly value = signal(new Date(2024, 0, 15, 10, 30, 0)); +} + +@Component({ + template: ` + + + `, + standalone: true, + imports: [MatTimepicker, MatTimepickerInput, ReactiveFormsModule], +}) +class TimepickerWithForms { + @ViewChild(MatTimepickerInput) input: MatTimepickerInput; + readonly control = new FormControl(null, [Validators.required]); + readonly min = signal(null); + readonly max = signal(null); +} + +@Component({ + template: ` + + + + `, + standalone: true, + imports: [MatTimepicker, MatTimepickerInput], +}) +class TimepickerWithMultipleInputs {} + @Component({ template: '', standalone: true, imports: [MatTimepicker], }) -class BasicTimepicker {} +class TimepickerWithoutInput { + @ViewChild(MatTimepicker) timepicker: MatTimepicker; +} diff --git a/src/material/timepicker/timepicker.ts b/src/material/timepicker/timepicker.ts index 4b115bcaac95..3eb1260ddfc6 100644 --- a/src/material/timepicker/timepicker.ts +++ b/src/material/timepicker/timepicker.ts @@ -6,18 +6,446 @@ * found in the LICENSE file at https://angular.dev/license */ -import {ChangeDetectionStrategy, Component, ViewEncapsulation} from '@angular/core'; +import { + afterNextRender, + AfterRenderRef, + booleanAttribute, + ChangeDetectionStrategy, + Component, + effect, + ElementRef, + inject, + Injector, + input, + InputSignal, + InputSignalWithTransform, + OnDestroy, + output, + OutputEmitterRef, + Signal, + signal, + TemplateRef, + untracked, + viewChild, + viewChildren, + ViewContainerRef, + ViewEncapsulation, +} from '@angular/core'; +import {animate, group, state, style, transition, trigger} from '@angular/animations'; +import { + DateAdapter, + MAT_DATE_FORMATS, + MAT_OPTION_PARENT_COMPONENT, + MatOption, + MatOptionParentComponent, +} from '@angular/material/core'; +import {Directionality} from '@angular/cdk/bidi'; +import {Overlay, OverlayRef} from '@angular/cdk/overlay'; +import {TemplatePortal} from '@angular/cdk/portal'; +import {_getEventTarget} from '@angular/cdk/platform'; +import {ENTER, ESCAPE, hasModifierKey, TAB} from '@angular/cdk/keycodes'; +import {ActiveDescendantKeyManager} from '@angular/cdk/a11y'; +import type {MatTimepickerInput} from './timepicker-input'; +import { + generateOptions, + MAT_TIMEPICKER_CONFIG, + MatTimepickerOption, + parseInterval, + validateAdapter, +} from './util'; +import {Subscription} from 'rxjs'; +/** Counter used to generate unique IDs. */ +let uniqueId = 0; + +/** Event emitted when a value is selected in the timepicker. */ +export interface MatTimepickerSelected { + value: D; + source: MatTimepicker; +} + +/** + * Renders out a listbox that can be used to select a time of day. + * Intended to be used together with `MatTimepickerInput`. + */ @Component({ selector: 'mat-timepicker', exportAs: 'matTimepicker', templateUrl: 'timepicker.html', styleUrl: 'timepicker.css', - host: { - 'class': 'mat-timepicker', - }, changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, standalone: true, + imports: [MatOption], + providers: [ + { + provide: MAT_OPTION_PARENT_COMPONENT, + useExisting: MatTimepicker, + }, + ], + animations: [ + trigger('panel', [ + state('void', style({opacity: 0, transform: 'scaleY(0.8)'})), + transition(':enter', [ + group([ + animate('0.03s linear', style({opacity: 1})), + animate('0.12s cubic-bezier(0, 0, 0.2, 1)', style({transform: 'scaleY(1)'})), + ]), + ]), + transition(':leave', [animate('0.075s linear', style({opacity: 0}))]), + ]), + ], }) -export class MatTimepicker {} +export class MatTimepicker implements OnDestroy, MatOptionParentComponent { + private _overlay = inject(Overlay); + private _dir = inject(Directionality, {optional: true}); + private _viewContainerRef = inject(ViewContainerRef); + private _injector = inject(Injector); + private _defaultConfig = inject(MAT_TIMEPICKER_CONFIG, {optional: true}); + private _dateAdapter = inject>(DateAdapter, {optional: true})!; + private _dateFormats = inject(MAT_DATE_FORMATS, {optional: true})!; + + private _isOpen = signal(false); + private _activeDescendant = signal(null); + + private _input: MatTimepickerInput; + private _overlayRef: OverlayRef | null = null; + private _portal: TemplatePortal | null = null; + private _optionsCacheKey: string | null = null; + private _localeChanges: Subscription; + private _onOpenRender: AfterRenderRef | null = null; + + protected _panelTemplate = viewChild.required>('panelTemplate'); + protected _timeOptions: readonly MatTimepickerOption[] = []; + protected _options = viewChildren(MatOption); + + private _keyManager = new ActiveDescendantKeyManager(this._options, this._injector) + .withHomeAndEnd(true) + .withPageUpDown(true) + .withVerticalOrientation(true); + + /** + * Interval between each option in the timepicker. The value can either be an amount of + * seconds (e.g. 90) or a number with a unit (e.g. 45m). Supported units are `s` for seconds, + * `m` for minutes or `h` for hours. + */ + readonly interval: InputSignalWithTransform = input( + parseInterval(this._defaultConfig?.interval || null), + {transform: parseInterval}, + ); + + /** + * Array of pre-defined options that the user can select from, as an alternative to using the + * `interval` input. An error will be thrown if both `options` and `interval` are specified. + */ + readonly options: InputSignal[] | null> = input< + readonly MatTimepickerOption[] | null + >(null); + + /** Whether the timepicker is open. */ + readonly isOpen: Signal = this._isOpen.asReadonly(); + + /** Emits when the user selects a time. */ + readonly selected: OutputEmitterRef> = output(); + + /** Emits when the timepicker is opened. */ + readonly opened: OutputEmitterRef = output(); + + /** Emits when the timepicker is closed. */ + readonly closed: OutputEmitterRef = output(); + + /** ID of the active descendant option. */ + readonly activeDescendant: Signal = this._activeDescendant.asReadonly(); + + /** Unique ID of the timepicker's panel */ + readonly panelId = `mat-timepicker-panel-${uniqueId++}`; + + /** Whether ripples within the timepicker should be disabled. */ + readonly disableRipple: InputSignalWithTransform = input( + this._defaultConfig?.disableRipple ?? false, + { + transform: booleanAttribute, + }, + ); + + /** ARIA label for the timepicker panel. */ + readonly ariaLabel: InputSignal = input(null, { + alias: 'aria-label', + }); + + /** ID of the label element for the timepicker panel. */ + readonly ariaLabelledby: InputSignal = input(null, { + alias: 'aria-labelledby', + }); + + constructor() { + if (typeof ngDevMode === 'undefined' || ngDevMode) { + validateAdapter(this._dateAdapter, this._dateFormats); + + effect(() => { + const options = this.options(); + const interval = this.interval(); + + if (options !== null && interval !== null) { + throw new Error( + 'Cannot specify both the `options` and `interval` inputs at the same time', + ); + } else if (options?.length === 0) { + throw new Error('Value of `options` input cannot be an empty array'); + } + }); + } + + // Since the panel ID is static, we can set it once without having to maintain a host binding. + const element = inject>(ElementRef); + element.nativeElement.setAttribute('mat-timepicker-panel-id', this.panelId); + this._handleLocaleChanges(); + this._handleInputStateChanges(); + this._keyManager.change.subscribe(() => + this._activeDescendant.set(this._keyManager.activeItem?.id || null), + ); + } + + /** Opens the timepicker. */ + open(): void { + if (!this._input || this._isOpen()) { + return; + } + + this._generateOptions(); + const overlayRef = this._getOverlayRef(); + overlayRef.updateSize({width: this._input.getOverlayOrigin().nativeElement.offsetWidth}); + this._portal ??= new TemplatePortal(this._panelTemplate(), this._viewContainerRef); + overlayRef.attach(this._portal); + this._isOpen.set(true); + + // Focus should already be on the input, but this call is + // in case the timepicker is opened programmatically. + this._input.focus(); + this._onOpenRender?.destroy(); + this._onOpenRender = afterNextRender( + () => { + const options = this._options(); + this._syncSelectedState(this._input.value(), options, options[0]); + this._onOpenRender = null; + }, + {injector: this._injector}, + ); + + this.opened.emit(); + } + + /** Closes the timepicker. */ + close(): void { + if (this._isOpen()) { + this._isOpen.set(false); + this._overlayRef?.detach(); + this.closed.emit(); + } + } + + /** Registers an input with the timepicker. */ + registerInput(input: MatTimepickerInput): void { + if (this._input && input !== this._input && (typeof ngDevMode === 'undefined' || ngDevMode)) { + throw new Error('MatTimepicker can only be registered with one input at a time'); + } + + this._input = input; + } + + ngOnDestroy(): void { + this._keyManager.destroy(); + this._localeChanges.unsubscribe(); + this._onOpenRender?.destroy(); + this._overlayRef?.dispose(); + } + + /** Selects a specific time value. */ + protected _selectValue(value: D) { + this.close(); + this.selected.emit({value, source: this}); + this._input.focus(); + } + + /** Gets the value of the `aria-labelledby` attribute. */ + protected _getAriaLabelledby(): string | null { + if (this.ariaLabel()) { + return null; + } + return this.ariaLabelledby() || this._input?._getLabelId() || null; + } + + /** Creates an overlay reference for the timepicker panel. */ + private _getOverlayRef(): OverlayRef { + if (this._overlayRef) { + return this._overlayRef; + } + + const positionStrategy = this._overlay + .position() + .flexibleConnectedTo(this._input.getOverlayOrigin()) + .withFlexibleDimensions(false) + .withPush(false) + .withTransformOriginOn('.mat-timepicker-panel') + .withPositions([ + { + originX: 'start', + originY: 'bottom', + overlayX: 'start', + overlayY: 'top', + }, + { + originX: 'start', + originY: 'top', + overlayX: 'start', + overlayY: 'bottom', + panelClass: 'mat-timepicker-above', + }, + ]); + + this._overlayRef = this._overlay.create({ + positionStrategy, + scrollStrategy: this._overlay.scrollStrategies.reposition(), + direction: this._dir || 'ltr', + hasBackdrop: false, + }); + + this._overlayRef.keydownEvents().subscribe(event => { + this._handleKeydown(event); + }); + + this._overlayRef.outsidePointerEvents().subscribe(event => { + const target = _getEventTarget(event) as HTMLElement; + const origin = this._input.getOverlayOrigin().nativeElement; + + if (target && target !== origin && !origin.contains(target)) { + this.close(); + } + }); + + return this._overlayRef; + } + + /** Generates the list of options from which the user can select.. */ + private _generateOptions(): void { + // Default the interval to 30 minutes. + const interval = this.interval() ?? 30 * 60; + const options = this.options(); + + if (options !== null) { + this._timeOptions = options; + } else { + const adapter = this._dateAdapter; + const timeFormat = this._dateFormats.display.timeInput; + const min = this._input.min() || adapter.setTime(adapter.today(), 0, 0, 0); + const max = this._input.max() || adapter.setTime(adapter.today(), 23, 59, 0); + const cacheKey = + interval + '/' + adapter.format(min, timeFormat) + '/' + adapter.format(max, timeFormat); + + // Don't re-generate the options if the inputs haven't changed. + if (cacheKey !== this._optionsCacheKey) { + this._optionsCacheKey = cacheKey; + this._timeOptions = generateOptions(adapter, this._dateFormats, min, max, interval); + } + } + } + + /** + * Synchronizes the internal state of the component based on a specific selected date. + * @param value Currently selected date. + * @param options Options rendered out in the timepicker. + * @param fallback Option to set as active if no option is selected. + */ + private _syncSelectedState( + value: D | null, + options: readonly MatOption[], + fallback: MatOption | null, + ): void { + let hasSelected = false; + + for (const option of options) { + if (value && this._dateAdapter.sameTime(option.value, value)) { + option.select(false); + scrollOptionIntoView(option, 'center'); + untracked(() => this._keyManager.setActiveItem(option)); + hasSelected = true; + } else { + option.deselect(false); + } + } + + // If no option was selected, we need to reset the key manager since + // it might be holding onto an option that no longer exists. + if (!hasSelected) { + if (fallback) { + untracked(() => this._keyManager.setActiveItem(fallback)); + scrollOptionIntoView(fallback, 'center'); + } else { + untracked(() => this._keyManager.setActiveItem(-1)); + } + } + } + + /** Handles keyboard events while the overlay is open. */ + private _handleKeydown(event: KeyboardEvent): void { + const keyCode = event.keyCode; + + if (keyCode === TAB) { + this.close(); + } else if (keyCode === ESCAPE && !hasModifierKey(event)) { + event.preventDefault(); + this.close(); + } else if (keyCode === ENTER) { + event.preventDefault(); + + if (this._keyManager.activeItem) { + this._selectValue(this._keyManager.activeItem.value); + } else { + this.close(); + } + } else { + const previousActive = this._keyManager.activeItem; + this._keyManager.onKeydown(event); + const currentActive = this._keyManager.activeItem; + + if (currentActive && currentActive !== previousActive) { + scrollOptionIntoView(currentActive, 'nearest'); + } + } + } + + /** Sets up the logic that updates the timepicker when the locale changes. */ + private _handleLocaleChanges(): void { + // Re-generate the options list if the locale changes. + this._localeChanges = this._dateAdapter.localeChanges.subscribe(() => { + this._optionsCacheKey = null; + + if (this.isOpen()) { + this._generateOptions(); + } + }); + } + + /** + * Sets up the logic that updates the timepicker when the state of the connected input changes. + */ + private _handleInputStateChanges(): void { + effect(() => { + const value = this._input?.value(); + const options = this._options(); + + if (this._isOpen()) { + this._syncSelectedState(value, options, null); + } + }); + } +} + +/** + * Scrolls an option into view. + * @param option Option to be scrolled into view. + * @param position Position to which to align the option relative to the scrollable container. + */ +function scrollOptionIntoView(option: MatOption, position: ScrollLogicalPosition) { + option._getHostElement().scrollIntoView({block: position, inline: position}); +} diff --git a/src/material/timepicker/util.ts b/src/material/timepicker/util.ts index 26bbca1b7621..7a4135950160 100644 --- a/src/material/timepicker/util.ts +++ b/src/material/timepicker/util.ts @@ -6,11 +6,30 @@ * found in the LICENSE file at https://angular.dev/license */ +import {InjectionToken} from '@angular/core'; import {DateAdapter, MatDateFormats} from '@angular/material/core'; /** Pattern that interval strings have to match. */ const INTERVAL_PATTERN = /^(\d*\.?\d+)(h|m|s)?$/i; +/** + * Object that can be used to configure the default options for the timepicker component. + */ +export interface MatTimepickerConfig { + /** Default interval for all time pickers. */ + interval?: string | number; + + /** Whether ripples inside the timepicker should be disabled by default. */ + disableRipple?: boolean; +} + +/** + * Injection token that can be used to configure the default options for the timepicker component. + */ +export const MAT_TIMEPICKER_CONFIG = new InjectionToken( + 'MAT_TIMEPICKER_CONFIG', +); + /** * Time selection option that can be displayed within a `mat-timepicker`. */ @@ -84,3 +103,37 @@ export function generateOptions( return options; } + +/** Checks whether a date adapter is set up correctly for use with the timepicker. */ +export function validateAdapter( + adapter: DateAdapter | null, + formats: MatDateFormats | null, +) { + function missingAdapterError(provider: string) { + return Error( + `MatTimepicker: No provider found for ${provider}. You must add one of the following ` + + `to your app config: provideNativeDateAdapter, provideDateFnsAdapter, ` + + `provideLuxonDateAdapter, provideMomentDateAdapter, or provide a custom implementation.`, + ); + } + + if (!adapter) { + throw missingAdapterError('DateAdapter'); + } + + if (!formats) { + throw missingAdapterError('MAT_DATE_FORMATS'); + } + + if ( + formats.display.timeInput === undefined || + formats.display.timeOptionLabel === undefined || + formats.parse.timeInput === undefined + ) { + throw new Error( + 'MatTimepicker: Incomplete `MAT_DATE_FORMATS` has been provided. ' + + '`MAT_DATE_FORMATS` must provide `display.timeInput`, `display.timeOptionLabel` ' + + 'and `parse.timeInput` formats in order to be compatible with MatTimepicker.', + ); + } +} diff --git a/tools/public_api_guard/material/timepicker.md b/tools/public_api_guard/material/timepicker.md new file mode 100644 index 000000000000..c30900da874a --- /dev/null +++ b/tools/public_api_guard/material/timepicker.md @@ -0,0 +1,139 @@ +## API Report File for "components-srcs" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { AbstractControl } from '@angular/forms'; +import { ControlValueAccessor } from '@angular/forms'; +import { ElementRef } from '@angular/core'; +import * as i0 from '@angular/core'; +import * as i4 from '@angular/cdk/scrolling'; +import { InjectionToken } from '@angular/core'; +import { InputSignal } from '@angular/core'; +import { InputSignalWithTransform } from '@angular/core'; +import { MatOption } from '@angular/material/core'; +import { MatOptionParentComponent } from '@angular/material/core'; +import { ModelSignal } from '@angular/core'; +import { OnDestroy } from '@angular/core'; +import { OutputEmitterRef } from '@angular/core'; +import { Signal } from '@angular/core'; +import { TemplateRef } from '@angular/core'; +import { ValidationErrors } from '@angular/forms'; +import { Validator } from '@angular/forms'; + +// @public +export const MAT_TIMEPICKER_CONFIG: InjectionToken; + +// @public +export class MatTimepicker implements OnDestroy, MatOptionParentComponent { + constructor(); + readonly activeDescendant: Signal; + readonly ariaLabel: InputSignal; + readonly ariaLabelledby: InputSignal; + close(): void; + readonly closed: OutputEmitterRef; + readonly disableRipple: InputSignalWithTransform; + protected _getAriaLabelledby(): string | null; + readonly interval: InputSignalWithTransform; + readonly isOpen: Signal; + // (undocumented) + ngOnDestroy(): void; + open(): void; + readonly opened: OutputEmitterRef; + readonly options: InputSignal[] | null>; + // (undocumented) + protected _options: Signal[]>; + readonly panelId: string; + // (undocumented) + protected _panelTemplate: Signal>; + registerInput(input: MatTimepickerInput): void; + readonly selected: OutputEmitterRef>; + protected _selectValue(value: D): void; + // (undocumented) + protected _timeOptions: readonly MatTimepickerOption[]; + // (undocumented) + static ɵcmp: i0.ɵɵComponentDeclaration, "mat-timepicker", ["matTimepicker"], { "interval": { "alias": "interval"; "required": false; "isSignal": true; }; "options": { "alias": "options"; "required": false; "isSignal": true; }; "disableRipple": { "alias": "disableRipple"; "required": false; "isSignal": true; }; "ariaLabel": { "alias": "aria-label"; "required": false; "isSignal": true; }; "ariaLabelledby": { "alias": "aria-labelledby"; "required": false; "isSignal": true; }; }, { "selected": "selected"; "opened": "opened"; "closed": "closed"; }, never, never, true, never>; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration, never>; +} + +// @public +export interface MatTimepickerConfig { + disableRipple?: boolean; + interval?: string | number; +} + +// @public +export class MatTimepickerInput implements ControlValueAccessor, Validator, OnDestroy { + constructor(); + protected readonly _ariaActiveDescendant: Signal; + protected readonly _ariaControls: Signal; + protected readonly _ariaExpanded: Signal; + readonly disabled: Signal; + protected readonly disabledInput: InputSignalWithTransform; + focus(): void; + _getLabelId(): string | null; + getOverlayOrigin(): ElementRef; + protected _handleBlur(): void; + protected _handleInput(value: string): void; + protected _handleKeydown(event: KeyboardEvent): void; + readonly max: InputSignalWithTransform; + readonly min: InputSignalWithTransform; + // (undocumented) + ngOnDestroy(): void; + registerOnChange(fn: (value: any) => void): void; + registerOnTouched(fn: () => void): void; + registerOnValidatorChange(fn: () => void): void; + setDisabledState(isDisabled: boolean): void; + readonly timepicker: InputSignal>; + validate(control: AbstractControl): ValidationErrors | null; + readonly value: ModelSignal; + writeValue(value: any): void; + // (undocumented) + static ɵdir: i0.ɵɵDirectiveDeclaration, "input[matTimepicker]", ["matTimepickerInput"], { "value": { "alias": "value"; "required": false; "isSignal": true; }; "timepicker": { "alias": "matTimepicker"; "required": true; "isSignal": true; }; "min": { "alias": "matTimepickerMin"; "required": false; "isSignal": true; }; "max": { "alias": "matTimepickerMax"; "required": false; "isSignal": true; }; "disabledInput": { "alias": "disabled"; "required": false; "isSignal": true; }; }, { "value": "valueChange"; }, never, never, true, never>; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration, never>; +} + +// @public (undocumented) +export class MatTimepickerModule { + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration; + // (undocumented) + static ɵinj: i0.ɵɵInjectorDeclaration; + // (undocumented) + static ɵmod: i0.ɵɵNgModuleDeclaration; +} + +// @public +export interface MatTimepickerOption { + label: string; + value: D; +} + +// @public +export interface MatTimepickerSelected { + // (undocumented) + source: MatTimepicker; + // (undocumented) + value: D; +} + +// @public +export class MatTimepickerToggle { + readonly ariaLabel: InputSignal; + readonly disabled: InputSignalWithTransform; + readonly disableRipple: InputSignalWithTransform; + protected _open(event: Event): void; + readonly tabIndex: InputSignal; + readonly timepicker: InputSignal>; + // (undocumented) + static ɵcmp: i0.ɵɵComponentDeclaration, "mat-timepicker-toggle", ["matTimepickerToggle"], { "timepicker": { "alias": "for"; "required": true; "isSignal": true; }; "ariaLabel": { "alias": "aria-label"; "required": false; "isSignal": true; }; "disabled": { "alias": "disabled"; "required": false; "isSignal": true; }; "tabIndex": { "alias": "tabIndex"; "required": false; "isSignal": true; }; "disableRipple": { "alias": "disableRipple"; "required": false; "isSignal": true; }; }, {}, never, ["[matTimepickerToggleIcon]"], true, never>; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration, never>; +} + +// (No @packageDocumentation comment for this package) + +``` From d33d3d9e2f48ac4f4791240ea1a89a1868b66353 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Tue, 1 Oct 2024 10:31:02 +0200 Subject: [PATCH 06/13] feat(material/timepicker): add test harnesses Adds test harnesses for `MatTimepickerInput`, `MatTimepicker` and `MatTimepickerToggle`. --- src/material/timepicker/testing/BUILD.bazel | 3 + src/material/timepicker/testing/public-api.ts | 2 + .../testing/timepicker-harness-filters.ts | 11 ++ .../testing/timepicker-harness.spec.ts | 61 +++++- .../timepicker/testing/timepicker-harness.ts | 60 +++++- .../testing/timepicker-input-harness.spec.ts | 181 ++++++++++++++++++ .../testing/timepicker-input-harness.ts | 146 ++++++++++++++ .../testing/timepicker-toggle-harness.spec.ts | 64 +++++++ .../testing/timepicker-toggle-harness.ts | 54 ++++++ .../material/timepicker-testing.md | 70 +++++++ 10 files changed, 635 insertions(+), 17 deletions(-) create mode 100644 src/material/timepicker/testing/timepicker-input-harness.spec.ts create mode 100644 src/material/timepicker/testing/timepicker-input-harness.ts create mode 100644 src/material/timepicker/testing/timepicker-toggle-harness.spec.ts create mode 100644 src/material/timepicker/testing/timepicker-toggle-harness.ts create mode 100644 tools/public_api_guard/material/timepicker-testing.md diff --git a/src/material/timepicker/testing/BUILD.bazel b/src/material/timepicker/testing/BUILD.bazel index bd293e9cb214..200894a54557 100644 --- a/src/material/timepicker/testing/BUILD.bazel +++ b/src/material/timepicker/testing/BUILD.bazel @@ -9,7 +9,9 @@ ts_library( exclude = ["**/*.spec.ts"], ), deps = [ + "//src/cdk/coercion", "//src/cdk/testing", + "//src/material/core/testing", "//src/material/timepicker", ], ) @@ -27,6 +29,7 @@ ng_test_library( "//src/cdk/testing", "//src/cdk/testing/private", "//src/cdk/testing/testbed", + "//src/material/core", "//src/material/timepicker", "@npm//@angular/platform-browser", ], diff --git a/src/material/timepicker/testing/public-api.ts b/src/material/timepicker/testing/public-api.ts index 466ff114decc..3aa001551b7b 100644 --- a/src/material/timepicker/testing/public-api.ts +++ b/src/material/timepicker/testing/public-api.ts @@ -8,3 +8,5 @@ export * from './timepicker-harness'; export * from './timepicker-harness-filters'; +export * from './timepicker-input-harness'; +export * from './timepicker-toggle-harness'; diff --git a/src/material/timepicker/testing/timepicker-harness-filters.ts b/src/material/timepicker/testing/timepicker-harness-filters.ts index 32f174cec78f..0b34e2bdecc0 100644 --- a/src/material/timepicker/testing/timepicker-harness-filters.ts +++ b/src/material/timepicker/testing/timepicker-harness-filters.ts @@ -10,3 +10,14 @@ import {BaseHarnessFilters} from '@angular/cdk/testing'; /** A set of criteria that can be used to filter a list of `MatTimepickerHarness` instances. */ export interface TimepickerHarnessFilters extends BaseHarnessFilters {} + +/** A set of criteria that can be used to filter a list of timepicker input instances. */ +export interface TimepickerInputHarnessFilters extends BaseHarnessFilters { + /** Filters based on the value of the input. */ + value?: string | RegExp; + /** Filters based on the placeholder text of the input. */ + placeholder?: string | RegExp; +} + +/** A set of criteria that can be used to filter a list of timepicker toggle instances. */ +export interface TimepickerToggleHarnessFilters extends BaseHarnessFilters {} diff --git a/src/material/timepicker/testing/timepicker-harness.spec.ts b/src/material/timepicker/testing/timepicker-harness.spec.ts index 5e66176b5b68..bc670144dc6c 100644 --- a/src/material/timepicker/testing/timepicker-harness.spec.ts +++ b/src/material/timepicker/testing/timepicker-harness.spec.ts @@ -1,20 +1,25 @@ -import {Component} from '@angular/core'; +import {Component, signal} from '@angular/core'; import {ComponentFixture, TestBed} from '@angular/core/testing'; -import {HarnessLoader} from '@angular/cdk/testing'; +import {HarnessLoader, parallel} from '@angular/cdk/testing'; +import {DateAdapter, provideNativeDateAdapter} from '@angular/material/core'; import {TestbedHarnessEnvironment} from '@angular/cdk/testing/testbed'; import {NoopAnimationsModule} from '@angular/platform-browser/animations'; -import {MatTimepicker} from '@angular/material/timepicker'; +import {MatTimepicker, MatTimepickerInput} from '@angular/material/timepicker'; import {MatTimepickerHarness} from './timepicker-harness'; +import {MatTimepickerInputHarness} from './timepicker-input-harness'; -describe('MatTimepicker', () => { +describe('MatTimepickerHarness', () => { let fixture: ComponentFixture; let loader: HarnessLoader; beforeEach(() => { TestBed.configureTestingModule({ + providers: [provideNativeDateAdapter()], imports: [NoopAnimationsModule, TimepickerHarnessTest], }); + const adapter = TestBed.inject(DateAdapter); + adapter.setLocale('en-US'); fixture = TestBed.createComponent(TimepickerHarnessTest); fixture.detectChanges(); loader = TestbedHarnessEnvironment.documentRootLoader(fixture); @@ -24,14 +29,54 @@ describe('MatTimepicker', () => { const harnesses = await loader.getAllHarnesses(MatTimepickerHarness); expect(harnesses.length).toBe(2); }); + + it('should get the open state of a timepicker', async () => { + const input = await loader.getHarness(MatTimepickerInputHarness.with({selector: '#one'})); + const timepicker = await input.getTimepicker(); + expect(await timepicker.isOpen()).toBe(false); + + await input.openTimepicker(); + expect(await timepicker.isOpen()).toBe(true); + }); + + it('should throw when trying to get the options while closed', async () => { + const input = await loader.getHarness(MatTimepickerInputHarness.with({selector: '#one'})); + const timepicker = await input.getTimepicker(); + + await expectAsync(timepicker.getOptions()).toBeRejectedWithError( + /Unable to retrieve options for timepicker\. Timepicker panel is closed\./, + ); + }); + + it('should get the options in a timepicker', async () => { + const input = await loader.getHarness(MatTimepickerInputHarness.with({selector: '#one'})); + const timepicker = await input.openTimepicker(); + const options = await timepicker.getOptions(); + const labels = await parallel(() => options.map(o => o.getText())); + expect(labels).toEqual(['12:00 AM', '4:00 AM', '8:00 AM', '12:00 PM', '4:00 PM', '8:00 PM']); + }); + + it('should be able to select an option', async () => { + const input = await loader.getHarness(MatTimepickerInputHarness.with({selector: '#one'})); + const timepicker = await input.openTimepicker(); + expect(await input.getValue()).toBe(''); + + await timepicker.selectOption({text: '4:00 PM'}); + expect(await input.getValue()).toBe('4:00 PM'); + expect(await timepicker.isOpen()).toBe(false); + }); }); @Component({ template: ` - - + + + + `, standalone: true, - imports: [MatTimepicker], + imports: [MatTimepickerInput, MatTimepicker], }) -class TimepickerHarnessTest {} +class TimepickerHarnessTest { + interval = signal('4h'); +} diff --git a/src/material/timepicker/testing/timepicker-harness.ts b/src/material/timepicker/testing/timepicker-harness.ts index 3e127a497033..06de38c22075 100644 --- a/src/material/timepicker/testing/timepicker-harness.ts +++ b/src/material/timepicker/testing/timepicker-harness.ts @@ -6,21 +6,63 @@ * found in the LICENSE file at https://angular.dev/license */ -import {ComponentHarness, HarnessPredicate} from '@angular/cdk/testing'; +import { + ComponentHarness, + ComponentHarnessConstructor, + HarnessPredicate, +} from '@angular/cdk/testing'; +import {MatOptionHarness, OptionHarnessFilters} from '@angular/material/core/testing'; import {TimepickerHarnessFilters} from './timepicker-harness-filters'; -/** Harness for interacting with a standard `MatTimepicker` in tests. */ export class MatTimepickerHarness extends ComponentHarness { - /** The selector for the host element of a `MatTimepicker` instance. */ - static hostSelector = '.mat-timepicker'; + private _documentRootLocator = this.documentRootLocatorFactory(); + static hostSelector = 'mat-timepicker'; /** - * Gets a `HarnessPredicate` that can be used to search for a `MatTimepicker` - * that meets certain criteria. - * @param options Options for filtering which dialog instances are considered a match. + * Gets a `HarnessPredicate` that can be used to search for a timepicker with specific + * attributes. + * @param options Options for filtering which timepicker instances are considered a match. * @return a `HarnessPredicate` configured with the given options. */ - static with(options: TimepickerHarnessFilters = {}): HarnessPredicate { - return new HarnessPredicate(MatTimepickerHarness, options); + static with( + this: ComponentHarnessConstructor, + options: TimepickerHarnessFilters = {}, + ): HarnessPredicate { + return new HarnessPredicate(this, options); + } + + /** Whether the timepicker is open. */ + async isOpen(): Promise { + const selector = await this._getPanelSelector(); + const panel = await this._documentRootLocator.locatorForOptional(selector)(); + return panel !== null; + } + + /** Gets the options inside the timepicker panel. */ + async getOptions(filters?: Omit): Promise { + if (!(await this.isOpen())) { + throw new Error('Unable to retrieve options for timepicker. Timepicker panel is closed.'); + } + + return this._documentRootLocator.locatorForAll( + MatOptionHarness.with({ + ...(filters || {}), + ancestor: await this._getPanelSelector(), + } as OptionHarnessFilters), + )(); + } + + /** Selects the first option matching the given filters. */ + async selectOption(filters: OptionHarnessFilters): Promise { + const options = await this.getOptions(filters); + if (!options.length) { + throw Error(`Could not find a mat-option matching ${JSON.stringify(filters)}`); + } + await options[0].click(); + } + + /** Gets the selector that can be used to find the timepicker's panel. */ + protected async _getPanelSelector(): Promise { + return `#${await (await this.host()).getAttribute('mat-timepicker-panel-id')}`; } } diff --git a/src/material/timepicker/testing/timepicker-input-harness.spec.ts b/src/material/timepicker/testing/timepicker-input-harness.spec.ts new file mode 100644 index 000000000000..5ee6a178a985 --- /dev/null +++ b/src/material/timepicker/testing/timepicker-input-harness.spec.ts @@ -0,0 +1,181 @@ +import {HarnessLoader, parallel} from '@angular/cdk/testing'; +import {TestbedHarnessEnvironment} from '@angular/cdk/testing/testbed'; +import {Component, signal} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {NoopAnimationsModule} from '@angular/platform-browser/animations'; +import {DateAdapter, provideNativeDateAdapter} from '@angular/material/core'; +import {MatTimepicker, MatTimepickerInput} from '@angular/material/timepicker'; +import {MatTimepickerHarness} from './timepicker-harness'; +import {MatTimepickerInputHarness} from './timepicker-input-harness'; + +describe('MatTimepickerInputHarness', () => { + let fixture: ComponentFixture; + let loader: HarnessLoader; + let adapter: DateAdapter; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideNativeDateAdapter()], + imports: [NoopAnimationsModule, TimepickerInputHarnessTest], + }); + + adapter = TestBed.inject(DateAdapter); + adapter.setLocale('en-US'); + fixture = TestBed.createComponent(TimepickerInputHarnessTest); + fixture.detectChanges(); + loader = TestbedHarnessEnvironment.loader(fixture); + }); + + it('should load all timepicker input harnesses', async () => { + const inputs = await loader.getAllHarnesses(MatTimepickerInputHarness); + expect(inputs.length).toBe(2); + }); + + it('should filter inputs based on their value', async () => { + fixture.componentInstance.value.set(createTime(15, 10)); + fixture.changeDetectorRef.markForCheck(); + const inputs = await loader.getAllHarnesses(MatTimepickerInputHarness.with({value: /3:10/})); + expect(inputs.length).toBe(1); + }); + + it('should filter inputs based on their placeholder', async () => { + const inputs = await loader.getAllHarnesses( + MatTimepickerInputHarness.with({ + placeholder: /^Pick/, + }), + ); + + expect(inputs.length).toBe(1); + }); + + it('should get whether the input is disabled', async () => { + const input = await loader.getHarness(MatTimepickerInputHarness.with({selector: '#bound'})); + expect(await input.isDisabled()).toBe(false); + + fixture.componentInstance.disabled.set(true); + expect(await input.isDisabled()).toBe(true); + }); + + it('should get whether the input is required', async () => { + const input = await loader.getHarness(MatTimepickerInputHarness.with({selector: '#bound'})); + expect(await input.isRequired()).toBe(false); + + fixture.componentInstance.required.set(true); + expect(await input.isRequired()).toBe(true); + }); + + it('should get the input value', async () => { + const input = await loader.getHarness(MatTimepickerInputHarness.with({selector: '#bound'})); + fixture.componentInstance.value.set(createTime(15, 10)); + fixture.changeDetectorRef.markForCheck(); + + expect(await input.getValue()).toBe('3:10 PM'); + }); + + it('should set the input value', async () => { + const input = await loader.getHarness(MatTimepickerInputHarness.with({selector: '#bound'})); + expect(await input.getValue()).toBeFalsy(); + + await input.setValue('3:10 PM'); + expect(await input.getValue()).toBe('3:10 PM'); + }); + + it('should set the input value based on date adapter validation and formatting', async () => { + const input = await loader.getHarness(MatTimepickerInputHarness.with({selector: '#bound'})); + const validValues: any[] = [createTime(15, 10), '', 0, false]; + const invalidValues: any[] = [null, undefined]; + spyOn(adapter, 'format').and.returnValue('FORMATTED_VALUE'); + spyOn(adapter, 'isValid').and.callFake(value => validValues.includes(value)); + spyOn(adapter, 'deserialize').and.callFake(value => + validValues.includes(value) ? value : null, + ); + spyOn(adapter, 'getValidDateOrNull').and.callFake((value: Date) => + adapter.isValid(value) ? value : null, + ); + + for (let value of validValues) { + fixture.componentInstance.value.set(value); + fixture.changeDetectorRef.markForCheck(); + expect(await input.getValue()).toBe('FORMATTED_VALUE'); + } + + for (let value of invalidValues) { + fixture.componentInstance.value.set(value); + fixture.changeDetectorRef.markForCheck(); + expect(await input.getValue()).toBe(''); + } + }); + + it('should get the input placeholder', async () => { + const inputs = await loader.getAllHarnesses(MatTimepickerInputHarness); + expect(await parallel(() => inputs.map(input => input.getPlaceholder()))).toEqual([ + 'Pick a time', + 'Select a time', + ]); + }); + + it('should be able to change the input focused state', async () => { + const input = await loader.getHarness(MatTimepickerInputHarness.with({selector: '#bound'})); + expect(await input.isFocused()).toBe(false); + + await input.focus(); + expect(await input.isFocused()).toBe(true); + + await input.blur(); + expect(await input.isFocused()).toBe(false); + }); + + it('should be able to open and close a timepicker', async () => { + const input = await loader.getHarness(MatTimepickerInputHarness.with({selector: '#bound'})); + expect(await input.isTimepickerOpen()).toBe(false); + + await input.openTimepicker(); + expect(await input.isTimepickerOpen()).toBe(true); + + await input.closeTimepicker(); + expect(await input.isTimepickerOpen()).toBe(false); + }); + + it('should be able to get the harness for the associated timepicker', async () => { + const input = await loader.getHarness(MatTimepickerInputHarness.with({selector: '#bound'})); + await input.openTimepicker(); + expect(await input.getTimepicker()).toBeInstanceOf(MatTimepickerHarness); + }); + + it('should emit the `valueChange` event when the value is changed', async () => { + const input = await loader.getHarness(MatTimepickerInputHarness.with({selector: '#bound'})); + expect(fixture.componentInstance.changeCount).toBe(0); + + await input.setValue('3:15 PM'); + expect(fixture.componentInstance.changeCount).toBeGreaterThan(0); + }); + + function createTime(hours: number, minutes: number): Date { + return adapter.setTime(adapter.today(), hours, minutes, 0); + } +}); + +@Component({ + template: ` + + + + + + `, + standalone: true, + imports: [MatTimepickerInput, MatTimepicker], +}) +class TimepickerInputHarnessTest { + readonly value = signal(null); + readonly disabled = signal(false); + readonly required = signal(false); + changeCount = 0; +} diff --git a/src/material/timepicker/testing/timepicker-input-harness.ts b/src/material/timepicker/testing/timepicker-input-harness.ts new file mode 100644 index 000000000000..4ac18ba5b3ac --- /dev/null +++ b/src/material/timepicker/testing/timepicker-input-harness.ts @@ -0,0 +1,146 @@ +/** + * @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 { + ComponentHarness, + ComponentHarnessConstructor, + HarnessPredicate, + TestKey, +} from '@angular/cdk/testing'; +import {MatTimepickerHarness} from './timepicker-harness'; +import { + TimepickerHarnessFilters, + TimepickerInputHarnessFilters, +} from './timepicker-harness-filters'; + +/** Harness for interacting with a standard Material timepicker inputs in tests. */ +export class MatTimepickerInputHarness extends ComponentHarness { + private _documentRootLocator = this.documentRootLocatorFactory(); + static hostSelector = '.mat-timepicker-input'; + + /** + * Gets a `HarnessPredicate` that can be used to search for a `MatTimepickerInputHarness` + * that meets certain criteria. + * @param options Options for filtering which input instances are considered a match. + * @return a `HarnessPredicate` configured with the given options. + */ + static with( + this: ComponentHarnessConstructor, + options: TimepickerInputHarnessFilters = {}, + ): HarnessPredicate { + return new HarnessPredicate(this, options) + .addOption('value', options.value, (harness, value) => { + return HarnessPredicate.stringMatches(harness.getValue(), value); + }) + .addOption('placeholder', options.placeholder, (harness, placeholder) => { + return HarnessPredicate.stringMatches(harness.getPlaceholder(), placeholder); + }); + } + + /** Gets whether the timepicker associated with the input is open. */ + async isTimepickerOpen(): Promise { + const host = await this.host(); + return (await host.getAttribute('aria-expanded')) === 'true'; + } + + /** Opens the timepicker associated with the input and returns the timepicker instance. */ + async openTimepicker(): Promise { + if (!(await this.isDisabled())) { + const host = await this.host(); + await host.sendKeys(TestKey.DOWN_ARROW); + } + + return this.getTimepicker(); + } + + /** Closes the timepicker associated with the input. */ + async closeTimepicker(): Promise { + await this._documentRootLocator.rootElement.click(); + + // This is necessary so that we wait for the closing animation. + await this.forceStabilize(); + } + + /** + * Gets the `MatTimepickerHarness` that is associated with the input. + * @param filter Optionally filters which timepicker is included. + */ + async getTimepicker(filter: TimepickerHarnessFilters = {}): Promise { + const host = await this.host(); + const timepickerId = await host.getAttribute('mat-timepicker-id'); + + if (!timepickerId) { + throw Error('Element is not associated with a timepicker'); + } + + return this._documentRootLocator.locatorFor( + MatTimepickerHarness.with({ + ...filter, + selector: `[mat-timepicker-panel-id="${timepickerId}"]`, + }), + )(); + } + + /** Whether the input is disabled. */ + async isDisabled(): Promise { + return (await this.host()).getProperty('disabled'); + } + + /** Whether the input is required. */ + async isRequired(): Promise { + return (await this.host()).getProperty('required'); + } + + /** Gets the value of the input. */ + async getValue(): Promise { + // The "value" property of the native input is always defined. + return await (await this.host()).getProperty('value'); + } + + /** + * Sets the value of the input. The value will be set by simulating + * keypresses that correspond to the given value. + */ + async setValue(newValue: string): Promise { + const inputEl = await this.host(); + await inputEl.clear(); + + // We don't want to send keys for the value if the value is an empty + // string in order to clear the value. Sending keys with an empty string + // still results in unnecessary focus events. + if (newValue) { + await inputEl.sendKeys(newValue); + } + } + + /** Gets the placeholder of the input. */ + async getPlaceholder(): Promise { + return await (await this.host()).getProperty('placeholder'); + } + + /** + * Focuses the input and returns a promise that indicates when the + * action is complete. + */ + async focus(): Promise { + return (await this.host()).focus(); + } + + /** + * Blurs the input and returns a promise that indicates when the + * action is complete. + */ + async blur(): Promise { + return (await this.host()).blur(); + } + + /** Whether the input is focused. */ + async isFocused(): Promise { + return (await this.host()).isFocused(); + } +} diff --git a/src/material/timepicker/testing/timepicker-toggle-harness.spec.ts b/src/material/timepicker/testing/timepicker-toggle-harness.spec.ts new file mode 100644 index 000000000000..d6d87b8ca072 --- /dev/null +++ b/src/material/timepicker/testing/timepicker-toggle-harness.spec.ts @@ -0,0 +1,64 @@ +import {Component, signal} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {HarnessLoader} from '@angular/cdk/testing'; +import {DateAdapter, provideNativeDateAdapter} from '@angular/material/core'; +import {TestbedHarnessEnvironment} from '@angular/cdk/testing/testbed'; +import {NoopAnimationsModule} from '@angular/platform-browser/animations'; +import {MatTimepicker, MatTimepickerInput, MatTimepickerToggle} from '@angular/material/timepicker'; +import {MatTimepickerToggleHarness} from './timepicker-toggle-harness'; + +describe('MatTimepickerToggleHarness', () => { + let fixture: ComponentFixture; + let loader: HarnessLoader; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideNativeDateAdapter()], + imports: [NoopAnimationsModule, TimepickerHarnessTest], + }); + + const adapter = TestBed.inject(DateAdapter); + adapter.setLocale('en-US'); + fixture = TestBed.createComponent(TimepickerHarnessTest); + fixture.detectChanges(); + loader = TestbedHarnessEnvironment.documentRootLoader(fixture); + }); + + it('should be able to load timepicker toggle harnesses', async () => { + const harnesses = await loader.getAllHarnesses(MatTimepickerToggleHarness); + expect(harnesses.length).toBe(2); + }); + + it('should get the open state of a timepicker toggle', async () => { + const toggle = await loader.getHarness(MatTimepickerToggleHarness.with({selector: '#one'})); + expect(await toggle.isTimepickerOpen()).toBe(false); + + await toggle.openTimepicker(); + expect(await toggle.isTimepickerOpen()).toBe(true); + }); + + it('should get the disabled state of a toggle', async () => { + const toggle = await loader.getHarness(MatTimepickerToggleHarness.with({selector: '#one'})); + expect(await toggle.isDisabled()).toBe(false); + + fixture.componentInstance.disabled.set(true); + expect(await toggle.isDisabled()).toBe(true); + }); +}); + +@Component({ + template: ` + + + + + + + + `, + standalone: true, + imports: [MatTimepickerInput, MatTimepicker, MatTimepickerToggle], +}) +class TimepickerHarnessTest { + disabled = signal(false); +} diff --git a/src/material/timepicker/testing/timepicker-toggle-harness.ts b/src/material/timepicker/testing/timepicker-toggle-harness.ts new file mode 100644 index 000000000000..4c4219531dff --- /dev/null +++ b/src/material/timepicker/testing/timepicker-toggle-harness.ts @@ -0,0 +1,54 @@ +/** + * @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 {ComponentHarness, HarnessPredicate} from '@angular/cdk/testing'; +import {coerceBooleanProperty} from '@angular/cdk/coercion'; +import {TimepickerToggleHarnessFilters} from './timepicker-harness-filters'; + +/** Harness for interacting with a standard Material timepicker toggle in tests. */ +export class MatTimepickerToggleHarness extends ComponentHarness { + static hostSelector = '.mat-timepicker-toggle'; + + /** The clickable button inside the toggle. */ + private _button = this.locatorFor('button'); + + /** + * Gets a `HarnessPredicate` that can be used to search for a `MatTimepickerToggleHarness` that + * meets certain criteria. + * @param options Options for filtering which timepicker toggle instances are considered a match. + * @return a `HarnessPredicate` configured with the given options. + */ + static with( + options: TimepickerToggleHarnessFilters = {}, + ): HarnessPredicate { + return new HarnessPredicate(MatTimepickerToggleHarness, options); + } + + /** Opens the timepicker associated with the toggle. */ + async openTimepicker(): Promise { + const isOpen = await this.isTimepickerOpen(); + + if (!isOpen) { + const button = await this._button(); + await button.click(); + } + } + + /** Gets whether the timepicker associated with the toggle is open. */ + async isTimepickerOpen(): Promise { + const button = await this._button(); + const ariaExpanded = await button.getAttribute('aria-expanded'); + return ariaExpanded === 'true'; + } + + /** Whether the toggle is disabled. */ + async isDisabled(): Promise { + const button = await this._button(); + return coerceBooleanProperty(await button.getAttribute('disabled')); + } +} diff --git a/tools/public_api_guard/material/timepicker-testing.md b/tools/public_api_guard/material/timepicker-testing.md new file mode 100644 index 000000000000..edd0e626e0b7 --- /dev/null +++ b/tools/public_api_guard/material/timepicker-testing.md @@ -0,0 +1,70 @@ +## API Report File for "components-srcs" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { BaseHarnessFilters } from '@angular/cdk/testing'; +import { ComponentHarness } from '@angular/cdk/testing'; +import { ComponentHarnessConstructor } from '@angular/cdk/testing'; +import { HarnessPredicate } from '@angular/cdk/testing'; +import { MatOptionHarness } from '@angular/material/core/testing'; +import { OptionHarnessFilters } from '@angular/material/core/testing'; + +// @public (undocumented) +export class MatTimepickerHarness extends ComponentHarness { + getOptions(filters?: Omit): Promise; + protected _getPanelSelector(): Promise; + // (undocumented) + static hostSelector: string; + isOpen(): Promise; + selectOption(filters: OptionHarnessFilters): Promise; + static with(this: ComponentHarnessConstructor, options?: TimepickerHarnessFilters): HarnessPredicate; +} + +// @public +export class MatTimepickerInputHarness extends ComponentHarness { + blur(): Promise; + closeTimepicker(): Promise; + focus(): Promise; + getPlaceholder(): Promise; + getTimepicker(filter?: TimepickerHarnessFilters): Promise; + getValue(): Promise; + // (undocumented) + static hostSelector: string; + isDisabled(): Promise; + isFocused(): Promise; + isRequired(): Promise; + isTimepickerOpen(): Promise; + openTimepicker(): Promise; + setValue(newValue: string): Promise; + static with(this: ComponentHarnessConstructor, options?: TimepickerInputHarnessFilters): HarnessPredicate; +} + +// @public +export class MatTimepickerToggleHarness extends ComponentHarness { + // (undocumented) + static hostSelector: string; + isDisabled(): Promise; + isTimepickerOpen(): Promise; + openTimepicker(): Promise; + static with(options?: TimepickerToggleHarnessFilters): HarnessPredicate; +} + +// @public +export interface TimepickerHarnessFilters extends BaseHarnessFilters { +} + +// @public +export interface TimepickerInputHarnessFilters extends BaseHarnessFilters { + placeholder?: string | RegExp; + value?: string | RegExp; +} + +// @public +export interface TimepickerToggleHarnessFilters extends BaseHarnessFilters { +} + +// (No @packageDocumentation comment for this package) + +``` From 60b0bb1086e7e01d6e55b2b5b440d3d849b3f966 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Tue, 1 Oct 2024 11:18:18 +0200 Subject: [PATCH 07/13] docs(material/timepicker): add a basic docs example Adds a basic example to work around a CI failure. --- .../material/timepicker/BUILD.bazel | 2 + .../material/timepicker/index.ts | 3 +- .../timepicker-harness-example.html | 2 + .../timepicker-harness-example.spec.ts | 50 +++++++++++++++++++ .../timepicker-harness-example.ts | 23 +++++++++ .../timepicker-overview-example.html | 6 +++ .../timepicker-overview-example.ts | 16 ++++++ 7 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 src/components-examples/material/timepicker/timepicker-harness/timepicker-harness-example.html create mode 100644 src/components-examples/material/timepicker/timepicker-harness/timepicker-harness-example.spec.ts create mode 100644 src/components-examples/material/timepicker/timepicker-harness/timepicker-harness-example.ts create mode 100644 src/components-examples/material/timepicker/timepicker-overview/timepicker-overview-example.html create mode 100644 src/components-examples/material/timepicker/timepicker-overview/timepicker-overview-example.ts diff --git a/src/components-examples/material/timepicker/BUILD.bazel b/src/components-examples/material/timepicker/BUILD.bazel index 050f921bcad1..6743551b16cf 100644 --- a/src/components-examples/material/timepicker/BUILD.bazel +++ b/src/components-examples/material/timepicker/BUILD.bazel @@ -38,8 +38,10 @@ ng_test_library( ":timepicker", "//src/cdk/testing", "//src/cdk/testing/testbed", + "//src/material/core", "//src/material/timepicker", "//src/material/timepicker/testing", + "@npm//@angular/platform-browser", ], ) diff --git a/src/components-examples/material/timepicker/index.ts b/src/components-examples/material/timepicker/index.ts index f7d88401b1c3..f9d81a9e356d 100644 --- a/src/components-examples/material/timepicker/index.ts +++ b/src/components-examples/material/timepicker/index.ts @@ -1 +1,2 @@ -export const TEMP = true; +export {TimepickerOverviewExample} from './timepicker-overview/timepicker-overview-example'; +export {TimepickerHarnessExample} from './timepicker-harness/timepicker-harness-example'; diff --git a/src/components-examples/material/timepicker/timepicker-harness/timepicker-harness-example.html b/src/components-examples/material/timepicker/timepicker-harness/timepicker-harness-example.html new file mode 100644 index 000000000000..ea09a46a8ff9 --- /dev/null +++ b/src/components-examples/material/timepicker/timepicker-harness/timepicker-harness-example.html @@ -0,0 +1,2 @@ + + diff --git a/src/components-examples/material/timepicker/timepicker-harness/timepicker-harness-example.spec.ts b/src/components-examples/material/timepicker/timepicker-harness/timepicker-harness-example.spec.ts new file mode 100644 index 000000000000..332491f6f617 --- /dev/null +++ b/src/components-examples/material/timepicker/timepicker-harness/timepicker-harness-example.spec.ts @@ -0,0 +1,50 @@ +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {TestbedHarnessEnvironment} from '@angular/cdk/testing/testbed'; +import {MatTimepickerInputHarness} from '@angular/material/timepicker/testing'; +import {HarnessLoader} from '@angular/cdk/testing'; +import {TimepickerHarnessExample} from './timepicker-harness-example'; +import {NoopAnimationsModule} from '@angular/platform-browser/animations'; +import {DateAdapter, MatNativeDateModule} from '@angular/material/core'; + +describe('TimepickerHarnessExample', () => { + let fixture: ComponentFixture; + let loader: HarnessLoader; + + beforeEach(() => { + TestBed.configureTestingModule({imports: [NoopAnimationsModule, MatNativeDateModule]}); + TestBed.inject(DateAdapter).setLocale('en-US'); // Set the locale to en-US to guarantee consistent tests. + fixture = TestBed.createComponent(TimepickerHarnessExample); + fixture.detectChanges(); + loader = TestbedHarnessEnvironment.loader(fixture); + }); + + it('should load all timepicker input harnesses', async () => { + const inputs = await loader.getAllHarnesses(MatTimepickerInputHarness); + expect(inputs.length).toBe(1); + }); + + it('should open and close a timepicker', async () => { + const input = await loader.getHarness(MatTimepickerInputHarness); + expect(await input.isTimepickerOpen()).toBe(false); + + await input.openTimepicker(); + expect(await input.isTimepickerOpen()).toBe(true); + }); + + it('should set the input value', async () => { + const input = await loader.getHarness(MatTimepickerInputHarness); + expect(await input.getValue()).toBe('11:45 AM'); + + await input.setValue('3:21 PM'); + expect(await input.getValue()).toBe('3:21 PM'); + }); + + it('should select an option from the timepicker', async () => { + const input = await loader.getHarness(MatTimepickerInputHarness); + const timepicker = await input.openTimepicker(); + expect(await input.getValue()).toBe('11:45 AM'); + + await timepicker.selectOption({text: '1:00 PM'}); + expect(await input.getValue()).toBe('1:00 PM'); + }); +}); diff --git a/src/components-examples/material/timepicker/timepicker-harness/timepicker-harness-example.ts b/src/components-examples/material/timepicker/timepicker-harness/timepicker-harness-example.ts new file mode 100644 index 000000000000..5be40ccb9cd4 --- /dev/null +++ b/src/components-examples/material/timepicker/timepicker-harness/timepicker-harness-example.ts @@ -0,0 +1,23 @@ +import {ChangeDetectionStrategy, Component, Signal, signal} from '@angular/core'; +import {provideNativeDateAdapter} from '@angular/material/core'; +import {MatTimepickerModule} from '@angular/material/timepicker'; + +/** + * @title Testing with MatTimepickerInputHarness + */ +@Component({ + selector: 'timepicker-harness-example', + templateUrl: 'timepicker-harness-example.html', + standalone: true, + providers: [provideNativeDateAdapter()], + imports: [MatTimepickerModule], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class TimepickerHarnessExample { + date: Signal; + + constructor() { + const today = new Date(); + this.date = signal(new Date(today.getFullYear(), today.getMonth(), today.getDate(), 11, 45)); + } +} diff --git a/src/components-examples/material/timepicker/timepicker-overview/timepicker-overview-example.html b/src/components-examples/material/timepicker/timepicker-overview/timepicker-overview-example.html new file mode 100644 index 000000000000..9c7394525960 --- /dev/null +++ b/src/components-examples/material/timepicker/timepicker-overview/timepicker-overview-example.html @@ -0,0 +1,6 @@ + + Pick a time + + + + diff --git a/src/components-examples/material/timepicker/timepicker-overview/timepicker-overview-example.ts b/src/components-examples/material/timepicker/timepicker-overview/timepicker-overview-example.ts new file mode 100644 index 000000000000..a1612d747725 --- /dev/null +++ b/src/components-examples/material/timepicker/timepicker-overview/timepicker-overview-example.ts @@ -0,0 +1,16 @@ +import {ChangeDetectionStrategy, Component} from '@angular/core'; +import {MatTimepickerModule} from '@angular/material/timepicker'; +import {MatInputModule} from '@angular/material/input'; +import {MatFormFieldModule} from '@angular/material/form-field'; +import {provideNativeDateAdapter} from '@angular/material/core'; + +/** @title Basic timepicker */ +@Component({ + selector: 'timepicker-overview-example', + templateUrl: 'timepicker-overview-example.html', + standalone: true, + providers: [provideNativeDateAdapter()], + imports: [MatFormFieldModule, MatInputModule, MatTimepickerModule], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class TimepickerOverviewExample {} From e0408a5e84dae261a42baf65cf606d300bf4232f Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Thu, 3 Oct 2024 14:15:20 +0200 Subject: [PATCH 08/13] fix(material/timepicker): always re-focus input Fixes that nothing was happening if the user clicks on the toggle while the timepicker is open. --- src/material/timepicker/timepicker.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/material/timepicker/timepicker.ts b/src/material/timepicker/timepicker.ts index 3eb1260ddfc6..be39cb8ab1f4 100644 --- a/src/material/timepicker/timepicker.ts +++ b/src/material/timepicker/timepicker.ts @@ -208,20 +208,25 @@ export class MatTimepicker implements OnDestroy, MatOptionParentComponent { /** Opens the timepicker. */ open(): void { - if (!this._input || this._isOpen()) { + if (!this._input) { return; } + // Focus should already be on the input, but this call is in case the timepicker is opened + // programmatically. We need to call this even if the timepicker is already open, because + // the user might be clicking the toggle. + this._input.focus(); + + if (this._isOpen()) { + return; + } + + this._isOpen.set(true); this._generateOptions(); const overlayRef = this._getOverlayRef(); overlayRef.updateSize({width: this._input.getOverlayOrigin().nativeElement.offsetWidth}); this._portal ??= new TemplatePortal(this._panelTemplate(), this._viewContainerRef); overlayRef.attach(this._portal); - this._isOpen.set(true); - - // Focus should already be on the input, but this call is - // in case the timepicker is opened programmatically. - this._input.focus(); this._onOpenRender?.destroy(); this._onOpenRender = afterNextRender( () => { From 34378f34fefaed315fe707ef4af0a6b32a78087f Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Thu, 3 Oct 2024 14:18:21 +0200 Subject: [PATCH 09/13] fix(material/timepicker): hide toggle icon from assistive technology Fixes that the timepicker toggle icon was being read out as an image. --- src/material/timepicker/timepicker-toggle.html | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/material/timepicker/timepicker-toggle.html b/src/material/timepicker/timepicker-toggle.html index 310469073cd2..dab7a3d38c84 100644 --- a/src/material/timepicker/timepicker-toggle.html +++ b/src/material/timepicker/timepicker-toggle.html @@ -14,7 +14,9 @@ height="24px" width="24px" viewBox="0 -960 960 960" - fill="currentColor"> + fill="currentColor" + focusable="false" + aria-hidden="true"> From 253fcb004f6dd102e1b8538b56ea4f31d4431679 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Thu, 3 Oct 2024 14:31:32 +0200 Subject: [PATCH 10/13] fix(material/timepicker): text field in parse error not up to date Fixes that the `text` field in the `matTimepickerParse` error wasn't up-to-date with the element's value. --- src/material/timepicker/timepicker-input.ts | 10 ++++++++-- src/material/timepicker/timepicker.spec.ts | 17 ++++++++++++++++- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/material/timepicker/timepicker-input.ts b/src/material/timepicker/timepicker-input.ts index 9cc958f0b50e..82a535abdfd7 100644 --- a/src/material/timepicker/timepicker-input.ts +++ b/src/material/timepicker/timepicker-input.ts @@ -251,8 +251,14 @@ export class MatTimepickerInput implements ControlValueAccessor, Validator, O const date = this._dateAdapter.parseTime(value, this._dateFormats.parse.timeInput); const hasChanged = !this._dateAdapter.sameTime(date, currentValue); - // We need to fire the CVA change event for all nulls, otherwise the validators won't run. - this._assignUserSelection(date, !date || hasChanged || !!(value && !currentValue)); + if (!date || hasChanged || !!(value && !currentValue)) { + // We need to fire the CVA change event for all nulls, otherwise the validators won't run. + this._assignUserSelection(date, true); + } else { + // Call the validator even if the value hasn't changed since + // some fields change depending on what the user has entered. + this._validatorOnChange?.(); + } } /** Handles the `blur` event. */ diff --git a/src/material/timepicker/timepicker.spec.ts b/src/material/timepicker/timepicker.spec.ts index ce461ff3a818..7cff9457ffa1 100644 --- a/src/material/timepicker/timepicker.spec.ts +++ b/src/material/timepicker/timepicker.spec.ts @@ -1007,7 +1007,22 @@ describe('MatTimepicker', () => { clearElement(input); typeInElement(input, 'not a valid date'); fixture.detectChanges(); - expect(control.errors?.['matTimepickerParse']).toBeTruthy(); + expect(control.errors?.['matTimepickerParse']).toEqual( + jasmine.objectContaining({ + text: 'not a valid date', + }), + ); + expect(control.value).toBeTruthy(); + expect(adapter.isValid(control.value!)).toBe(false); + + // Change from one invalid value to the other to make sure that the object stays in sync. + typeInElement(input, ' (changed)'); + fixture.detectChanges(); + expect(control.errors?.['matTimepickerParse']).toEqual( + jasmine.objectContaining({ + text: 'not a valid date (changed)', + }), + ); expect(control.value).toBeTruthy(); expect(adapter.isValid(control.value!)).toBe(false); From fdc351160c4c7c67224db3efa00ee9a99029e170 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Thu, 3 Oct 2024 14:40:01 +0200 Subject: [PATCH 11/13] fix(material/timepicker): more flexible interval parsing * Allows spaces between the interval value and unit. * Adds support for `hour`, `hours`, `minute`, `min`, `minutes`, `second` and `seconds` units in the interval. --- src/material/timepicker/util.spec.ts | 15 +++++++++++++++ src/material/timepicker/util.ts | 6 +++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/material/timepicker/util.spec.ts b/src/material/timepicker/util.spec.ts index aa21a94bddaf..20a2cc8e4390 100644 --- a/src/material/timepicker/util.spec.ts +++ b/src/material/timepicker/util.spec.ts @@ -56,6 +56,21 @@ describe('timepicker utilities', () => { expect(parseInterval('3M')).toBe(180); expect(parseInterval('3S')).toBe(3); }); + + it('should parse interval with space', () => { + expect(parseInterval('3 h')).toBe(10_800); + expect(parseInterval('6 h')).toBe(21_600); + }); + + it('should handle long versions of units', () => { + expect(parseInterval('1 hour')).toBe(3600); + expect(parseInterval('3 hours')).toBe(10_800); + expect(parseInterval('1 minute')).toBe(60); + expect(parseInterval('3 min')).toBe(180); + expect(parseInterval('3 minutes')).toBe(180); + expect(parseInterval('1 second')).toBe(1); + expect(parseInterval('10 seconds')).toBe(10); + }); }); describe('generateOptions', () => { diff --git a/src/material/timepicker/util.ts b/src/material/timepicker/util.ts index 7a4135950160..1a7b0ddc8f7d 100644 --- a/src/material/timepicker/util.ts +++ b/src/material/timepicker/util.ts @@ -10,7 +10,7 @@ import {InjectionToken} from '@angular/core'; import {DateAdapter, MatDateFormats} from '@angular/material/core'; /** Pattern that interval strings have to match. */ -const INTERVAL_PATTERN = /^(\d*\.?\d+)(h|m|s)?$/i; +const INTERVAL_PATTERN = /^(\d*\.?\d+)\s*(h|hour|hours|m|min|minute|minutes|s|second|seconds)?$/i; /** * Object that can be used to configure the default options for the timepicker component. @@ -62,9 +62,9 @@ export function parseInterval(value: number | string | null): number | null { return null; } - if (unit === 'h') { + if (unit === 'h' || unit === 'hour' || unit === 'hours') { result = amount * 3600; - } else if (unit === 'm') { + } else if (unit === 'm' || unit === 'min' || unit === 'minute' || unit === 'minutes') { result = amount * 60; } else { result = amount; From 1b2b24b8ff3fefa2553e6be5874391e3f0994c46 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Thu, 3 Oct 2024 15:04:19 +0200 Subject: [PATCH 12/13] fix(material/core): avoid browser inconsistencies when parsing time Previously we were trying to rely on the browser to parse time, but we couldn't rely on it fully because browsers are inconsistent in how the handle time strings. We had some fallback logic to try and patch over it, but it led to some bug. These changes switch to relying fully on our own logic for parsing times. --- .../adapter/date-fns-adapter.spec.ts | 1 + .../adapter/luxon-date-adapter.spec.ts | 1 + .../core/datetime/native-date-adapter.spec.ts | 13 +-- .../core/datetime/native-date-adapter.ts | 99 +++++++++---------- 4 files changed, 50 insertions(+), 64 deletions(-) diff --git a/src/material-date-fns-adapter/adapter/date-fns-adapter.spec.ts b/src/material-date-fns-adapter/adapter/date-fns-adapter.spec.ts index f31457cbbce7..d85b32e8341d 100644 --- a/src/material-date-fns-adapter/adapter/date-fns-adapter.spec.ts +++ b/src/material-date-fns-adapter/adapter/date-fns-adapter.spec.ts @@ -546,6 +546,7 @@ describe('DateFnsAdapter', () => { expect(adapter.isValid(adapter.parseTime('24:05', 'p')!)).toBe(false); expect(adapter.isValid(adapter.parseTime('00:61:05', 'p')!)).toBe(false); expect(adapter.isValid(adapter.parseTime('14:52:78', 'p')!)).toBe(false); + expect(adapter.isValid(adapter.parseTime('12:10 PM11:10 PM', 'p')!)).toBe(false); }); it('should compare times', () => { diff --git a/src/material-luxon-adapter/adapter/luxon-date-adapter.spec.ts b/src/material-luxon-adapter/adapter/luxon-date-adapter.spec.ts index 4cbcf3655a37..12a4a95f81f3 100644 --- a/src/material-luxon-adapter/adapter/luxon-date-adapter.spec.ts +++ b/src/material-luxon-adapter/adapter/luxon-date-adapter.spec.ts @@ -648,6 +648,7 @@ describe('LuxonDateAdapter', () => { expect(adapter.isValid(adapter.parseTime('24:05', 't')!)).toBeFalse(); expect(adapter.isValid(adapter.parseTime('00:61:05', 'tt')!)).toBeFalse(); expect(adapter.isValid(adapter.parseTime('14:52:78', 'tt')!)).toBeFalse(); + expect(adapter.isValid(adapter.parseTime('12:10 PM11:10 PM', 'tt')!)).toBeFalse(); }); it('should return null when parsing unsupported time values', () => { diff --git a/src/material/core/datetime/native-date-adapter.spec.ts b/src/material/core/datetime/native-date-adapter.spec.ts index 5a2df4672f5b..a1264175bf02 100644 --- a/src/material/core/datetime/native-date-adapter.spec.ts +++ b/src/material/core/datetime/native-date-adapter.spec.ts @@ -1,18 +1,15 @@ import {LOCALE_ID} from '@angular/core'; import {TestBed} from '@angular/core/testing'; -import {Platform} from '@angular/cdk/platform'; import {DEC, FEB, JAN, MAR} from '../../testing'; import {DateAdapter, MAT_DATE_LOCALE, NativeDateAdapter, NativeDateModule} from './index'; describe('NativeDateAdapter', () => { let adapter: NativeDateAdapter; let assertValidDate: (d: Date | null, valid: boolean) => void; - let platform: Platform; beforeEach(() => { TestBed.configureTestingModule({imports: [NativeDateModule]}); adapter = TestBed.inject(DateAdapter) as NativeDateAdapter; - platform = TestBed.inject(Platform); assertValidDate = (d: Date | null, valid: boolean) => { expect(adapter.isDateInstance(d)) @@ -587,13 +584,9 @@ describe('NativeDateAdapter', () => { expect(adapter.isValid(adapter.parseTime('123')!)).toBe(false); expect(adapter.isValid(adapter.parseTime('14:52 PM')!)).toBe(false); expect(adapter.isValid(adapter.parseTime('24:05')!)).toBe(false); - - // Firefox is a bit more forgiving of invalid times than other browsers. - // E.g. these just roll over instead of producing an invalid object. - if (!platform.FIREFOX) { - expect(adapter.isValid(adapter.parseTime('00:61:05')!)).toBe(false); - expect(adapter.isValid(adapter.parseTime('14:52:78')!)).toBe(false); - } + expect(adapter.isValid(adapter.parseTime('00:61:05')!)).toBe(false); + expect(adapter.isValid(adapter.parseTime('14:52:78')!)).toBe(false); + expect(adapter.isValid(adapter.parseTime('12:10 PM11:10 PM')!)).toBe(false); }); it('should return null when parsing unsupported time values', () => { diff --git a/src/material/core/datetime/native-date-adapter.ts b/src/material/core/datetime/native-date-adapter.ts index dff89e74e6de..b4663da4dcef 100644 --- a/src/material/core/datetime/native-date-adapter.ts +++ b/src/material/core/datetime/native-date-adapter.ts @@ -28,7 +28,7 @@ const ISO_8601_REGEX = * - {{hours}}.{{minutes}} AM/PM * - {{hours}}.{{minutes}}.{{seconds}} AM/PM */ -const TIME_REGEX = /(\d?\d)[:.](\d?\d)(?:[:.](\d?\d))?\s*(AM|PM)?/i; +const TIME_REGEX = /^(\d?\d)[:.](\d?\d)(?:[:.](\d?\d))?\s*(AM|PM)?$/i; /** Creates an array and fills it with values. */ function range(length: number, valueFunction: (index: number) => T): T[] { @@ -292,67 +292,20 @@ export class NativeDateAdapter extends DateAdapter { return null; } - const today = this.today(); - const base = this.toIso8601(today); + // Attempt to parse the value directly. + let result = this._parseTimeString(value); - // JS is able to parse colon-separated times (including AM/PM) by - // appending it to a valid date string. Generate one from today's date. - let result = Date.parse(`${base} ${value}`); - - // Some locales use a dot instead of a colon as a separator, try replacing it before parsing. - if (!result && value.includes('.')) { - result = Date.parse(`${base} ${value.replace(/\./g, ':')}`); - } - - // Other locales add extra characters around the time, but are otherwise parseable + // Some locales add extra characters around the time, but are otherwise parseable // (e.g. `00:05 ч.` in bg-BG). Try replacing all non-number and non-colon characters. - if (!result) { + if (result === null) { const withoutExtras = value.replace(/[^0-9:(AM|PM)]/gi, '').trim(); if (withoutExtras.length > 0) { - result = Date.parse(`${base} ${withoutExtras}`); - } - } - - // Some browser implementations of Date aren't very flexible with the time formats. - // E.g. Safari doesn't support AM/PM or padded numbers. As a final resort, we try - // parsing some of the more common time formats ourselves. - if (!result) { - const parsed = value.toUpperCase().match(TIME_REGEX); - - if (parsed) { - let hours = parseInt(parsed[1]); - const minutes = parseInt(parsed[2]); - let seconds: number | undefined = parsed[3] == null ? undefined : parseInt(parsed[3]); - const amPm = parsed[4] as 'AM' | 'PM' | undefined; - - if (hours === 12) { - hours = amPm === 'AM' ? 0 : hours; - } else if (amPm === 'PM') { - hours += 12; - } - - if ( - inRange(hours, 0, 23) && - inRange(minutes, 0, 59) && - (seconds == null || inRange(seconds, 0, 59)) - ) { - return this.setTime(today, hours, minutes, seconds || 0); - } - } - } - - if (result) { - const date = new Date(result); - - // Firefox allows overflows in the time string, e.g. 25:00 gets parsed as the next day. - // Other browsers return invalid date objects in such cases so try to normalize it. - if (this.sameDate(today, date)) { - return date; + result = this._parseTimeString(withoutExtras); } } - return this.invalid(); + return result || this.invalid(); } override addSeconds(date: Date, amount: number): Date { @@ -397,6 +350,44 @@ export class NativeDateAdapter extends DateAdapter { d.setUTCHours(date.getHours(), date.getMinutes(), date.getSeconds(), date.getMilliseconds()); return dtf.format(d); } + + /** + * Attempts to parse a time string into a date object. Returns null if it cannot be parsed. + * @param value Time string to parse. + */ + private _parseTimeString(value: string): Date | null { + // Note: we can technically rely on the browser for the time parsing by generating + // an ISO string and appending the string to the end of it. We don't do it, because + // browsers aren't consistent in what they support. Some examples: + // - Safari doesn't support AM/PM. + // - Firefox produces a valid date object if the time string has overflows (e.g. 12:75) while + // other browsers produce an invalid date. + // - Safari doesn't allow padded numbers. + const parsed = value.toUpperCase().match(TIME_REGEX); + + if (parsed) { + let hours = parseInt(parsed[1]); + const minutes = parseInt(parsed[2]); + let seconds: number | undefined = parsed[3] == null ? undefined : parseInt(parsed[3]); + const amPm = parsed[4] as 'AM' | 'PM' | undefined; + + if (hours === 12) { + hours = amPm === 'AM' ? 0 : hours; + } else if (amPm === 'PM') { + hours += 12; + } + + if ( + inRange(hours, 0, 23) && + inRange(minutes, 0, 59) && + (seconds == null || inRange(seconds, 0, 59)) + ) { + return this.setTime(this.today(), hours, minutes, seconds || 0); + } + } + + return null; + } } /** Checks whether a number is within a certain range. */ From 3c27b91960a2435c00184725a193d5b1ed80eddb Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Thu, 3 Oct 2024 20:15:05 +0200 Subject: [PATCH 13/13] build: set up token extraction for the timepicker Adds a target to extract the tokens from the timepicker. --- src/material/timepicker/BUILD.bazel | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/material/timepicker/BUILD.bazel b/src/material/timepicker/BUILD.bazel index 3de8ce63c87d..a46395de2a01 100644 --- a/src/material/timepicker/BUILD.bazel +++ b/src/material/timepicker/BUILD.bazel @@ -1,5 +1,6 @@ load( "//tools:defaults.bzl", + "extract_tokens", "markdown_to_html", "ng_module", "ng_test_library", @@ -72,6 +73,11 @@ markdown_to_html( srcs = [":timepicker.md"], ) +extract_tokens( + name = "tokens", + srcs = [":timepicker_scss_lib"], +) + filegroup( name = "source-files", srcs = glob(["**/*.ts"]),