Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions build/date-fns-locales-plugin.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: CC0-1.0
*/
import type { Plugin } from 'vite'

import { exactRegex } from '@rolldown/pluginutils'
import { glob } from 'fs/promises'
import { createRequire } from 'module'
import { dirname, join } from 'path'

/**
* This plugin generates a module that can be used to dynamically load locales from `date-fns`.
* It generates it in a way that it works with the externalization of the `date-fns` locales
* and with Vite and Webpack apps.
*
* A plugin is used because Vite's dynamic imports and glob imports cannot generate imports in such a way.
*/
export default () => {
let localeFiles: string[] = []
const virtualModuleId = 'virtual:date-fns-locales'
const resolvedVirtualModuleId = '\0' + virtualModuleId
return {
name: 'date-fns-locales-plugin',
enforce: 'pre',
async buildStart() {
this.info('Collectioning locales in date-fns/locales')
const require = createRequire(import.meta.url)
const pkgJsonPath = require.resolve('date-fns/package.json')
const localeDir = join(dirname(pkgJsonPath), 'locale')
localeFiles = await Array.fromAsync(glob('*.js', {
cwd: localeDir,
exclude: ['types.js', 'en-US.js', 'cdn.js', 'cdn.min.js'],
}))
},
resolveId: {
filter: { id: /dateFnsLocaleLoader\.ts$/ },
handler() {
return resolvedVirtualModuleId
},
},
load: {
filter: { id: exactRegex(resolvedVirtualModuleId) },
handler() {
let content = ''
content += 'const loader = {}\n'
content += 'export default loader\n'
for (const localeFile of localeFiles) {
// e.g., "pt-BR.js" -> "pt-BR"
const localeCode = localeFile.slice(0, -3)
let exportName = localeCode.replaceAll('-', '')
// One locale is named inconsistently...
if (localeCode === 'be-tarask') {
exportName = 'beTarasak'
}
content += `loader['${localeCode}'] = async () => (await import('date-fns/locale/${localeCode}')).${exportName}\n`

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generated module looks like this.

const loader = {};
loader["af"] = async () => (await import("date-fns/locale/af")).af;
loader["ar-DZ"] = async () => (await import("date-fns/locale/ar-DZ")).arDZ;
...
loader["zh-HK"] = async () => (await import("date-fns/locale/zh-HK")).zhHK;
loader["zh-TW"] = async () => (await import("date-fns/locale/zh-TW")).zhTW;

@odzhychko odzhychko Jun 16, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It tested it with Vite and Rspack apps.

}
return content
},
},
} as Plugin
}
8 changes: 8 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@
"@playwright/test": "^1.61.1",
"@types/gettext-parser": "^9.0.0",
"@types/node": "^26.0.1",
"@types/webpack-env": "^1.18.8",
"@vitest/coverage-v8": "^4.1.9",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.9.1",
Expand Down
2 changes: 2 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ export default defineConfig({

ctViteConfig: async () => ({
plugins: [
// Add to support multiple locales
(await import('./build/date-fns-locales-plugin.mts')).default(),
// normally added by default but we overwrite the plugins so we need to add it back manually
(await import('@vitejs/plugin-vue')).default(),
// We do have some dependencies that use node modules -> we need to polyfill
Expand Down
9 changes: 9 additions & 0 deletions src/components/NcDateTimePicker/NcDateTimePicker.vue
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,7 @@ import NcIconSvgWrapper from '../NcIconSvgWrapper/NcIconSvgWrapper.vue'
import NcTimezonePicker from '../NcTimezonePicker/NcTimezonePicker.vue'
import { t } from '../../l10n.ts'
import NcButton from '../NcButton/index.ts'
import useDateFnsLocale from './useDateFnsLocale.ts'

type LibraryFormatOptions = VueDatePickerProps['format']

Expand Down Expand Up @@ -539,6 +540,8 @@ const placeholderFallback = computed(() => {
return t('Select date and time')
})

const dateFnsLocale = useDateFnsLocale(realLocale)

/**
* The date (time) formatting to be used by the library.
* We use the provided format if possible, otherwise we provide a formatting function
Expand Down Expand Up @@ -775,7 +778,12 @@ function sameDay(a: Date, b: Date): boolean {

<template>
<div class="vue-date-time-picker__wrapper">
<!-- Setting :key="dateFnsLocale.code" forces the component to rerender when `:formatLocale` changes.
Without it, the formatted date only changes after the user focuses on the text input.
This issue was only observed with :format="realFormat" being a pattern, e.g., 'dd MMM yyyy'.
See https://github.com/Vuepic/vue-datepicker/issues/1284 -->
<VueDatePicker
:key="dateFnsLocale.code"
ref="picker"
:aria-labels
:autoApply="!confirm"
Expand All @@ -787,6 +795,7 @@ function sameDay(a: Date, b: Date): boolean {
:placeholder="placeholder ?? placeholderFallback"
:format="realFormat"
:locale="realLocale"
:formatLocale="dateFnsLocale"
:minDate="calcMinMaxTime.minDate"
:maxDate="calcMinMaxTime.maxDate"
:minTime="calcMinMaxTime.minTime"
Expand Down
38 changes: 38 additions & 0 deletions src/components/NcDateTimePicker/dateFnsLocaleLoader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

/// <reference types="@types/webpack-env" />

import type { Locale } from 'date-fns'

// This is only required for `npm run styleguide`.
// For `npm run build`, `npm run dev` and everything else using Vite
// all the code from this file is overriden by `build/date-fns-locales-plugin.mts`.
const webpackContext = import.meta.webpackContext('../../../node_modules/date-fns/locale', {
recursive: false,
regExp: /\.js$/,
exclude: /(types|en-US|cdn(\.min)?)\.js$/,
mode: 'lazy',
})

/**
* Given a `key` load the locale from the webpack context.
*
* @param key File name for the locale e.g., ./nl-BE.js'
*/
async function loadLocale(key: string) {
const mod = await webpackContext<Promise<{ default: Locale }>>(key)
return mod.default
}

const loader: Record<string, () => Promise<Locale>> = {}

for (const key of webpackContext.keys()) {
// Remove prefix `./` and suffix `.js` to get the locale code.
const localeCode = key.slice(2, -3)
loader[localeCode] = () => loadLocale(key)
}

export default loader
42 changes: 42 additions & 0 deletions src/components/NcDateTimePicker/useDateFnsLocale.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import type { Locale } from 'date-fns'
import type { ComputedRef, MaybeRefOrGetter } from 'vue'

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

/**
* Given a locale try to load the corresponding locale from date-fns.
* Fall back to en-US while loading, if the request locale does not exist or could not be loaded.
*
* @param locale locale code (e.g., 'de-DE')
*/
export default function useDateFnsLocale(locale: MaybeRefOrGetter<string>): ComputedRef<Locale> {
const loadedLocale = computedAsync(() => loadDateFnsLocale(toValue(locale)))
return computed(() => loadedLocale.value ?? enUS)
}

/**
* Given a locale code load the locale from the loader.
*
* @param locale Locale code (e.g, nl-BE.js)
*/
async function loadDateFnsLocale(locale: string): Promise<Locale | undefined> {
if (locale in loader) {
return await loader[locale]()
}

if (locale.includes('-')) {
// Try without region
const shortLocale = locale.split('-')[0]
return loadDateFnsLocale(shortLocale)
}

return undefined
}
21 changes: 21 additions & 0 deletions tests/component/components/NcDateTimePicker.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,27 @@ for (const [type, modelValue, locale, expectedValue] of l10nTestcases) {
})
}

const customFormatTestcases = [
['date', 'yyyy MMM dd', new Date(2000, 9, 2), 'de-DE', '2000 Okt. 02'],
['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'],
['date', 'EEEE d MMMM yy', new Date(2026, 9, 2), 'ru', 'пятница 2 октября 26'],
['month', 'LLLL yy', new Date(2026, 9, 2), 'ru', 'октябрь 26'],
] as const
for (const [type, format, modelValue, locale, expectedValue] of customFormatTestcases) {
test(`Handle format ${format} for type ${type} with locale ${locale}`, async ({ mount, page }) => {
page.addScriptTag({ content: `document.getElementsByTagName('html')[0].dataset.locale = "${locale}";` })
await mount(NcDateTimePicker, {
props: {
modelValue,
format,
type,
},
})

await expect(page.getByRole('textbox')).toHaveValue(expectedValue)
})
}

test('Today is selected by default', async ({ mount, page }) => {
await page.clock.setSystemTime(new Date(2000, 0, 22))

Expand Down
4 changes: 3 additions & 1 deletion vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { createLibConfig } from '@nextcloud/vite-config'
import { globSync } from 'glob'
import { join, resolve } from 'node:path'
import { defineConfig } from 'vite'
import dateFnsLocalesPlugin from './build/date-fns-locales-plugin.mts'
import vueDocsPlugin from './build/docs-plugin.ts'
import l10nPlugin from './build/l10n-plugin.mjs'
import packageJson from './package.json' with { type: 'json' }
Expand All @@ -31,6 +32,7 @@ const entryPoints = {
const overrides = defineConfig({
plugins: [
vueDocsPlugin,
dateFnsLocalesPlugin(),
l10nPlugin(resolve(import.meta.dirname, 'l10n')),
],
css: {
Expand Down Expand Up @@ -71,7 +73,7 @@ export default defineConfig((env) => {
// By default all dependencies are external, but no path imports
nodeExternalsOptions: {
// Packages with paths imports should be added here to mark them as external as well
include: [/^@nextcloud\/.+\//, /^@mdi\/svg\//],
include: [/^@nextcloud\/.+\//, /^@mdi\/svg\//, /^date-fns\/locale\//],

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Externalized as suggested in #7767 (comment)

// Make sure to not provide uncompiled vue files as dependencies, this will break unit tests
exclude: [/\.vue(\?|$)/],
},
Expand Down
Loading