Skip to content

Commit bfcb8de

Browse files
committed
feat(NcDateTimePicker): add time range picker and align naming
1. Add a time range picker mode 2. Align type names to `TYPE(-range)?` 3. Fix some issues related to time mode (also without range) Signed-off-by: Ferdinand Thiessen <[email protected]>
1 parent bd8537b commit bfcb8de

File tree

3 files changed

+100
-36
lines changed

3 files changed

+100
-36
lines changed

CHANGELOG.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ Especially the following are now provided as composables:
136136
- `NcModal`
137137
- `NcPopover`
138138
- `NcDateTimePicker`
139-
- The `range` property was removed in favor of `type="range"` (datetime ranges) and `type="range-date"` (date only ranges).
139+
- The `range` property was removed in favor of `type="datetime-range"` (datetime ranges), `type="date-range"` (date only ranges), and `type="time-range"` (time only ranges).
140140
- The `lang` property was replaced with the `locale` property.
141141
- The `formatter` property was removed.
142142

src/components/NcDateTimePicker/NcDateTimePicker.vue

+96-33
Original file line numberDiff line numberDiff line change
@@ -89,11 +89,13 @@ Meaning an array with two dates is used, the first date is the range start and t
8989
<div>
9090
<fieldset class="type-select">
9191
<legend>Picker mode</legend>
92-
<NcCheckboxRadioSwitch v-model="type" type="radio" value="range">Date</NcCheckboxRadioSwitch>
93-
<NcCheckboxRadioSwitch v-model="type" type="radio" value="range-datetime">Date and time</NcCheckboxRadioSwitch>
92+
<NcCheckboxRadioSwitch v-model="type" type="radio" value="date-range">Date</NcCheckboxRadioSwitch>
93+
<NcCheckboxRadioSwitch v-model="type" type="radio" value="time-range">Time</NcCheckboxRadioSwitch>
94+
<NcCheckboxRadioSwitch v-model="type" type="radio" value="datetime-range">Date and time</NcCheckboxRadioSwitch>
9495
</fieldset>
9596

9697
<NcDateTimePicker
98+
:key="type"
9799
v-model="time"
98100
:type />
99101
<div>
@@ -106,17 +108,20 @@ Meaning an array with two dates is used, the first date is the range start and t
106108
export default {
107109
data() {
108110
return {
109-
time: [new Date(2025, 3, 18), new Date(2025, 3, 21)],
110-
type: 'range',
111+
time: [new Date(2025, 3, 18, 12, 30), new Date(2025, 3, 21, 13, 30)],
112+
type: 'date-range',
111113
}
112114
},
113115
methods: {
114116
formatDate(date) {
115-
const text = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
116-
if (this.type === 'range') {
117-
return text
117+
const dateString = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
118+
const timeString = `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
119+
if (this.type === 'date-range') {
120+
return dateString
121+
} else if (this.type === 'time-range') {
122+
return timeString
118123
}
119-
return `${text} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
124+
return `${dateString} ${timeString}`
120125
},
121126
},
122127
}
@@ -161,6 +166,13 @@ export default {
161166
</docs>
162167

163168
<script setup lang="ts">
169+
import type {
170+
// The emitted object for time picker
171+
TimeObj as LibraryTimeObject,
172+
// The accepted model value
173+
ModelValue as LibraryModelValue,
174+
} from '@vuepic/vue-datepicker'
175+
164176
import {
165177
mdiCalendarBlank,
166178
mdiChevronDown,
@@ -271,11 +283,14 @@ const props = withDefaults(defineProps<{
271283

272284
/**
273285
* Type of the picker.
274-
* The 'range' type will enable a range picker for dates,
275-
* while 'range-datetime' will allow picking a date range with times.
286+
* There is some special handling for ranges as those types require a `[Date, Date]` model value.
287+
* - The 'date-range' type will enable a range picker for dates
288+
* - The 'time-range' allows picking a time range.
289+
* - The 'datetime-range' allows picking dates with times assigned.
290+
*
276291
* @default 'date'
277292
*/
278-
type?: 'date' | 'datetime' | 'time' | 'week' | 'month' | 'year' | 'range' | 'range-datetime'
293+
type?: 'date' | 'datetime' | 'time' | 'week' | 'month' | 'year' | 'date-range' | 'time-range' | 'datetime-range'
279294
}>(), {
280295
ariaLabel: t('Datepicker input'),
281296
ariaLabelMenu: t('Datepicker menu'),
@@ -303,8 +318,9 @@ const emit = defineEmits<{
303318
/**
304319
* If range picker is enabled then an array containing start and end date are emitted.
305320
* Otherwise the selected date is emitted.
321+
* `null` is emitted if `clearable` is set to `true` and the value was cleared.
306322
*/
307-
'update:modelValue': [Date | [Date, Date]]
323+
'update:modelValue': [Date | [Date, Date] | null]
308324
'update:timezoneId': [string]
309325
}>()
310326

@@ -323,20 +339,32 @@ const value = computed(() => {
323339
const end = new Date(date)
324340
end.setUTCDate(date.getUTCDate() + 6)
325341
return [date, end]
326-
} else if (props.type.startsWith('range')) {
342+
} else if (props.type === 'year') {
343+
const date = props.modelValue instanceof Date ? props.modelValue : new Date()
344+
return date.getUTCFullYear()
345+
} else if (props.type === 'month') {
346+
const date = props.modelValue instanceof Date ? props.modelValue : new Date()
347+
return { year: date.getUTCFullYear(), month: date.getUTCMonth() }
348+
} else if (props.type === 'time' || props.type === 'time-range') {
349+
const time = [props.modelValue ?? (props.type === 'time-range' ? [new Date(), new Date()] : new Date())].flat()
350+
// default time range is 1 hour
351+
if (props.modelValue === undefined && props.type === 'time-range') {
352+
time[1].setHours(time[1].getHours() + 1)
353+
}
354+
const timeValue = time.map((date) => ({
355+
hours: date.getHours(),
356+
minutes: date.getMinutes(),
357+
seconds: date.getSeconds(),
358+
} as LibraryTimeObject))
359+
return props.type === 'time' ? timeValue[0] : timeValue
360+
} else if (props.type.endsWith('-range')) {
327361
if (props.modelValue === undefined) {
328362
const start = new Date()
329363
const end = new Date(start)
330364
end.setUTCDate(start.getUTCDate() + 7)
331365
return [start, end]
332366
}
333367
return props.modelValue
334-
} else if (props.type === 'year') {
335-
const date = props.modelValue instanceof Date ? props.modelValue : new Date()
336-
return date.getUTCFullYear()
337-
} else if (props.type === 'month') {
338-
const date = props.modelValue instanceof Date ? props.modelValue : new Date()
339-
return { year: date.getUTCFullYear(), month: date.getUTCMonth() }
340368
}
341369

342370
// no special handling for other types needed
@@ -356,7 +384,7 @@ const placeholderFallback = computed(() => {
356384
return t('Select month')
357385
} else if (props.type === 'year') {
358386
return t('Select year')
359-
} else if (props.type.startsWith('range')) {
387+
} else if (props.type.endsWith('-range')) {
360388
return t('Select time range')
361389
}
362390
// should not be reached
@@ -377,10 +405,12 @@ const realFormat = computed(() => {
377405
}
378406

379407
let formatter: Intl.DateTimeFormat | undefined
380-
if (props.type === 'datetime' || props.type === 'range-datetime') {
381-
formatter = new Intl.DateTimeFormat(getCanonicalLocale(), { dateStyle: 'medium', timeStyle: 'short' })
382-
} else if (props.type === 'date' || props.type === 'range') {
408+
if (props.type === 'date' || props.type === 'date-range') {
383409
formatter = new Intl.DateTimeFormat(getCanonicalLocale(), { dateStyle: 'medium' })
410+
} else if (props.type === 'time' || props.type === 'time-range') {
411+
formatter = new Intl.DateTimeFormat(getCanonicalLocale(), { timeStyle: 'short' })
412+
} else if (props.type === 'datetime' || props.type === 'datetime-range') {
413+
formatter = new Intl.DateTimeFormat(getCanonicalLocale(), { dateStyle: 'medium', timeStyle: 'short' })
384414
} else if (props.type === 'month') {
385415
formatter = new Intl.DateTimeFormat(getCanonicalLocale(), { year: 'numeric', month: '2-digit' })
386416
} else if (props.type === 'year') {
@@ -398,17 +428,17 @@ const realFormat = computed(() => {
398428
})
399429

400430
const pickerType = computed(() => ({
401-
timePicker: props.type === 'time',
431+
timePicker: props.type === 'time' || props.type === 'time-range',
402432
yearPicker: props.type === 'year',
403433
monthPicker: props.type === 'month',
404434
weekPicker: props.type === 'week',
405-
range: props.type.startsWith('range') && {
435+
range: props.type.endsWith('-range') && {
406436
// do not use partial ranges (meaning after selecting the start [Date, null] will be emitted)
407437
// if this is needed someday we can enable it,
408438
// but its not covered by our component interface (props / events) documentation so just disabled for now.
409439
partialRange: false,
410440
},
411-
enableTimePicker: !(props.type === 'date' || props.type === 'range'),
441+
enableTimePicker: !(props.type === 'date' || props.type === 'date-range'),
412442
flow: props.type === 'datetime'
413443
? ['calendar', 'time'] as ['calendar', 'time']
414444
: undefined,
@@ -418,17 +448,50 @@ const pickerType = computed(() => ({
418448
* Called on model value update of the library.
419449
* @param value The value emitted from the underlying library
420450
*/
421-
function onUpdateModelValue(value: Date | [Date, Date] | number | { month: number, year: number }): void {
422-
let date = value as Date | [Date, Date]
423-
if (props.type === 'month') {
451+
function onUpdateModelValue(value: LibraryModelValue): void {
452+
if (value === null) {
453+
return emit('update:modelValue', null)
454+
}
455+
456+
if (props.type === 'time') {
457+
// time is provided as an object
458+
emit('update:modelValue', formatLibraryTime(value as LibraryTimeObject))
459+
} else if (props.type === 'time-range') {
460+
// same as time but as an array with two elements
461+
const start = formatLibraryTime(value[0])
462+
const end = formatLibraryTime(value[1])
463+
// ensure end is beyond the start
464+
if (end.getTime() < start.getTime()) {
465+
end.setDate(end.getDate() + 1)
466+
}
467+
emit('update:modelValue', [start, end])
468+
} else if (props.type === 'month') {
469+
// month is emitted as an object with month and year attribute
424470
const data = value as { month: number, year: number }
425-
date = new Date(data.year, data.month, 1)
471+
emit('update:modelValue', new Date(data.year, data.month, 1))
426472
} else if (props.type === 'year') {
427-
date = new Date(value as number, 0)
473+
// Years are emitted as the numeric year e.g. 2022
474+
emit('update:modelValue', new Date(value as number, 0))
428475
} else if (props.type === 'week') {
429-
date = value[0]
476+
// weeks are emitted as [Date, Date]
477+
emit('update:modelValue', value[0])
478+
} else {
479+
// otherwise it already emits the correct format
480+
emit('update:modelValue', value as Date | [Date, Date])
430481
}
431-
emit('update:modelValue', date)
482+
}
483+
484+
/**
485+
* Format a vuepick time object to native JS Date object.
486+
*
487+
* @param time - The library time value object
488+
*/
489+
function formatLibraryTime(time: LibraryTimeObject): Date {
490+
const date = new Date()
491+
date.setHours(time.hours)
492+
date.setMinutes(time.minutes)
493+
date.setSeconds(time.seconds)
494+
return date
432495
}
433496

434497
// Localization

tests/component/components/NcDateTimePicker.spec.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,9 @@ const testcases = [
2323
['week', new Date(2000, 0, 2, 3, 4), '1999-52'],
2424
['month', new Date(2000, 0, 2, 3, 4), '01/2000'],
2525
['year', new Date(2000, 0, 2, 3, 4), '2000'],
26-
['range', [new Date(2000, 0, 1), new Date(2000, 0, 7)] as [Date, Date], /Jan 1\s\s7, 2000/i],
27-
['range-datetime', [new Date(2000, 0, 1, 2, 3), new Date(2000, 0, 7, 8, 9)] as [Date, Date], /Jan 1, 2000, 2:03\sAM\s\sJan 7, 2000, 8:09\sAM/i],
26+
['date-range', [new Date(2000, 0, 1), new Date(2000, 0, 7)] as [Date, Date], /Jan 1\s\s7, 2000/i],
27+
['time-range', [new Date(2000, 0, 1, 2, 3), new Date(2000, 0, 1, 8, 9)] as [Date, Date], /2:03\s(AM\s)?\s8:09\sAM/i],
28+
['datetime-range', [new Date(2000, 0, 1, 2, 3), new Date(2000, 0, 7, 8, 9)] as [Date, Date], /Jan 1, 2000, 2:03\sAM\s\sJan 7, 2000, 8:09\sAM/i],
2829
] as const
2930

3031
for (const [type, modelValue, expectedValue] of testcases) {

0 commit comments

Comments
 (0)