@enigmax/primitives 0.19.0 → 0.20.0

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.
@@ -22,9 +22,56 @@ function resetFlagConfig() {
22
22
  var FLAG_RATIO = { rect: 4 / 3, square: 1, circle: 1 };
23
23
  var EMOJI_FLAG = /^[\u{1F1E6}-\u{1F1FF}]{2}$/u;
24
24
  var LOCALE_TAG = /^([A-Za-z]{2,3})[-_]([A-Z]{2})$/;
25
- var FILE_CODE = /^[a-z]{2,}(?:[-_][a-z0-9]+)*$/;
25
+ var FILE_CODE = /^[a-z]{2,}[-_][a-z0-9_-]+$/;
26
26
  var COUNTRY_CODE = /^[a-z]{2}$/;
27
- function normalizeFlagCode(value) {
27
+ function fold(value) {
28
+ return value.normalize("NFD").replace(/\p{Diacritic}/gu, "").replace(/['‘’ʼ`´]/g, "").toLowerCase().replace(/&/g, " and ").replace(/\bst\.?\s/g, "saint ").replace(/^the\s+/, "").replace(/[.,]/g, "").replace(/\s+/g, " ").trim();
29
+ }
30
+ var nameIndexes = /* @__PURE__ */ new Map();
31
+ function canonicalRegion(code) {
32
+ try {
33
+ return new Intl.Locale(`und-${code}`).region?.toLowerCase() ?? code.toLowerCase();
34
+ } catch {
35
+ return code.toLowerCase();
36
+ }
37
+ }
38
+ function unknownRegionName(names) {
39
+ return names.of("ZZ");
40
+ }
41
+ function regionsByName(locale) {
42
+ const cached = nameIndexes.get(locale);
43
+ if (cached !== void 0) return cached;
44
+ let index = null;
45
+ try {
46
+ const names = new Intl.DisplayNames([locale], { type: "region" });
47
+ const unknown = unknownRegionName(names);
48
+ index = /* @__PURE__ */ new Map();
49
+ for (let first = 65; first <= 90; first++) {
50
+ for (let second = 65; second <= 90; second++) {
51
+ const code = String.fromCharCode(first, second);
52
+ const name = names.of(code);
53
+ if (!name || name === code || name === unknown) continue;
54
+ const key = fold(name);
55
+ if (!index.has(key)) index.set(key, canonicalRegion(code));
56
+ }
57
+ }
58
+ } catch {
59
+ index = null;
60
+ }
61
+ nameIndexes.set(locale, index);
62
+ return index;
63
+ }
64
+ function isKnownRegion(code) {
65
+ try {
66
+ const names = new Intl.DisplayNames(["en"], { type: "region" });
67
+ const upper = code.toUpperCase();
68
+ const name = names.of(upper);
69
+ return Boolean(name) && name !== upper && name !== unknownRegionName(names);
70
+ } catch {
71
+ return true;
72
+ }
73
+ }
74
+ function normalizeFlagCode(value, locale) {
28
75
  if (!value) return null;
29
76
  const raw = value.trim();
30
77
  if (!raw) return null;
@@ -32,10 +79,18 @@ function normalizeFlagCode(value) {
32
79
  const letters = [...raw].map((char) => String.fromCharCode((char.codePointAt(0) ?? 0) - 127462 + 97));
33
80
  return letters.join("");
34
81
  }
35
- const locale = LOCALE_TAG.exec(raw);
36
- if (locale) return locale[2].toLowerCase();
37
- const code = raw.toLowerCase();
38
- return FILE_CODE.test(code) ? code : null;
82
+ const tag = LOCALE_TAG.exec(raw);
83
+ if (tag) return tag[2].toLowerCase();
84
+ const lower = raw.toLowerCase();
85
+ if (COUNTRY_CODE.test(lower)) return isKnownRegion(lower) ? canonicalRegion(lower) : null;
86
+ if (FILE_CODE.test(lower)) return lower;
87
+ const folded = fold(raw);
88
+ for (const candidate of [locale, documentLocale(), "en", runtimeLocale()]) {
89
+ if (!candidate) continue;
90
+ const found = regionsByName(candidate)?.get(folded);
91
+ if (found) return found;
92
+ }
93
+ return null;
39
94
  }
40
95
  function flagName(code, locale) {
41
96
  const normalized = normalizeFlagCode(code);
@@ -49,6 +104,13 @@ function flagName(code, locale) {
49
104
  return null;
50
105
  }
51
106
  }
107
+ function runtimeLocale() {
108
+ try {
109
+ return Intl.DateTimeFormat().resolvedOptions().locale;
110
+ } catch {
111
+ return void 0;
112
+ }
113
+ }
52
114
  function documentLocale() {
53
115
  if (typeof document === "undefined") return void 0;
54
116
  return document.documentElement.lang || void 0;
@@ -100,7 +162,7 @@ function flagAttributes(code, options = {}) {
100
162
  "data-enigma-flag": "",
101
163
  "data-flag-code": view.code,
102
164
  "data-flag-shape": view.shape,
103
- ...label ? {} : { "aria-hidden": "true" }
165
+ ...label ? { title: label } : { "aria-hidden": "true" }
104
166
  };
105
167
  }
106
168
 
@@ -1,4 +1,4 @@
1
- import { flagView } from './chunk-AU3H5WIY.js';
1
+ import { flagView } from './chunk-LMZJBOZG.js';
2
2
  import { jsx } from 'react/jsx-runtime';
3
3
 
4
4
  function Flag({ code, label, decorative = false, locale, shape, format, source, basePath, size, fallback = null, ...rest }) {
@@ -14,6 +14,7 @@ function Flag({ code, label, decorative = false, locale, shape, format, source,
14
14
  {
15
15
  src: view.src,
16
16
  alt: name,
17
+ title: name || void 0,
17
18
  width: view.width,
18
19
  height: view.height,
19
20
  loading: "lazy",
@@ -71,12 +71,20 @@ declare const FLAG_RATIO: Record<FlagShape, number>;
71
71
  /**
72
72
  * Anything a codebase calls a country into the file name the sets use, or null.
73
73
  *
74
- * Null rather than a guess: a code this cannot resolve would otherwise become a 404 image
75
- * with no alt text, and a broken flag beside a country name is worse than no flag at all.
76
- * An emoji flag is accepted and converted, so migrating an existing picker is a rename of
77
- * the component and nothing else.
74
+ * What it accepts: a code (`es`, `ES`), a locale tag (`en-GB`, `es_ES`), a subdivision or
75
+ * named file (`gb-eng`, `au-nsw`, `easter_island`), the emoji flag you are replacing, and
76
+ * the country's NAME - in English or in the page's own language, accents optional, so
77
+ * `Spain`, `España` and `espana` all resolve. A name is what a database column or an API
78
+ * payload usually holds, and requiring the caller to convert it first is how a flag ends up
79
+ * not being rendered at all.
80
+ *
81
+ * Null rather than a guess, and that is the point of the strictness: `banana` used to pass
82
+ * as a file code and render as a 404 with no alt text, which is worse than no flag. A
83
+ * two-letter code is checked against the runtime's own region list, a bare word is resolved
84
+ * as a name or refused, and only a separator-carrying file name is taken on trust, because
85
+ * nothing can check those without shipping the whole directory listing.
78
86
  */
79
- declare function normalizeFlagCode(value: string | null | undefined): string | null;
87
+ declare function normalizeFlagCode(value: string | null | undefined, locale?: string): string | null;
80
88
  /**
81
89
  * The country's name, in the reader's language, or null.
82
90
  *
@@ -127,6 +135,12 @@ interface FlagAttributeOptions extends FlagOptions {
127
135
  * `Intl.DisplayNames`. `label` replaces it and `decorative` drops it. Nothing is ever
128
136
  * invented - a subdivision the platform cannot name, or a runtime without `Intl`, renders
129
137
  * as decoration rather than putting a bare "ES" into a screen reader.
138
+ *
139
+ * The name goes on `title` as well as `alt`, because those two do different jobs and only
140
+ * one of them is visible: `alt` is what a screen reader reads and what shows when the image
141
+ * fails, while a POINTER tooltip comes from `title` and from nothing else. A flag with a
142
+ * name nobody can see on hover is the common complaint, and it is not an accessibility
143
+ * nicety - the flag is often the only thing in a cell. A decorative flag gets neither.
130
144
  */
131
145
  declare function flagAttributes(code: string | null | undefined, options?: FlagAttributeOptions): Record<string, string | number> | null;
132
146
 
package/dist/index.d.ts CHANGED
@@ -6,7 +6,7 @@ export { E as EstimateOptions, G as GeneratePasswordOptions, P as PasswordAlphab
6
6
  export { N as Notification, a as NotificationAction, b as NotificationInput, c as NotificationTone, d as Notifications, e as NotificationsOptions, P as PromiseMessages, f as createNotifications } from './notifications-BpVV6sel.js';
7
7
  export { N as NetworkMonitor, a as NetworkState, S as SERVER_NETWORK_STATE, c as createNetworkMonitor } from './network-D2LsBG_k.js';
8
8
  export { R as RelativeTimeFormat, a as RelativeTimeOptions, b as RelativeTimePrecision, c as RelativeTimeStyle, d as RelativeTimeTense, e as RelativeTimeView, f as ensureZone, n as normalizeDate, p as parseDuration, r as relativeTimeAttributes, g as relativeTimeView } from './relative-time-YpRTG7YH.js';
9
- export { F as FLAG_CDN, a as FLAG_RATIO, b as FlagConfig, c as FlagFormat, d as FlagOptions, e as FlagShape, f as FlagSource, g as FlagView, h as configureFlags, i as flagAttributes, j as flagConfig, k as flagName, l as flagSrc, m as flagView, n as normalizeFlagCode, r as resetFlagConfig } from './flags-BBJc9unY.js';
9
+ export { F as FLAG_CDN, a as FLAG_RATIO, b as FlagConfig, c as FlagFormat, d as FlagOptions, e as FlagShape, f as FlagSource, g as FlagView, h as configureFlags, i as flagAttributes, j as flagConfig, k as flagName, l as flagSrc, m as flagView, n as normalizeFlagCode, r as resetFlagConfig } from './flags-BQU1V9rE.js';
10
10
  export { P as PaletteKey, a as PositionedRow, R as RecentEntry, b as RecentStore, c as RecentStoreOptions, d as RowGroup, e as createRecentStore, g as groupRows, i as isPaletteShortcut, m as moveActive, r as recentKey, s as shortcutLabel } from './palette-D7iuQh_T.js';
11
11
 
12
12
  /**
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  export { createInput } from './chunk-KJINGUQN.js';
2
2
  export { estimatePasswordStrength, generatePassword } from './chunk-HC2ME5PU.js';
3
- export { FLAG_CDN, FLAG_RATIO, configureFlags, flagAttributes, flagConfig, flagName, flagSrc, flagView, normalizeFlagCode, resetFlagConfig } from './chunk-AU3H5WIY.js';
3
+ export { FLAG_CDN, FLAG_RATIO, configureFlags, flagAttributes, flagConfig, flagName, flagSrc, flagView, normalizeFlagCode, resetFlagConfig } from './chunk-LMZJBOZG.js';
4
4
  export { createMarquee } from './chunk-UOSSNUSC.js';
5
5
  export { SERVER_NETWORK_STATE, createNetworkMonitor } from './chunk-2QFTRNAZ.js';
6
6
  export { createNotifications } from './chunk-SNYUBXWQ.js';
@@ -18,7 +18,7 @@ export { RelativeTime, RelativeTimeProps } from '../react/relative-time.js';
18
18
  export { R as RelativeTimeFormat, a as RelativeTimeOptions, b as RelativeTimePrecision, c as RelativeTimeStyle, d as RelativeTimeTense } from '../relative-time-YpRTG7YH.js';
19
19
  export { N as Notification, a as NotificationAction, b as NotificationInput, c as NotificationTone, e as NotificationsOptions } from '../notifications-BpVV6sel.js';
20
20
  export { Flag, FlagProps } from '../react/flag.js';
21
- export { c as FlagFormat, d as FlagOptions, e as FlagShape, f as FlagSource, h as configureFlags, i as flagAttributes, k as flagName, l as flagSrc, m as flagView, n as normalizeFlagCode } from '../flags-BBJc9unY.js';
21
+ export { c as FlagFormat, d as FlagOptions, e as FlagShape, f as FlagSource, h as configureFlags, i as flagAttributes, k as flagName, l as flagSrc, m as flagView, n as normalizeFlagCode } from '../flags-BQU1V9rE.js';
22
22
  export { PaletteContent, PaletteContextValue, PaletteField, PaletteFooter, PaletteItem, PaletteList, PaletteListProps, PaletteRoot, PaletteRootProps, PaletteRow, PaletteSection, PaletteTrigger, SearchPalette, SearchPaletteProps, usePaletteContext } from '../react/palette.js';
23
23
  export { R as RecentEntry, b as RecentStore, e as createRecentStore, g as groupRows, i as isPaletteShortcut, m as moveActive, r as recentKey, s as shortcutLabel } from '../palette-D7iuQh_T.js';
24
24
 
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
  export { useInput } from '../chunk-U3V4EHOB.js';
3
3
  export { PasswordStrength } from '../chunk-XQHCZAPJ.js';
4
- export { Flag } from '../chunk-XNNQRA35.js';
4
+ export { Flag } from '../chunk-NPHRS2Q7.js';
5
5
  export { Toaster } from '../chunk-ZCUFYBPB.js';
6
6
  export { useMarquee } from '../chunk-ZWR2EXHQ.js';
7
7
  export { useSearch } from '../chunk-QQFNAKMY.js';
@@ -10,7 +10,7 @@ export { createNotificationQueue, defaultQueue, useNotifications } from '../chun
10
10
  export { RelativeTime } from '../chunk-OCMI7R6H.js';
11
11
  import '../chunk-KJINGUQN.js';
12
12
  export { estimatePasswordStrength, generatePassword } from '../chunk-HC2ME5PU.js';
13
- export { configureFlags, flagAttributes, flagName, flagSrc, flagView, normalizeFlagCode } from '../chunk-AU3H5WIY.js';
13
+ export { configureFlags, flagAttributes, flagName, flagSrc, flagView, normalizeFlagCode } from '../chunk-LMZJBOZG.js';
14
14
  import '../chunk-UOSSNUSC.js';
15
15
  import '../chunk-2QFTRNAZ.js';
16
16
  import '../chunk-SNYUBXWQ.js';
@@ -1,5 +1,5 @@
1
1
  import { ImgHTMLAttributes, ReactNode } from 'react';
2
- import { e as FlagShape, c as FlagFormat, f as FlagSource } from '../flags-BBJc9unY.js';
2
+ import { e as FlagShape, c as FlagFormat, f as FlagSource } from '../flags-BQU1V9rE.js';
3
3
 
4
4
  interface FlagProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, "src" | "alt" | "width" | "height"> {
5
5
  /** `es`, `ES`, `en-GB`, `gb-eng`, or the emoji flag you are replacing. */
@@ -1,3 +1,3 @@
1
1
  "use client";
2
- export { Flag } from '../chunk-XNNQRA35.js';
3
- import '../chunk-AU3H5WIY.js';
2
+ export { Flag } from '../chunk-NPHRS2Q7.js';
3
+ import '../chunk-LMZJBOZG.js';
@@ -20,7 +20,7 @@ export { RelativeTime, RelativeTimeProps } from './relative-time.js';
20
20
  export { R as RelativeTimeFormat, a as RelativeTimeOptions, b as RelativeTimePrecision, c as RelativeTimeStyle, d as RelativeTimeTense } from '../relative-time-YpRTG7YH.js';
21
21
  export { N as Notification, a as NotificationAction, b as NotificationInput, c as NotificationTone, e as NotificationsOptions } from '../notifications-BpVV6sel.js';
22
22
  export { Flag, FlagProps } from './flag.js';
23
- export { c as FlagFormat, d as FlagOptions, e as FlagShape, f as FlagSource, h as configureFlags, i as flagAttributes, k as flagName, l as flagSrc, m as flagView, n as normalizeFlagCode } from '../flags-BBJc9unY.js';
23
+ export { c as FlagFormat, d as FlagOptions, e as FlagShape, f as FlagSource, h as configureFlags, i as flagAttributes, k as flagName, l as flagSrc, m as flagView, n as normalizeFlagCode } from '../flags-BQU1V9rE.js';
24
24
  export { PaletteContent, PaletteContextValue, PaletteField, PaletteFooter, PaletteItem, PaletteList, PaletteListProps, PaletteRoot, PaletteRootProps, PaletteRow, PaletteSection, PaletteTrigger, SearchPalette, SearchPaletteProps, usePaletteContext } from './palette.js';
25
25
  export { R as RecentEntry, b as RecentStore, e as createRecentStore, g as groupRows, i as isPaletteShortcut, m as moveActive, r as recentKey, s as shortcutLabel } from '../palette-D7iuQh_T.js';
26
26
 
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
  export { useInput } from '../chunk-U3V4EHOB.js';
3
3
  export { PasswordStrength } from '../chunk-XQHCZAPJ.js';
4
- export { Flag } from '../chunk-XNNQRA35.js';
4
+ export { Flag } from '../chunk-NPHRS2Q7.js';
5
5
  export { Toaster } from '../chunk-ZCUFYBPB.js';
6
6
  export { useMarquee } from '../chunk-ZWR2EXHQ.js';
7
7
  export { useSearch } from '../chunk-QQFNAKMY.js';
@@ -10,7 +10,7 @@ export { createNotificationQueue, defaultQueue, useNotifications } from '../chun
10
10
  export { RelativeTime } from '../chunk-OCMI7R6H.js';
11
11
  import '../chunk-KJINGUQN.js';
12
12
  export { estimatePasswordStrength, generatePassword } from '../chunk-HC2ME5PU.js';
13
- export { configureFlags, flagAttributes, flagName, flagSrc, flagView, normalizeFlagCode } from '../chunk-AU3H5WIY.js';
13
+ export { configureFlags, flagAttributes, flagName, flagSrc, flagView, normalizeFlagCode } from '../chunk-LMZJBOZG.js';
14
14
  import '../chunk-UOSSNUSC.js';
15
15
  import '../chunk-2QFTRNAZ.js';
16
16
  import '../chunk-SNYUBXWQ.js';
@@ -18,7 +18,7 @@ export { RelativeTime, RelativeTimeProps } from '../react/relative-time.js';
18
18
  export { R as RelativeTimeFormat, a as RelativeTimeOptions, b as RelativeTimePrecision, c as RelativeTimeStyle, d as RelativeTimeTense } from '../relative-time-YpRTG7YH.js';
19
19
  export { N as Notification, a as NotificationAction, b as NotificationInput, c as NotificationTone, e as NotificationsOptions } from '../notifications-BpVV6sel.js';
20
20
  export { Flag, FlagProps } from '../react/flag.js';
21
- export { c as FlagFormat, d as FlagOptions, e as FlagShape, f as FlagSource, h as configureFlags, i as flagAttributes, k as flagName, l as flagSrc, m as flagView, n as normalizeFlagCode } from '../flags-BBJc9unY.js';
21
+ export { c as FlagFormat, d as FlagOptions, e as FlagShape, f as FlagSource, h as configureFlags, i as flagAttributes, k as flagName, l as flagSrc, m as flagView, n as normalizeFlagCode } from '../flags-BQU1V9rE.js';
22
22
  export { PaletteContent, PaletteContextValue, PaletteField, PaletteFooter, PaletteItem, PaletteList, PaletteListProps, PaletteRoot, PaletteRootProps, PaletteRow, PaletteSection, PaletteTrigger, SearchPalette, SearchPaletteProps, usePaletteContext } from '../react/palette.js';
23
23
  export { R as RecentEntry, b as RecentStore, e as createRecentStore, g as groupRows, i as isPaletteShortcut, m as moveActive, r as recentKey, s as shortcutLabel } from '../palette-D7iuQh_T.js';
24
24
 
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
  export { useInput } from '../chunk-U3V4EHOB.js';
3
3
  export { PasswordStrength } from '../chunk-XQHCZAPJ.js';
4
- export { Flag } from '../chunk-XNNQRA35.js';
4
+ export { Flag } from '../chunk-NPHRS2Q7.js';
5
5
  export { Toaster } from '../chunk-ZCUFYBPB.js';
6
6
  export { useMarquee } from '../chunk-ZWR2EXHQ.js';
7
7
  export { useSearch } from '../chunk-QQFNAKMY.js';
@@ -10,7 +10,7 @@ export { createNotificationQueue, defaultQueue, useNotifications } from '../chun
10
10
  export { RelativeTime } from '../chunk-OCMI7R6H.js';
11
11
  import '../chunk-KJINGUQN.js';
12
12
  export { estimatePasswordStrength, generatePassword } from '../chunk-HC2ME5PU.js';
13
- export { configureFlags, flagAttributes, flagName, flagSrc, flagView, normalizeFlagCode } from '../chunk-AU3H5WIY.js';
13
+ export { configureFlags, flagAttributes, flagName, flagSrc, flagView, normalizeFlagCode } from '../chunk-LMZJBOZG.js';
14
14
  import '../chunk-UOSSNUSC.js';
15
15
  import '../chunk-2QFTRNAZ.js';
16
16
  import '../chunk-SNYUBXWQ.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enigmax/primitives",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "Headless components: the behaviour, the timing and the accessibility, with no styles of their own. Framework-agnostic core plus thin adapters.",
5
5
  "type": "module",
6
6
  "sideEffects": [
package/src/core/flags.ts CHANGED
@@ -106,20 +106,141 @@ const EMOJI_FLAG = /^[\u{1F1E6}-\u{1F1FF}]{2}$/u;
106
106
  * two the same string, and then one of them is always wrong.
107
107
  */
108
108
  const LOCALE_TAG = /^([A-Za-z]{2,3})[-_]([A-Z]{2})$/;
109
- /** What the sets actually name their files: `es`, `gb-eng`, `au-nsw`, `easter_island`. */
110
- const FILE_CODE = /^[a-z]{2,}(?:[-_][a-z0-9]+)*$/;
109
+ /**
110
+ * A SUBDIVISION or a named file, which is the only shape that cannot be checked against
111
+ * anything: `gb-eng`, `au-nsw`, `es-ct`, `easter_island`. It must carry a separator - that
112
+ * is what distinguishes a file name from an ordinary word, and without it `banana` was
113
+ * accepted as a code and rendered as a 404.
114
+ */
115
+ const FILE_CODE = /^[a-z]{2,}[-_][a-z0-9_-]+$/;
111
116
  /** A plain country: exactly the shape `Intl.DisplayNames` can put a name to. */
112
117
  const COUNTRY_CODE = /^[a-z]{2}$/;
113
118
 
119
+ /**
120
+ * Accent-, case- and apostrophe-insensitive, so `espana`, `España` and `ESPAÑA` are one
121
+ * string - and so are `Cote d'Ivoire` and `Côte d’Ivoire`.
122
+ *
123
+ * The apostrophe matters more than it looks: CLDR writes the typographic one (U+2019) and
124
+ * every keyboard, database and API writes the straight one, so a lookup that kept them
125
+ * apart failed on exactly the countries whose names contain one.
126
+ */
127
+ function fold(value: string): string {
128
+ return value
129
+ .normalize("NFD")
130
+ .replace(/\p{Diacritic}/gu, "")
131
+ .replace(/['‘’ʼ`´]/g, "")
132
+ .toLowerCase()
133
+ // Three spellings of the same name, and all three are mechanical - CLDR writes
134
+ // "St. Lucia" and "Trinidad & Tobago", while a form, a database and an API write
135
+ // "Saint Lucia" and "Trinidad and Tobago". Both sides of the lookup go through this,
136
+ // so it is a normalization rather than a table of alternative names.
137
+ .replace(/&/g, " and ")
138
+ .replace(/\bst\.?\s/g, "saint ")
139
+ .replace(/^the\s+/, "")
140
+ .replace(/[.,]/g, "")
141
+ .replace(/\s+/g, " ")
142
+ .trim();
143
+ }
144
+
145
+ /**
146
+ * Every two-letter region the runtime can name, folded, mapped back to its code.
147
+ *
148
+ * Built by asking `Intl.DisplayNames` for all 676 letter pairs and keeping the ones it has a
149
+ * name for - which is also how an unknown code is detected, since it echoes the input back.
150
+ * The runtime already holds this data, translated and maintained; shipping a table of 250
151
+ * country names would be a copy of it that goes stale.
152
+ *
153
+ * Memoized per locale and built only when something that is not a code arrives, so a page
154
+ * that passes codes never pays for it.
155
+ */
156
+ const nameIndexes = new Map<string, Map<string, string> | null>();
157
+
158
+ /**
159
+ * The code a region is FILED under.
160
+ *
161
+ * `Intl.Locale` canonicalizes a deprecated or alias subtag, which is the difference between
162
+ * a flag and a 404: `UK` is not the ISO code for the United Kingdom (`GB` is), `AN` became
163
+ * `CW`, `SU` became `RU`. All three have a name, so a name lookup that skipped this step
164
+ * resolved "United Kingdom" to `uk` and asked for a file that does not exist.
165
+ */
166
+ function canonicalRegion(code: string): string {
167
+ try {
168
+ return new Intl.Locale(`und-${code}`).region?.toLowerCase() ?? code.toLowerCase();
169
+ } catch {
170
+ return code.toLowerCase();
171
+ }
172
+ }
173
+
174
+ /**
175
+ * The name the runtime uses for a region it does not know.
176
+ *
177
+ * `ZZ` IS that region by definition, so its name is the sentinel: any code the runtime
178
+ * cannot place comes back either echoing the code (`QQ`) or carrying this exact string, and
179
+ * both have to be refused. Comparing against `of("ZZ")` rather than the English words keeps
180
+ * that true in every language.
181
+ */
182
+ function unknownRegionName(names: Intl.DisplayNames): string | undefined {
183
+ return names.of("ZZ");
184
+ }
185
+
186
+ function regionsByName(locale: string): Map<string, string> | null {
187
+ const cached = nameIndexes.get(locale);
188
+ if (cached !== undefined) return cached;
189
+
190
+ let index: Map<string, string> | null = null;
191
+ try {
192
+ const names = new Intl.DisplayNames([locale], { type: "region" });
193
+ const unknown = unknownRegionName(names);
194
+ index = new Map();
195
+ for (let first = 65; first <= 90; first++) {
196
+ for (let second = 65; second <= 90; second++) {
197
+ const code = String.fromCharCode(first, second);
198
+ const name = names.of(code);
199
+ if (!name || name === code || name === unknown) continue;
200
+ const key = fold(name);
201
+ // First wins, and the code is canonical, so an alias can neither overwrite
202
+ // the real entry nor become one.
203
+ if (!index.has(key)) index.set(key, canonicalRegion(code));
204
+ }
205
+ }
206
+ } catch {
207
+ index = null; // No Intl, or no data for this locale.
208
+ }
209
+ nameIndexes.set(locale, index);
210
+ return index;
211
+ }
212
+
213
+ /** Whether the runtime knows this two-letter code as a region at all. */
214
+ function isKnownRegion(code: string): boolean {
215
+ try {
216
+ const names = new Intl.DisplayNames(["en"], { type: "region" });
217
+ const upper = code.toUpperCase();
218
+ const name = names.of(upper);
219
+ return Boolean(name) && name !== upper && name !== unknownRegionName(names);
220
+ } catch {
221
+ // Without Intl there is nothing to check against, and refusing every code would be
222
+ // worse than passing one through: two letters is the shape of a flag file.
223
+ return true;
224
+ }
225
+ }
226
+
114
227
  /**
115
228
  * Anything a codebase calls a country into the file name the sets use, or null.
116
229
  *
117
- * Null rather than a guess: a code this cannot resolve would otherwise become a 404 image
118
- * with no alt text, and a broken flag beside a country name is worse than no flag at all.
119
- * An emoji flag is accepted and converted, so migrating an existing picker is a rename of
120
- * the component and nothing else.
230
+ * What it accepts: a code (`es`, `ES`), a locale tag (`en-GB`, `es_ES`), a subdivision or
231
+ * named file (`gb-eng`, `au-nsw`, `easter_island`), the emoji flag you are replacing, and
232
+ * the country's NAME - in English or in the page's own language, accents optional, so
233
+ * `Spain`, `España` and `espana` all resolve. A name is what a database column or an API
234
+ * payload usually holds, and requiring the caller to convert it first is how a flag ends up
235
+ * not being rendered at all.
236
+ *
237
+ * Null rather than a guess, and that is the point of the strictness: `banana` used to pass
238
+ * as a file code and render as a 404 with no alt text, which is worse than no flag. A
239
+ * two-letter code is checked against the runtime's own region list, a bare word is resolved
240
+ * as a name or refused, and only a separator-carrying file name is taken on trust, because
241
+ * nothing can check those without shipping the whole directory listing.
121
242
  */
122
- export function normalizeFlagCode(value: string | null | undefined): string | null {
243
+ export function normalizeFlagCode(value: string | null | undefined, locale?: string): string | null {
123
244
  if (!value) return null;
124
245
  const raw = value.trim();
125
246
  if (!raw) return null;
@@ -129,11 +250,25 @@ export function normalizeFlagCode(value: string | null | undefined): string | nu
129
250
  return letters.join("");
130
251
  }
131
252
 
132
- const locale = LOCALE_TAG.exec(raw);
133
- if (locale) return locale[2].toLowerCase();
253
+ const tag = LOCALE_TAG.exec(raw);
254
+ if (tag) return tag[2].toLowerCase();
134
255
 
135
- const code = raw.toLowerCase();
136
- return FILE_CODE.test(code) ? code : null;
256
+ const lower = raw.toLowerCase();
257
+ // Canonical, so `uk` reaches the file the United Kingdom is actually filed under.
258
+ if (COUNTRY_CODE.test(lower)) return isKnownRegion(lower) ? canonicalRegion(lower) : null;
259
+ if (FILE_CODE.test(lower)) return lower;
260
+
261
+ // A name, then, in this order: the locale you asked for, the page's own, English, and
262
+ // finally the runtime's. English is always tried because it is what an API or a database
263
+ // column holds even on a translated page; the runtime's is last so it can rescue a name
264
+ // nothing else placed without ever overriding a match that was more specific.
265
+ const folded = fold(raw);
266
+ for (const candidate of [locale, documentLocale(), "en", runtimeLocale()]) {
267
+ if (!candidate) continue;
268
+ const found = regionsByName(candidate)?.get(folded);
269
+ if (found) return found;
270
+ }
271
+ return null;
137
272
  }
138
273
 
139
274
  /**
@@ -161,6 +296,15 @@ export function flagName(code: string | null | undefined, locale?: string): stri
161
296
  }
162
297
  }
163
298
 
299
+ /** Whatever language this runtime is set to - the browser's, or the server's. */
300
+ function runtimeLocale(): string | undefined {
301
+ try {
302
+ return Intl.DateTimeFormat().resolvedOptions().locale;
303
+ } catch {
304
+ return undefined;
305
+ }
306
+ }
307
+
164
308
  /** The page's own language, which is what a translated page wants the name written in. */
165
309
  function documentLocale(): string | undefined {
166
310
  if (typeof document === "undefined") return undefined;
@@ -246,6 +390,12 @@ export interface FlagAttributeOptions extends FlagOptions {
246
390
  * `Intl.DisplayNames`. `label` replaces it and `decorative` drops it. Nothing is ever
247
391
  * invented - a subdivision the platform cannot name, or a runtime without `Intl`, renders
248
392
  * as decoration rather than putting a bare "ES" into a screen reader.
393
+ *
394
+ * The name goes on `title` as well as `alt`, because those two do different jobs and only
395
+ * one of them is visible: `alt` is what a screen reader reads and what shows when the image
396
+ * fails, while a POINTER tooltip comes from `title` and from nothing else. A flag with a
397
+ * name nobody can see on hover is the common complaint, and it is not an accessibility
398
+ * nicety - the flag is often the only thing in a cell. A decorative flag gets neither.
249
399
  */
250
400
  export function flagAttributes(code: string | null | undefined, options: FlagAttributeOptions = {}): Record<string, string | number> | null {
251
401
  const view = flagView(code, options);
@@ -261,6 +411,6 @@ export function flagAttributes(code: string | null | undefined, options: FlagAtt
261
411
  "data-enigma-flag": "",
262
412
  "data-flag-code": view.code,
263
413
  "data-flag-shape": view.shape,
264
- ...(label ? {} : { "aria-hidden": "true" })
414
+ ...(label ? { title: label } : { "aria-hidden": "true" })
265
415
  };
266
416
  }
@@ -52,6 +52,9 @@ export function Flag({ code, label, decorative = false, locale, shape, format, s
52
52
  <img
53
53
  src={view.src}
54
54
  alt={name}
55
+ // `alt` is read; `title` is what a pointer shows. Only one of them is visible,
56
+ // and a flag whose name never appears on hover is the thing people report.
57
+ title={name || undefined}
55
58
  width={view.width}
56
59
  height={view.height}
57
60
  loading="lazy"