svelte-date-input 0.0.0-stage → 0.2.0
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.
- package/LICENSE +22 -0
- package/README.md +42 -2
- package/dist/DateInput.svelte +38 -0
- package/dist/DateInput.svelte.d.ts +16 -0
- package/dist/Divider.svelte +13 -0
- package/dist/Divider.svelte.d.ts +7 -0
- package/dist/SegmentInput.svelte +106 -0
- package/dist/SegmentInput.svelte.d.ts +22 -0
- package/dist/SegmentSelect.svelte +60 -0
- package/dist/SegmentSelect.svelte.d.ts +19 -0
- package/dist/SegmentedInput.svelte +363 -0
- package/dist/SegmentedInput.svelte.d.ts +54 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +8 -0
- package/dist/shared/fields.d.ts +51 -0
- package/dist/shared/fields.js +163 -0
- package/dist/shared/format.d.ts +19 -0
- package/dist/shared/format.js +99 -0
- package/dist/shared/types.d.ts +6 -0
- package/dist/shared/types.js +1 -0
- package/package.json +69 -4
- package/src/DateInput.svelte +38 -0
- package/src/Divider.svelte +13 -0
- package/src/SegmentInput.svelte +106 -0
- package/src/SegmentSelect.svelte +60 -0
- package/src/SegmentedInput.svelte +363 -0
- package/src/index.ts +28 -0
- package/src/shared/fields.ts +245 -0
- package/src/shared/format.ts +129 -0
- package/src/shared/types.ts +19 -0
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { getDaysInMonth } from 'svelte-date-utils';
|
|
3
|
+
import { getUserLocale } from 'get-user-locale';
|
|
4
|
+
import { onMount, untrack } from 'svelte';
|
|
5
|
+
|
|
6
|
+
import Divider from './Divider.svelte';
|
|
7
|
+
import SegmentInput from './SegmentInput.svelte';
|
|
8
|
+
import SegmentSelect from './SegmentSelect.svelte';
|
|
9
|
+
import {
|
|
10
|
+
composeDate,
|
|
11
|
+
emptyFields,
|
|
12
|
+
fieldBySegment,
|
|
13
|
+
fieldsFromDate,
|
|
14
|
+
formatTime,
|
|
15
|
+
fromNativeValue,
|
|
16
|
+
toNativeValue,
|
|
17
|
+
valueToDate,
|
|
18
|
+
} from './shared/fields.js';
|
|
19
|
+
import {
|
|
20
|
+
getAmPmLabels,
|
|
21
|
+
getDefaultFormatParts,
|
|
22
|
+
getMonthNames,
|
|
23
|
+
parseFormat,
|
|
24
|
+
} from './shared/format.js';
|
|
25
|
+
|
|
26
|
+
import type { Fields } from './shared/fields.js';
|
|
27
|
+
import type { InputDetail, InputKind, LooseValuePiece, SegmentName } from './shared/types.js';
|
|
28
|
+
|
|
29
|
+
type SegmentedInputProps = {
|
|
30
|
+
/** `aria-label` for the AM/PM select. */
|
|
31
|
+
amPmAriaLabel?: string;
|
|
32
|
+
/** Automatically focuses the first field on mount. */
|
|
33
|
+
autoFocus?: boolean;
|
|
34
|
+
/** Base class name. Fields get `${className}__input`, dividers get `${className}__divider`, and so on. */
|
|
35
|
+
className?: string;
|
|
36
|
+
/** `form` attribute for the custom fields (but not the native input). */
|
|
37
|
+
customInputsForm?: string;
|
|
38
|
+
dayAriaLabel?: string;
|
|
39
|
+
/** @default '--' */
|
|
40
|
+
dayPlaceholder?: string;
|
|
41
|
+
disabled?: boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Input format based on Unicode Technical Standard #35. Supported tokens: `y`, `M`, `MM`, `MMM`, `MMMM`, `d`, `dd`, `H`, `HH`, `h`, `hh`, `m`, `mm`, `s`, `ss`, `a`.
|
|
44
|
+
*/
|
|
45
|
+
format?: string;
|
|
46
|
+
hourAriaLabel?: string;
|
|
47
|
+
/** @default '--' */
|
|
48
|
+
hourPlaceholder?: string;
|
|
49
|
+
/** Which kind of value the input edits. */
|
|
50
|
+
kind: InputKind;
|
|
51
|
+
locale?: string;
|
|
52
|
+
maxDate?: Date;
|
|
53
|
+
/** The most detailed field the user shall see. */
|
|
54
|
+
maxDetail?: InputDetail;
|
|
55
|
+
minDate?: Date;
|
|
56
|
+
minuteAriaLabel?: string;
|
|
57
|
+
/** @default '--' */
|
|
58
|
+
minutePlaceholder?: string;
|
|
59
|
+
monthAriaLabel?: string;
|
|
60
|
+
/** @default '--' */
|
|
61
|
+
monthPlaceholder?: string;
|
|
62
|
+
/** `name` of the native input. */
|
|
63
|
+
name?: string;
|
|
64
|
+
nativeInputAriaLabel?: string;
|
|
65
|
+
/** Called when the value changes. For the `time` kind, the value is an `HH:mm[:ss]` string. */
|
|
66
|
+
onChange?: (value: Date | string | null, shouldCloseWidgets: boolean) => void;
|
|
67
|
+
/** Called when the fields contain an incomplete or invalid value. */
|
|
68
|
+
onInvalidChange?: () => void;
|
|
69
|
+
required?: boolean;
|
|
70
|
+
secondAriaLabel?: string;
|
|
71
|
+
/** @default '--' */
|
|
72
|
+
secondPlaceholder?: string;
|
|
73
|
+
showLeadingZeros?: boolean;
|
|
74
|
+
value?: LooseValuePiece;
|
|
75
|
+
yearAriaLabel?: string;
|
|
76
|
+
/** @default '----' */
|
|
77
|
+
yearPlaceholder?: string;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
let {
|
|
81
|
+
amPmAriaLabel,
|
|
82
|
+
autoFocus,
|
|
83
|
+
className = '',
|
|
84
|
+
customInputsForm,
|
|
85
|
+
dayAriaLabel,
|
|
86
|
+
dayPlaceholder = '--',
|
|
87
|
+
disabled,
|
|
88
|
+
format,
|
|
89
|
+
hourAriaLabel,
|
|
90
|
+
hourPlaceholder = '--',
|
|
91
|
+
kind,
|
|
92
|
+
locale,
|
|
93
|
+
maxDate,
|
|
94
|
+
maxDetail,
|
|
95
|
+
minDate,
|
|
96
|
+
minuteAriaLabel,
|
|
97
|
+
minutePlaceholder = '--',
|
|
98
|
+
monthAriaLabel,
|
|
99
|
+
monthPlaceholder = '--',
|
|
100
|
+
name,
|
|
101
|
+
nativeInputAriaLabel,
|
|
102
|
+
onChange,
|
|
103
|
+
onInvalidChange,
|
|
104
|
+
required,
|
|
105
|
+
secondAriaLabel,
|
|
106
|
+
secondPlaceholder = '--',
|
|
107
|
+
showLeadingZeros = false,
|
|
108
|
+
value,
|
|
109
|
+
yearAriaLabel,
|
|
110
|
+
yearPlaceholder = '----',
|
|
111
|
+
}: SegmentedInputProps = $props();
|
|
112
|
+
|
|
113
|
+
let root = $state<HTMLDivElement>();
|
|
114
|
+
|
|
115
|
+
const resolvedLocale = $derived(locale ?? getUserLocale());
|
|
116
|
+
const detail = $derived<InputDetail>(maxDetail ?? (kind === 'date' ? 'month' : 'minute'));
|
|
117
|
+
const parts = $derived(
|
|
118
|
+
format ? parseFormat(format) : getDefaultFormatParts(resolvedLocale, kind, detail),
|
|
119
|
+
);
|
|
120
|
+
const present = $derived(
|
|
121
|
+
new Set(parts.flatMap((part) => (part.type === 'segment' ? [part.segment] : []))),
|
|
122
|
+
);
|
|
123
|
+
const use12Hours = $derived(present.has('hour12'));
|
|
124
|
+
const dividers = $derived(
|
|
125
|
+
[...new Set(parts.flatMap((part) => (part.type === 'literal' ? [...part.text] : [])))]
|
|
126
|
+
.filter((character) => !/\d/.test(character))
|
|
127
|
+
.join(''),
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const amPmLabels = $derived(getAmPmLabels(resolvedLocale));
|
|
131
|
+
const amPmOptions = $derived([
|
|
132
|
+
{ label: amPmLabels[0], value: 'am' },
|
|
133
|
+
{ label: amPmLabels[1], value: 'pm' },
|
|
134
|
+
]);
|
|
135
|
+
|
|
136
|
+
let fields = $state<Fields>(emptyFields());
|
|
137
|
+
|
|
138
|
+
const dateValue = $derived(valueToDate(value, kind));
|
|
139
|
+
// Primitive key, so that parents passing equal-but-new Date objects don't reset the fields
|
|
140
|
+
const valueKey = $derived(
|
|
141
|
+
dateValue
|
|
142
|
+
? kind === 'time'
|
|
143
|
+
? dateValue.getHours() * 3600 + dateValue.getMinutes() * 60 + dateValue.getSeconds()
|
|
144
|
+
: dateValue.getTime()
|
|
145
|
+
: null,
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
$effect(() => {
|
|
149
|
+
void valueKey;
|
|
150
|
+
const twelveHours = use12Hours;
|
|
151
|
+
|
|
152
|
+
untrack(() => {
|
|
153
|
+
fields = dateValue ? fieldsFromDate(dateValue, twelveHours) : emptyFields();
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
function emit(date: Date): Date | string {
|
|
158
|
+
return kind === 'time' ? formatTime(date, detail) : date;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function process(nextFields: Fields) {
|
|
162
|
+
fields = nextFields;
|
|
163
|
+
|
|
164
|
+
const result = composeDate({
|
|
165
|
+
detail,
|
|
166
|
+
fields: nextFields,
|
|
167
|
+
max: kind === 'time' ? undefined : maxDate,
|
|
168
|
+
min: kind === 'time' ? undefined : minDate,
|
|
169
|
+
present,
|
|
170
|
+
use12Hours,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
switch (result.status) {
|
|
174
|
+
case 'empty':
|
|
175
|
+
if (valueKey !== null) {
|
|
176
|
+
onChange?.(null, false);
|
|
177
|
+
}
|
|
178
|
+
break;
|
|
179
|
+
case 'valid':
|
|
180
|
+
onChange?.(emit(result.date), false);
|
|
181
|
+
break;
|
|
182
|
+
default:
|
|
183
|
+
onInvalidChange?.();
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function setField(field: keyof Fields, next: string) {
|
|
188
|
+
process({ ...fields, [field]: next });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function onNativeChange(event: Event & { currentTarget: HTMLInputElement }) {
|
|
192
|
+
const date = fromNativeValue(event.currentTarget.value, kind);
|
|
193
|
+
|
|
194
|
+
if (date) {
|
|
195
|
+
fields = fieldsFromDate(date, use12Hours);
|
|
196
|
+
onChange?.(emit(date), false);
|
|
197
|
+
} else {
|
|
198
|
+
fields = emptyFields();
|
|
199
|
+
onChange?.(null, false);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function navigate(element: HTMLElement, direction: -1 | 1) {
|
|
204
|
+
const segments = [...(root?.querySelectorAll<HTMLElement>('[data-segment]') ?? [])];
|
|
205
|
+
const target = segments[segments.indexOf(element) + direction];
|
|
206
|
+
|
|
207
|
+
if (target) {
|
|
208
|
+
target.focus();
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
onMount(() => {
|
|
213
|
+
if (autoFocus) {
|
|
214
|
+
root?.querySelector<HTMLElement>('[data-segment]')?.focus();
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const nativeType = $derived(
|
|
219
|
+
kind === 'date' ? 'date' : kind === 'time' ? 'time' : 'datetime-local',
|
|
220
|
+
);
|
|
221
|
+
const nativeStep = $derived(
|
|
222
|
+
kind === 'date' ? undefined : detail === 'second' ? 1 : detail === 'hour' ? 3600 : 60,
|
|
223
|
+
);
|
|
224
|
+
const nativeValue = $derived(dateValue ? toNativeValue(dateValue, kind, detail) : '');
|
|
225
|
+
const nativeMin = $derived(
|
|
226
|
+
minDate && kind !== 'time' ? toNativeValue(minDate, kind, detail) : undefined,
|
|
227
|
+
);
|
|
228
|
+
const nativeMax = $derived(
|
|
229
|
+
maxDate && kind !== 'time' ? toNativeValue(maxDate, kind, detail) : undefined,
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
const ariaLabels = $derived<Record<SegmentName, string | undefined>>({
|
|
233
|
+
amPm: amPmAriaLabel,
|
|
234
|
+
day: dayAriaLabel,
|
|
235
|
+
hour12: hourAriaLabel,
|
|
236
|
+
hour24: hourAriaLabel,
|
|
237
|
+
minute: minuteAriaLabel,
|
|
238
|
+
month: monthAriaLabel,
|
|
239
|
+
second: secondAriaLabel,
|
|
240
|
+
year: yearAriaLabel,
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const placeholders = $derived<Record<SegmentName, string>>({
|
|
244
|
+
amPm: '--',
|
|
245
|
+
day: dayPlaceholder,
|
|
246
|
+
hour12: hourPlaceholder,
|
|
247
|
+
hour24: hourPlaceholder,
|
|
248
|
+
minute: minutePlaceholder,
|
|
249
|
+
month: monthPlaceholder,
|
|
250
|
+
second: secondPlaceholder,
|
|
251
|
+
year: yearPlaceholder,
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
function getBounds(segment: SegmentName): { max: number; maxLength: number; min: number } {
|
|
255
|
+
switch (segment) {
|
|
256
|
+
case 'year':
|
|
257
|
+
return {
|
|
258
|
+
max: maxDate && kind !== 'time' ? maxDate.getFullYear() : 275760,
|
|
259
|
+
maxLength: 4,
|
|
260
|
+
min: minDate && kind !== 'time' ? minDate.getFullYear() : 1,
|
|
261
|
+
};
|
|
262
|
+
case 'month':
|
|
263
|
+
return { max: 12, maxLength: 2, min: 1 };
|
|
264
|
+
case 'day': {
|
|
265
|
+
const year = Number(fields.year);
|
|
266
|
+
const month = Number(fields.month);
|
|
267
|
+
const daysInMonth =
|
|
268
|
+
fields.year && fields.month && month >= 1 && month <= 12
|
|
269
|
+
? getDaysInMonth(new Date(year, month - 1, 1))
|
|
270
|
+
: 31;
|
|
271
|
+
|
|
272
|
+
return { max: daysInMonth, maxLength: 2, min: 1 };
|
|
273
|
+
}
|
|
274
|
+
case 'hour12':
|
|
275
|
+
return { max: 12, maxLength: 2, min: 1 };
|
|
276
|
+
case 'hour24':
|
|
277
|
+
return { max: 23, maxLength: 2, min: 0 };
|
|
278
|
+
case 'minute':
|
|
279
|
+
case 'second':
|
|
280
|
+
return { max: 59, maxLength: 2, min: 0 };
|
|
281
|
+
default:
|
|
282
|
+
return { max: 0, maxLength: 0, min: 0 };
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function getMonthOptions(token: string) {
|
|
287
|
+
return getMonthNames(resolvedLocale, token === 'MMM' ? 'short' : 'long').map(
|
|
288
|
+
(label, index) => ({
|
|
289
|
+
label,
|
|
290
|
+
value: String(index + 1),
|
|
291
|
+
}),
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
</script>
|
|
295
|
+
|
|
296
|
+
<div bind:this={root} class={className}>
|
|
297
|
+
<input
|
|
298
|
+
aria-label={nativeInputAriaLabel}
|
|
299
|
+
{disabled}
|
|
300
|
+
max={nativeMax}
|
|
301
|
+
min={nativeMin}
|
|
302
|
+
{name}
|
|
303
|
+
onchange={onNativeChange}
|
|
304
|
+
{required}
|
|
305
|
+
step={nativeStep}
|
|
306
|
+
style="visibility: hidden; position: absolute; z-index: -999;"
|
|
307
|
+
type={nativeType}
|
|
308
|
+
value={nativeValue}
|
|
309
|
+
/>
|
|
310
|
+
{#each parts as part, index (index)}
|
|
311
|
+
{#if part.type === 'literal'}
|
|
312
|
+
<Divider class="{className}__divider" text={part.text} />
|
|
313
|
+
{:else if part.segment === 'amPm'}
|
|
314
|
+
<SegmentSelect
|
|
315
|
+
ariaLabel={ariaLabels.amPm}
|
|
316
|
+
{className}
|
|
317
|
+
{disabled}
|
|
318
|
+
form={customInputsForm}
|
|
319
|
+
name="amPm"
|
|
320
|
+
onChange={(next) => setField('amPm', next)}
|
|
321
|
+
onNavigate={navigate}
|
|
322
|
+
options={amPmOptions}
|
|
323
|
+
placeholder={placeholders.amPm}
|
|
324
|
+
{required}
|
|
325
|
+
value={fields.amPm}
|
|
326
|
+
/>
|
|
327
|
+
{:else if part.segment === 'month' && part.token.length >= 3}
|
|
328
|
+
<SegmentSelect
|
|
329
|
+
ariaLabel={ariaLabels.month}
|
|
330
|
+
{className}
|
|
331
|
+
{disabled}
|
|
332
|
+
form={customInputsForm}
|
|
333
|
+
name="month"
|
|
334
|
+
onChange={(next) => setField('month', next)}
|
|
335
|
+
onNavigate={navigate}
|
|
336
|
+
options={getMonthOptions(part.token)}
|
|
337
|
+
placeholder={placeholders.month}
|
|
338
|
+
{required}
|
|
339
|
+
value={fields.month}
|
|
340
|
+
/>
|
|
341
|
+
{:else}
|
|
342
|
+
{@const bounds = getBounds(part.segment)}
|
|
343
|
+
{@const field = fieldBySegment[part.segment]}
|
|
344
|
+
<SegmentInput
|
|
345
|
+
ariaLabel={ariaLabels[part.segment]}
|
|
346
|
+
{className}
|
|
347
|
+
{disabled}
|
|
348
|
+
{dividers}
|
|
349
|
+
form={customInputsForm}
|
|
350
|
+
max={bounds.max}
|
|
351
|
+
maxLength={bounds.maxLength}
|
|
352
|
+
min={bounds.min}
|
|
353
|
+
name={part.segment}
|
|
354
|
+
onChange={(next) => setField(field, next)}
|
|
355
|
+
onNavigate={navigate}
|
|
356
|
+
placeholder={placeholders[part.segment]}
|
|
357
|
+
{required}
|
|
358
|
+
showLeadingZeros={part.segment !== 'year' && (showLeadingZeros || part.token.length === 2)}
|
|
359
|
+
value={fields[field]}
|
|
360
|
+
/>
|
|
361
|
+
{/if}
|
|
362
|
+
{/each}
|
|
363
|
+
</div>
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import DateInput from './DateInput.svelte';
|
|
2
|
+
import Divider from './Divider.svelte';
|
|
3
|
+
import SegmentedInput from './SegmentedInput.svelte';
|
|
4
|
+
import SegmentInput from './SegmentInput.svelte';
|
|
5
|
+
import SegmentSelect from './SegmentSelect.svelte';
|
|
6
|
+
|
|
7
|
+
import type { ComponentProps } from 'svelte';
|
|
8
|
+
|
|
9
|
+
export type DateInputProps = ComponentProps<typeof DateInput>;
|
|
10
|
+
export type SegmentedInputProps = ComponentProps<typeof SegmentedInput>;
|
|
11
|
+
export type {
|
|
12
|
+
DateDetail,
|
|
13
|
+
InputDetail,
|
|
14
|
+
InputKind,
|
|
15
|
+
LooseValuePiece,
|
|
16
|
+
SegmentName,
|
|
17
|
+
TimeDetail,
|
|
18
|
+
} from './shared/types.js';
|
|
19
|
+
export type { FormatPart } from './shared/format.js';
|
|
20
|
+
export {
|
|
21
|
+
getAmPmLabels,
|
|
22
|
+
getDefaultFormatParts,
|
|
23
|
+
getMonthNames,
|
|
24
|
+
parseFormat,
|
|
25
|
+
} from './shared/format.js';
|
|
26
|
+
export { DateInput, Divider, SegmentedInput, SegmentInput, SegmentSelect };
|
|
27
|
+
|
|
28
|
+
export default DateInput;
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { getISOLocalDate, getISOLocalDateTime } from 'svelte-date-utils';
|
|
2
|
+
|
|
3
|
+
import type { InputDetail, InputKind, LooseValuePiece, SegmentName } from './types.js';
|
|
4
|
+
|
|
5
|
+
export type Fields = {
|
|
6
|
+
amPm: '' | 'am' | 'pm';
|
|
7
|
+
day: string;
|
|
8
|
+
hour: string;
|
|
9
|
+
minute: string;
|
|
10
|
+
month: string;
|
|
11
|
+
second: string;
|
|
12
|
+
year: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type FieldName = keyof Fields;
|
|
16
|
+
|
|
17
|
+
export const fieldBySegment: Record<SegmentName, FieldName> = {
|
|
18
|
+
amPm: 'amPm',
|
|
19
|
+
day: 'day',
|
|
20
|
+
hour12: 'hour',
|
|
21
|
+
hour24: 'hour',
|
|
22
|
+
minute: 'minute',
|
|
23
|
+
month: 'month',
|
|
24
|
+
second: 'second',
|
|
25
|
+
year: 'year',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function emptyFields(): Fields {
|
|
29
|
+
return { amPm: '', day: '', hour: '', minute: '', month: '', second: '', year: '' };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function fieldsFromDate(date: Date, use12Hours: boolean): Fields {
|
|
33
|
+
const hours = date.getHours();
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
amPm: hours >= 12 ? 'pm' : 'am',
|
|
37
|
+
day: String(date.getDate()),
|
|
38
|
+
hour: String(use12Hours ? hours % 12 || 12 : hours),
|
|
39
|
+
minute: String(date.getMinutes()),
|
|
40
|
+
month: String(date.getMonth() + 1),
|
|
41
|
+
second: String(date.getSeconds()),
|
|
42
|
+
year: String(date.getFullYear()),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function floorTo(date: Date, detail: InputDetail): Date {
|
|
47
|
+
const year = date.getFullYear();
|
|
48
|
+
const month = date.getMonth();
|
|
49
|
+
const day = date.getDate();
|
|
50
|
+
|
|
51
|
+
switch (detail) {
|
|
52
|
+
case 'year':
|
|
53
|
+
return new Date(year, 0, 1);
|
|
54
|
+
case 'month':
|
|
55
|
+
return new Date(year, month, day);
|
|
56
|
+
case 'hour':
|
|
57
|
+
return new Date(year, month, day, date.getHours());
|
|
58
|
+
case 'minute':
|
|
59
|
+
return new Date(year, month, day, date.getHours(), date.getMinutes());
|
|
60
|
+
case 'second':
|
|
61
|
+
return new Date(year, month, day, date.getHours(), date.getMinutes(), date.getSeconds());
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type ComposeResult =
|
|
66
|
+
| { status: 'empty' }
|
|
67
|
+
| { status: 'partial' }
|
|
68
|
+
| { status: 'invalid' }
|
|
69
|
+
| { status: 'valid'; date: Date };
|
|
70
|
+
|
|
71
|
+
type ComposeOptions = {
|
|
72
|
+
detail: InputDetail;
|
|
73
|
+
fields: Fields;
|
|
74
|
+
max?: Date;
|
|
75
|
+
min?: Date;
|
|
76
|
+
present: ReadonlySet<SegmentName>;
|
|
77
|
+
use12Hours: boolean;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Builds a Date from field values. Fields excluded from the format fall back to
|
|
82
|
+
* the current year, January, the 1st, and 00:00:00.
|
|
83
|
+
*/
|
|
84
|
+
export function composeDate({
|
|
85
|
+
detail,
|
|
86
|
+
fields,
|
|
87
|
+
max,
|
|
88
|
+
min,
|
|
89
|
+
present,
|
|
90
|
+
use12Hours,
|
|
91
|
+
}: ComposeOptions): ComposeResult {
|
|
92
|
+
const names = [...new Set([...present].map((segment) => fieldBySegment[segment]))];
|
|
93
|
+
const filled = names.filter((name) => fields[name] !== '');
|
|
94
|
+
|
|
95
|
+
if (filled.length === 0) {
|
|
96
|
+
return { status: 'empty' };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (filled.length < names.length) {
|
|
100
|
+
return { status: 'partial' };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const hasHour = present.has('hour12') || present.has('hour24');
|
|
104
|
+
const year = present.has('year') ? Number(fields.year) : new Date().getFullYear();
|
|
105
|
+
const month = present.has('month') ? Number(fields.month) - 1 : 0;
|
|
106
|
+
const day = present.has('day') ? Number(fields.day) : 1;
|
|
107
|
+
let hour = hasHour ? Number(fields.hour) : 0;
|
|
108
|
+
const minute = present.has('minute') ? Number(fields.minute) : 0;
|
|
109
|
+
const second = present.has('second') ? Number(fields.second) : 0;
|
|
110
|
+
|
|
111
|
+
const numbers = [year, month, day, hour, minute, second];
|
|
112
|
+
|
|
113
|
+
if (numbers.some((value) => !Number.isInteger(value))) {
|
|
114
|
+
return { status: 'invalid' };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (use12Hours) {
|
|
118
|
+
if (hour < 1 || hour > 12) {
|
|
119
|
+
return { status: 'invalid' };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
hour = (hour % 12) + (fields.amPm === 'pm' ? 12 : 0);
|
|
123
|
+
} else if (hour < 0 || hour > 23) {
|
|
124
|
+
return { status: 'invalid' };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (year < 1 || month < 0 || month > 11 || day < 1 || day > 31) {
|
|
128
|
+
return { status: 'invalid' };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (minute < 0 || minute > 59 || second < 0 || second > 59) {
|
|
132
|
+
return { status: 'invalid' };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const date = new Date(0);
|
|
136
|
+
date.setFullYear(year, month, day);
|
|
137
|
+
date.setHours(hour, minute, second, 0);
|
|
138
|
+
|
|
139
|
+
if (date.getFullYear() !== year || date.getMonth() !== month || date.getDate() !== day) {
|
|
140
|
+
return { status: 'invalid' };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const floored = floorTo(date, detail).getTime();
|
|
144
|
+
|
|
145
|
+
if (
|
|
146
|
+
(min && floored < floorTo(min, detail).getTime()) ||
|
|
147
|
+
(max && floored > floorTo(max, detail).getTime())
|
|
148
|
+
) {
|
|
149
|
+
return { status: 'invalid' };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return { date, status: 'valid' };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function pad(value: number): string {
|
|
156
|
+
return String(value).padStart(2, '0');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function parseTime(
|
|
160
|
+
value: string,
|
|
161
|
+
): { hours: number; minutes: number; seconds: number } | null {
|
|
162
|
+
const match = /^(\d{1,2}):(\d{2})(?::(\d{2}))?/.exec(value);
|
|
163
|
+
|
|
164
|
+
if (!match) {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
hours: Number(match[1]),
|
|
170
|
+
minutes: Number(match[2]),
|
|
171
|
+
seconds: Number(match[3] ?? 0),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function formatTime(date: Date, detail: InputDetail): string {
|
|
176
|
+
const time = `${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
177
|
+
|
|
178
|
+
return detail === 'second' ? `${time}:${pad(date.getSeconds())}` : time;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Converts a loose value to a Date. For the `time` kind, `"HH:mm[:ss]"` strings are
|
|
183
|
+
* accepted and resolved against today's date.
|
|
184
|
+
*/
|
|
185
|
+
export function valueToDate(value: LooseValuePiece | undefined, kind: InputKind): Date | null {
|
|
186
|
+
if (value === null || value === undefined || value === '') {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (kind === 'time' && typeof value === 'string') {
|
|
191
|
+
const time = parseTime(value);
|
|
192
|
+
|
|
193
|
+
if (!time) {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const date = new Date();
|
|
198
|
+
date.setHours(time.hours, time.minutes, time.seconds, 0);
|
|
199
|
+
|
|
200
|
+
return date;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
204
|
+
|
|
205
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function toNativeValue(date: Date, kind: InputKind, detail: InputDetail): string {
|
|
209
|
+
switch (kind) {
|
|
210
|
+
case 'date':
|
|
211
|
+
return getISOLocalDate(date);
|
|
212
|
+
case 'time':
|
|
213
|
+
return formatTime(date, detail);
|
|
214
|
+
case 'datetime':
|
|
215
|
+
return getISOLocalDateTime(date).slice(0, detail === 'second' ? 19 : 16);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function fromNativeValue(value: string, kind: InputKind): Date | null {
|
|
220
|
+
if (!value) {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (kind === 'time') {
|
|
225
|
+
return valueToDate(value, 'time');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (kind === 'date') {
|
|
229
|
+
const [year, month, day] = value.split('-').map(Number);
|
|
230
|
+
|
|
231
|
+
if (!year || !month || !day) {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const date = new Date(0);
|
|
236
|
+
date.setFullYear(year, month - 1, day);
|
|
237
|
+
date.setHours(0, 0, 0, 0);
|
|
238
|
+
|
|
239
|
+
return date;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const date = new Date(value);
|
|
243
|
+
|
|
244
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
245
|
+
}
|