@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.
@@ -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="en" dict={{}}>{ui}</I18nProvider>)
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(), 'MMMM yyyy', locale ? { locale } : undefined)
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, 'MMMM yyyy', locale ? { locale } : undefined)
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), 'MMM', locale ? { locale } : undefined),
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