Skip to content

Commit 1760ae8

Browse files
committed
support localization in date-fns implementation
1 parent 4c94bd4 commit 1760ae8

File tree

4 files changed

+148
-7
lines changed

4 files changed

+148
-7
lines changed

components/dash-core-components/src/utils/calendar/CalendarMonth.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,12 @@ import type {Day} from 'date-fns';
1515
import CalendarDay from './CalendarDay';
1616
import CalendarDayPadding from './CalendarDayPadding';
1717
import {createMonthGrid} from './createMonthGrid';
18-
import {isDateInRange, isDateDisabled, isSameDay} from './helpers';
18+
import {
19+
isDateInRange,
20+
isDateDisabled,
21+
isSameDay,
22+
getUserLocale,
23+
} from './helpers';
1924
import {CalendarDirection} from '../../types';
2025
import '../../components/css/calendar.css';
2126
import CalendarMonthHeader from './CalendarMonthHeader';
@@ -94,7 +99,7 @@ export const CalendarMonth = ({
9499
const daysOfTheWeek = useMemo(() => {
95100
return Array.from({length: 7}, (_, i) => {
96101
const date = setDay(new Date(), (i + firstDayOfWeek) % 7);
97-
return format(date, 'EEEEEE');
102+
return format(date, 'EEEEEE', {locale: getUserLocale()});
98103
});
99104
}, [firstDayOfWeek]);
100105

components/dash-core-components/src/utils/calendar/helpers.ts

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,17 @@ import {
1212
min,
1313
max,
1414
} from 'date-fns';
15+
import type {Locale} from 'date-fns';
1516
import {DatePickerSingleProps} from '../../types';
1617

18+
declare global {
19+
interface Window {
20+
dateFns?: {
21+
locale?: Record<string, Locale>;
22+
};
23+
}
24+
}
25+
1726
/**
1827
* Converts relevant moment.js format tokens to unicode tokens suitable for use
1928
* in date-fns. This maintains backwards compatibility with our publicly
@@ -31,12 +40,42 @@ function convertFormatTokens(momentFormat: string): string {
3140
.replace(/X/g, 't'); // Unix timestamp (seconds)
3241
}
3342

43+
/**
44+
* Matches the user's preferred locale against the locales that have been loaded
45+
* externally on the page from the assets folder or via a script tag
46+
*/
47+
export function getUserLocale(): Locale | undefined {
48+
// Check if page has loaded any external locales
49+
const availableLocales = window.dateFns?.locale ?? {};
50+
51+
// Match available locales against user locale preferences
52+
const localeKeys = Object.keys(availableLocales);
53+
const userLanguages = navigator.languages || [navigator.language];
54+
for (const lang of userLanguages) {
55+
// First check full locale string for regional variants (e.g., 'fr-CA')
56+
const normalizedLang = lang.replace('-', '');
57+
if (availableLocales[normalizedLang]) {
58+
return availableLocales[normalizedLang];
59+
}
60+
61+
// Fallback to simple language code (e.g., 'fr')
62+
const langCode = lang.split('-')[0];
63+
if (availableLocales[langCode]) {
64+
return availableLocales[langCode];
65+
}
66+
}
67+
68+
// No match found against user language preferences, we'll use first
69+
// loaded locale (ultimately determined by script order in HTML)
70+
return availableLocales[localeKeys[0]];
71+
}
72+
3473
export function formatDate(date?: Date, formatStr = 'YYYY-MM-DD'): string {
3574
if (!date) {
3675
return '';
3776
}
3877
const convertedFormat = convertFormatTokens(formatStr);
39-
return format(date, convertedFormat);
78+
return format(date, convertedFormat, {locale: getUserLocale()});
4079
}
4180

4281
/*
@@ -59,8 +98,11 @@ export function strAsDate(
5998
return undefined;
6099
}
61100

101+
const locale = getUserLocale();
62102
let parsed = formatStr
63-
? parse(dateStr, convertFormatTokens(formatStr), new Date())
103+
? parse(dateStr, convertFormatTokens(formatStr), new Date(), {
104+
locale,
105+
})
64106
: parseISO(dateStr);
65107

66108
// Fallback to native Date constructor for non-ISO formats
@@ -148,7 +190,9 @@ export function formatMonth(
148190
): string {
149191
const {monthFormat} = extractFormats(formatStr);
150192
const convertedFormat = convertFormatTokens(monthFormat);
151-
return format(new Date(year, month, 1), convertedFormat);
193+
return format(new Date(year, month, 1), convertedFormat, {
194+
locale: getUserLocale(),
195+
});
152196
}
153197

154198
/**
@@ -187,7 +231,9 @@ export function getMonthOptions(
187231

188232
return Array.from({length: 12}, (_, i) => {
189233
const monthStart = new Date(year, i, 1);
190-
const label = format(monthStart, convertedFormat);
234+
const label = format(monthStart, convertedFormat, {
235+
locale: getUserLocale(),
236+
});
191237

192238
// Check if this month is outside the allowed range (month-level comparison)
193239
const disabled =
@@ -204,7 +250,9 @@ export function getMonthOptions(
204250
export function formatYear(year: number, formatStr?: string): string {
205251
const {yearFormat} = extractFormats(formatStr);
206252
const convertedFormat = convertFormatTokens(yearFormat);
207-
return format(new Date(year, 0, 1), convertedFormat);
253+
return format(new Date(year, 0, 1), convertedFormat, {
254+
locale: getUserLocale(),
255+
});
208256
}
209257

210258
/**

components/dash-core-components/tests/integration/calendar/locales/assets/date-fns-locale-fr.min.js

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
from datetime import datetime
2+
3+
from dash import Dash, html, dcc, Input, Output
4+
from selenium.webdriver.common.keys import Keys
5+
6+
7+
def test_dtps030_french_localization_via_cdn(dash_dcc):
8+
"""Test that French locale from CDN is applied to date picker."""
9+
app = Dash(
10+
__name__,
11+
)
12+
13+
app.layout = html.Div(
14+
[
15+
html.P("DatePicker localization - translations in assets folder"),
16+
dcc.DatePickerSingle(
17+
id="dps",
18+
date="2025-01-15",
19+
initial_visible_month=datetime(2025, 1, 1),
20+
display_format="MMMM DD, YYYY",
21+
month_format="MMMM YYYY",
22+
),
23+
html.Div(id="output"),
24+
]
25+
)
26+
27+
@app.callback(
28+
Output("output", "children"),
29+
Input("dps", "date"),
30+
)
31+
def update_output(date):
32+
return f"Date: {date}"
33+
34+
dash_dcc.start_server(app)
35+
36+
# Wait for date picker to render
37+
input_element = dash_dcc.wait_for_element("#dps")
38+
39+
# Check initial callback output shows ISO format date
40+
dash_dcc.wait_for_text_to_equal("#output", "Date: 2025-01-15")
41+
42+
# Check that display format uses French month name
43+
display_value = input_element.get_attribute("value")
44+
assert (
45+
"janvier" in display_value.lower()
46+
), f"Display format should use French month name 'janvier', but got: {display_value}"
47+
48+
# Test typing a French month name in the input
49+
input_element.clear()
50+
input_element.send_keys("février 20, 2025")
51+
input_element.send_keys(Keys.TAB) # Blur to trigger parsing
52+
53+
# Wait for the date to be parsed and formatted
54+
dash_dcc.wait_for_text_to_equal("#dps", "février 20, 2025")
55+
56+
# Verify the input now shows the French formatted date
57+
display_value = input_element.get_attribute("value")
58+
assert (
59+
"février" in display_value.lower()
60+
), f"Input should accept and display French month name 'février', but got: {display_value}"
61+
62+
# Verify the callback received the correct ISO format date (locale-independent)
63+
dash_dcc.wait_for_text_to_equal("#output", "Date: 2025-02-20")
64+
65+
# Open the calendar
66+
input_element.click()
67+
dash_dcc.wait_for_element(".dash-datepicker-calendar-container")
68+
69+
# Check that days of the week are in French
70+
# French abbreviated days: Lu, Ma, Me, Je, Ve, Sa, Di
71+
day_headers = dash_dcc.find_elements(".dash-datepicker-calendar thead th span")
72+
day_texts = [header.text for header in day_headers]
73+
74+
# Check for French day abbreviations (2-letter format)
75+
french_days = ["lu", "ma", "me", "je", "ve", "sa", "di"]
76+
assert (
77+
len(day_texts) == 7
78+
), f"Should have 7 day headers, but got {len(day_texts)}: {day_texts}"
79+
80+
for day_text in day_texts:
81+
assert any(
82+
french_day in day_text.lower() for french_day in french_days
83+
), f"Day header '{day_text}' should be a French day abbreviation, expected one of: {french_days}"
84+
85+
assert dash_dcc.get_logs() == []

0 commit comments

Comments
 (0)