Skip to content

Commit f103fd8

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 c89c406 commit f103fd8

8 files changed

Lines changed: 11692 additions & 11033 deletions

File tree

src/components/NcDateTimePicker/NcDateTimePicker.vue

Lines changed: 50 additions & 20 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, toRef, useTemplateRef } 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)
@@ -533,47 +537,74 @@ const placeholderFallback = computed(() => {
533537
})
534538
535539
const dateFnsLocale = useDateFnsLocale(toRef(() => props.locale))
536-
const dateFnsLocaleCode = computed(() => dateFnsLocale.value?.code || 'default')
540+
const dateFnsLocaleCode = computed(() => dateFnsLocale.value.code)
537541
538542
/**
539543
* The date (time) formatting to be used by the library.
540-
* We use the provided format if possible, otherwise we provide a formatting function
541-
* which uses the browsers Intl API to format the date / time in the current users locale.
544+
* We use the provided format if possible, otherwise we provide a localized formatting
545+
* using `date-fns/locale' which is mostly similiar to Intl.DateTime formating.
542546
*/
543547
const realFormat = computed<LibraryFormatOptions>(() => {
544548
if (props.format) {
545549
// we can cast the type here as in this case its either string
546550
// function `(Date) => string` or `([Date, Date]) => string` where we cast to `(Date[]) => string` here.
547551
return props.format as LibraryFormatOptions
548-
} else if (props.type === 'week') {
549-
// cannot format weeks with Intl.
550-
return 'RR-II'
551552
}
552553
553-
const locale = getCanonicalLocale()
554554
switch (props.type) {
555555
case 'date':
556-
return getDateFormat(locale)
557556
case 'date-range':
558-
return getDateRangeFormat(locale)
557+
return getDateFormat(dateFnsLocale.value)
559558
case 'time':
560-
return getTimeFormat(locale)
561559
case 'time-range':
562-
return getTimeRangeFormat(locale)
560+
return getTimeFormat()
563561
case 'datetime':
564-
return getDateTimeFormat(locale)
565562
case 'datetime-range':
566-
return getDateTimeRangeFormat(locale)
563+
return getDateTimeFormat(dateFnsLocale.value)
567564
case 'month':
568-
return getMonthFormat(locale)
565+
return getMonthFormat(dateFnsLocale.value)
569566
case 'year':
570-
return getYearFormat(locale)
567+
return getYearFormat(dateFnsLocale.value)
568+
case 'week':
569+
return getWeekFormat()
571570
}
572571
573572
// fallback to default formatting
574573
return undefined
575574
})
576575
576+
const textInput = computed<LibraryTextInputOptions>(() => {
577+
const realFormatVal = realFormat.value
578+
if (typeof realFormatVal === 'function') {
579+
// NOTE We could provide a format string through `textInput.format`.
580+
// Then Vuepic uses it when the user focuses the input.
581+
// But this feature has a bug.
582+
// It does not respect the provided locale when formatting.
583+
// See https://github.com/Vuepic/vue-datepicker/issues/1286
584+
//
585+
// Possible workarounds that seem not to be worth implementing for now:
586+
// - replace input value when user focuses the input by ourselves
587+
588+
// Disable text input if user provided a format function.
589+
// We do not know how to parse such values.
590+
return false
591+
}
592+
593+
if (typeof realFormatVal === 'string') {
594+
return {
595+
// This should not be required in Vuepic because `format` is automatically used for parsing.
596+
// But v11 has a bug because the format string ist cut off wrongly.
597+
// This is fixed in v12.1.0 and can then be simplified.
598+
// See https://github.com/Vuepic/vue-datepicker/issues/1208
599+
format: (value: string) => {
600+
return parse(value, realFormatVal, new Date(), { locale: (dateFnsLocale.value) })
601+
},
602+
}
603+
}
604+
605+
return true
606+
})
607+
577608
const pickerType = computed(() => ({
578609
timePicker: props.type === 'time' || props.type === 'time-range',
579610
yearPicker: props.type === 'year',
@@ -788,7 +819,6 @@ function sameDay(a: Date, b: Date): boolean {
788819
:placeholder="placeholder ?? placeholderFallback"
789820
:format="realFormat"
790821
:formatLocale="dateFnsLocale"
791-
:locale
792822
:minDate="calcMinMaxTime.minDate"
793823
:maxDate="calcMinMaxTime.maxDate"
794824
:minTime="calcMinMaxTime.minTime"
@@ -800,7 +830,7 @@ function sameDay(a: Date, b: Date): boolean {
800830
sixWeeks="fair"
801831
:inline
802832
:teleport="appendToBody ? (targetElement || undefined) : false"
803-
textInput
833+
:textInput
804834
:weekNumName
805835
:weekNumbers="showWeekNumber ? { type: 'iso' } : undefined"
806836
: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
}

src/components/NcDateTimePicker/useDateFnsLocale.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@
44
*/
55

66
import type { Locale } from 'date-fns'
7-
import type { MaybeRefOrGetter } from 'vue'
7+
import type { MaybeRefOrGetter, Ref } from 'vue'
88

99
import { computedAsync } from '@vueuse/core'
1010
import { enUS } from 'date-fns/locale/en-US'
11-
import { toValue } from 'vue'
11+
import { computed, toValue } from 'vue'
1212
import loader from './dateFnsLocaleLoader.ts'
1313

1414
/**
@@ -17,8 +17,9 @@ import loader from './dateFnsLocaleLoader.ts'
1717
*
1818
* @param locale locale code (e.g., 'de-DE')
1919
*/
20-
export default function useDateFnsLocale(locale: MaybeRefOrGetter<string>) {
21-
return computedAsync(() => loadDateFnsLocale(toValue(locale)), enUS)
20+
export default function useDateFnsLocale(locale: MaybeRefOrGetter<string>): Ref<Locale> {
21+
const loadedLocale = computedAsync(() => loadDateFnsLocale(toValue(locale)))
22+
return computed(() => loadedLocale.value ?? enUS)
2223
}
2324

2425
/**

0 commit comments

Comments
 (0)