Skip to content

Commit a8c41b9

Browse files
committed
refactor(material/timepicker): add logic to parse intervals and generate options
Adds the following utilities to the timpicker: * `parseInterval` - turns an interval value into a number of seconds. * `generateOptions` - generates a list of timepicker options between a minimum and maximum, and with a specific interval.
1 parent f70e351 commit a8c41b9

File tree

2 files changed

+273
-0
lines changed

2 files changed

+273
-0
lines changed

src/material/timepicker/util.spec.ts

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import {TestBed} from '@angular/core/testing';
2+
import {
3+
DateAdapter,
4+
MAT_DATE_FORMATS,
5+
MatDateFormats,
6+
provideNativeDateAdapter,
7+
} from '@angular/material/core';
8+
import {generateOptions, parseInterval} from './util';
9+
10+
describe('timepicker utilities', () => {
11+
describe('parseInterval', () => {
12+
it('should parse null', () => {
13+
expect(parseInterval(null)).toBe(null);
14+
});
15+
16+
it('should parse a number', () => {
17+
expect(parseInterval(75)).toBe(75);
18+
});
19+
20+
it('should parse a number in a string', () => {
21+
expect(parseInterval('75')).toBe(75);
22+
expect(parseInterval('75.50')).toBe(75.5);
23+
});
24+
25+
it('should handle invalid strings', () => {
26+
expect(parseInterval('')).toBe(null);
27+
expect(parseInterval(' ')).toBe(null);
28+
expect(parseInterval('abc')).toBe(null);
29+
expect(parseInterval('1a')).toBe(null);
30+
expect(parseInterval('m1')).toBe(null);
31+
expect(parseInterval('10.')).toBe(null);
32+
});
33+
34+
it('should parse hours', () => {
35+
expect(parseInterval('3h')).toBe(10_800);
36+
expect(parseInterval('4.5h')).toBe(16_200);
37+
expect(parseInterval('11h')).toBe(39_600);
38+
});
39+
40+
it('should parse minutes', () => {
41+
expect(parseInterval('3m')).toBe(180);
42+
expect(parseInterval('7.5m')).toBe(450);
43+
expect(parseInterval('90m')).toBe(5_400);
44+
expect(parseInterval('100.5m')).toBe(6_030);
45+
});
46+
47+
it('should parse seconds', () => {
48+
expect(parseInterval('3s')).toBe(3);
49+
expect(parseInterval('7.5s')).toBe(7.5);
50+
expect(parseInterval('90s')).toBe(90);
51+
expect(parseInterval('100.5s')).toBe(100.5);
52+
});
53+
54+
it('should parse uppercase units', () => {
55+
expect(parseInterval('3H')).toBe(10_800);
56+
expect(parseInterval('3M')).toBe(180);
57+
expect(parseInterval('3S')).toBe(3);
58+
});
59+
});
60+
61+
describe('generateOptions', () => {
62+
let adapter: DateAdapter<Date>;
63+
let formats: MatDateFormats;
64+
65+
beforeEach(() => {
66+
TestBed.configureTestingModule({providers: [provideNativeDateAdapter()]});
67+
adapter = TestBed.inject(DateAdapter);
68+
formats = TestBed.inject(MAT_DATE_FORMATS);
69+
adapter.setLocale('en-US');
70+
});
71+
72+
it('should generate a list of options', () => {
73+
const min = new Date(2024, 0, 1, 9, 0, 0, 0);
74+
const max = new Date(2024, 0, 1, 22, 0, 0, 0);
75+
const options = generateOptions(adapter, formats, min, max, 3600).map(o => o.label);
76+
expect(options).toEqual([
77+
'9:00 AM',
78+
'10:00 AM',
79+
'11:00 AM',
80+
'12:00 PM',
81+
'1:00 PM',
82+
'2:00 PM',
83+
'3:00 PM',
84+
'4:00 PM',
85+
'5:00 PM',
86+
'6:00 PM',
87+
'7:00 PM',
88+
'8:00 PM',
89+
'9:00 PM',
90+
'10:00 PM',
91+
]);
92+
});
93+
94+
it('should generate a list of options with a sub-hour interval', () => {
95+
const min = new Date(2024, 0, 1, 9, 0, 0, 0);
96+
const max = new Date(2024, 0, 1, 22, 0, 0, 0);
97+
const options = generateOptions(adapter, formats, min, max, 43 * 60).map(o => o.label);
98+
expect(options).toEqual([
99+
'9:00 AM',
100+
'9:43 AM',
101+
'10:26 AM',
102+
'11:09 AM',
103+
'11:52 AM',
104+
'12:35 PM',
105+
'1:18 PM',
106+
'2:01 PM',
107+
'2:44 PM',
108+
'3:27 PM',
109+
'4:10 PM',
110+
'4:53 PM',
111+
'5:36 PM',
112+
'6:19 PM',
113+
'7:02 PM',
114+
'7:45 PM',
115+
'8:28 PM',
116+
'9:11 PM',
117+
'9:54 PM',
118+
]);
119+
});
120+
121+
it('should generate a list of options with a minute interval', () => {
122+
const min = new Date(2024, 0, 1, 9, 0, 0, 0);
123+
const max = new Date(2024, 0, 1, 9, 16, 0, 0);
124+
const options = generateOptions(adapter, formats, min, max, 60).map(o => o.label);
125+
expect(options).toEqual([
126+
'9:00 AM',
127+
'9:01 AM',
128+
'9:02 AM',
129+
'9:03 AM',
130+
'9:04 AM',
131+
'9:05 AM',
132+
'9:06 AM',
133+
'9:07 AM',
134+
'9:08 AM',
135+
'9:09 AM',
136+
'9:10 AM',
137+
'9:11 AM',
138+
'9:12 AM',
139+
'9:13 AM',
140+
'9:14 AM',
141+
'9:15 AM',
142+
'9:16 AM',
143+
]);
144+
});
145+
146+
it('should generate a list of options with a sub-minute interval', () => {
147+
const previousFormat = formats.display.timeOptionLabel;
148+
formats.display.timeOptionLabel = {hour: 'numeric', minute: 'numeric', second: 'numeric'};
149+
const min = new Date(2024, 0, 1, 9, 0, 0, 0);
150+
const max = new Date(2024, 0, 1, 9, 3, 0, 0);
151+
const options = generateOptions(adapter, formats, min, max, 12).map(o => o.label);
152+
expect(options).toEqual([
153+
'9:00:00 AM',
154+
'9:00:12 AM',
155+
'9:00:24 AM',
156+
'9:00:36 AM',
157+
'9:00:48 AM',
158+
'9:01:00 AM',
159+
'9:01:12 AM',
160+
'9:01:24 AM',
161+
'9:01:36 AM',
162+
'9:01:48 AM',
163+
'9:02:00 AM',
164+
'9:02:12 AM',
165+
'9:02:24 AM',
166+
'9:02:36 AM',
167+
'9:02:48 AM',
168+
'9:03:00 AM',
169+
]);
170+
formats.display.timeOptionLabel = previousFormat;
171+
});
172+
173+
it('should generate at least one option if the interval is too large', () => {
174+
const min = new Date(2024, 0, 1, 0, 0, 0, 0);
175+
const max = new Date(2024, 0, 1, 23, 59, 0, 0);
176+
const options = generateOptions(adapter, formats, min, max, 60 * 60 * 24).map(o => o.label);
177+
expect(options).toEqual(['12:00 AM']);
178+
});
179+
180+
it('should generate at least one option if the max is later than the min', () => {
181+
const min = new Date(2024, 0, 1, 23, 0, 0, 0);
182+
const max = new Date(2024, 0, 1, 13, 0, 0, 0);
183+
const options = generateOptions(adapter, formats, min, max, 3600).map(o => o.label);
184+
expect(options).toEqual(['1:00 PM']);
185+
});
186+
});
187+
});

src/material/timepicker/util.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import {DateAdapter, MatDateFormats} from '@angular/material/core';
10+
11+
/** Pattern that interval strings have to match. */
12+
const INTERVAL_PATTERN = /^(\d*\.?\d+)(h|m|s)?$/i;
13+
14+
/**
15+
* Time selection option that can be displayed within a `mat-timepicker`.
16+
*/
17+
export interface MatTimepickerOption<D = unknown> {
18+
/** Date value of the option. */
19+
value: D;
20+
21+
/** Label to show to the user. */
22+
label: string;
23+
}
24+
25+
/** Parses an interval value into seconds. */
26+
export function parseInterval(value: number | string | null): number | null {
27+
let result: number;
28+
29+
if (value === null) {
30+
return null;
31+
} else if (typeof value === 'number') {
32+
result = value;
33+
} else {
34+
if (value.trim().length === 0) {
35+
return null;
36+
}
37+
38+
const parsed = value.match(INTERVAL_PATTERN);
39+
const amount = parsed ? parseFloat(parsed[1]) : null;
40+
const unit = parsed?.[2]?.toLowerCase() || null;
41+
42+
if (!parsed || amount === null || isNaN(amount)) {
43+
return null;
44+
}
45+
46+
if (unit === 'h') {
47+
result = amount * 3600;
48+
} else if (unit === 'm') {
49+
result = amount * 60;
50+
} else {
51+
result = amount;
52+
}
53+
}
54+
55+
return result;
56+
}
57+
58+
/**
59+
* Generates the options to show in a timepicker.
60+
* @param adapter Date adapter to be used to generate the options.
61+
* @param formats Formatting config to use when displaying the options.
62+
* @param min Time from which to start generating the options.
63+
* @param max Time at which to stop generating the options.
64+
* @param interval Amount of seconds between each option.
65+
*/
66+
export function generateOptions<D>(
67+
adapter: DateAdapter<D>,
68+
formats: MatDateFormats,
69+
min: D,
70+
max: D,
71+
interval: number,
72+
): MatTimepickerOption<D>[] {
73+
const options: MatTimepickerOption<D>[] = [];
74+
let current = adapter.compareTime(min, max) < 1 ? min : max;
75+
76+
while (
77+
adapter.sameDate(current, min) &&
78+
adapter.compareTime(current, max) < 1 &&
79+
adapter.isValid(current)
80+
) {
81+
options.push({value: current, label: adapter.format(current, formats.display.timeOptionLabel)});
82+
current = adapter.addSeconds(current, interval);
83+
}
84+
85+
return options;
86+
}

0 commit comments

Comments
 (0)