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'); expectSameTime(fixture.componentInstance.input.value(), 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(); expectSameTime(inputInstance.value(), 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); expectSameTime(fixture.componentInstance.value(), 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); expectSameTime(fixture.componentInstance.value(), 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'); expectSameTime(fixture.componentInstance.input.value(), 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'); expectSameTime(inputInstance.value(), 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(); expectSameTime(fixture.componentInstance.input.min(), 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(); expectSameTime(fixture.componentInstance.input.max(), 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(); expectSameTime(fixture.componentInstance.input.min(), 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(); expectSameTime(fixture.componentInstance.input.max(), 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'); expectSameTime(fixture.componentInstance.input.value(), 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(); expectSameTime(control.value, 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(); expectSameTime(control.value, 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(); expectSameTime(control.value, 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(); expectSameTime(control.value, 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(); expectSameTime(control.value, 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(); expectSameTime(control.value, 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(); expectSameTime(control.value, 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(); expectSameTime(control.value, 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 expectSameTime(one: Date | null, two: Date | null): void { expect(adapter.sameTime(one, two)) .withContext(`Expected ${one} to be same time as ${two}`) .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; }