sass-references/angular-material/material/timepicker/timepicker.spec.ts

1362 lines
48 KiB
TypeScript

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<Date>;
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<unknown>): 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<unknown>): HTMLButtonElement {
return fixture.nativeElement.querySelector('mat-timepicker-toggle button');
}
});
@Component({
template: `
<input
[matTimepicker]="picker"
[disabled]="disabled()"
[matTimepickerMin]="min()"
[matTimepickerMax]="max()"
[value]="value()"/>
<mat-timepicker
#picker
(opened)="openedSpy()"
(closed)="closedSpy()"
(selected)="selectedSpy($event)"
[interval]="interval()"
[options]="customOptions()"
[aria-label]="ariaLabel()"
[aria-labelledby]="ariaLabelledby()"/>
<mat-timepicker-toggle
[for]="picker"
[aria-label]="toggleAriaLabel()"
[disabled]="toggleDisabled()"
[tabIndex]="toggleTabIndex()"/>
`,
standalone: true,
imports: [MatTimepicker, MatTimepickerInput, MatTimepickerToggle],
})
class StandaloneTimepicker {
@ViewChild(MatTimepickerInput) input: MatTimepickerInput<Date>;
@ViewChild(MatTimepicker) timepicker: MatTimepicker<Date>;
readonly value = signal<Date | null>(null);
readonly disabled = signal(false);
readonly interval = signal<string | null>(null);
readonly min = signal<Date | string | null>(null);
readonly max = signal<Date | string | null>(null);
readonly ariaLabel = signal<string | null>(null);
readonly ariaLabelledby = signal<string | null>(null);
readonly toggleAriaLabel = signal<string | null>(null);
readonly toggleDisabled = signal<boolean>(false);
readonly toggleTabIndex = signal<number>(0);
readonly customOptions = signal<MatTimepickerOption<Date>[] | null>(null);
readonly openedSpy = jasmine.createSpy('opened');
readonly closedSpy = jasmine.createSpy('closed');
readonly selectedSpy = jasmine.createSpy('selected');
}
@Component({
template: `
<mat-form-field>
<mat-label>Pick a time</mat-label>
<input matInput [matTimepicker]="picker"/>
<mat-timepicker #picker/>
<mat-timepicker-toggle [for]="picker" matSuffix/>
</mat-form-field>
`,
standalone: true,
imports: [
MatTimepicker,
MatTimepickerInput,
MatTimepickerToggle,
MatInput,
MatLabel,
MatFormField,
MatSuffix,
],
})
class TimepickerInFormField {
@ViewChild(MatTimepicker) timepicker: MatTimepicker<Date>;
@ViewChild(MatTimepickerToggle) toggle: MatTimepickerToggle<Date>;
}
@Component({
template: `
<input [matTimepicker]="picker" [(value)]="value"/>
<mat-timepicker #picker/>
`,
standalone: true,
imports: [MatTimepicker, MatTimepickerInput],
})
class TimepickerTwoWayBinding {
@ViewChild(MatTimepickerInput) input: MatTimepickerInput<Date>;
readonly value = signal(new Date(2024, 0, 15, 10, 30, 0));
}
@Component({
template: `
<input
[formControl]="control"
[matTimepicker]="picker"
[matTimepickerMin]="min()"
[matTimepickerMax]="max()"/>
<mat-timepicker #picker/>
`,
standalone: true,
imports: [MatTimepicker, MatTimepickerInput, ReactiveFormsModule],
})
class TimepickerWithForms {
@ViewChild(MatTimepickerInput) input: MatTimepickerInput<Date>;
readonly control = new FormControl<Date | null>(null, [Validators.required]);
readonly min = signal<Date | null>(null);
readonly max = signal<Date | null>(null);
}
@Component({
template: `
<input [matTimepicker]="picker"/>
<input [matTimepicker]="picker"/>
<mat-timepicker #picker/>
`,
standalone: true,
imports: [MatTimepicker, MatTimepickerInput],
})
class TimepickerWithMultipleInputs {}
@Component({
template: '<mat-timepicker/>',
standalone: true,
imports: [MatTimepicker],
})
class TimepickerWithoutInput {
@ViewChild(MatTimepicker) timepicker: MatTimepicker<Date>;
}