@juspay/svelte-ui-components 2.68.0 → 2.69.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -6,6 +6,16 @@
|
|
|
6
6
|
import Button from '../Button/Button.svelte';
|
|
7
7
|
import chevronDownSvg from '../assets/chevron-down.svg?raw';
|
|
8
8
|
import checkmarkSvg from '../assets/checkmark.svg?raw';
|
|
9
|
+
import chevronRightSvg from '../assets/chevron-right.svg?raw';
|
|
10
|
+
import {
|
|
11
|
+
TIME_DISPLAY_PATTERN,
|
|
12
|
+
applyTimeDisplay,
|
|
13
|
+
formatTimeDisplay,
|
|
14
|
+
toMinutesOfDay
|
|
15
|
+
} from './timeUtils';
|
|
16
|
+
|
|
17
|
+
const clockSvg =
|
|
18
|
+
'<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="8" cy="8" r="6.25" stroke="currentColor" stroke-width="1.5"/><path d="M8 4.5V8l2.25 1.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
|
9
19
|
|
|
10
20
|
let {
|
|
11
21
|
rangeStart = $bindable(null),
|
|
@@ -18,6 +28,8 @@
|
|
|
18
28
|
maxRangeDays = null,
|
|
19
29
|
presets = null,
|
|
20
30
|
presetCheckmark = false,
|
|
31
|
+
showDateInputs = false,
|
|
32
|
+
showTimeSelection = false,
|
|
21
33
|
placeholder = 'Select date',
|
|
22
34
|
dualMonth,
|
|
23
35
|
timePicker,
|
|
@@ -58,6 +70,14 @@
|
|
|
58
70
|
// made a custom calendar selection (or before any selection is made).
|
|
59
71
|
let selectedPresetLabel: string | null = $state(null);
|
|
60
72
|
|
|
73
|
+
// Built-in time-of-day selection (opt-in via showTimeSelection). Display strings
|
|
74
|
+
// are 12-hour ("02:30 PM"); they seed from the draft dates when the picker opens
|
|
75
|
+
// and are combined back onto the committed range in handleApply. showTimeRow drives
|
|
76
|
+
// the collapsible time inputs, toggled by the clock button in the date-input row.
|
|
77
|
+
let startTimeDisplay: string = $state('12:00 AM');
|
|
78
|
+
let endTimeDisplay: string = $state('11:59 PM');
|
|
79
|
+
let showTimeRow: boolean = $state(false);
|
|
80
|
+
|
|
61
81
|
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
62
82
|
|
|
63
83
|
function isBaseDisabledDate(date: Date): boolean {
|
|
@@ -170,6 +190,29 @@
|
|
|
170
190
|
return d.toLocaleDateString(locale, { month: 'short', day: 'numeric', year: 'numeric' });
|
|
171
191
|
}
|
|
172
192
|
|
|
193
|
+
// Read-only date-input box contents (opt-in via showDateInputs), reflecting the draft.
|
|
194
|
+
const draftStartDateLabel: string = $derived(draftStart !== null ? formatDate(draftStart) : '');
|
|
195
|
+
const draftEndDateLabel: string = $derived(draftEnd !== null ? formatDate(draftEnd) : '');
|
|
196
|
+
|
|
197
|
+
const isStartTimeValid: boolean = $derived(TIME_DISPLAY_PATTERN.test(startTimeDisplay.trim()));
|
|
198
|
+
const isEndTimeValid: boolean = $derived(TIME_DISPLAY_PATTERN.test(endTimeDisplay.trim()));
|
|
199
|
+
// Apply is blocked while a time is malformed, or while both ends fall on the same
|
|
200
|
+
// calendar day and the start time is after the end time. Different days are fine.
|
|
201
|
+
const isTimeRangeValid: boolean = $derived.by(() => {
|
|
202
|
+
if (!showTimeSelection) {
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
if (!isStartTimeValid || !isEndTimeValid) {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
if (draftStart === null || draftEnd === null || !isSameDay(draftStart, draftEnd)) {
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
const startMinutes = toMinutesOfDay(startTimeDisplay);
|
|
212
|
+
const endMinutes = toMinutesOfDay(endTimeDisplay);
|
|
213
|
+
return startMinutes === null || endMinutes === null || startMinutes <= endMinutes;
|
|
214
|
+
});
|
|
215
|
+
|
|
173
216
|
const triggerLabel: string = $derived.by(() => {
|
|
174
217
|
// If an initial preset label is active (seeded on mount, not yet overridden), show it
|
|
175
218
|
if (activePresetLabel !== null) {
|
|
@@ -206,6 +249,14 @@
|
|
|
206
249
|
// by label. A direct calendar click later clears this, reverting to date matching.
|
|
207
250
|
selectedPresetLabel = committedPresetLabel;
|
|
208
251
|
|
|
252
|
+
// Seed the time inputs from the committed range's time-of-day, and collapse the
|
|
253
|
+
// time row so each open starts from the date view.
|
|
254
|
+
if (showTimeSelection) {
|
|
255
|
+
startTimeDisplay = draftStart !== null ? formatTimeDisplay(draftStart) : '12:00 AM';
|
|
256
|
+
endTimeDisplay = draftEnd !== null ? formatTimeDisplay(draftEnd) : '11:59 PM';
|
|
257
|
+
showTimeRow = false;
|
|
258
|
+
}
|
|
259
|
+
|
|
209
260
|
// Navigate left calendar so it shows the committed start month (or today)
|
|
210
261
|
const anchor = rangeStart !== null ? rangeStart : value !== null ? value : now;
|
|
211
262
|
leftYear = anchor.getFullYear();
|
|
@@ -221,6 +272,17 @@
|
|
|
221
272
|
onopentoggle?.({ open: false });
|
|
222
273
|
}
|
|
223
274
|
|
|
275
|
+
// The trigger toggles: clicking it while the panel is already open dismisses
|
|
276
|
+
// the picker instead of re-opening it onto itself. The open-only handler left
|
|
277
|
+
// a second click feeling dead — the panel could only be closed by clicking away.
|
|
278
|
+
function togglePicker(): void {
|
|
279
|
+
if (isOpen) {
|
|
280
|
+
closePicker();
|
|
281
|
+
} else {
|
|
282
|
+
openPicker();
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
224
286
|
function openComparePicker(): void {
|
|
225
287
|
compareFocusReturnEl =
|
|
226
288
|
document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
@@ -273,10 +335,21 @@
|
|
|
273
335
|
activePresetLabel = null;
|
|
274
336
|
if (mode === 'range') {
|
|
275
337
|
if (draftStart !== null && draftEnd !== null) {
|
|
276
|
-
|
|
277
|
-
|
|
338
|
+
// Fold the time-of-day inputs onto the committed dates when time selection is on.
|
|
339
|
+
const appliedStart = showTimeSelection
|
|
340
|
+
? (applyTimeDisplay(draftStart, startTimeDisplay) ?? draftStart)
|
|
341
|
+
: draftStart;
|
|
342
|
+
const appliedEnd = showTimeSelection
|
|
343
|
+
? (applyTimeDisplay(draftEnd, endTimeDisplay) ?? draftEnd)
|
|
344
|
+
: draftEnd;
|
|
345
|
+
rangeStart = appliedStart;
|
|
346
|
+
rangeEnd = appliedEnd;
|
|
278
347
|
committedPresetLabel = selectedPresetLabel;
|
|
279
|
-
onapply?.({
|
|
348
|
+
onapply?.({
|
|
349
|
+
rangeStart: appliedStart,
|
|
350
|
+
rangeEnd: appliedEnd,
|
|
351
|
+
presetLabel: selectedPresetLabel
|
|
352
|
+
});
|
|
280
353
|
}
|
|
281
354
|
if (
|
|
282
355
|
typeof compareCalendar === 'function' &&
|
|
@@ -389,7 +462,7 @@
|
|
|
389
462
|
|
|
390
463
|
const canApply: boolean = $derived.by(() => {
|
|
391
464
|
if (mode === 'range') {
|
|
392
|
-
return draftStart !== null && draftEnd !== null;
|
|
465
|
+
return draftStart !== null && draftEnd !== null && isTimeRangeValid;
|
|
393
466
|
}
|
|
394
467
|
return draftValue !== null;
|
|
395
468
|
});
|
|
@@ -442,7 +515,7 @@
|
|
|
442
515
|
<!-- Trigger wrapper — bind:this here so outside-click detection works -->
|
|
443
516
|
<div bind:this={triggerRef} class="drp-trigger-wrapper">
|
|
444
517
|
<Button
|
|
445
|
-
onclick={
|
|
518
|
+
onclick={togglePicker}
|
|
446
519
|
ariaLabel="Open date picker"
|
|
447
520
|
classes="drp-trigger {isOpen ? 'drp-trigger-open' : ''}"
|
|
448
521
|
>
|
|
@@ -578,6 +651,68 @@
|
|
|
578
651
|
|
|
579
652
|
<!-- Calendar area -->
|
|
580
653
|
<div class="drp-calendars">
|
|
654
|
+
{#if (showDateInputs || showTimeSelection) && mode === 'range'}
|
|
655
|
+
<div class="drp-datetime-header">
|
|
656
|
+
<div class="drp-date-input-row">
|
|
657
|
+
<div class="drp-date-input" data-pw={testId ? `${testId}-start-date` : null}>
|
|
658
|
+
<span class="drp-date-input-value">{draftStartDateLabel || 'Start date'}</span>
|
|
659
|
+
</div>
|
|
660
|
+
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
|
661
|
+
<span class="drp-datetime-arrow" aria-hidden="true">{@html chevronRightSvg}</span>
|
|
662
|
+
<div class="drp-date-input" data-pw={testId ? `${testId}-end-date` : null}>
|
|
663
|
+
<span class="drp-date-input-value">{draftEndDateLabel || 'End date'}</span>
|
|
664
|
+
</div>
|
|
665
|
+
{#if showTimeSelection}
|
|
666
|
+
<button
|
|
667
|
+
type="button"
|
|
668
|
+
class="drp-time-toggle"
|
|
669
|
+
class:drp-time-toggle-active={showTimeRow}
|
|
670
|
+
aria-label="Toggle time selection"
|
|
671
|
+
aria-pressed={showTimeRow}
|
|
672
|
+
data-pw={testId ? `${testId}-time-toggle` : null}
|
|
673
|
+
onclick={() => (showTimeRow = !showTimeRow)}
|
|
674
|
+
>
|
|
675
|
+
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
|
676
|
+
<span class="drp-time-toggle-icon" aria-hidden="true">{@html clockSvg}</span>
|
|
677
|
+
</button>
|
|
678
|
+
{/if}
|
|
679
|
+
</div>
|
|
680
|
+
{#if showTimeSelection && showTimeRow}
|
|
681
|
+
<div class="drp-time-input-row">
|
|
682
|
+
<div class="drp-time-input" class:drp-time-input-invalid={!isStartTimeValid}>
|
|
683
|
+
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
|
684
|
+
<span class="drp-time-input-icon" aria-hidden="true">{@html clockSvg}</span>
|
|
685
|
+
<input
|
|
686
|
+
type="text"
|
|
687
|
+
class="drp-time-field"
|
|
688
|
+
bind:value={startTimeDisplay}
|
|
689
|
+
maxlength="8"
|
|
690
|
+
placeholder="12:00 AM"
|
|
691
|
+
aria-label="Start time"
|
|
692
|
+
aria-invalid={!isStartTimeValid}
|
|
693
|
+
data-pw={testId ? `${testId}-start-time` : null}
|
|
694
|
+
/>
|
|
695
|
+
</div>
|
|
696
|
+
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
|
697
|
+
<span class="drp-datetime-arrow" aria-hidden="true">{@html chevronRightSvg}</span>
|
|
698
|
+
<div class="drp-time-input" class:drp-time-input-invalid={!isEndTimeValid}>
|
|
699
|
+
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
|
700
|
+
<span class="drp-time-input-icon" aria-hidden="true">{@html clockSvg}</span>
|
|
701
|
+
<input
|
|
702
|
+
type="text"
|
|
703
|
+
class="drp-time-field"
|
|
704
|
+
bind:value={endTimeDisplay}
|
|
705
|
+
maxlength="8"
|
|
706
|
+
placeholder="11:59 PM"
|
|
707
|
+
aria-label="End time"
|
|
708
|
+
aria-invalid={!isEndTimeValid}
|
|
709
|
+
data-pw={testId ? `${testId}-end-time` : null}
|
|
710
|
+
/>
|
|
711
|
+
</div>
|
|
712
|
+
</div>
|
|
713
|
+
{/if}
|
|
714
|
+
</div>
|
|
715
|
+
{/if}
|
|
581
716
|
{#if isDualMonth}
|
|
582
717
|
<!-- Dual-month layout with shared nav -->
|
|
583
718
|
<div class="drp-dual-header">
|
|
@@ -930,6 +1065,132 @@
|
|
|
930
1065
|
flex-wrap: wrap;
|
|
931
1066
|
}
|
|
932
1067
|
|
|
1068
|
+
/* ── Built-in date + time inputs (showDateInputs / showTimeSelection) ── */
|
|
1069
|
+
.drp-datetime-header {
|
|
1070
|
+
display: flex;
|
|
1071
|
+
flex-direction: column;
|
|
1072
|
+
gap: var(--drp-datetime-gap, 8px);
|
|
1073
|
+
padding-bottom: var(--drp-datetime-padding-bottom, 12px);
|
|
1074
|
+
margin-bottom: var(--drp-datetime-margin-bottom, 4px);
|
|
1075
|
+
border-bottom: var(--drp-datetime-divider, 1px solid #e8e8e8);
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
.drp-date-input-row,
|
|
1079
|
+
.drp-time-input-row {
|
|
1080
|
+
display: flex;
|
|
1081
|
+
align-items: center;
|
|
1082
|
+
gap: var(--drp-datetime-row-gap, 12px);
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
.drp-date-input {
|
|
1086
|
+
flex: 1;
|
|
1087
|
+
min-width: 0;
|
|
1088
|
+
padding: var(--drp-date-input-padding, 10px 14px);
|
|
1089
|
+
border: var(--drp-date-input-border, 1px solid #d4d4d4);
|
|
1090
|
+
border-radius: var(--drp-date-input-radius, 8px);
|
|
1091
|
+
background: var(--drp-date-input-background, #ffffff);
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
.drp-date-input-value {
|
|
1095
|
+
display: block;
|
|
1096
|
+
color: var(--drp-date-input-color, #333333);
|
|
1097
|
+
font-size: var(--drp-date-input-font-size, 13px);
|
|
1098
|
+
white-space: nowrap;
|
|
1099
|
+
overflow: hidden;
|
|
1100
|
+
text-overflow: ellipsis;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
.drp-datetime-arrow {
|
|
1104
|
+
flex-shrink: 0;
|
|
1105
|
+
display: inline-flex;
|
|
1106
|
+
width: var(--drp-datetime-arrow-size, 16px);
|
|
1107
|
+
height: var(--drp-datetime-arrow-size, 16px);
|
|
1108
|
+
color: var(--drp-datetime-arrow-color, #888888);
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
.drp-datetime-arrow :global(svg) {
|
|
1112
|
+
width: 100%;
|
|
1113
|
+
height: 100%;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
.drp-time-toggle {
|
|
1117
|
+
flex-shrink: 0;
|
|
1118
|
+
display: inline-flex;
|
|
1119
|
+
align-items: center;
|
|
1120
|
+
justify-content: center;
|
|
1121
|
+
width: var(--drp-time-toggle-size, 40px);
|
|
1122
|
+
height: var(--drp-time-toggle-size, 40px);
|
|
1123
|
+
padding: 0;
|
|
1124
|
+
border: var(--drp-time-toggle-border, 1px solid #d4d4d4);
|
|
1125
|
+
border-radius: var(--drp-time-toggle-radius, 8px);
|
|
1126
|
+
background: var(--drp-time-toggle-background, #f6f7f9);
|
|
1127
|
+
color: var(--drp-time-toggle-color, #555555);
|
|
1128
|
+
cursor: pointer;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
.drp-time-toggle-active {
|
|
1132
|
+
border-color: var(--drp-time-toggle-active-border, currentColor);
|
|
1133
|
+
color: var(--drp-time-toggle-active-color, #1b85ff);
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
.drp-time-toggle-icon {
|
|
1137
|
+
display: inline-flex;
|
|
1138
|
+
width: var(--drp-time-toggle-icon-size, 16px);
|
|
1139
|
+
height: var(--drp-time-toggle-icon-size, 16px);
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
.drp-time-toggle-icon :global(svg) {
|
|
1143
|
+
width: 100%;
|
|
1144
|
+
height: 100%;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
.drp-time-input {
|
|
1148
|
+
position: relative;
|
|
1149
|
+
flex: 1;
|
|
1150
|
+
min-width: 0;
|
|
1151
|
+
display: flex;
|
|
1152
|
+
align-items: center;
|
|
1153
|
+
border: var(--drp-time-input-border, 1px solid #d4d4d4);
|
|
1154
|
+
border-radius: var(--drp-time-input-radius, 8px);
|
|
1155
|
+
background: var(--drp-time-input-background, #ffffff);
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
.drp-time-input-invalid {
|
|
1159
|
+
border-color: var(--drp-time-input-invalid-border, #e5484d);
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
.drp-time-input-icon {
|
|
1163
|
+
display: inline-flex;
|
|
1164
|
+
flex-shrink: 0;
|
|
1165
|
+
width: var(--drp-time-input-icon-size, 16px);
|
|
1166
|
+
height: var(--drp-time-input-icon-size, 16px);
|
|
1167
|
+
margin-left: var(--drp-time-input-icon-gap, 12px);
|
|
1168
|
+
color: var(--drp-time-input-icon-color, #888888);
|
|
1169
|
+
pointer-events: none;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
.drp-time-input-icon :global(svg) {
|
|
1173
|
+
width: 100%;
|
|
1174
|
+
height: 100%;
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
.drp-time-field {
|
|
1178
|
+
flex: 1;
|
|
1179
|
+
min-width: 0;
|
|
1180
|
+
width: 100%;
|
|
1181
|
+
padding: var(--drp-time-field-padding, 10px 14px 10px 8px);
|
|
1182
|
+
border: none;
|
|
1183
|
+
outline: none;
|
|
1184
|
+
background: transparent;
|
|
1185
|
+
color: var(--drp-time-field-color, #333333);
|
|
1186
|
+
font-size: var(--drp-time-field-font-size, 13px);
|
|
1187
|
+
font-family: inherit;
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
.drp-time-field::placeholder {
|
|
1191
|
+
color: var(--drp-time-field-placeholder-color, #aaaaaa);
|
|
1192
|
+
}
|
|
1193
|
+
|
|
933
1194
|
/* ── Compare calendar slot ── */
|
|
934
1195
|
.drp-compare-section {
|
|
935
1196
|
display: flex;
|
|
@@ -40,6 +40,18 @@ export type OptionalDateRangePickerProperties = {
|
|
|
40
40
|
* its highlighted background regardless of this flag. Default: false.
|
|
41
41
|
*/
|
|
42
42
|
presetCheckmark?: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Show read-only start/end date boxes at the top of the calendar area (range mode),
|
|
45
|
+
* reflecting the current draft selection. Opt-in. Default: false.
|
|
46
|
+
*/
|
|
47
|
+
showDateInputs?: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Show built-in time-of-day selection (range mode): a clock toggle in the date-input
|
|
50
|
+
* row that reveals start/end time inputs ("HH:MM AM/PM"). On Apply the entered times
|
|
51
|
+
* are folded onto the committed range's start/end. Implies the date-input row.
|
|
52
|
+
* Opt-in. Default: false.
|
|
53
|
+
*/
|
|
54
|
+
showTimeSelection?: boolean;
|
|
43
55
|
/** Placeholder shown when no range is selected. */
|
|
44
56
|
placeholder?: string;
|
|
45
57
|
/** Show two months side by side. Defaults to true for range mode, false for single. */
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Matches a 12-hour clock display such as "2:30 PM" or "02:05 am". */
|
|
2
|
+
export declare const TIME_DISPLAY_PATTERN: RegExp;
|
|
3
|
+
/** Parse a 12-hour display string into 24-hour hours/minutes, or null when invalid. */
|
|
4
|
+
export declare const parseTimeDisplay: (display: string) => {
|
|
5
|
+
hours: number;
|
|
6
|
+
minutes: number;
|
|
7
|
+
} | null;
|
|
8
|
+
/** Format a Date's time-of-day as a 12-hour display string, e.g. "02:30 PM". */
|
|
9
|
+
export declare const formatTimeDisplay: (date: Date) => string;
|
|
10
|
+
/** Return a new Date with the time-of-day from a 12-hour display string applied, or null when invalid. */
|
|
11
|
+
export declare const applyTimeDisplay: (date: Date, display: string) => Date | null;
|
|
12
|
+
/** Minutes-since-midnight for ordering comparisons, or null when the display is invalid. */
|
|
13
|
+
export declare const toMinutesOfDay: (display: string) => number | null;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Time-of-day helpers for the DateRangePicker's built-in date + time inputs.
|
|
2
|
+
/** Matches a 12-hour clock display such as "2:30 PM" or "02:05 am". */
|
|
3
|
+
export const TIME_DISPLAY_PATTERN = /^(1[0-2]|0?[1-9]):([0-5][0-9])\s?(AM|PM)$/i;
|
|
4
|
+
const pad = (n) => n.toString().padStart(2, '0');
|
|
5
|
+
/** Parse a 12-hour display string into 24-hour hours/minutes, or null when invalid. */
|
|
6
|
+
export const parseTimeDisplay = (display) => {
|
|
7
|
+
const match = TIME_DISPLAY_PATTERN.exec(display.trim());
|
|
8
|
+
if (match === null) {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
let hours = Number(match[1]);
|
|
12
|
+
const minutes = Number(match[2]);
|
|
13
|
+
const meridiem = match[3].toUpperCase();
|
|
14
|
+
if (meridiem === 'PM' && hours !== 12) {
|
|
15
|
+
hours += 12;
|
|
16
|
+
}
|
|
17
|
+
else if (meridiem === 'AM' && hours === 12) {
|
|
18
|
+
hours = 0;
|
|
19
|
+
}
|
|
20
|
+
return { hours, minutes };
|
|
21
|
+
};
|
|
22
|
+
/** Format a Date's time-of-day as a 12-hour display string, e.g. "02:30 PM". */
|
|
23
|
+
export const formatTimeDisplay = (date) => {
|
|
24
|
+
const hours24 = date.getHours();
|
|
25
|
+
const meridiem = hours24 >= 12 ? 'PM' : 'AM';
|
|
26
|
+
const hours12 = hours24 % 12 === 0 ? 12 : hours24 % 12;
|
|
27
|
+
return `${pad(hours12)}:${pad(date.getMinutes())} ${meridiem}`;
|
|
28
|
+
};
|
|
29
|
+
/** Return a new Date with the time-of-day from a 12-hour display string applied, or null when invalid. */
|
|
30
|
+
export const applyTimeDisplay = (date, display) => {
|
|
31
|
+
const parsed = parseTimeDisplay(display);
|
|
32
|
+
if (parsed === null) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
const next = new Date(date.getTime());
|
|
36
|
+
next.setHours(parsed.hours, parsed.minutes, 0, 0);
|
|
37
|
+
return next;
|
|
38
|
+
};
|
|
39
|
+
/** Minutes-since-midnight for ordering comparisons, or null when the display is invalid. */
|
|
40
|
+
export const toMinutesOfDay = (display) => {
|
|
41
|
+
const parsed = parseTimeDisplay(display);
|
|
42
|
+
return parsed === null ? null : parsed.hours * 60 + parsed.minutes;
|
|
43
|
+
};
|