@open-mercato/ui 0.8.1-develop.7215.1.60786cf836 → 0.8.1-develop.7219.1.f96cb57e70
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/build.mjs +34 -7
- package/dist/backend/inputs/TimePicker.js +8 -3
- package/dist/backend/inputs/TimePicker.js.map +2 -2
- package/dist/primitives/calendar.js +5 -3
- package/dist/primitives/calendar.js.map +2 -2
- package/dist/primitives/date-locale.js +47 -0
- package/dist/primitives/date-locale.js.map +7 -0
- package/dist/primitives/date-picker.js +4 -1
- package/dist/primitives/date-picker.js.map +2 -2
- package/dist/primitives/time-picker.js.map +1 -1
- package/package.json +3 -3
- package/scripts/lucideRegistrySource.cjs +16 -1
- package/src/backend/__tests__/CrudForm.datetime.test.tsx +62 -0
- package/src/backend/icons/__tests__/lucideRegistryGenerator.test.ts +27 -1
- package/src/backend/inputs/TimePicker.tsx +17 -4
- package/src/backend/inputs/__tests__/DatePicker.test.tsx +1 -0
- package/src/backend/inputs/__tests__/TimePicker.test.tsx +31 -0
- package/src/primitives/__tests__/date-locale.test.ts +53 -0
- package/src/primitives/__tests__/date-picker.test.tsx +54 -2
- package/src/primitives/calendar.tsx +11 -3
- package/src/primitives/date-locale.ts +80 -0
- package/src/primitives/date-picker.tsx +16 -1
- package/src/primitives/time-picker.tsx +1 -1
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import * as React from 'react'
|
|
2
2
|
import { act, fireEvent, render, screen } from '@testing-library/react'
|
|
3
|
+
import { pl } from 'date-fns/locale/pl'
|
|
3
4
|
import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
|
|
4
5
|
import { DatePicker } from '../date-picker'
|
|
5
6
|
import { DatePicker as LegacyDatePicker } from '../../backend/inputs/DatePicker'
|
|
6
7
|
import { DateTimePicker as LegacyDateTimePicker } from '../../backend/inputs/DateTimePicker'
|
|
7
8
|
|
|
8
|
-
function renderWithI18n(ui: React.ReactElement) {
|
|
9
|
-
return render(<I18nProvider locale=
|
|
9
|
+
function renderWithI18n(ui: React.ReactElement, locale = 'en') {
|
|
10
|
+
return render(<I18nProvider locale={locale} dict={{}}>{ui}</I18nProvider>)
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
function getTrigger(): HTMLElement {
|
|
@@ -244,6 +245,57 @@ describe('DatePicker primitive', () => {
|
|
|
244
245
|
})
|
|
245
246
|
})
|
|
246
247
|
|
|
248
|
+
describe('DatePicker calendar under a non-English locale', () => {
|
|
249
|
+
async function openPolishCalendar() {
|
|
250
|
+
renderWithI18n(
|
|
251
|
+
<DatePicker value={new Date(2026, 0, 15)} onChange={() => {}} locale={pl} />,
|
|
252
|
+
)
|
|
253
|
+
await openPopover()
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
it('names the month in the standalone form, not the genitive one used inside a date', async () => {
|
|
257
|
+
await openPolishCalendar()
|
|
258
|
+
expect(screen.getByText(/styczeń 2026/)).toBeInTheDocument()
|
|
259
|
+
expect(screen.queryByText(/stycznia 2026/)).not.toBeInTheDocument()
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
it('labels the month navigation buttons with the standalone month form', async () => {
|
|
263
|
+
await openPolishCalendar()
|
|
264
|
+
const navLabels = screen
|
|
265
|
+
.getAllByRole('button')
|
|
266
|
+
.map((button) => button.getAttribute('aria-label') ?? '')
|
|
267
|
+
.filter((label) => /^Go to (previous|next) month: /.test(label))
|
|
268
|
+
expect(navLabels).toEqual([
|
|
269
|
+
'Go to previous month: grudzień 2025',
|
|
270
|
+
'Go to next month: luty 2026',
|
|
271
|
+
])
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
// 2025-12-29 is a Monday and 2025-12-28 the Sunday before it, so the first cell of the
|
|
275
|
+
// January 2026 grid says which day the calendar treats as the start of the week.
|
|
276
|
+
it('starts the week on the day the locale starts it on', async () => {
|
|
277
|
+
await openPolishCalendar()
|
|
278
|
+
expect(screen.getAllByRole('gridcell')[0]).toHaveAttribute('data-day', '2025-12-29')
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
it('keeps the picker default week start when no locale is passed', async () => {
|
|
282
|
+
renderWithI18n(<DatePicker value={new Date(2026, 0, 15)} onChange={() => {}} />)
|
|
283
|
+
await openPopover()
|
|
284
|
+
expect(screen.getAllByRole('gridcell')[0]).toHaveAttribute('data-day', '2025-12-28')
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
// A call site that forgets to thread a `locale` prop is exactly the #5942 bug: without
|
|
288
|
+
// this fallback the picker silently renders English/Sunday-first for every tenant that
|
|
289
|
+
// does not explicitly pass one. `date-picker.tsx` must default to the active app locale
|
|
290
|
+
// so future call sites cannot regress the same way.
|
|
291
|
+
it('falls back to the app locale when no explicit locale prop is passed', async () => {
|
|
292
|
+
renderWithI18n(<DatePicker value={new Date(2026, 0, 15)} onChange={() => {}} />, 'pl')
|
|
293
|
+
await openPopover()
|
|
294
|
+
expect(screen.getByText(/styczeń 2026/)).toBeInTheDocument()
|
|
295
|
+
expect(screen.getAllByRole('gridcell')[0]).toHaveAttribute('data-day', '2025-12-29')
|
|
296
|
+
})
|
|
297
|
+
})
|
|
298
|
+
|
|
247
299
|
describe('DatePicker backwards-compat shims', () => {
|
|
248
300
|
it('backend/inputs/DatePicker defaults to the Figma-aligned Apply/Cancel footer', async () => {
|
|
249
301
|
renderWithI18n(<LegacyDatePicker value={null} onChange={() => {}} />)
|
|
@@ -38,6 +38,14 @@ export function CalendarMonthSelector({
|
|
|
38
38
|
)
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
// `LLLL`/`LLL`, not `MMMM`/`MMM`: these labels name a month on its own, and languages that
|
|
42
|
+
// inflect month names — Polish among the five this repo ships — spell that differently from a
|
|
43
|
+
// month inside a full date. `MMMM` is the inflected (genitive) form, so a Polish header read
|
|
44
|
+
// "stycznia" ("of January") where it should read "styczeń". The two tokens are identical in
|
|
45
|
+
// English, which is why the bug was invisible until a non-English tenant opened a picker.
|
|
46
|
+
const STANDALONE_MONTH_YEAR = 'LLLL yyyy'
|
|
47
|
+
const STANDALONE_MONTH_SHORT = 'LLL'
|
|
48
|
+
|
|
41
49
|
const navButtonClassName = cn(
|
|
42
50
|
'h-9 w-9 inline-flex items-center justify-center rounded-md shrink-0',
|
|
43
51
|
'border border-border bg-background text-muted-foreground transition-colors',
|
|
@@ -60,7 +68,7 @@ function MonthNavButton({
|
|
|
60
68
|
}
|
|
61
69
|
const target = direction === 'prev' ? dayPicker.previousMonth : dayPicker.nextMonth
|
|
62
70
|
const Icon = direction === 'prev' ? ChevronLeft : ChevronRight
|
|
63
|
-
const targetLabel = format(target ?? new Date(),
|
|
71
|
+
const targetLabel = format(target ?? new Date(), STANDALONE_MONTH_YEAR, locale ? { locale } : undefined)
|
|
64
72
|
const ariaLabel = `Go to ${direction === 'prev' ? 'previous' : 'next'} month: ${targetLabel}`
|
|
65
73
|
return (
|
|
66
74
|
<button
|
|
@@ -89,7 +97,7 @@ function buildMonthCaption(
|
|
|
89
97
|
calendarMonth: CalendarMonth
|
|
90
98
|
displayIndex?: number
|
|
91
99
|
}) {
|
|
92
|
-
const label = format(calendarMonth.date,
|
|
100
|
+
const label = format(calendarMonth.date, STANDALONE_MONTH_YEAR, locale ? { locale } : undefined)
|
|
93
101
|
const index = typeof displayIndex === 'number' ? displayIndex : 0
|
|
94
102
|
// For multi-month layouts (e.g. range pickers) only the leftmost month
|
|
95
103
|
// exposes the previous-month chevron and only the rightmost exposes the
|
|
@@ -156,7 +164,7 @@ function MonthGrid({
|
|
|
156
164
|
const monthLabels = React.useMemo(
|
|
157
165
|
() =>
|
|
158
166
|
Array.from({ length: 12 }, (_, monthIndex) =>
|
|
159
|
-
format(new Date(year, monthIndex, 1),
|
|
167
|
+
format(new Date(year, monthIndex, 1), STANDALONE_MONTH_SHORT, locale ? { locale } : undefined),
|
|
160
168
|
),
|
|
161
169
|
[year, locale],
|
|
162
170
|
)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import type { Locale as DateFnsLocale } from 'date-fns/locale'
|
|
4
|
+
import { de } from 'date-fns/locale/de'
|
|
5
|
+
import { enUS } from 'date-fns/locale/en-US'
|
|
6
|
+
import { es } from 'date-fns/locale/es'
|
|
7
|
+
import { ko } from 'date-fns/locale/ko'
|
|
8
|
+
import { pl } from 'date-fns/locale/pl'
|
|
9
|
+
import { useOptionalLocale } from '@open-mercato/shared/lib/i18n/context'
|
|
10
|
+
|
|
11
|
+
export type TimeDisplayFormat = '12h' | '24h'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The app locale is a bare language code (`'pl'`), but `react-day-picker` and every
|
|
15
|
+
* date-fns formatter want a `Locale` object — the thing that knows month names and,
|
|
16
|
+
* critically, `weekStartsOn`. Without one the picker falls back to the bundled `enUS`:
|
|
17
|
+
* English month/weekday names and a Sunday-first week for every tenant.
|
|
18
|
+
*
|
|
19
|
+
* The five entries mirror `locales` in `@open-mercato/shared/lib/i18n/config`. They are
|
|
20
|
+
* imported statically rather than loaded on demand because a picker renders its calendar
|
|
21
|
+
* synchronously — an async locale would flash an English month header first.
|
|
22
|
+
*/
|
|
23
|
+
const DATE_FNS_LOCALES: Record<string, DateFnsLocale> = {
|
|
24
|
+
de,
|
|
25
|
+
en: enUS,
|
|
26
|
+
es,
|
|
27
|
+
ko,
|
|
28
|
+
pl,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The date-fns locale for an app locale code, or `undefined` when there is none to apply.
|
|
33
|
+
*
|
|
34
|
+
* `undefined` rather than `enUS` for an unknown code: the consumers pass this straight into
|
|
35
|
+
* a `locale?: Locale` prop, and leaving it unset keeps their own documented default instead
|
|
36
|
+
* of pinning English on a deployment that added a locale this map has not caught up with.
|
|
37
|
+
*/
|
|
38
|
+
export function resolveDateFnsLocale(locale?: string | null): DateFnsLocale | undefined {
|
|
39
|
+
const code = locale?.split('-')[0]?.toLowerCase()
|
|
40
|
+
return code ? DATE_FNS_LOCALES[code] : undefined
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The date-fns locale for the active `I18nProvider` locale, or `undefined` outside a provider. */
|
|
44
|
+
export function useDateFnsLocale(): DateFnsLocale | undefined {
|
|
45
|
+
return resolveDateFnsLocale(useOptionalLocale())
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Whether a locale writes the time of day on a 12- or 24-hour clock.
|
|
50
|
+
*
|
|
51
|
+
* Asked of `Intl` rather than a "not English means 24h" heuristic, because that heuristic is
|
|
52
|
+
* wrong for two of the five locales this repo ships: Korean is a 12-hour locale (오전/오후), as
|
|
53
|
+
* is English, while Polish, German and Spanish are not. `12h` is the fallback when `Intl` cannot
|
|
54
|
+
* answer, matching the `TimePicker` primitive's own default.
|
|
55
|
+
*/
|
|
56
|
+
// Constructing an `Intl.DateTimeFormat` costs ~40x using one, and this runs on every render of
|
|
57
|
+
// every time picker — so the answer is kept per locale, exactly as `date-format.ts` caches its
|
|
58
|
+
// formatters. The key space is the locales a deployment ships and the answer never changes.
|
|
59
|
+
const timeFormatCache = new Map<string, TimeDisplayFormat>()
|
|
60
|
+
|
|
61
|
+
export function deriveTimeDisplayFormat(locale?: string | null): TimeDisplayFormat {
|
|
62
|
+
if (!locale) return '12h'
|
|
63
|
+
const cached = timeFormatCache.get(locale)
|
|
64
|
+
if (cached) return cached
|
|
65
|
+
let resolved: TimeDisplayFormat = '12h'
|
|
66
|
+
try {
|
|
67
|
+
if (new Intl.DateTimeFormat(locale, { hour: 'numeric' }).resolvedOptions().hour12 === false) {
|
|
68
|
+
resolved = '24h'
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
resolved = '12h'
|
|
72
|
+
}
|
|
73
|
+
timeFormatCache.set(locale, resolved)
|
|
74
|
+
return resolved
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The clock convention of the active `I18nProvider` locale; `12h` outside a provider. */
|
|
78
|
+
export function useTimeDisplayFormat(): TimeDisplayFormat {
|
|
79
|
+
return deriveTimeDisplayFormat(useOptionalLocale())
|
|
80
|
+
}
|
|
@@ -11,6 +11,7 @@ import { Button } from './button'
|
|
|
11
11
|
import { LinkButton } from './link-button'
|
|
12
12
|
import { TimeInput } from '../backend/inputs/TimeInput'
|
|
13
13
|
import { formatWithPublicDateFormat, resolvePublicDateFormat, resolvePublicDateTimeFormat } from './date-format'
|
|
14
|
+
import { useDateFnsLocale } from './date-locale'
|
|
14
15
|
|
|
15
16
|
export type DatePickerFooter = 'apply-cancel' | 'today-clear' | 'none'
|
|
16
17
|
|
|
@@ -56,6 +57,12 @@ export type DatePickerProps = {
|
|
|
56
57
|
align?: 'start' | 'center' | 'end'
|
|
57
58
|
minDate?: Date
|
|
58
59
|
maxDate?: Date
|
|
60
|
+
/**
|
|
61
|
+
* date-fns locale for month/weekday names and the first day of the week.
|
|
62
|
+
* Defaults to the active `I18nProvider` locale (`undefined` outside a
|
|
63
|
+
* provider, matching pre-existing behavior); pass one explicitly to pin it
|
|
64
|
+
* regardless of the app locale.
|
|
65
|
+
*/
|
|
59
66
|
locale?: Locale
|
|
60
67
|
displayFormat?: string
|
|
61
68
|
className?: string
|
|
@@ -104,7 +111,7 @@ export function DatePicker({
|
|
|
104
111
|
align = 'start',
|
|
105
112
|
minDate,
|
|
106
113
|
maxDate,
|
|
107
|
-
locale,
|
|
114
|
+
locale: localeProp,
|
|
108
115
|
displayFormat,
|
|
109
116
|
className,
|
|
110
117
|
popoverClassName,
|
|
@@ -116,6 +123,14 @@ export function DatePicker({
|
|
|
116
123
|
}: DatePickerProps) {
|
|
117
124
|
const resolvedCloseOnSelect = closeOnSelect ?? footer === 'today-clear'
|
|
118
125
|
const t = useT()
|
|
126
|
+
// Callers may pin a locale explicitly (e.g. a field-level override); absent
|
|
127
|
+
// that, fall back to the active app locale so every consumer of this
|
|
128
|
+
// primitive — not just the ones that remember to thread a `locale` prop —
|
|
129
|
+
// renders a localized month/weekday grid instead of silently defaulting to
|
|
130
|
+
// English. Outside an `I18nProvider` this resolves to `undefined`, same as
|
|
131
|
+
// not passing a locale at all today.
|
|
132
|
+
const appLocale = useDateFnsLocale()
|
|
133
|
+
const locale = localeProp ?? appLocale
|
|
119
134
|
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false)
|
|
120
135
|
const open = controlledOpen ?? uncontrolledOpen
|
|
121
136
|
const setOpen = React.useCallback((next: boolean) => {
|
|
@@ -16,7 +16,7 @@ type SlotInteractiveState = 'default' | 'hover' | 'active' | 'disabled'
|
|
|
16
16
|
type DurationInteractiveState = SlotInteractiveState
|
|
17
17
|
type StatusInteractiveState = 'default' | 'hover' | 'selected' | 'disabled'
|
|
18
18
|
|
|
19
|
-
type TimeFormat = '12h' | '24h'
|
|
19
|
+
export type TimeFormat = '12h' | '24h'
|
|
20
20
|
|
|
21
21
|
type LegacyFooterAction = {
|
|
22
22
|
label: string
|