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..6743551b16cf --- /dev/null +++ b/src/components-examples/material/timepicker/BUILD.bazel @@ -0,0 +1,51 @@ +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/core", + "//src/material/timepicker", + "//src/material/timepicker/testing", + "@npm//@angular/platform-browser", + ], +) + +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..f9d81a9e356d --- /dev/null +++ b/src/components-examples/material/timepicker/index.ts @@ -0,0 +1,2 @@ +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 {} 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.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) { 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/theme-m3.scss b/src/dev-app/theme-m3.scss index 666639fe21f6..6ac97f257b29 100644 --- a/src/dev-app/theme-m3.scss +++ b/src/dev-app/theme-m3.scss @@ -129,3 +129,8 @@ $density-scales: (-1, -2, -3, -4, minimum, maximum); .demo-config-buttons button { margin: 4px; } + +// In M3 we need some spacing around the list in the sidenav. +mat-nav-list.demo-nav-list { + margin: 8px; +} diff --git a/src/dev-app/timepicker/BUILD.bazel b/src/dev-app/timepicker/BUILD.bazel new file mode 100644 index 000000000000..7b8dabbbe47a --- /dev/null +++ b/src/dev-app/timepicker/BUILD.bazel @@ -0,0 +1,28 @@ +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/core", + "//src/material/datepicker", + "//src/material/form-field", + "//src/material/icon", + "//src/material/input", + "//src/material/select", + "//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..52abf423e2d6 --- /dev/null +++ b/src/dev-app/timepicker/timepicker-demo.html @@ -0,0 +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 new file mode 100644 index 000000000000..ae671762a74a --- /dev/null +++ b/src/dev-app/timepicker/timepicker-demo.scss @@ -0,0 +1,22 @@ +.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 new file mode 100644 index 000000000000..96e4e1fe317a --- /dev/null +++ b/src/dev-app/timepicker/timepicker-demo.ts @@ -0,0 +1,73 @@ +/** + * @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, inject, OnDestroy} from '@angular/core'; +import {DateAdapter} from '@angular/material/core'; +import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms'; +import {MatTimepickerModule} from '@angular/material/timepicker'; +import {MatFormFieldModule} from '@angular/material/form-field'; +import {MatInputModule} from '@angular/material/input'; +import {JsonPipe} from '@angular/common'; +import {MatButtonModule} from '@angular/material/button'; +import {MatSelectModule} from '@angular/material/select'; +import {Subscription} from 'rxjs'; +import {MatCardModule} from '@angular/material/card'; +import {MatDatepickerModule} from '@angular/material/datepicker'; + +@Component({ + selector: 'timepicker-demo', + templateUrl: 'timepicker-demo.html', + styleUrl: 'timepicker-demo.css', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + MatTimepickerModule, + MatDatepickerModule, + MatFormFieldModule, + MatInputModule, + ReactiveFormsModule, + FormsModule, + JsonPipe, + MatButtonModule, + MatSelectModule, + MatCardModule, + ], +}) +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-date-fns-adapter/adapter/date-fns-adapter.spec.ts b/src/material-date-fns-adapter/adapter/date-fns-adapter.spec.ts index f1f684f64b78..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,26 +546,29 @@ 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', () => { - 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..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', () => { @@ -657,25 +658,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/_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/datetime/native-date-adapter.spec.ts b/src/material/core/datetime/native-date-adapter.spec.ts index cd1979274c2e..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', () => { @@ -605,23 +598,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); }); 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. */ 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..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), ), 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..c40620529cf9 --- /dev/null +++ b/src/material/core/tokens/m2/mat/_timepicker.scss @@ -0,0 +1,44 @@ +@use '../../token-definition'; +@use '../../../theming/inspection'; +@use '../../../style/sass-utils'; +@use '../../../style/elevation'; + +// The prefix used to generate the fully qualified name for tokens in this file. +$prefix: (mat, timepicker); + +// Tokens that can't be configured through Angular Material's current theming API, +// but may be in a future version of the theming API. +@function get-unthemable-tokens() { + @return ( + 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 ( + 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 (); +} + +// 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..8e9388d3ac6c --- /dev/null +++ b/src/material/core/tokens/m3/mat/_timepicker.scss @@ -0,0 +1,22 @@ +@use 'sass:map'; +@use '../../../style/elevation'; +@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: ( + 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/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..a46395de2a01 --- /dev/null +++ b/src/material/timepicker/BUILD.bazel @@ -0,0 +1,84 @@ +load( + "//tools:defaults.bzl", + "extract_tokens", + "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/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", + ], +) + +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/keycodes", + "//src/cdk/testing/private", + "//src/material/core", + "//src/material/form-field", + "//src/material/input", + "@npm//@angular/forms", + "@npm//@angular/platform-browser", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":unit_test_sources"], +) + +markdown_to_html( + name = "overview", + srcs = [":timepicker.md"], +) + +extract_tokens( + name = "tokens", + srcs = [":timepicker_scss_lib"], +) + +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..769ffa9eb0d4 --- /dev/null +++ b/src/material/timepicker/_timepicker-theme.scss @@ -0,0 +1,111 @@ +@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'; + +/// 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)); + } + @else { + @include sass-utils.current-selector-or-root() { + @include token-utils.create-token-values(tokens-mat-timepicker.$prefix, + tokens-mat-timepicker.get-unthemable-tokens()); + } + } +} + +/// 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...); + } + @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)); + } + } +} + +/// 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)); + } + @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)); + } + } +} + +/// 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)); + } + @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)); + } + } +} + +/// Outputs the CSS variable values for the given tokens. +/// @param {Map} $tokens The token values to emit. +@mixin overrides($tokens: ()) { + @include token-utils.batch-create-token-values( + $tokens, + (prefix: tokens-mat-timepicker.$prefix, tokens: tokens-mat-timepicker.get-token-slots()), + ); +} + +/// Outputs all (base, color, typography, and density) theme styles for the mat-timepicker. +/// @param {Map} $theme The theme to generate styles for. +/// @param {ArgList} Additional optional arguments (only supported for M3 themes): +/// $color-variant: The color variant to use for the main selection: primary, secondary, tertiary, +/// or error (If not specified, default primary color will be used). +@mixin theme($theme) { + @include theming.private-check-duplicate-theme-styles($theme, 'mat-timepicker') { + @if inspection.get-theme-version($theme) == 1 { + @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..d544ec4520a5 --- /dev/null +++ b/src/material/timepicker/public-api.ts @@ -0,0 +1,13 @@ +/** + * @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'; +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/testing/BUILD.bazel b/src/material/timepicker/testing/BUILD.bazel new file mode 100644 index 000000000000..200894a54557 --- /dev/null +++ b/src/material/timepicker/testing/BUILD.bazel @@ -0,0 +1,41 @@ +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/coercion", + "//src/cdk/testing", + "//src/material/core/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/core", + "//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..3aa001551b7b --- /dev/null +++ b/src/material/timepicker/testing/public-api.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 + */ + +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 new file mode 100644 index 000000000000..0b34e2bdecc0 --- /dev/null +++ b/src/material/timepicker/testing/timepicker-harness-filters.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 {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 new file mode 100644 index 000000000000..bc670144dc6c --- /dev/null +++ b/src/material/timepicker/testing/timepicker-harness.spec.ts @@ -0,0 +1,82 @@ +import {Component, signal} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/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, MatTimepickerInput} from '@angular/material/timepicker'; +import {MatTimepickerHarness} from './timepicker-harness'; +import {MatTimepickerInputHarness} from './timepicker-input-harness'; + +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); + }); + + it('should be able to load timepicker harnesses', async () => { + 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: [MatTimepickerInput, MatTimepicker], +}) +class TimepickerHarnessTest { + interval = signal('4h'); +} diff --git a/src/material/timepicker/testing/timepicker-harness.ts b/src/material/timepicker/testing/timepicker-harness.ts new file mode 100644 index 000000000000..06de38c22075 --- /dev/null +++ b/src/material/timepicker/testing/timepicker-harness.ts @@ -0,0 +1,68 @@ +/** + * @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, +} from '@angular/cdk/testing'; +import {MatOptionHarness, OptionHarnessFilters} from '@angular/material/core/testing'; +import {TimepickerHarnessFilters} from './timepicker-harness-filters'; + +export class MatTimepickerHarness extends ComponentHarness { + private _documentRootLocator = this.documentRootLocatorFactory(); + static hostSelector = 'mat-timepicker'; + + /** + * 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( + 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/src/material/timepicker/timepicker-input.ts b/src/material/timepicker/timepicker-input.ts new file mode 100644 index 000000000000..82a535abdfd7 --- /dev/null +++ b/src/material/timepicker/timepicker-input.ts @@ -0,0 +1,421 @@ +/** + * @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); + + 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. */ + 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 new file mode 100644 index 000000000000..a6850da97066 --- /dev/null +++ b/src/material/timepicker/timepicker-module.ts @@ -0,0 +1,19 @@ +/** + * @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 {CdkScrollableModule} from '@angular/cdk/scrolling'; +import {MatTimepicker} from './timepicker'; +import {MatTimepickerInput} from './timepicker-input'; +import {MatTimepickerToggle} from './timepicker-toggle'; + +@NgModule({ + 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..dab7a3d38c84 --- /dev/null +++ b/src/material/timepicker/timepicker-toggle.html @@ -0,0 +1,23 @@ + 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 new file mode 100644 index 000000000000..86d86a41a89e --- /dev/null +++ b/src/material/timepicker/timepicker.html @@ -0,0 +1,15 @@ + +
+ @for (option of _timeOptions; track option.value) { + {{option.label}} + } +
+
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..ae8cfc84fff8 --- /dev/null +++ b/src/material/timepicker/timepicker.scss @@ -0,0 +1,53 @@ +@use '@angular/cdk'; +@use '../core/tokens/token-utils'; +@use '../core/tokens/m2/mat/timepicker' as tokens-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(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 new file mode 100644 index 000000000000..7cff9457ffa1 --- /dev/null +++ b/src/material/timepicker/timepicker.spec.ts @@ -0,0 +1,1355 @@ +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', () => { + 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']).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); + + 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 TimepickerWithoutInput { + @ViewChild(MatTimepicker) timepicker: MatTimepicker; +} diff --git a/src/material/timepicker/timepicker.ts b/src/material/timepicker/timepicker.ts new file mode 100644 index 000000000000..be39cb8ab1f4 --- /dev/null +++ b/src/material/timepicker/timepicker.ts @@ -0,0 +1,456 @@ +/** + * @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 { + 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', + 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 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) { + 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._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.spec.ts b/src/material/timepicker/util.spec.ts new file mode 100644 index 000000000000..20a2cc8e4390 --- /dev/null +++ b/src/material/timepicker/util.spec.ts @@ -0,0 +1,202 @@ +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); + }); + + 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', () => { + 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..1a7b0ddc8f7d --- /dev/null +++ b/src/material/timepicker/util.ts @@ -0,0 +1,139 @@ +/** + * @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 {InjectionToken} from '@angular/core'; +import {DateAdapter, MatDateFormats} from '@angular/material/core'; + +/** Pattern that interval strings have to match. */ +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. + */ +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`. + */ +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' || unit === 'hour' || unit === 'hours') { + result = amount * 3600; + } else if (unit === 'm' || unit === 'min' || unit === 'minute' || unit === 'minutes') { + 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; +} + +/** 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-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) + +``` 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) + +```