@open-mercato/ui 0.7.1-develop.7153.1.7145d295e6 → 0.7.1-develop.7170.1.d95074d7ba

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.
@@ -75,7 +75,7 @@ import { loadGeneratedFieldRegistrations } from './fields/registry'
75
75
  import type { CustomFieldDefDto, CustomFieldDefinitionsPayload, CustomFieldsetDto } from './utils/customFieldDefs'
76
76
  import { isDefVisible } from './utils/customFieldDefs'
77
77
  import { buildFormFieldsFromCustomFields, buildFormFieldFromCustomFieldDef } from './utils/customFieldForms'
78
- import { useT } from '@open-mercato/shared/lib/i18n/context'
78
+ import { useT, useOptionalLocale } from '@open-mercato/shared/lib/i18n/context'
79
79
  import { TagsInput } from './inputs/TagsInput'
80
80
  import { ComboboxInput } from './inputs/ComboboxInput'
81
81
  import { format } from 'date-fns/format'
@@ -96,6 +96,7 @@ import { useInjectionSpotEvents, InjectionSpot, useInjectionWidgets } from './in
96
96
  import { dispatchBackendMutationError } from './injection/mutationEvents'
97
97
  import { VersionHistoryAction } from './version-history/VersionHistoryAction'
98
98
  import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
99
+ import { parseLocaleNumber, resolveLocaleNumberSeparators } from '@open-mercato/shared/lib/number'
99
100
  import { cn } from '@open-mercato/shared/lib/utils'
100
101
  import { createLogger } from '@open-mercato/shared/lib/logger'
101
102
  import { useInjectionDataWidgets } from './injection/useInjectionDataWidgets'
@@ -4036,14 +4037,23 @@ function NumberInput({
4036
4037
  autoFocus?: boolean
4037
4038
  onSubmit?: () => void
4038
4039
  }) {
4040
+ const locale = useOptionalLocale()
4039
4041
  const serializedValue = value !== undefined && value !== null ? String(value) : ''
4040
4042
  const [local, setLocal] = React.useState<string>(serializedValue)
4041
4043
  const isFocusedRef = React.useRef(false)
4044
+ // Users type the separator the surrounding UI displays, which follows the application
4045
+ // locale — `110,70` under Polish. `Number()` only ever accepted `.` (issue #5552).
4046
+ const parse = React.useCallback(
4047
+ (raw: string): number | undefined => {
4048
+ if (raw === '') return undefined
4049
+ return parseLocaleNumber(raw, locale) ?? undefined
4050
+ },
4051
+ [locale],
4052
+ )
4042
4053
  const commitIfChanged = React.useCallback(() => {
4043
4054
  if (local === serializedValue) return
4044
- const numValue = local === '' ? undefined : Number(local)
4045
- onChange(numValue)
4046
- }, [local, onChange, serializedValue])
4055
+ onChange(parse(local))
4056
+ }, [local, onChange, parse, serializedValue])
4047
4057
 
4048
4058
  React.useEffect(() => {
4049
4059
  // Only sync from props when not focused to avoid caret jumps
@@ -4055,17 +4065,31 @@ function NumberInput({
4055
4065
  const handleChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
4056
4066
  const next = e.target.value
4057
4067
  setLocal(next)
4058
- const numValue = next === '' ? undefined : Number(next)
4059
- onChange(numValue)
4060
- }, [onChange])
4068
+ onChange(parse(next))
4069
+ }, [onChange, parse])
4061
4070
 
4062
4071
  const handleKeyDown = React.useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
4063
4072
  if (e.key === 'Enter' && !e.shiftKey) {
4064
4073
  e.preventDefault()
4065
4074
  commitIfChanged()
4066
4075
  onSubmit?.()
4076
+ return
4077
+ }
4078
+ // A text input has no native spinner, so keep the step affordance the previous
4079
+ // type="number" field offered.
4080
+ if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
4081
+ e.preventDefault()
4082
+ // Binary floating point puts 8.2 - 1 at 7.199999999999999; the native spinner
4083
+ // re-serialized against the step rather than committing that to form state.
4084
+ const base = parse(local) ?? 0
4085
+ const stepped = Number((base + (e.key === 'ArrowUp' ? 1 : -1)).toFixed(10))
4086
+ // The box must keep showing the separator the rest of the UI displays, which is
4087
+ // the application locale's — not the dot String() always emits (issue #5552).
4088
+ const { decimal } = resolveLocaleNumberSeparators(locale)
4089
+ setLocal(decimal === '.' ? String(stepped) : String(stepped).replace('.', decimal))
4090
+ onChange(stepped)
4067
4091
  }
4068
- }, [commitIfChanged, onSubmit])
4092
+ }, [commitIfChanged, local, locale, onChange, onSubmit, parse])
4069
4093
 
4070
4094
  const handleFocus = React.useCallback(() => {
4071
4095
  isFocusedRef.current = true
@@ -4077,8 +4101,15 @@ function NumberInput({
4077
4101
  }, [commitIfChanged])
4078
4102
 
4079
4103
  return (
4104
+ // type="text" rather than type="number": the browser sanitizes a type="number" value
4105
+ // against the BROWSER locale, so it discards the separator the app locale displays
4106
+ // before React ever sees it (issue #5552). inputMode keeps the mobile numeric keypad.
4080
4107
  <Input
4081
- type="number"
4108
+ type="text"
4109
+ inputMode="decimal"
4110
+ // A text input is eligible for autofill and spellcheck; a number field is not.
4111
+ autoComplete="off"
4112
+ spellCheck={false}
4082
4113
  placeholder={placeholder}
4083
4114
  value={local}
4084
4115
  onChange={handleChange}
@@ -40,10 +40,11 @@ import { raiseCrudError } from './utils/serverErrors'
40
40
  import { computeMenuViewportShiftX } from './utils/viewport'
41
41
  import { PerspectiveSidebar } from './PerspectiveSidebar'
42
42
  import { Popover, PopoverTrigger, PopoverContent } from '../primitives/popover'
43
- import { formatWithPublicDateFormat, normalizeDateFormatPattern } from '../primitives/date-format'
43
+ import { parseISO } from 'date-fns/parseISO'
44
+ import { formatDisplayDateTime } from '../primitives/date-format'
44
45
  import { cn } from '@open-mercato/shared/lib/utils'
45
46
  import { readVersionedPreference, writeVersionedPreference, clearVersionedPreference } from '@open-mercato/shared/lib/browser/versionedPreference'
46
- import { useT } from '@open-mercato/shared/lib/i18n/context'
47
+ import { useT, useLocale } from '@open-mercato/shared/lib/i18n/context'
47
48
  import { flash } from './FlashMessages'
48
49
  import { useConfirmDialog } from './confirm-dialog'
49
50
  import { surfaceRecordConflict } from './conflicts'
@@ -1665,15 +1666,9 @@ export function DataTable<T extends RowData>({
1665
1666
  return <RowActions items={injectedItems} />
1666
1667
  }, [injectedRowActions, rowActions, router, t])
1667
1668
 
1668
- // Date formatting setup. The OM-prefixed env vars are the new public contract;
1669
- // NEXT_PUBLIC_DATE_FORMAT remains supported for existing apps.
1670
- const DATE_FORMAT = (
1671
- normalizeDateFormatPattern(process.env.NEXT_PUBLIC_OM_DATE_TIME_FORMAT)
1672
- ?? normalizeDateFormatPattern(process.env.NEXT_PUBLIC_DATE_TIME_FORMAT)
1673
- ?? normalizeDateFormatPattern(process.env.NEXT_PUBLIC_OM_DATE_FORMAT)
1674
- ?? normalizeDateFormatPattern(process.env.NEXT_PUBLIC_DATE_FORMAT)
1675
- ?? 'yyyy-MM-dd HH:mm'
1676
- )
1669
+ // Locale-aware for the same reason the detail fields are: with no env override a table cell and
1670
+ // the field beside it must not disagree about the convention. An env override still wins.
1671
+ const dateLocale = useLocale()
1677
1672
 
1678
1673
  const tryParseDate = (v: unknown): Date | null => {
1679
1674
  if (v == null) return null
@@ -1685,9 +1680,10 @@ export function DataTable<T extends RowData>({
1685
1680
  if (typeof v === 'string') {
1686
1681
  const s = v.trim()
1687
1682
  if (!s) return null
1688
- // ISO-like detection (YYYY-MM-DD ...)
1683
+ // ISO-like detection (YYYY-MM-DD ...). `parseISO`, not `new Date`: the latter reads a bare
1684
+ // `yyyy-MM-dd` as UTC midnight, which renders as the previous day west of UTC.
1689
1685
  if (/^\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)?$/.test(s)) {
1690
- const d = new Date(s)
1686
+ const d = parseISO(s)
1691
1687
  return isNaN(d.getTime()) ? null : d
1692
1688
  }
1693
1689
  // Fallback: Date.parse
@@ -3540,7 +3536,7 @@ export function DataTable<T extends RowData>({
3540
3536
  if (isDateCol) {
3541
3537
  const raw = cell.getValue() as any
3542
3538
  const d = tryParseDate(raw)
3543
- content = d ? (formatWithPublicDateFormat(d, DATE_FORMAT) ?? raw) : (raw as any)
3539
+ content = d ? (formatDisplayDateTime(d, dateLocale) ?? raw) : (raw as any)
3544
3540
  } else {
3545
3541
  content = flexRender(cell.column.columnDef.cell, cell.getContext())
3546
3542
  }
@@ -3563,7 +3559,7 @@ export function DataTable<T extends RowData>({
3563
3559
  tooltipText = metaTooltipContent(row.original)
3564
3560
  } else if (isDateCol && cellValue != null) {
3565
3561
  const parsedDate = tryParseDate(cellValue)
3566
- tooltipText = parsedDate ? (formatWithPublicDateFormat(parsedDate, DATE_FORMAT) ?? String(cellValue)) : String(cellValue)
3562
+ tooltipText = parsedDate ? (formatDisplayDateTime(parsedDate, dateLocale) ?? String(cellValue)) : String(cellValue)
3567
3563
  } else {
3568
3564
  tooltipText = cellValue != null ? String(cellValue) : undefined
3569
3565
  }
@@ -4,6 +4,7 @@ import * as React from 'react'
4
4
  import { ChevronDown } from 'lucide-react'
5
5
  import { Badge } from '../primitives/badge'
6
6
  import { cn } from '@open-mercato/shared/lib/utils'
7
+ import { useOptionalT } from '@open-mercato/shared/lib/i18n/context'
7
8
 
8
9
  export type SectionHeaderProps = {
9
10
  /** Section title */
@@ -14,6 +15,11 @@ export type SectionHeaderProps = {
14
15
  action?: React.ReactNode
15
16
  /** Additional className */
16
17
  className?: string
18
+ /**
19
+ * Additional className on the title element. Supplying it also lets the title row
20
+ * shrink below its content width, so `truncate` takes effect in narrow containers.
21
+ */
22
+ titleClassName?: string
17
23
  }
18
24
 
19
25
  export function SectionHeader({
@@ -21,11 +27,13 @@ export function SectionHeader({
21
27
  count,
22
28
  action,
23
29
  className,
30
+ titleClassName,
24
31
  }: SectionHeaderProps) {
32
+ const allowsTitleShrink = Boolean(titleClassName)
25
33
  return (
26
34
  <div className={cn('flex items-center justify-between', className)}>
27
- <div className="flex items-center gap-2">
28
- <h3 className="text-sm font-semibold">{title}</h3>
35
+ <div className={cn('flex items-center gap-2', allowsTitleShrink && 'min-w-0')}>
36
+ <h3 className={cn('text-sm font-semibold', allowsTitleShrink && 'min-w-0', titleClassName)}>{title}</h3>
29
37
  {count != null && (
30
38
  <Badge variant="muted" className="text-xs tabular-nums">
31
39
  {count}
@@ -56,6 +64,11 @@ export type CollapsibleSectionProps = {
56
64
  className?: string
57
65
  /** Additional className on content wrapper */
58
66
  contentClassName?: string
67
+ /**
68
+ * Additional className on the title element. Supplying it also lets the header row
69
+ * shrink below its content width, so `truncate` takes effect in narrow containers.
70
+ */
71
+ titleClassName?: string
59
72
  }
60
73
 
61
74
  export function CollapsibleSection({
@@ -68,6 +81,7 @@ export function CollapsibleSection({
68
81
  children,
69
82
  className,
70
83
  contentClassName,
84
+ titleClassName,
71
85
  }: CollapsibleSectionProps) {
72
86
  const [internalCollapsed, setInternalCollapsed] = React.useState(defaultCollapsed)
73
87
  const isControlled = controlledCollapsed !== undefined
@@ -79,23 +93,32 @@ export function CollapsibleSection({
79
93
  onCollapsedChange?.(next)
80
94
  }, [isCollapsed, isControlled, onCollapsedChange])
81
95
 
96
+ const contextT = useOptionalT()
97
+ const t = (key: string, fallback: string) =>
98
+ contextT ? contextT(key, fallback, { title }) : fallback.replace('{title}', title)
99
+ const toggleAriaLabel = isCollapsed
100
+ ? t('ui.sectionHeader.expand', 'Expand {title} section')
101
+ : t('ui.sectionHeader.collapse', 'Collapse {title} section')
102
+ const allowsTitleShrink = Boolean(titleClassName)
103
+
82
104
  return (
83
105
  <div className={cn('space-y-3', className)}>
84
106
  <div className="flex items-center justify-between">
85
107
  <button
86
108
  type="button"
87
109
  onClick={toggle}
88
- className="flex items-center gap-2 group"
110
+ className={cn('flex items-center gap-2 group', allowsTitleShrink && 'min-w-0')}
89
111
  aria-expanded={!isCollapsed}
90
- aria-label={`${isCollapsed ? 'Expand' : 'Collapse'} ${title} section`}
112
+ aria-label={toggleAriaLabel}
91
113
  >
92
114
  <ChevronDown
93
115
  className={cn(
94
116
  'h-4 w-4 text-muted-foreground transition-transform duration-200',
95
117
  isCollapsed && '-rotate-90',
118
+ allowsTitleShrink && 'shrink-0',
96
119
  )}
97
120
  />
98
- <h3 className="text-sm font-semibold">{title}</h3>
121
+ <h3 className={cn('text-sm font-semibold', allowsTitleShrink && 'min-w-0', titleClassName)}>{title}</h3>
99
122
  {count != null && (
100
123
  <Badge variant="muted" className="text-xs tabular-nums">
101
124
  {count}
@@ -0,0 +1,159 @@
1
+ /** @jest-environment jsdom */
2
+ jest.setTimeout(15000)
3
+
4
+ jest.mock('next/navigation', () => ({
5
+ useRouter: () => ({ push: () => {} }),
6
+ usePathname: () => '/',
7
+ useSearchParams: () => new URLSearchParams(),
8
+ }))
9
+ jest.mock('remark-gfm', () => ({ __esModule: true, default: {} }))
10
+ jest.mock('../injection/InjectionSpot', () => ({
11
+ __esModule: true,
12
+ InjectionSpot: () => null,
13
+ useInjectionWidgets: () => ({ widgets: [], loading: false, error: null }),
14
+ useInjectionSpotEvents: () => ({ triggerEvent: jest.fn() }),
15
+ }))
16
+ jest.mock('../injection/useInjectionDataWidgets', () => ({
17
+ __esModule: true,
18
+ useInjectionDataWidgets: () => ({ widgets: [], isLoading: false, error: null }),
19
+ }))
20
+
21
+ import * as React from 'react'
22
+ import { act, fireEvent, waitFor } from '@testing-library/react'
23
+ import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
24
+ import { CrudForm, type CrudField } from '../CrudForm'
25
+
26
+ const dict = { 'ui.forms.actions.save': 'Save' }
27
+ const fields: CrudField[] = [{ id: 'amount', label: 'Amount', type: 'number' }]
28
+
29
+ // The locale is pinned to one that does NOT use a dot decimal separator, because an
30
+ // en-US pin passes on the buggy implementation too.
31
+ function renderNumberForm(locale: string, onSubmit: (values: Record<string, unknown>) => void) {
32
+ return renderWithProviders(
33
+ <CrudForm title="Form" fields={fields} onSubmit={onSubmit} />,
34
+ { locale, dict },
35
+ )
36
+ }
37
+
38
+ function amountInput(container: HTMLElement): HTMLInputElement {
39
+ const input = container.querySelector('[data-crud-field-id="amount"] input')
40
+ expect(input).not.toBeNull()
41
+ return input as HTMLInputElement
42
+ }
43
+
44
+ async function submitWith(container: HTMLElement, typed: string) {
45
+ const input = amountInput(container)
46
+ await act(async () => {
47
+ fireEvent.change(input, { target: { value: typed } })
48
+ })
49
+ await act(async () => {
50
+ fireEvent.blur(input)
51
+ })
52
+ await act(async () => {
53
+ fireEvent.submit(container.querySelector('form') as HTMLFormElement)
54
+ })
55
+ }
56
+
57
+ describe('CrudForm number fields accept the locale decimal separator (issue #5552)', () => {
58
+ it('does not render a type="number" input, whose value the browser sanitizes per BROWSER locale', () => {
59
+ const { container } = renderNumberForm('pl', () => {})
60
+ const input = amountInput(container)
61
+ // A type="number" input discards `110,70` before React sees it when the browser
62
+ // locale differs from the application locale — the original defect.
63
+ expect(input.getAttribute('type')).toBe('text')
64
+ expect(input.getAttribute('inputMode')).toBe('decimal')
65
+ })
66
+
67
+ it('commits a comma-typed value as the number the UI displays under a Polish locale', async () => {
68
+ const onSubmit = jest.fn()
69
+ const { container } = renderNumberForm('pl', onSubmit)
70
+
71
+ await submitWith(container, '110,70')
72
+
73
+ await waitFor(() => expect(onSubmit).toHaveBeenCalled())
74
+ expect(onSubmit.mock.calls[0][0].amount).toBe(110.7)
75
+ })
76
+
77
+ it('still accepts a dot under a comma-decimal locale, so the existing input keeps working', async () => {
78
+ const onSubmit = jest.fn()
79
+ const { container } = renderNumberForm('pl', onSubmit)
80
+
81
+ await submitWith(container, '110.70')
82
+
83
+ await waitFor(() => expect(onSubmit).toHaveBeenCalled())
84
+ expect(onSubmit.mock.calls[0][0].amount).toBe(110.7)
85
+ })
86
+
87
+ it('commits a dot-typed value under an English locale', async () => {
88
+ const onSubmit = jest.fn()
89
+ const { container } = renderNumberForm('en', onSubmit)
90
+
91
+ await submitWith(container, '2.5')
92
+
93
+ await waitFor(() => expect(onSubmit).toHaveBeenCalled())
94
+ expect(onSubmit.mock.calls[0][0].amount).toBe(2.5)
95
+ })
96
+
97
+ it('keeps a half-typed decimal in the box on the way to a complete one', async () => {
98
+ const { container } = renderNumberForm('pl', () => {})
99
+ const input = amountInput(container)
100
+
101
+ await act(async () => {
102
+ fireEvent.focus(input)
103
+ })
104
+ // `110,` is not yet the final value; the local buffer has to survive the round trip
105
+ // through the form state so the user can finish typing.
106
+ await act(async () => {
107
+ fireEvent.change(input, { target: { value: '110,' } })
108
+ })
109
+ expect(input.value).toBe('110,')
110
+
111
+ await act(async () => {
112
+ fireEvent.change(input, { target: { value: '110,70' } })
113
+ })
114
+ expect(input.value).toBe('110,70')
115
+ })
116
+
117
+ it('steps the value with the arrow keys, writing back the separator the locale displays', async () => {
118
+ const onSubmit = jest.fn()
119
+ const { container } = renderNumberForm('pl', onSubmit)
120
+ const input = amountInput(container)
121
+
122
+ await act(async () => {
123
+ fireEvent.change(input, { target: { value: '4,5' } })
124
+ })
125
+ await act(async () => {
126
+ fireEvent.keyDown(input, { key: 'ArrowUp' })
127
+ })
128
+ // A dot here would contradict the separator the rest of the Polish UI shows, which is
129
+ // the complaint issue #5552 opened with.
130
+ expect(input.value).toBe('5,5')
131
+
132
+ await act(async () => {
133
+ fireEvent.keyDown(input, { key: 'ArrowDown' })
134
+ })
135
+ expect(input.value).toBe('4,5')
136
+ })
137
+
138
+ it('does not commit a binary floating point artifact when stepping', async () => {
139
+ const onSubmit = jest.fn()
140
+ const { container } = renderNumberForm('pl', onSubmit)
141
+ const input = amountInput(container)
142
+
143
+ await act(async () => {
144
+ fireEvent.change(input, { target: { value: '8,2' } })
145
+ })
146
+ await act(async () => {
147
+ fireEvent.keyDown(input, { key: 'ArrowDown' })
148
+ })
149
+ // 8.2 - 1 is 7.199999999999999 in binary floating point, and onChange puts whatever
150
+ // this commits straight into the submitted form value.
151
+ expect(input.value).toBe('7,2')
152
+
153
+ await act(async () => {
154
+ fireEvent.submit(container.querySelector('form') as HTMLFormElement)
155
+ })
156
+ await waitFor(() => expect(onSubmit).toHaveBeenCalled())
157
+ expect(onSubmit.mock.calls[0][0].amount).toBe(7.2)
158
+ })
159
+ })
@@ -191,7 +191,7 @@ describe('CrudForm validation state', () => {
191
191
  },
192
192
  )
193
193
 
194
- const priorityInput = container.querySelector('[data-crud-field-id="cf_priority"] input[type="number"]')
194
+ const priorityInput = container.querySelector('[data-crud-field-id="cf_priority"] input')
195
195
  expect(priorityInput).not.toBeNull()
196
196
 
197
197
  // Blurring an untouched field must NOT flag the required error — tabbing
@@ -0,0 +1,87 @@
1
+ /** @jest-environment jsdom */
2
+ import * as React from 'react'
3
+ import { DataTable } from '../DataTable'
4
+ import type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'
5
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
6
+ import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
7
+ import { render, screen } from '@testing-library/react'
8
+ import { formatWithPublicDateFormat } from '../../primitives/date-format'
9
+
10
+ jest.mock('next/navigation', () => ({
11
+ useRouter: () => ({ push: jest.fn(), replace: jest.fn(), prefetch: jest.fn() }),
12
+ }))
13
+
14
+ jest.mock('../injection/useInjectionDataWidgets', () => ({
15
+ useInjectionDataWidgets: () => ({ widgets: [], isLoading: false }),
16
+ }))
17
+
18
+ type Row = { id: string; created_at: string }
19
+
20
+ const ORIGINAL_ENV = { ...process.env }
21
+
22
+ afterEach(() => {
23
+ process.env = { ...ORIGINAL_ENV }
24
+ })
25
+
26
+ function renderTable(data: Row[], locale = 'pl') {
27
+ const columns: ColumnDef<Row>[] = [{ accessorKey: 'created_at', header: 'Created' }]
28
+ const queryClient = new QueryClient({ defaultOptions: { queries: { gcTime: 0 } } })
29
+ return render(
30
+ React.createElement(
31
+ QueryClientProvider as any,
32
+ { client: queryClient },
33
+ React.createElement(
34
+ I18nProvider as any,
35
+ { locale, dict: {} },
36
+ React.createElement(DataTable as any, { columns, data, title: 'Test' }),
37
+ ),
38
+ ),
39
+ )
40
+ }
41
+
42
+ describe('DataTable date cells', () => {
43
+ // `new Date('2026-07-01')` reads a bare date as UTC midnight. Asserting the time too, not just
44
+ // the day, catches that in either direction: a UTC parse reads `02:00` in Europe/Warsaw and
45
+ // `2026-06-30 17:00` in America/Los_Angeles, while local midnight is `00:00` everywhere.
46
+ it('renders a date-only value as local midnight of the stored day', () => {
47
+ renderTable([{ id: '1', created_at: '2026-07-01' }])
48
+
49
+ // `pl`, not `en`: under `en` a component that never reads the locale renders the same string as
50
+ // one that does, so the assertion would hold either way — the corollary in
51
+ // `.ai/lessons/tests-asserting-intl-output-must-pin-locale-and-date.md`. Mutation-checked:
52
+ // dropping the locale from `DataTable`'s formatter call fails this case.
53
+ //
54
+ // The time is asserted too: a UTC parse reads `02:00` in Europe/Warsaw and 30 June further west,
55
+ // while local midnight is `00:00` everywhere.
56
+ expect(screen.getByText('1 lip 2026, 00:00')).toBeInTheDocument()
57
+ })
58
+
59
+ // The `locale` parameter exists for this case. `en` is `defaultLocale`, so a 12-hour clock in
60
+ // every backend table is what most deployments get from this change; asserting it here means the
61
+ // most-seen rendering in the product is guarded rather than inferred from the `pl` case above.
62
+ it('renders the default locale on a 12-hour clock', () => {
63
+ renderTable([{ id: '1', created_at: '2026-07-01' }], 'en')
64
+
65
+ expect(screen.getByText('Jul 1, 2026, 12:00 AM')).toBeInTheDocument()
66
+ })
67
+
68
+ // One env chain with the detail-page helpers: a table cell and a detail field must not disagree.
69
+ it('honours the date-time env format', () => {
70
+ process.env.NEXT_PUBLIC_OM_DATE_TIME_FORMAT = 'dd.MM.yyyy HH:mm'
71
+ renderTable([{ id: '1', created_at: '2026-07-01T09:30:00Z' }])
72
+
73
+ // Same reason as the date-format suite: `09:30Z` is 30 June at UTC-10 and further west,
74
+ // so the expectation comes from the formatter rather than a literal.
75
+ const expected = formatWithPublicDateFormat(new Date('2026-07-01T09:30:00Z'), 'dd.MM.yyyy HH:mm')!
76
+ expect(screen.getByText(expected)).toBeInTheDocument()
77
+ })
78
+
79
+ // The date vars stay in the chain and stay bare: appending a time to a caller's date-only
80
+ // pattern would change how every existing table renders.
81
+ it('uses a date-only env format bare, without inventing a time', () => {
82
+ process.env.NEXT_PUBLIC_OM_DATE_FORMAT = 'dd.MM.yyyy'
83
+ renderTable([{ id: '1', created_at: '2026-07-01' }])
84
+
85
+ expect(screen.getByText('01.07.2026')).toBeInTheDocument()
86
+ })
87
+ })
@@ -0,0 +1,105 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import * as React from 'react'
5
+ import { fireEvent, render, screen } from '@testing-library/react'
6
+ import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
7
+ import { CollapsibleSection, SectionHeader } from '../SectionHeader'
8
+
9
+ const plDict = {
10
+ 'ui.sectionHeader.collapse': 'Zwiń sekcję {title}',
11
+ 'ui.sectionHeader.expand': 'Rozwiń sekcję {title}',
12
+ }
13
+
14
+ describe('CollapsibleSection', () => {
15
+ it('translates the toggle aria-label for the active locale', () => {
16
+ renderWithProviders(
17
+ <CollapsibleSection title="Skrzynka">
18
+ <p>Treść</p>
19
+ </CollapsibleSection>,
20
+ { locale: 'pl', dict: plDict },
21
+ )
22
+
23
+ expect(screen.getByRole('button', { name: 'Zwiń sekcję Skrzynka' })).toBeTruthy()
24
+ })
25
+
26
+ it('switches to the expand key once collapsed', () => {
27
+ renderWithProviders(
28
+ <CollapsibleSection title="Skrzynka">
29
+ <p>Treść</p>
30
+ </CollapsibleSection>,
31
+ { locale: 'pl', dict: plDict },
32
+ )
33
+
34
+ fireEvent.click(screen.getByRole('button', { name: 'Zwiń sekcję Skrzynka' }))
35
+
36
+ expect(screen.getByRole('button', { name: 'Rozwiń sekcję Skrzynka' })).toBeTruthy()
37
+ })
38
+
39
+ it('falls back to English when the locale has no translation', () => {
40
+ renderWithProviders(
41
+ <CollapsibleSection title="Billing details">
42
+ <p>Content</p>
43
+ </CollapsibleSection>,
44
+ { locale: 'en', dict: {} },
45
+ )
46
+
47
+ expect(screen.getByRole('button', { name: 'Collapse Billing details section' })).toBeTruthy()
48
+ })
49
+
50
+ it('renders outside an I18nProvider using the English fallback', () => {
51
+ render(
52
+ <CollapsibleSection title="Billing details">
53
+ <p>Content</p>
54
+ </CollapsibleSection>,
55
+ )
56
+
57
+ expect(screen.getByRole('button', { name: 'Collapse Billing details section' })).toBeTruthy()
58
+ })
59
+
60
+ it('applies titleClassName and lets the header row shrink so truncation works', () => {
61
+ renderWithProviders(
62
+ <CollapsibleSection title="a-fairly-long-identifier@example.com" titleClassName="truncate">
63
+ <p>Content</p>
64
+ </CollapsibleSection>,
65
+ { locale: 'en', dict: {} },
66
+ )
67
+
68
+ const heading = screen.getByRole('heading', { level: 3 })
69
+ expect(heading.className).toContain('truncate')
70
+ expect(heading.className).toContain('min-w-0')
71
+ expect(heading.className).toContain('text-sm')
72
+ expect(screen.getByRole('button').className).toContain('min-w-0')
73
+ })
74
+
75
+ it('keeps the header row unconstrained when no titleClassName is supplied', () => {
76
+ renderWithProviders(
77
+ <CollapsibleSection title="Billing details">
78
+ <p>Content</p>
79
+ </CollapsibleSection>,
80
+ { locale: 'en', dict: {} },
81
+ )
82
+
83
+ expect(screen.getByRole('heading', { level: 3 }).className).not.toContain('min-w-0')
84
+ expect(screen.getByRole('button').className).not.toContain('min-w-0')
85
+ })
86
+ })
87
+
88
+ describe('SectionHeader', () => {
89
+ it('applies titleClassName to the title element', () => {
90
+ renderWithProviders(<SectionHeader title="a-fairly-long-identifier@example.com" titleClassName="truncate" />, {
91
+ locale: 'en',
92
+ dict: {},
93
+ })
94
+
95
+ const heading = screen.getByRole('heading', { level: 3 })
96
+ expect(heading.className).toContain('truncate')
97
+ expect(heading.className).toContain('min-w-0')
98
+ })
99
+
100
+ it('keeps the title unconstrained when no titleClassName is supplied', () => {
101
+ renderWithProviders(<SectionHeader title="Billing details" />, { locale: 'en', dict: {} })
102
+
103
+ expect(screen.getByRole('heading', { level: 3 }).className).not.toContain('min-w-0')
104
+ })
105
+ })