Skip to content

Commit d25f659

Browse files
committed
fix(NcDateTimePicker): use translated words in dates formatted with a format string
Signed-off-by: Oleksandr Dzhychko <hey@oleks.dev>
1 parent 65a6f32 commit d25f659

9 files changed

Lines changed: 186 additions & 2 deletions

File tree

build/date-fns-locales-plugin.mts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/*!
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: CC0-1.0
4+
*/
5+
import type { Plugin } from 'vite'
6+
7+
import { exactRegex } from '@rolldown/pluginutils'
8+
import { glob } from 'fs/promises'
9+
import { createRequire } from 'module'
10+
import { dirname, join } from 'path'
11+
12+
/**
13+
* This plugin generates a module that can be used to dynamically load locales from `date-fns`.
14+
* It generates it in a way that it works with the externalization of the `date-fns` locales
15+
* and with Vite and Webpack apps.
16+
*
17+
* A plugin is used because Vite's dynamic imports and glob imports cannot generate imports in such a way.
18+
*/
19+
export default () => {
20+
let localeFiles: string[] = []
21+
const virtualModuleId = 'virtual:date-fns-locales'
22+
const resolvedVirtualModuleId = '\0' + virtualModuleId
23+
return {
24+
name: 'date-fns-locales-plugin',
25+
enforce: 'pre',
26+
async buildStart() {
27+
this.info('Collectioning locales in date-fns/locales')
28+
const require = createRequire(import.meta.url)
29+
const pkgJsonPath = require.resolve('date-fns/package.json')
30+
const localeDir = join(dirname(pkgJsonPath), 'locale')
31+
localeFiles = await Array.fromAsync(glob('*.js', {
32+
cwd: localeDir,
33+
exclude: ['types.js', 'en-US.js', 'cdn.js', 'cdn.min.js'],
34+
}))
35+
},
36+
resolveId: {
37+
filter: { id: /dateFnsLocaleLoader\.ts$/ },
38+
handler() {
39+
return resolvedVirtualModuleId
40+
},
41+
},
42+
load: {
43+
filter: { id: exactRegex(resolvedVirtualModuleId) },
44+
handler() {
45+
let content = ''
46+
content += 'const loader = {}\n'
47+
content += 'export default loader\n'
48+
for (const localeFile of localeFiles) {
49+
// e.g., "pt-BR.js" -> "pt-BR"
50+
const localeCode = localeFile.slice(0, -3)
51+
let exportName = localeCode.replaceAll('-', '')
52+
// One locale is named inconsistently...
53+
if (localeCode === 'be-tarask') {
54+
exportName = 'beTarasak'
55+
}
56+
content += `loader['${localeCode}'] = async () => (await import('date-fns/locale/${localeCode}')).${exportName}\n`
57+
}
58+
return content
59+
},
60+
},
61+
} as Plugin
62+
}

package-lock.json

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@
137137
"@playwright/test": "^1.60.0",
138138
"@types/gettext-parser": "^9.0.0",
139139
"@types/node": "^24.13.1",
140+
"@types/webpack-env": "^1.18.8",
140141
"@vitest/coverage-v8": "^4.1.8",
141142
"@vue/test-utils": "^2.4.6",
142143
"@vue/tsconfig": "^0.9.1",

playwright.config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ export default defineConfig({
6464

6565
ctViteConfig: async () => ({
6666
plugins: [
67+
// Add to support multiple locales
68+
(await import('./build/date-fns-locales-plugin.mts')).default(),
6769
// normally added by default but we overwrite the plugins so we need to add it back manually
6870
(await import('@vitejs/plugin-vue')).default(),
6971
// We do have some dependencies that use node modules -> we need to polyfill

src/components/NcDateTimePicker/NcDateTimePicker.vue

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -288,11 +288,12 @@ import {
288288
getFirstDay,
289289
} from '@nextcloud/l10n'
290290
import VueDatePicker from '@vuepic/vue-datepicker'
291-
import { computed, useTemplateRef } from 'vue'
291+
import { computed, toRef, useTemplateRef } from 'vue'
292292
import NcIconSvgWrapper from '../NcIconSvgWrapper/NcIconSvgWrapper.vue'
293293
import NcTimezonePicker from '../NcTimezonePicker/NcTimezonePicker.vue'
294294
import { t } from '../../l10n.ts'
295295
import NcButton from '../NcButton/index.ts'
296+
import useDateFnsLocale from './useDateFnsLocale.ts'
296297
297298
type LibraryFormatOptions = VueDatePickerProps['format']
298299
@@ -530,6 +531,8 @@ const placeholderFallback = computed(() => {
530531
return t('Select date and time')
531532
})
532533
534+
const dateFnsLocale = useDateFnsLocale(toRef(() => props.locale))
535+
533536
/**
534537
* The date (time) formatting to be used by the library.
535538
* We use the provided format if possible, otherwise we provide a formatting function
@@ -766,7 +769,12 @@ function sameDay(a: Date, b: Date): boolean {
766769

767770
<template>
768771
<div class="vue-date-time-picker__wrapper">
772+
<!-- Setting :key="dateFnsLocale.code" forces the component to rerender when `:formatLocale` changes.
773+
Without it, the formatted date only changes after the user focuses on the text input.
774+
This issue was only observed with :format="realFormat" being a pattern, e.g., 'dd MMM yyyy'.
775+
See https://github.com/Vuepic/vue-datepicker/issues/1284 -->
769776
<VueDatePicker
777+
:key="dateFnsLocale?.code"
770778
ref="picker"
771779
:aria-labels
772780
:autoApply="!confirm"
@@ -777,6 +785,7 @@ function sameDay(a: Date, b: Date): boolean {
777785
:dayNames
778786
:placeholder="placeholder ?? placeholderFallback"
779787
:format="realFormat"
788+
:formatLocale="dateFnsLocale"
780789
:locale
781790
:minDate="calcMinMaxTime.minDate"
782791
:maxDate="calcMinMaxTime.maxDate"
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/*!
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
/// <reference types="@types/webpack-env" />
7+
8+
import type { Locale } from 'date-fns'
9+
10+
// This is only required for `npm run styleguide`.
11+
// For `npm run build`, `npm run dev` and everything else using Vite
12+
// all the code from this file is overriden by `build/date-fns-locales-plugin.mts`.
13+
const webpackContext = import.meta.webpackContext('../../../node_modules/date-fns/locale', {
14+
recursive: false,
15+
regExp: /\.js$/,
16+
exclude: /(types|en-US|cdn(\.min)?)\.js$/,
17+
mode: 'lazy',
18+
})
19+
20+
/**
21+
* Given a `key` load the locale from the webpack context.
22+
*
23+
* @param key File name for the locale e.g., ./nl-BE.js'
24+
*/
25+
async function loadLocale(key: string) {
26+
const mod = await webpackContext<Promise<{ default: Locale }>>(key)
27+
return mod.default
28+
}
29+
30+
const loader: Record<string, () => Promise<Locale>> = {}
31+
32+
for (const key of webpackContext.keys()) {
33+
// Remove prefix `./` and suffix `.js` to get the locale code.
34+
const localeCode = key.slice(2, -3)
35+
loader[localeCode] = () => loadLocale(key)
36+
}
37+
38+
export default loader
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/*!
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import type { Locale } from 'date-fns'
7+
import type { MaybeRefOrGetter } from 'vue'
8+
9+
import { computedAsync } from '@vueuse/core'
10+
import { enUS } from 'date-fns/locale/en-US'
11+
import { toValue } from 'vue'
12+
import loader from './dateFnsLocaleLoader.ts'
13+
14+
/**
15+
* Given a locale try to load the corresponding locale from date-fns.
16+
* Fall back to en-US while loading, if the request locale does not exist or could not be loaded.
17+
*
18+
* @param locale locale code (e.g., 'de-DE')
19+
*/
20+
export default function useDateFnsLocale(locale: MaybeRefOrGetter<string>) {
21+
return computedAsync(() => loadDateFnsLocale(toValue(locale)), enUS)
22+
}
23+
24+
/**
25+
* Given a locale code load the locale from the loader.
26+
*
27+
* @param locale Locale code (e.g, nl-BE.js)
28+
*/
29+
async function loadDateFnsLocale(locale: string): Promise<Locale | undefined> {
30+
if (locale in loader) {
31+
return await loader[locale]()
32+
}
33+
34+
if (locale.includes('-')) {
35+
// Try without region
36+
const shortLocale = locale.split('-')[0]
37+
return loadDateFnsLocale(shortLocale)
38+
}
39+
40+
return undefined
41+
}

tests/component/components/NcDateTimePicker.spec.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,27 @@ for (const [type, modelValue, locale, expectedValue] of l10nTestcases) {
6262
})
6363
}
6464

65+
const customFormatTestcases = [
66+
['date', 'yyyy MMM dd', new Date(2000, 9, 2), 'de-DE', '2000 Okt. 02'],
67+
['date-range', 'dd MMM yyyy', [new Date(2000, 0, 1), new Date(2000, 9, 7)] as [Date, Date], 'es-ES', '01 ene 2000 - 07 oct 2000'],
68+
['date', 'EEEE d MMMM yy', new Date(2026, 9, 2), 'ru', 'пятница 2 октября 26'],
69+
['month', 'LLLL yy', new Date(2026, 9, 2), 'ru', 'октябрь 26'],
70+
] as const
71+
for (const [type, format, modelValue, locale, expectedValue] of customFormatTestcases) {
72+
test(`Handle format ${format} for type ${type} with locale ${locale}`, async ({ mount, page }) => {
73+
page.addScriptTag({ content: `document.getElementsByTagName('html')[0].dataset.locale = "${locale}";` })
74+
await mount(NcDateTimePicker, {
75+
props: {
76+
modelValue,
77+
format,
78+
type,
79+
},
80+
})
81+
82+
await expect(page.getByRole('textbox')).toHaveValue(expectedValue)
83+
})
84+
}
85+
6586
test('Today is selected by default', async ({ mount, page }) => {
6687
await page.clock.setSystemTime(new Date(2000, 0, 22))
6788

vite.config.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { createLibConfig } from '@nextcloud/vite-config'
99
import { globSync } from 'glob'
1010
import { join, resolve } from 'node:path'
1111
import { defineConfig } from 'vite'
12+
import dateFnsLocalesPlugin from './build/date-fns-locales-plugin.mts'
1213
import vueDocsPlugin from './build/docs-plugin.ts'
1314
import l10nPlugin from './build/l10n-plugin.mjs'
1415
import packageJson from './package.json' with { type: 'json' }
@@ -31,6 +32,7 @@ const entryPoints = {
3132
const overrides = defineConfig({
3233
plugins: [
3334
vueDocsPlugin,
35+
dateFnsLocalesPlugin(),
3436
l10nPlugin(resolve(import.meta.dirname, 'l10n')),
3537
],
3638
css: {
@@ -71,7 +73,7 @@ export default defineConfig((env) => {
7173
// By default all dependencies are external, but no path imports
7274
nodeExternalsOptions: {
7375
// Packages with paths imports should be added here to mark them as external as well
74-
include: [/^@nextcloud\/.+\//, /^@mdi\/svg\//],
76+
include: [/^@nextcloud\/.+\//, /^@mdi\/svg\//, /^date-fns\/locale\//],
7577
// Make sure to not provide uncompiled vue files as dependencies, this will break unit tests
7678
exclude: [/\.vue(\?|$)/],
7779
},

0 commit comments

Comments
 (0)