Summary
The time picker's hour column shows labels that don't match its configured 12/24-hour mode
whenever the locale's default hour cycle disagrees with the picker's mode. This is most
visible for Canadian users:
en-CA + 24-hour picker → the hour column shows 12-hour labels with a.m./p.m. baked in
(12 a.m., 01 a.m., … 11 p.m.) instead of 00 … 23.
fr-CA + 12-hour picker → the hour column shows 24-hour labels with no a.m./p.m.
(12, 01, 02, … 11).
Minutes and seconds are unaffected; only the hour partial is wrong.
Environment
@coreui/react-pro (current — reproduced against the formatTimePartials util in time-picker/utils)
- Any browser / Node with a full-ICU
Intl (i.e. all modern environments)
- OS: any
Root cause
formatTimePartials builds its Intl.DateTimeFormat without passing hour12:
const formatter = new Intl.DateTimeFormat(locale, {
hour: forceTwoDigit ? '2-digit' : 'numeric',
minute: '2-digit',
second: '2-digit',
// hour12 not passed ← bug
})
The list of hour values is generated from the picker's resolved hour12 setting (1–12 vs 0–23),
but the labels are formatted using whatever hour cycle the locale defaults to. When the two
disagree, the formatter coerces the values into the locale's default cycle, producing labels that
don't correspond to the picker's mode.
Why it only affects Canada
The bug surfaces precisely when the locale default hour cycle ≠ the picker mode. The default cycles:
| Locale |
Intl default hour cycle |
en-US |
12-hour |
en-CA |
12-hour |
fr-CA |
24-hour |
It's invisible in en-US (the usual dev/test locale) because its default cycle matches the common
12-hour picker mode, so the missing option is never exercised. Canada is the textbook trigger
because English-Canada defaults to 12-hour while French-Canada defaults to 24-hour — any
Canadian app that supports both languages (or uses a 24-hour picker in English) hits it immediately.
Reduced test case
// 24-hour picker in en-CA — expected hour labels: 00 01 02 … 23
const f = new Intl.DateTimeFormat('en-CA', { hour: '2-digit' }) // hour12 omitted (buggy)
const d = new Date(2020, 0, 1, 0, 0, 0)
[0, 1, 13, 23].forEach((h) => { d.setHours(h); console.log(h, '→', f.format(d)) })
// buggy output: 0 → "12 a.m.", 1 → "01 a.m.", 13 → "01 p.m.", 23 → "11 p.m."
// expected: 0 → "00", 1 → "01", 13 → "13", 23 → "23"
Fix
src/components/time-picker/utils.ts — thread the resolved hour12 through to the formatter so
the hour labels honour the picker's mode instead of the locale default. (Diff shown in TS; the
compiled dist/esm/components/time-picker/utils.js equivalent was used to confirm the change.)
/**
* @param {number[]} values An array of time values to format.
* @param {string} locale The locale to use for formatting.
* @param {('hour' | 'minute' | 'second')} partial The type of time value to format.
+ * @param {boolean} [hour12] Whether to use 12-hour format for the labels.
* @returns {Array} An array of objects with the original value and its localized label.
*/
-const formatTimePartials = (values, locale, partial) => {
+const formatTimePartials = (values, locale, partial, hour12) => {
const date = new Date()
const forceTwoDigit = shouldUseTwoDigitHour(locale)
const formatter = new Intl.DateTimeFormat(locale, {
hour: forceTwoDigit ? '2-digit' : 'numeric',
minute: '2-digit',
second: '2-digit',
+ hour12,
})
// …unchanged…
}
@@ getLocalizedTimePartials @@
return {
- listOfHours: formatTimePartials(listOfHours, locale, 'hour'),
+ listOfHours: formatTimePartials(listOfHours, locale, 'hour', hour12),
listOfMinutes: formatTimePartials(listOfMinutes, locale, 'minute'),
listOfSeconds: formatTimePartials(listOfSeconds, locale, 'second'),
hour12,
}
Only the hour partial needs hour12; minute/second labels are cycle-independent, so they're left as-is.
Test case
Add to src/components/time-picker/__tests__/utils.spec.ts (new file). It asserts the hour labels
follow the configured mode for the two Canadian locales — these fail before the fix and pass after.
import { getLocalizedTimePartials } from '../utils'
const labels = (parts: { value: number; label: string }[]) => parts.map((p) => p.label)
const hasDayPeriod = (s: string) => /[ap]\.?\s?m\.?/i.test(s)
describe('getLocalizedTimePartials — hour labels honour the picker mode (Canada locales)', () => {
it('en-CA: a 24-hour picker shows 24-hour labels, not 12-hour + a.m./p.m.', () => {
// ampm `false` => 24-hour mode. en-CA defaults to a 12-hour cycle, so this is the regression case.
const { listOfHours } = getLocalizedTimePartials('en-CA', false)
const hours = labels(listOfHours)
expect(hours.some(hasDayPeriod)).toBe(false) // no a.m./p.m. leaking into the hour column
expect(hours).toContain('13') // 24-hour value must appear as 13, not "01 p.m."
expect(hours).toContain('23')
})
it('fr-CA: a 12-hour picker shows a.m./p.m. labels, not bare 24-hour values', () => {
// ampm `true` => 12-hour mode. fr-CA defaults to a 24-hour cycle, so this is the regression case.
const { listOfHours } = getLocalizedTimePartials('fr-CA', true)
const hours = labels(listOfHours)
expect(hours.some(hasDayPeriod)).toBe(true) // a.m./p.m. must be present in 12-hour mode
expect(hours.every((h) => Number.parseInt(h, 10) <= 12)).toBe(true) // no 13–23 in a 12-hour list
})
it('en-US: 12-hour picker is unchanged (regression guard)', () => {
const { listOfHours } = getLocalizedTimePartials('en-US', true)
expect(labels(listOfHours).some(hasDayPeriod)).toBe(true)
})
})
Summary
The time picker's hour column shows labels that don't match its configured 12/24-hour mode
whenever the locale's default hour cycle disagrees with the picker's mode. This is most
visible for Canadian users:
en-CA+ 24-hour picker → the hour column shows 12-hour labels with a.m./p.m. baked in(
12 a.m., 01 a.m., … 11 p.m.) instead of00 … 23.fr-CA+ 12-hour picker → the hour column shows 24-hour labels with no a.m./p.m.(
12, 01, 02, … 11).Minutes and seconds are unaffected; only the hour partial is wrong.
Environment
@coreui/react-pro(current — reproduced against theformatTimePartialsutil intime-picker/utils)Intl(i.e. all modern environments)Root cause
formatTimePartialsbuilds itsIntl.DateTimeFormatwithout passinghour12:The list of hour values is generated from the picker's resolved
hour12setting (1–12 vs 0–23),but the labels are formatted using whatever hour cycle the locale defaults to. When the two
disagree, the formatter coerces the values into the locale's default cycle, producing labels that
don't correspond to the picker's mode.
Why it only affects Canada
The bug surfaces precisely when the locale default hour cycle ≠ the picker mode. The default cycles:
Intldefault hour cycleen-USen-CAfr-CAIt's invisible in
en-US(the usual dev/test locale) because its default cycle matches the common12-hour picker mode, so the missing option is never exercised. Canada is the textbook trigger
because English-Canada defaults to 12-hour while French-Canada defaults to 24-hour — any
Canadian app that supports both languages (or uses a 24-hour picker in English) hits it immediately.
Reduced test case
Fix
src/components/time-picker/utils.ts— thread the resolvedhour12through to the formatter sothe hour labels honour the picker's mode instead of the locale default. (Diff shown in TS; the
compiled
dist/esm/components/time-picker/utils.jsequivalent was used to confirm the change.)/** * @param {number[]} values An array of time values to format. * @param {string} locale The locale to use for formatting. * @param {('hour' | 'minute' | 'second')} partial The type of time value to format. + * @param {boolean} [hour12] Whether to use 12-hour format for the labels. * @returns {Array} An array of objects with the original value and its localized label. */ -const formatTimePartials = (values, locale, partial) => { +const formatTimePartials = (values, locale, partial, hour12) => { const date = new Date() const forceTwoDigit = shouldUseTwoDigitHour(locale) const formatter = new Intl.DateTimeFormat(locale, { hour: forceTwoDigit ? '2-digit' : 'numeric', minute: '2-digit', second: '2-digit', + hour12, }) // …unchanged… } @@ getLocalizedTimePartials @@ return { - listOfHours: formatTimePartials(listOfHours, locale, 'hour'), + listOfHours: formatTimePartials(listOfHours, locale, 'hour', hour12), listOfMinutes: formatTimePartials(listOfMinutes, locale, 'minute'), listOfSeconds: formatTimePartials(listOfSeconds, locale, 'second'), hour12, }Only the hour partial needs
hour12; minute/second labels are cycle-independent, so they're left as-is.Test case
Add to
src/components/time-picker/__tests__/utils.spec.ts(new file). It asserts the hour labelsfollow the configured mode for the two Canadian locales — these fail before the fix and pass after.