@open-mercato/ui 0.8.1-develop.7275.1.f772b944d4 → 0.8.1-develop.7294.1.0ef99fc518

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.
@@ -6,7 +6,7 @@ import { render, screen, waitFor } from '@testing-library/react'
6
6
  import type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'
7
7
  import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
8
8
  import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
9
- import { DataTable } from '../DataTable'
9
+ import { DataTable, type DataTableViewApi } from '../DataTable'
10
10
 
11
11
  jest.mock('next/navigation', () => ({
12
12
  useRouter: () => ({ push: jest.fn(), replace: jest.fn(), prefetch: jest.fn(), refresh: jest.fn() }),
@@ -37,7 +37,11 @@ const COLUMNS_WITH_CUSTOM_FIELDS: ColumnDef<Row>[] = [
37
37
  { accessorKey: 'cf_active_note', header: 'Active note' },
38
38
  ]
39
39
 
40
- function Harness({ columns, initialSettings }: { columns: ColumnDef<Row>[]; initialSettings?: unknown }) {
40
+ function Harness({ columns, initialSettings, apiRef }: {
41
+ columns: ColumnDef<Row>[]
42
+ initialSettings?: unknown
43
+ apiRef?: React.MutableRefObject<DataTableViewApi | null>
44
+ }) {
41
45
  const queryClient = React.useMemo(
42
46
  () => new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }),
43
47
  [],
@@ -48,6 +52,7 @@ function Harness({ columns, initialSettings }: { columns: ColumnDef<Row>[]; init
48
52
  <DataTable
49
53
  columns={columns as never}
50
54
  data={ROWS as never}
55
+ viewApiRef={apiRef}
51
56
  {...(initialSettings
52
57
  ? ({ perspective: { tableId: 'meta-hidden-late-columns', initialState: { initialSettings } } } as never)
53
58
  : {})}
@@ -58,6 +63,17 @@ function Harness({ columns, initialSettings }: { columns: ColumnDef<Row>[]; init
58
63
  }
59
64
 
60
65
  describe('DataTable — meta.hidden on columns that arrive after the first render', () => {
66
+ beforeEach(() => {
67
+ // DataTable persists the applied view to localStorage and a cookie, and jsdom shares
68
+ // both across the cases in a file — clear them so a perspective set up by one case
69
+ // cannot hydrate into the next one and decide its columns.
70
+ window.localStorage.clear()
71
+ for (const entry of document.cookie.split(';')) {
72
+ const name = entry.split('=')[0]?.trim()
73
+ if (name) document.cookie = `${name}=; Max-Age=0; Path=/`
74
+ }
75
+ })
76
+
61
77
  it('hides a meta.hidden column that only appears on a later render', async () => {
62
78
  // The regression: the auto-hide pass used to be latched by a single boolean ref, so it
63
79
  // ran on the first render — when no `cf_*` column existed yet — found nothing to hide,
@@ -91,8 +107,9 @@ describe('DataTable — meta.hidden on columns that arrive after the first rende
91
107
  expect(screen.queryByText('Archived note')).toBeNull()
92
108
  })
93
109
 
94
- it('lets a stored perspective override the meta.hidden defaults', async () => {
95
- // A saved perspective seeds columnVisibility at mount and wins outright.
110
+ it('lets a stored perspective override the meta.hidden default for a column it names', async () => {
111
+ // A saved perspective wins over the declared default — but only for the columns it
112
+ // actually names, which is what the two cases below pin down (#5117).
96
113
  render(
97
114
  <Harness
98
115
  columns={COLUMNS_WITH_CUSTOM_FIELDS}
@@ -102,4 +119,77 @@ describe('DataTable — meta.hidden on columns that arrive after the first rende
102
119
 
103
120
  await waitFor(() => expect(screen.getByText('Archived note')).toBeTruthy())
104
121
  })
122
+
123
+ it('still applies meta.hidden to a column the stored perspective does not name', async () => {
124
+ // The regression from #5117: the pass used to be latched on "the stored view carried
125
+ // *some* visibility", so a view that only spoke about `name` silently suppressed the
126
+ // declared default of every other column. A view can only decide what it describes.
127
+ render(
128
+ <Harness
129
+ columns={COLUMNS_WITH_CUSTOM_FIELDS}
130
+ initialSettings={{ columnVisibility: { name: true } }}
131
+ />,
132
+ )
133
+
134
+ await waitFor(() => expect(screen.getByText('Active note')).toBeTruthy())
135
+ expect(screen.queryByText('Archived note')).toBeNull()
136
+ })
137
+
138
+ it('applies meta.hidden to a late column that a stored perspective could not have named', async () => {
139
+ // The reported scenario: the view was saved before the custom-field columns resolved,
140
+ // so it cannot carry a decision for them. They must still fall back to their declared
141
+ // default instead of appearing mid-hydration.
142
+ const { rerender } = render(
143
+ <Harness columns={BASE_COLUMNS} initialSettings={{ columnVisibility: { name: true } }} />,
144
+ )
145
+ await waitFor(() => expect(screen.getByText('Name')).toBeTruthy())
146
+
147
+ rerender(
148
+ <Harness columns={COLUMNS_WITH_CUSTOM_FIELDS} initialSettings={{ columnVisibility: { name: true } }} />,
149
+ )
150
+
151
+ await waitFor(() => expect(screen.getByText('Active note')).toBeTruthy())
152
+ expect(screen.queryByText('Archived note')).toBeNull()
153
+ })
154
+
155
+ it('honours meta.hidden that a column def only gains on a later render', async () => {
156
+ // The per-column record must key on "a default was applied", not on "this id has been
157
+ // seen". Marking a column decided the first time it appears would strand a def whose
158
+ // `meta` resolves in a later wave — an injected column widget, say — as permanently
159
+ // undecided, and an undecided column renders visible.
160
+ const withoutMeta: ColumnDef<Row>[] = [
161
+ ...BASE_COLUMNS,
162
+ { accessorKey: 'cf_late_note', header: 'Late note' },
163
+ ]
164
+ const withMeta: ColumnDef<Row>[] = [
165
+ ...BASE_COLUMNS,
166
+ { accessorKey: 'cf_late_note', header: 'Late note', meta: { hidden: true } },
167
+ ]
168
+ const { rerender } = render(<Harness columns={withoutMeta} />)
169
+ await waitFor(() => expect(screen.getByText('Late note')).toBeTruthy())
170
+
171
+ rerender(<Harness columns={withMeta} />)
172
+
173
+ await waitFor(() => expect(screen.queryByText('Late note')).toBeNull())
174
+ })
175
+
176
+ it('describes every leaf column in the settings it hands back, including late arrivals', async () => {
177
+ // Root cause 1 of #5117: `getCurrentSettings` copied the sparse TanStack state, so a
178
+ // saved view stored no decision for columns that registered after the save and an
179
+ // absent key renders visible. The serialized map must be self-describing.
180
+ const apiRef = React.createRef<DataTableViewApi | null>() as React.MutableRefObject<DataTableViewApi | null>
181
+ const { rerender } = render(<Harness columns={BASE_COLUMNS} apiRef={apiRef} />)
182
+ await waitFor(() => expect(screen.getByText('Name')).toBeTruthy())
183
+
184
+ rerender(<Harness columns={COLUMNS_WITH_CUSTOM_FIELDS} apiRef={apiRef} />)
185
+ await waitFor(() => expect(screen.getByText('Active note')).toBeTruthy())
186
+
187
+ await waitFor(() => {
188
+ expect(apiRef.current?.getCurrentSettings().columnVisibility).toEqual({
189
+ name: true,
190
+ cf_archived_note: false,
191
+ cf_active_note: true,
192
+ })
193
+ })
194
+ })
105
195
  })
@@ -140,6 +140,13 @@ const columns: ColumnDef<Row>[] = [
140
140
  { accessorKey: 'id', header: 'Id' },
141
141
  ]
142
142
 
143
+ // The shape the People / Companies / Deals lists actually render: a module column that
144
+ // declares its own default (`hidden: def.listVisible === false`) next to ordinary ones.
145
+ const COLUMNS_WITH_DECLARED_DEFAULT: ColumnDef<Row>[] = [
146
+ ...columns,
147
+ { accessorKey: 'cf_archived', header: 'Archived', meta: { hidden: true } } as ColumnDef<Row>,
148
+ ]
149
+
143
150
  type HarnessProps = {
144
151
  apiRef?: React.MutableRefObject<DataTableViewApi | null>
145
152
  onDirty?: (state: DataTableViewDirtyState) => void
@@ -149,6 +156,7 @@ type HarnessProps = {
149
156
  savedViews?: PerspectivesIndexResponse['perspectives']
150
157
  roleViews?: PerspectivesIndexResponse['rolePerspectives']
151
158
  initialSettings?: PerspectiveSettings
159
+ tableColumns?: ColumnDef<Row>[]
152
160
  /** `'granted'` seeds the permission check, `'denied'` seeds a refusal, `'pending'` leaves it in flight. */
153
161
  perspectivesFeature?: 'granted' | 'denied' | 'pending'
154
162
  }
@@ -162,6 +170,7 @@ function renderTable({
162
170
  savedViews = [],
163
171
  roleViews = [],
164
172
  initialSettings,
173
+ tableColumns = columns,
165
174
  perspectivesFeature = 'granted',
166
175
  }: HarnessProps) {
167
176
  const queryClient = new QueryClient({
@@ -183,7 +192,7 @@ function renderTable({
183
192
  <QueryClientProvider client={queryClient}>
184
193
  <I18nProvider locale="en" dict={{}}>
185
194
  <DataTable<Row>
186
- columns={columns}
195
+ columns={tableColumns}
187
196
  data={[]}
188
197
  searchValue={searchValue}
189
198
  onSearchChange={() => {}}
@@ -460,6 +469,125 @@ describe('DataTable public save-view API', () => {
460
469
  await waitFor(() => expect(screen.getByTestId('save-view-trigger')).not.toBeDisabled())
461
470
  })
462
471
 
472
+ it('describes every column in the settings it serializes, without reporting a change nobody made', async () => {
473
+ // #5117: the serialized visibility used to be a verbatim copy of the sparse TanStack
474
+ // state, so a saved view carried no decision for columns the user never toggled and an
475
+ // absent key renders visible. It is now seeded from the full leaf-column set — and the
476
+ // dense map must still compare equal to the sparse one the server stored, or every
477
+ // table with a saved view would report unsaved changes the moment it mounted.
478
+ const apiRef = React.createRef<DataTableViewApi | null>() as React.MutableRefObject<DataTableViewApi | null>
479
+ const states: DataTableViewDirtyState[] = []
480
+ renderTable({
481
+ apiRef,
482
+ onDirty: (state) => { states.push(state) },
483
+ initialSettings: { columnVisibility: { name: false } },
484
+ })
485
+
486
+ await waitFor(() => expect(apiRef.current).not.toBeNull())
487
+ expect(apiRef.current!.getCurrentSettings().columnVisibility).toEqual({ name: false, id: true })
488
+
489
+ await waitFor(() => expect(states.length).toBeGreaterThan(0))
490
+ expect(states.every((state) => !state.isDirty)).toBe(true)
491
+ })
492
+
493
+ it('stays clean on a table carrying a meta.hidden column the stored view never named', async () => {
494
+ // The case the density change can break, and the one the assertion above cannot see:
495
+ // its columns declare no default, so there is nothing for the live state to diverge on.
496
+ // Once the `meta.hidden` pass is allowed to run underneath a stored view it writes a
497
+ // `false` the server's sparse map does not carry, and `normalizeVisibility` keeps every
498
+ // `false` — so without folding the declared default into the baseline as well, every
499
+ // People / Companies / Deals view would mount showing "1 unsaved change" nobody made.
500
+ const apiRef = React.createRef<DataTableViewApi | null>() as React.MutableRefObject<DataTableViewApi | null>
501
+ const states: DataTableViewDirtyState[] = []
502
+ renderTable({
503
+ apiRef,
504
+ onDirty: (state) => { states.push(state) },
505
+ tableColumns: COLUMNS_WITH_DECLARED_DEFAULT,
506
+ initialSettings: { columnVisibility: { name: false } },
507
+ })
508
+
509
+ await waitFor(() => expect(apiRef.current).not.toBeNull())
510
+ await waitFor(() => expect(apiRef.current!.getCurrentSettings().columnVisibility)
511
+ .toEqual({ name: false, id: true, cf_archived: false }))
512
+
513
+ await waitFor(() => expect(states.length).toBeGreaterThan(0))
514
+ expect(states.every((state) => !state.isDirty)).toBe(true)
515
+ expect(apiRef.current!.getDirtyState().isDirty).toBe(false)
516
+ })
517
+
518
+ it('stays clean on first mount of a table with a meta.hidden column and no stored view at all', async () => {
519
+ // The case neither test above can see: with no `initialSettings`, the baseline is not
520
+ // seeded from `useState`'s initializer — it is seeded later by the mount-time effect
521
+ // from `currentViewSettings`, which closes over the render that *scheduled* the
522
+ // meta.hidden hide, not the one where it took effect. `setViewBaseline` must let the
523
+ // auto-hidden default win over that stale snapshot, or the baseline records the column
524
+ // visible while the live state (correctly) goes hidden on the next render, reporting a
525
+ // change nobody made the moment the table mounts.
526
+ const apiRef = React.createRef<DataTableViewApi | null>() as React.MutableRefObject<DataTableViewApi | null>
527
+ const states: DataTableViewDirtyState[] = []
528
+ renderTable({
529
+ apiRef,
530
+ onDirty: (state) => { states.push(state) },
531
+ tableColumns: COLUMNS_WITH_DECLARED_DEFAULT,
532
+ showSaveViewButton: true,
533
+ })
534
+
535
+ await waitFor(() => expect(apiRef.current).not.toBeNull())
536
+ await waitFor(() => expect(apiRef.current!.getCurrentSettings().columnVisibility)
537
+ .toEqual({ name: true, id: true, cf_archived: false }))
538
+
539
+ await waitFor(() => expect(states.length).toBeGreaterThan(0))
540
+ expect(states.every((state) => !state.isDirty)).toBe(true)
541
+ expect(apiRef.current!.getDirtyState().isDirty).toBe(false)
542
+ })
543
+
544
+ it('keeps a stored visibility decision for a column that has not registered yet', async () => {
545
+ // Serializing from the live column set alone drops any stored key whose column is not
546
+ // in the table — every `cf_*` decision, during the whole custom-field hydration window.
547
+ // Transiently that reports the view dirty; permanently, a save inside that window
548
+ // writes the user's "hide this custom field" away for good.
549
+ const apiRef = React.createRef<DataTableViewApi | null>() as React.MutableRefObject<DataTableViewApi | null>
550
+ const states: DataTableViewDirtyState[] = []
551
+ renderTable({
552
+ apiRef,
553
+ onDirty: (state) => { states.push(state) },
554
+ initialSettings: { columnVisibility: { name: false, cf_not_loaded_yet: false } },
555
+ })
556
+
557
+ await waitFor(() => expect(apiRef.current).not.toBeNull())
558
+ expect(apiRef.current!.getCurrentSettings().columnVisibility)
559
+ .toEqual({ name: false, cf_not_loaded_yet: false, id: true })
560
+
561
+ await waitFor(() => expect(states.length).toBeGreaterThan(0))
562
+ expect(states.every((state) => !state.isDirty)).toBe(true)
563
+ })
564
+
565
+ it('restores a declared default when the view that overrode it is switched away from', async () => {
566
+ // `applyPerspectiveSettings` re-seeds the per-column record and bumps the pass counter
567
+ // so the defaults are re-evaluated for the newly applied view. A view with no
568
+ // visibility of its own is exactly what "— No view —" applies, so this pins the clear
569
+ // path too: the column must go back to its declared default rather than staying
570
+ // visible for the rest of the session because an earlier view once un-hid it.
571
+ const showsHidden = {
572
+ ...SAVED_VIEW,
573
+ id: 'persp-shows-hidden',
574
+ settings: { columnVisibility: { cf_archived: true } },
575
+ }
576
+ const saysNothing = { ...SAVED_VIEW, id: 'persp-says-nothing', settings: {} }
577
+ renderTable({
578
+ tableColumns: COLUMNS_WITH_DECLARED_DEFAULT,
579
+ savedViews: [showsHidden, saysNothing],
580
+ })
581
+
582
+ // The first saved view auto-activates on load, so the column starts overridden.
583
+ await waitFor(() => expect(screen.getByText('Archived')).toBeTruthy())
584
+
585
+ const activateSaysNothing = await screen.findByTestId('activate-view-persp-says-nothing')
586
+ await act(async () => { fireEvent.click(activateSaysNothing) })
587
+
588
+ await waitFor(() => expect(screen.queryByText('Archived')).toBeNull())
589
+ })
590
+
463
591
  it('sends an unnamed save to the sidebar instead of inventing a name', async () => {
464
592
  const { setSearchValue } = renderTable({ showSaveViewButton: true })
465
593
  const trigger = await screen.findByTestId('save-view-trigger')
@@ -24,7 +24,19 @@ import { createLogger } from '@open-mercato/shared/lib/logger'
24
24
 
25
25
  const logger = createLogger('ui')
26
26
 
27
- function resolveInlineErrorMessage(err: unknown, fallbackMessage: string): string {
27
+ // Server validation errors arrive as stable i18n keys (e.g. `customers.people.form.*`), the
28
+ // same contract CrudForm resolves via translateValidationMessage; without this they render
29
+ // raw. `t(msg, msg)` is a no-op for already-localized strings.
30
+ function resolveInlineErrorMessage(
31
+ err: unknown,
32
+ fallbackMessage: string,
33
+ translate: (key: string, fallback: string) => string,
34
+ ): string {
35
+ const raw = resolveRawInlineErrorMessage(err, fallbackMessage)
36
+ return translate(raw, raw)
37
+ }
38
+
39
+ function resolveRawInlineErrorMessage(err: unknown, fallbackMessage: string): string {
28
40
  const { message, fieldErrors } = mapCrudServerErrorToFormErrors(err)
29
41
  const firstFieldError = fieldErrors
30
42
  ? Object.values(fieldErrors).find((text) => typeof text === 'string' && text.trim().length)
@@ -223,11 +235,11 @@ export function InlineTextEditor({
223
235
  await onSave(trimmed.length ? trimmed : null)
224
236
  setEditingSafe(false)
225
237
  } catch (err) {
226
- setError(resolveInlineErrorMessage(err, fallbackError))
238
+ setError(resolveInlineErrorMessage(err, fallbackError, t))
227
239
  } finally {
228
240
  setSaving(false)
229
241
  }
230
- }, [draft, fallbackError, onSave, setEditingSafe, validator])
242
+ }, [draft, fallbackError, onSave, setEditingSafe, t, validator])
231
243
 
232
244
  const interactiveProps: React.HTMLAttributes<HTMLDivElement> =
233
245
  activateOnClick && !editing
@@ -363,7 +375,7 @@ export function InlineTextEditor({
363
375
  await onSave(formatted.length ? formatted : null)
364
376
  setEditingSafe(false)
365
377
  } catch (err) {
366
- setError(resolveInlineErrorMessage(err, fallbackError))
378
+ setError(resolveInlineErrorMessage(err, fallbackError, t))
367
379
  } finally {
368
380
  setSaving(false)
369
381
  }
@@ -601,11 +613,11 @@ export function InlineMultilineEditor({
601
613
  await onSave(trimmed.length ? trimmed : null)
602
614
  setEditing(false)
603
615
  } catch (err) {
604
- setError(resolveInlineErrorMessage(err, fallbackError))
616
+ setError(resolveInlineErrorMessage(err, fallbackError, t))
605
617
  } finally {
606
618
  setSaving(false)
607
619
  }
608
- }, [adjustError, draft, fallbackError, onSave])
620
+ }, [adjustError, draft, fallbackError, onSave, t])
609
621
 
610
622
  return (
611
623
  <div className={containerClasses} onClick={handleInteractiveClick}>
@@ -137,3 +137,55 @@ describe('resolveSafeInlineUrlHref', () => {
137
137
  },
138
138
  )
139
139
  })
140
+
141
+ describe('InlineTextEditor server validation errors are localized (#5925)', () => {
142
+ const EMAIL_INVALID_KEY = 'customers.people.form.primaryEmail.invalid'
143
+ const TRANSLATED = 'Wprowadź prawidłowy adres e-mail'
144
+
145
+ function renderEditorRejectingWith(err: unknown, dict: Record<string, string>) {
146
+ renderWithProviders(
147
+ <InlineTextEditor
148
+ label="Primary email"
149
+ value="ada@example.com"
150
+ emptyLabel="No email"
151
+ type="email"
152
+ onSave={jest.fn().mockRejectedValue(err)}
153
+ />,
154
+ { dict },
155
+ )
156
+
157
+ const buttons = document.querySelectorAll('[data-slot="button"]')
158
+ const editToggle = buttons[buttons.length - 1] as HTMLButtonElement
159
+ act(() => {
160
+ fireEvent.click(editToggle)
161
+ })
162
+ }
163
+
164
+ async function submit() {
165
+ const saveButton = screen.getByRole('button', { name: /Save|⌘/ })
166
+ await act(async () => {
167
+ fireEvent.click(saveButton)
168
+ })
169
+ }
170
+
171
+ it('renders the translation for a server field error carrying an i18n key', async () => {
172
+ const err = Object.assign(new Error('Invalid input'), {
173
+ details: [{ path: ['primaryEmail'], message: EMAIL_INVALID_KEY }],
174
+ })
175
+ renderEditorRejectingWith(err, { [EMAIL_INVALID_KEY]: TRANSLATED })
176
+ await submit()
177
+
178
+ expect(screen.getByText(TRANSLATED)).toBeInTheDocument()
179
+ expect(screen.queryByText(EMAIL_INVALID_KEY)).not.toBeInTheDocument()
180
+ })
181
+
182
+ it('leaves an already-localized server message untouched', async () => {
183
+ const err = Object.assign(new Error('Invalid input'), {
184
+ details: [{ path: ['primaryEmail'], message: 'Enter a valid email address' }],
185
+ })
186
+ renderEditorRejectingWith(err, { [EMAIL_INVALID_KEY]: TRANSLATED })
187
+ await submit()
188
+
189
+ expect(screen.getByText('Enter a valid email address')).toBeInTheDocument()
190
+ })
191
+ })
@@ -1,7 +1,7 @@
1
1
  "use client"
2
2
 
3
3
  import * as React from 'react'
4
- import { useT } from '@open-mercato/shared/lib/i18n/context'
4
+ import { useOptionalLocale, useT, type TranslateFn } from '@open-mercato/shared/lib/i18n/context'
5
5
  import { extractPhoneDigits, validatePhoneNumber } from '@open-mercato/shared/lib/phone'
6
6
  import { cn } from '@open-mercato/shared/lib/utils'
7
7
  import {
@@ -23,7 +23,11 @@ export type PhoneCountry = {
23
23
  iso2: string
24
24
  /** International dial code (with `+`). */
25
25
  dialCode: string
26
- /** Human-readable country name (English). Override per surface for i18n. */
26
+ /**
27
+ * Human-readable country name (English). Used as the last-resort fallback
28
+ * when neither a locale file nor the platform's region data can name the
29
+ * country in the active locale.
30
+ */
27
31
  label: string
28
32
  /** Emoji flag (no asset dependency). */
29
33
  flag: string
@@ -311,6 +315,109 @@ export const PHONE_COUNTRIES: PhoneCountry[] = RAW_PHONE_COUNTRIES
311
315
  .map((country) => ({ ...country, flag: iso2ToFlagEmoji(country.iso2) }))
312
316
  .sort((a, b) => a.label.localeCompare(b.label, 'en', { sensitivity: 'base' }))
313
317
 
318
+ /**
319
+ * Translation key holding a country's display name, e.g.
320
+ * `ui.inputs.phoneNumberField.countryName.PL`. Locale files only need entries
321
+ * for the countries whose platform-provided name a deployment wants to
322
+ * change. This is a distinct namespace from
323
+ * `ui.inputs.phoneNumberField.country` (the picker's trigger label) so a
324
+ * nested locale file can hold both.
325
+ */
326
+ export function phoneCountryLabelKey(iso2: string): string {
327
+ return `ui.inputs.phoneNumberField.countryName.${iso2.toUpperCase()}`
328
+ }
329
+
330
+ const regionDisplayNamesByLocale = new Map<string, Intl.DisplayNames | null>()
331
+
332
+ function getRegionDisplayNames(locale: string): Intl.DisplayNames | null {
333
+ const cached = regionDisplayNamesByLocale.get(locale)
334
+ if (cached !== undefined) return cached
335
+ let displayNames: Intl.DisplayNames | null = null
336
+ try {
337
+ displayNames = new Intl.DisplayNames([locale], { type: 'region' })
338
+ } catch {
339
+ displayNames = null
340
+ }
341
+ regionDisplayNamesByLocale.set(locale, displayNames)
342
+ return displayNames
343
+ }
344
+
345
+ /**
346
+ * Country name in `locale` from the platform's own region data, or `null` when
347
+ * the runtime cannot name that region (unknown code, trimmed ICU data).
348
+ */
349
+ function regionDisplayName(iso2: string, locale: string | undefined): string | null {
350
+ if (!locale) return null
351
+ const code = iso2.toUpperCase()
352
+ if (!/^[A-Z]{2}$/.test(code)) return null
353
+ const displayNames = getRegionDisplayNames(locale)
354
+ if (!displayNames) return null
355
+ try {
356
+ const name = displayNames.of(code)
357
+ return name && name !== code ? name : null
358
+ } catch {
359
+ return null
360
+ }
361
+ }
362
+
363
+ function isEnglishLocale(locale: string | undefined): boolean {
364
+ return !locale || locale.toLowerCase().split(/[-_]/)[0] === 'en'
365
+ }
366
+
367
+ /**
368
+ * Display name for a built-in country in the active locale. A locale-file entry
369
+ * wins so a deployment can correct or override any name; otherwise the
370
+ * platform's region data localizes it; the English label is the last resort.
371
+ *
372
+ * English deliberately skips the region data: the dictionary already holds
373
+ * curated English names, and CLDR spells several of them differently
374
+ * ("Antigua & Barbuda" for "Antigua and Barbuda"), so consulting it would
375
+ * silently rewrite existing English copy for no gain.
376
+ */
377
+ export function resolvePhoneCountryLabel(
378
+ country: PhoneCountry,
379
+ t: TranslateFn,
380
+ locale: string | undefined,
381
+ ): string {
382
+ const translated = t(phoneCountryLabelKey(country.iso2), '')
383
+ if (translated) return translated
384
+ if (isEnglishLocale(locale)) return country.label
385
+ return regionDisplayName(country.iso2, locale) ?? country.label
386
+ }
387
+
388
+ export type PhoneCountryOption = {
389
+ country: PhoneCountry
390
+ label: string
391
+ }
392
+
393
+ /**
394
+ * Locale safe to hand to `localeCompare`. A tag `Intl` cannot construct would
395
+ * throw there just as it does in `getRegionDisplayNames`, so both fall back to
396
+ * English together.
397
+ */
398
+ function resolveCollationLocale(locale: string | undefined): string {
399
+ if (!locale) return 'en'
400
+ return getRegionDisplayNames(locale) ? locale : 'en'
401
+ }
402
+
403
+ /**
404
+ * Options rendered in the country dropdown. A caller-supplied list is
405
+ * authoritative — its labels and its order are preserved verbatim — while the
406
+ * built-in dictionary is localized and re-sorted for the active locale, since
407
+ * its module-level ordering is alphabetical by the English name.
408
+ */
409
+ export function buildPhoneCountryOptions(
410
+ countries: PhoneCountry[] | undefined,
411
+ t: TranslateFn,
412
+ locale: string | undefined,
413
+ ): PhoneCountryOption[] {
414
+ if (countries) return countries.map((country) => ({ country, label: country.label }))
415
+ const collationLocale = resolveCollationLocale(locale)
416
+ return PHONE_COUNTRIES
417
+ .map((country) => ({ country, label: resolvePhoneCountryLabel(country, t, locale) }))
418
+ .sort((a, b) => a.label.localeCompare(b.label, collationLocale, { sensitivity: 'base' }))
419
+ }
420
+
314
421
  /**
315
422
  * Sovereign/primary country that wins auto-detection for a calling code shared
316
423
  * by several territories. US vs. Canada is the one irreducible ambiguity — both
@@ -386,7 +493,11 @@ export type PhoneNumberFieldProps = {
386
493
  duplicateLinkLabel?: string
387
494
  invalidLabel?: string
388
495
  onDuplicateLookup?: (normalizedValue: string) => Promise<PhoneDuplicateMatch | null>
389
- /** Override the static country list (e.g. limit to specific markets). */
496
+ /**
497
+ * Override the static country list (e.g. limit to specific markets, or supply
498
+ * names already translated by the host). Both the labels and the order of the
499
+ * supplied list are rendered verbatim.
500
+ */
390
501
  countries?: PhoneCountry[]
391
502
  /** Initial country shown when `value` is empty / unparseable. Defaults to US. */
392
503
  defaultCountryIso2?: string
@@ -422,6 +533,7 @@ export function PhoneNumberField({
422
533
  renderCountryIcon,
423
534
  }: PhoneNumberFieldProps) {
424
535
  const t = useT()
536
+ const locale = useOptionalLocale()
425
537
  const resolvedInvalidLabel = invalidLabel ?? t(
426
538
  'ui.inputs.phoneNumberField.invalid',
427
539
  'Enter a valid phone number with country code (e.g. +1 212 555 1234)'
@@ -435,7 +547,10 @@ export function PhoneNumberField({
435
547
  'Open record'
436
548
  )
437
549
  const resolvedPlaceholder = placeholder ?? DEFAULT_PLACEHOLDER
438
- const countries = countriesProp ?? PHONE_COUNTRIES
550
+ const countryOptions = React.useMemo(
551
+ () => buildPhoneCountryOptions(countriesProp, t, locale),
552
+ [countriesProp, t, locale],
553
+ )
439
554
  const fallbackCountry = React.useMemo(
440
555
  () => (defaultCountryIso2 && findCountryByIso(defaultCountryIso2)) || DEFAULT_COUNTRY,
441
556
  [defaultCountryIso2],
@@ -609,12 +724,12 @@ export function PhoneNumberField({
609
724
  <span className={cn("text-sm text-foreground tabular-nums", disabled && "text-text-disabled")}>{country.dialCode}</span>
610
725
  </SelectTrigger>
611
726
  <SelectContent align="start">
612
- {countries.map((c) => (
727
+ {countryOptions.map(({ country: c, label }) => (
613
728
  <SelectItem key={`${c.iso2}-${c.dialCode}`} value={c.iso2}>
614
729
  <SelectItemLeading>
615
730
  <span className="text-base leading-none">{c.flag}</span>
616
731
  </SelectItemLeading>
617
- <span className="flex-1 truncate">{c.label}</span>
732
+ <span className="flex-1 truncate">{label}</span>
618
733
  <span className="ml-2 text-xs text-muted-foreground tabular-nums">{c.dialCode}</span>
619
734
  </SelectItem>
620
735
  ))}