Skip to content

Commit 141b4e8

Browse files
committed
fix(NcDateTimePicker): handle text input for default formatting
For some locales, the default formatting changes to accommodate the used parsing solution. Fixes #8635 Signed-off-by: Oleksandr Dzhychko <hey@oleks.dev>
1 parent a5a609a commit 141b4e8

7 files changed

Lines changed: 11686 additions & 11026 deletions

File tree

src/components/NcDateTimePicker/NcDateTimePicker.vue

Lines changed: 49 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -288,15 +288,17 @@ import {
288288
getFirstDay,
289289
} from '@nextcloud/l10n'
290290
import VueDatePicker from '@vuepic/vue-datepicker'
291+
import { parse } from 'date-fns/parse'
291292
import { computed, useTemplateRef, warn, watchEffect } from 'vue'
292293
import NcIconSvgWrapper from '../NcIconSvgWrapper/NcIconSvgWrapper.vue'
293294
import NcTimezonePicker from '../NcTimezonePicker/NcTimezonePicker.vue'
294295
import { t } from '../../l10n.ts'
295296
import NcButton from '../NcButton/index.ts'
296-
import { getDateFormat, getDateRangeFormat, getDateTimeFormat, getDateTimeRangeFormat, getMonthFormat, getTimeFormat, getTimeRangeFormat, getYearFormat } from './format.ts'
297+
import { getDateFormat, getDateTimeFormat, getMonthFormat, getTimeFormat, getWeekFormat, getYearFormat } from './format.ts'
297298
import useDateFnsLocale from './useDateFnsLocale.ts'
298299
299300
type LibraryFormatOptions = VueDatePickerProps['format']
301+
type LibraryTextInputOptions = VueDatePickerProps['textInput']
300302
301303
/**
302304
* The preselected IANA time zone ID for the time zone picker,
@@ -344,11 +346,13 @@ const props = withDefaults(defineProps<{
344346
confirm?: boolean
345347
346348
/**
347-
* Preview format for the picker input field.
349+
* Format for the picker input field.
348350
* Can either be a string of Unicode tokens or a function that takes a Date object
349351
* or for range picker an array of two dates, and returns the formatted date as string.
350352
*
351-
* @default Intl.DateTimeFormat is used to format dates and times
353+
* If no format is provided, localized date and time formats are used.
354+
* If a function is provided users cannot use text input.
355+
*
352356
* @see https://www.unicode.org/reports/tr35/tr35-dates.html#Date_Field_Symbol_Table
353357
*/
354358
format?: string | ((date: Date) => string) | ((dates: [Date, Date]) => string)
@@ -545,42 +549,70 @@ const dateFnsLocale = useDateFnsLocale(realLocale)
545549
546550
/**
547551
* The date (time) formatting to be used by the library.
548-
* We use the provided format if possible, otherwise we provide a formatting function
549-
* which uses the browsers Intl API to format the date / time in the current users locale.
552+
* We use the provided format if possible, otherwise we provide a localized formatting
553+
* using `date-fns/locale' which is mostly similiar to Intl.DateTime formating.
550554
*/
551555
const realFormat = computed<LibraryFormatOptions>(() => {
552556
if (props.format) {
553557
// we can cast the type here as in this case its either string
554558
// function `(Date) => string` or `([Date, Date]) => string` where we cast to `(Date[]) => string` here.
555559
return props.format as LibraryFormatOptions
556-
} else if (props.type === 'week') {
557-
// cannot format weeks with Intl.
558-
return 'RR-II'
559560
}
560561
561562
switch (props.type) {
562563
case 'date':
563-
return getDateFormat(realLocale)
564564
case 'date-range':
565-
return getDateRangeFormat(realLocale)
565+
return getDateFormat(dateFnsLocale.value)
566566
case 'time':
567-
return getTimeFormat(realLocale)
568567
case 'time-range':
569-
return getTimeRangeFormat(realLocale)
568+
return getTimeFormat()
570569
case 'datetime':
571-
return getDateTimeFormat(realLocale)
572570
case 'datetime-range':
573-
return getDateTimeRangeFormat(realLocale)
571+
return getDateTimeFormat(dateFnsLocale.value)
574572
case 'month':
575-
return getMonthFormat(realLocale)
573+
return getMonthFormat(dateFnsLocale.value)
576574
case 'year':
577-
return getYearFormat(realLocale)
575+
return getYearFormat(dateFnsLocale.value)
576+
case 'week':
577+
return getWeekFormat()
578578
}
579579
580580
// fallback to default formatting
581581
return undefined
582582
})
583583
584+
const textInput = computed<LibraryTextInputOptions>(() => {
585+
const realFormatVal = realFormat.value
586+
if (typeof realFormatVal === 'function') {
587+
// NOTE We could provide a format string through `textInput.format`.
588+
// Then Vuepic uses it when the user focuses the input.
589+
// But this feature has a bug.
590+
// It does not respect the provided locale when formatting.
591+
// See https://github.com/Vuepic/vue-datepicker/issues/1286
592+
//
593+
// Possible workarounds that seem not to be worth implementing for now:
594+
// - replace input value when user focuses the input by ourselves
595+
596+
// Disable text input if user provided a format function.
597+
// We do not know how to parse such values.
598+
return false
599+
}
600+
601+
if (typeof realFormatVal === 'string') {
602+
return {
603+
// This should not be required in Vuepic because `format` is automatically used for parsing.
604+
// But v11 has a bug because the format string ist cut off wrongly.
605+
// This is fixed in v12.1.0 and can then be simplified.
606+
// See https://github.com/Vuepic/vue-datepicker/issues/1208
607+
format: (value: string) => {
608+
return parse(value, realFormatVal, new Date(), { locale: (dateFnsLocale.value) })
609+
},
610+
}
611+
}
612+
613+
return true
614+
})
615+
584616
const pickerType = computed(() => ({
585617
timePicker: props.type === 'time' || props.type === 'time-range',
586618
yearPicker: props.type === 'year',
@@ -808,7 +840,7 @@ function sameDay(a: Date, b: Date): boolean {
808840
sixWeeks="fair"
809841
:inline
810842
:teleport="appendToBody ? (targetElement || undefined) : false"
811-
textInput
843+
:textInput
812844
:weekNumName
813845
:weekNumbers="showWeekNumber ? { type: 'iso' } : undefined"
814846
:weekStart

src/components/NcDateTimePicker/format.ts

Lines changed: 100 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -3,62 +3,121 @@
33
* SPDX-License-Identifier: AGPL-3.0-or-later
44
*/
55

6-
type DateFormat = ((date: Date) => string)
7-
type DateRangeFormat = ((dates: Date[]) => string)
8-
type LocaleCode = string
6+
import type { Locale } from 'date-fns/locale'
97

10-
export function getDateFormat(locale: LocaleCode): DateFormat {
11-
const formatter = new Intl.DateTimeFormat(locale, { dateStyle: 'medium' })
12-
return (date) => {
13-
return formatter.format(date)
8+
type DateFormat = string
9+
10+
/**
11+
* Provide the format string for dates.
12+
*
13+
* @param locale locale to get the format string for
14+
*/
15+
export function getDateFormat(locale: Locale): DateFormat {
16+
if (locale.code.split('-')[0] === 'de') {
17+
// For german 'P' aka. 'short' is similiar to `{ dateStyle: 'medium' }: Intl.DateTimeFormatOptions`
18+
// See https://github.com/date-fns/date-fns/blob/v4.1.0/src/locale/de/_lib/formatLong/index.ts#L8-L9
19+
return 'P'
1420
}
21+
// Usually 'PP' aka. 'medium' is similiar to `{ dateStyle: 'medium' }: Intl.DateTimeFormatOptions`
22+
return 'PP'
1523
}
1624

17-
export function getDateRangeFormat(locale: LocaleCode): DateRangeFormat {
18-
const formatter = new Intl.DateTimeFormat(locale, { dateStyle: 'medium' })
19-
return (dates) => {
20-
return formatter.formatRange(dates[0], dates[1])
21-
}
25+
/**
26+
* Provide the format string for times.
27+
*/
28+
export function getTimeFormat(): DateFormat {
29+
// 'p' aka. 'medium' is similiar to `{ timeStyle: 'medium' }: Intl.DateTimeFormatOptions`
30+
return 'p'
2231
}
2332

24-
export function getTimeFormat(locale: LocaleCode): DateFormat {
25-
const formatter = new Intl.DateTimeFormat(locale, { timeStyle: 'short' })
26-
return (date) => {
27-
return formatter.format(date)
28-
}
33+
/**
34+
* Provide the format string for date times.
35+
*
36+
* @param locale locale to get the format string for
37+
*/
38+
export function getDateTimeFormat(locale: Locale): DateFormat {
39+
return getDateFormat(locale) + getTimeFormat()
2940
}
3041

31-
export function getTimeRangeFormat(locale: LocaleCode): DateRangeFormat {
32-
const formatter = new Intl.DateTimeFormat(locale, { timeStyle: 'short' })
33-
return (dates) => {
34-
return formatter.formatRange(dates[0], dates[1])
35-
}
42+
/**
43+
* Provide the format string for weeks.
44+
*/
45+
export function getWeekFormat(): DateFormat {
46+
// cannot format weeks with Intl.
47+
// Do not use RR (or RRR) because what is formated with RR cannot be parsed with RR.
48+
// e.g. RR formats "2025" to "2025", but expects "25" when parsing.
49+
return 'RRRR-II'
3650
}
3751

38-
export function getDateTimeFormat(locale: LocaleCode): DateFormat {
39-
const formatter = new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' })
40-
return (date) => {
41-
return formatter.format(date)
42-
}
52+
/**
53+
* Provide the format string for month.
54+
*
55+
* @param locale locale to get the format string for
56+
*/
57+
export function getMonthFormat(locale: Locale): DateFormat {
58+
return guessFormat(locale.code, true)
4359
}
4460

45-
export function getDateTimeRangeFormat(locale: LocaleCode): DateRangeFormat {
46-
const formatter = new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' })
47-
return (dates) => {
48-
return formatter.formatRange(dates[0], dates[1])
49-
}
61+
/**
62+
* Provide the format string for year.
63+
*
64+
* @param locale locale to get the format string for
65+
*/
66+
export function getYearFormat(locale: Locale): DateFormat {
67+
return guessFormat(locale.code, false)
5068
}
5169

52-
export function getMonthFormat(locale: LocaleCode): DateFormat {
53-
const formatter = new Intl.DateTimeFormat(locale, { year: 'numeric', month: '2-digit' })
54-
return (date) => {
55-
return formatter.format(date)
70+
/**
71+
* Guess the format string for a numeric year with optionally a 2-digit month.
72+
*
73+
* @param localeCode locale to guess the format string for
74+
* @param includeMonth whether to include the month
75+
*/
76+
function guessFormat(localeCode: string, includeMonth: boolean): string {
77+
const sampleDate = new Date(2026, 0, 1)
78+
const formatOptions: Intl.DateTimeFormatOptions = {
79+
year: 'numeric',
80+
// Specify because other calendar systems are not supported
81+
calendar: 'gregory',
82+
}
83+
if (includeMonth) {
84+
formatOptions.month = '2-digit'
5685
}
86+
// Format extraction using `Intl.DateTime.formatToParts` is inspired from
87+
// `formatStr` in `@formkit/tempo` and `parseFormatForOpts` in `luxon`.
88+
const formatter = Intl.DateTimeFormat(localeCode, formatOptions)
89+
const parts = formatter.formatToParts(sampleDate)
90+
const partStrings = parts.map((part) => {
91+
switch (part.type) {
92+
case 'month':
93+
return 'MM'
94+
case 'year':
95+
return 'yyyy'
96+
case 'literal':
97+
return escapeLiteraForFormatString(part.value)
98+
// Everything else is not expected and supported.
99+
default:
100+
return ''
101+
}
102+
})
103+
return partStrings.join('')
57104
}
58105

59-
export function getYearFormat(locale: LocaleCode): DateFormat {
60-
const formatter = new Intl.DateTimeFormat(locale, { year: 'numeric' })
61-
return (date) => {
62-
return formatter.format(date)
63-
}
106+
/**
107+
* Escape literal value as required for by `date-fns/format`.
108+
*
109+
* > The characters wrapped between two single quotes characters (') are escaped.
110+
* > Two single quotes in a row, whether inside or outside a quoted sequence,
111+
* > represent a 'real' single quote. (see the last example).
112+
* https://date-fns.org/v4.4.0/docs/format
113+
*
114+
* @param literalValue literal value to be escaped
115+
*/
116+
function escapeLiteraForFormatString(literalValue): string {
117+
// > The characters wrapped between two single quotes characters (') are escaped.
118+
// > Two single quotes in a row, whether inside or outside a quoted sequence,
119+
// > represent a 'real' single quote. (see the last example).
120+
// https://date-fns.org/v4.1.0/docs/format
121+
literalValue = literalValue.replaceAll(/'/g, '\'\'')
122+
return literalValue.replaceAll(/[A-Za-z]+/g, (latinCharSeq) => `'${latinCharSeq}'`)
64123
}

tests/component/components/NcDateTimePicker/FormattingSnapshot.story.vue

Lines changed: 49 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,33 +8,68 @@
88
</template>
99

1010
<script setup lang="ts">
11-
import { getDateFormat, getDateRangeFormat, getDateTimeFormat, getDateTimeRangeFormat, getMonthFormat, getTimeFormat, getTimeRangeFormat, getYearFormat } from '../../../../src/components/NcDateTimePicker/format.ts'
11+
import type { Locale } from 'date-fns/locale'
12+
13+
import { format as dateFnsLocale } from 'date-fns/format'
14+
import * as allDateFnsLocales from 'date-fns/locale'
15+
import { getDateFormat, getDateTimeFormat, getMonthFormat, getTimeFormat, getYearFormat } from '../../../../src/components/NcDateTimePicker/format.ts'
1216
import allLocales from './allLocales.ts'
1317
1418
const date = new Date(2000, 0, 2, 3, 4)
15-
const dateRange = [new Date(2000, 0, 1), new Date(2000, 0, 7)]
16-
const timeRange = [new Date(2000, 0, 1, 2, 3), new Date(2000, 0, 1, 8, 9)]
17-
const dateTimeRange = [new Date(2000, 0, 1, 2, 3), new Date(2000, 0, 7, 8, 9)]
19+
const dateRange = [new Date(2000, 0, 1), new Date(2000, 0, 7)] as const
20+
const timeRange = [new Date(2000, 0, 1, 2, 3), new Date(2000, 0, 1, 8, 9)] as const
21+
const dateTimeRange = [new Date(2000, 0, 1, 2, 3), new Date(2000, 0, 7, 8, 9)] as const
1822
1923
const snapshot = {
2024
2125
}
2226
27+
const byLocaleCodeDateFnsLocale = new Map(Object.values(allDateFnsLocales).map((locale) => [locale.code, locale]))
28+
29+
function findDateFnsLocale(localeCode: string): Locale | undefined {
30+
const dateFnsLocale = byLocaleCodeDateFnsLocale.get(localeCode)
31+
if (dateFnsLocale !== undefined) {
32+
return dateFnsLocale
33+
}
34+
if (localeCode.includes('-')) {
35+
return findDateFnsLocale(localeCode.split('-')[0])
36+
}
37+
return undefined
38+
}
39+
40+
function format(date: Date, formatStr: string, locale: Locale) {
41+
return dateFnsLocale(date, formatStr, { locale })
42+
}
43+
44+
function formatRange(dates: readonly [Date, Date], formatStr: string, locale: Locale) {
45+
return format(dates[0], formatStr, locale) + ' - ' + format(dates[1], formatStr, locale)
46+
}
47+
2348
for (const locale of allLocales) {
2449
const localeCode = locale.code.replaceAll('_', '-')
25-
if (Intl.DateTimeFormat.supportedLocalesOf([localeCode]).length === 0) {
26-
continue
50+
51+
const supportedByIntl = Intl.DateTimeFormat.supportedLocalesOf([localeCode]).length !== 0
52+
let dateFnsLocale = findDateFnsLocale(localeCode)
53+
if (dateFnsLocale === undefined) {
54+
if (supportedByIntl) {
55+
dateFnsLocale = allDateFnsLocales.enUS
56+
} else {
57+
// Exclude snapshot if locale is not supported by Intl.DateTimeFormat or date-fns/locale.
58+
// Including such locales would only bloat the snapshot.
59+
continue
60+
}
2761
}
62+
2863
snapshot[localeCode] = {
2964
localeName: locale.name,
30-
date: getDateFormat(localeCode)(date),
31-
dateRange: getDateRangeFormat(localeCode)(dateRange),
32-
time: getTimeFormat(localeCode)(date),
33-
timeRange: getTimeRangeFormat(localeCode)(timeRange),
34-
dateTime: getDateTimeFormat(localeCode)(date),
35-
dateTimeRange: getDateTimeRangeFormat(localeCode)(dateTimeRange),
36-
month: getMonthFormat(localeCode)(date),
37-
year: getYearFormat(localeCode)(date),
65+
date: format(date, getDateFormat(dateFnsLocale), dateFnsLocale),
66+
dateRange: formatRange(dateRange, getDateFormat(dateFnsLocale), dateFnsLocale),
67+
time: format(date, getTimeFormat(), dateFnsLocale),
68+
timeRange: formatRange(timeRange, getTimeFormat(), dateFnsLocale),
69+
dateTime: format(date, getDateTimeFormat(dateFnsLocale), dateFnsLocale),
70+
dateTimeRange: formatRange(dateTimeRange, getDateTimeFormat(dateFnsLocale), dateFnsLocale),
71+
month: format(date, getMonthFormat(dateFnsLocale), dateFnsLocale),
72+
year: format(date, getYearFormat(dateFnsLocale), dateFnsLocale),
3873
}
3974
}
4075

0 commit comments

Comments
 (0)