@enigmax/primitives 0.19.1 → 0.21.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.
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();
255
+
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;
134
260
 
135
- const code = raw.toLowerCase();
136
- return FILE_CODE.test(code) ? code : null;
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;
@@ -0,0 +1,247 @@
1
+ /**
2
+ * The toast theme, as a string, because `<Toaster />` injects it.
3
+ *
4
+ * A toast is the one component where "bring your own CSS" is the wrong default: it is
5
+ * rendered into a portal at the edge of the screen, it stacks, and it animates - so a
6
+ * consumer who forgets the stylesheet does not get an unstyled toast, they get a pile of
7
+ * text in the corner. `<Toaster styles={false} />` turns it off for anyone who wants their
8
+ * own, and the injected sheet is PREPENDED to `<head>` so any stylesheet in the document
9
+ * outranks it by source order without needing a single `!important`.
10
+ *
11
+ * This module is the source of truth for `recipes/toast/styles.css` too, which
12
+ * `scripts/sync-recipes.mjs` writes from it - one look, whether it is injected or copied.
13
+ *
14
+ * The geometry is the stacked-card pattern: the newest toast is the front one, older ones
15
+ * sit behind it scaled down and clipped to the front one's height, and hovering the stack
16
+ * fans them out by the summed height of the ones in front. Every number below is one of
17
+ * those two states.
18
+ */
19
+ export const TOAST_STYLES = `
20
+ [data-enigma-toaster] {
21
+ --enigma-toast-width: 356px;
22
+ --enigma-toast-edge: 24px;
23
+ --enigma-toast-gap: 14px;
24
+ --enigma-toast-peek: 14px;
25
+ --enigma-toast-scale-step: 0.06;
26
+ --enigma-toast-radius: 12px;
27
+ --enigma-toast-enter: 420ms;
28
+ --enigma-toast-exit: 350ms;
29
+
30
+ --enigma-toast-bg: #101010;
31
+ --enigma-toast-border: #2a2a2a;
32
+ --enigma-toast-text: #f5f5f5;
33
+ --enigma-toast-muted: #a3a3a3;
34
+ --enigma-toast-shadow: 0 6px 22px rgb(0 0 0 / 34%);
35
+
36
+ --enigma-toast-success: hsl(150 80% 72%);
37
+ --enigma-toast-success-bg: hsl(150 38% 9%);
38
+ --enigma-toast-success-border: hsl(147 55% 18%);
39
+ --enigma-toast-error: hsl(358 100% 82%);
40
+ --enigma-toast-error-bg: hsl(358 42% 12%);
41
+ --enigma-toast-error-border: hsl(357 55% 24%);
42
+ --enigma-toast-info: hsl(216 87% 74%);
43
+ --enigma-toast-info-bg: hsl(215 45% 11%);
44
+ --enigma-toast-info-border: hsl(223 50% 24%);
45
+ --enigma-toast-warning: hsl(46 87% 70%);
46
+ --enigma-toast-warning-bg: hsl(40 55% 9%);
47
+ --enigma-toast-warning-border: hsl(40 55% 20%);
48
+
49
+ position: fixed;
50
+ z-index: 999999;
51
+ width: var(--enigma-toast-width);
52
+ margin: 0;
53
+ padding: 0;
54
+ list-style: none;
55
+ outline: none;
56
+ font-family: inherit;
57
+ font-size: 13px;
58
+ /* The stack is a positioning context: every toast is absolute inside it, which is what
59
+ lets the ones behind be clipped to the front one's height. */
60
+ transition: transform 350ms ease;
61
+ }
62
+
63
+ @media (prefers-color-scheme: light) {
64
+ [data-enigma-toaster] {
65
+ --enigma-toast-bg: #ffffff;
66
+ --enigma-toast-border: #e5e5e5;
67
+ --enigma-toast-text: #171717;
68
+ --enigma-toast-muted: #737373;
69
+ --enigma-toast-shadow: 0 6px 22px rgb(0 0 0 / 12%);
70
+ --enigma-toast-success-bg: hsl(143 85% 96%);
71
+ --enigma-toast-success-border: hsl(145 92% 87%);
72
+ --enigma-toast-success: hsl(140 100% 27%);
73
+ --enigma-toast-error-bg: hsl(359 100% 97%);
74
+ --enigma-toast-error-border: hsl(359 100% 94%);
75
+ --enigma-toast-error: hsl(360 100% 45%);
76
+ --enigma-toast-info-bg: hsl(208 100% 97%);
77
+ --enigma-toast-info-border: hsl(221 91% 93%);
78
+ --enigma-toast-info: hsl(210 92% 45%);
79
+ --enigma-toast-warning-bg: hsl(49 100% 97%);
80
+ --enigma-toast-warning-border: hsl(49 91% 84%);
81
+ --enigma-toast-warning: hsl(31 92% 45%);
82
+ }
83
+ }
84
+
85
+ [data-enigma-toaster][data-position^="top"] { top: var(--enigma-toast-edge); }
86
+ [data-enigma-toaster][data-position^="bottom"] { bottom: var(--enigma-toast-edge); }
87
+ [data-enigma-toaster][data-position$="-right"] { right: var(--enigma-toast-edge); }
88
+ [data-enigma-toaster][data-position$="-left"] { left: var(--enigma-toast-edge); }
89
+ [data-enigma-toaster][data-position$="-center"] { left: 50%; transform: translateX(-50%); }
90
+ /* The whole stack lifts a little when it fans out, so the front toast does not sit on the
91
+ exact pixel it did a moment ago. */
92
+ [data-enigma-toaster][data-expanded][data-position^="bottom"] { transform: translateY(-6px); }
93
+ [data-enigma-toaster][data-expanded][data-position^="top"] { transform: translateY(6px); }
94
+ [data-enigma-toaster][data-expanded][data-position="bottom-center"] { transform: translateX(-50%) translateY(-6px); }
95
+ [data-enigma-toaster][data-expanded][data-position="top-center"] { transform: translateX(-50%) translateY(6px); }
96
+
97
+ [data-enigma-toast] {
98
+ position: absolute;
99
+ left: 0;
100
+ right: 0;
101
+ width: 100%;
102
+ box-sizing: border-box;
103
+ display: flex;
104
+ align-items: center;
105
+ gap: 10px;
106
+ padding: 15px 16px;
107
+ color: var(--enigma-toast-text);
108
+ background: var(--enigma-toast-bg);
109
+ border: 1px solid var(--enigma-toast-border);
110
+ border-radius: var(--enigma-toast-radius);
111
+ box-shadow: var(--enigma-toast-shadow);
112
+ overflow-wrap: anywhere;
113
+ touch-action: none;
114
+ outline: none;
115
+ z-index: var(--enigma-toast-z, 1);
116
+ transition:
117
+ transform var(--enigma-toast-enter) cubic-bezier(0.21, 1.02, 0.73, 1),
118
+ opacity var(--enigma-toast-exit),
119
+ height 350ms;
120
+ }
121
+
122
+ [data-enigma-toaster][data-position^="bottom"] [data-enigma-toast] { bottom: 0; transform-origin: bottom center; }
123
+ [data-enigma-toaster][data-position^="top"] [data-enigma-toast] { top: 0; transform-origin: top center; }
124
+
125
+ /* Enter from off screen, in the direction the stack is pinned to. */
126
+ [data-enigma-toaster][data-position^="bottom"] [data-enigma-toast] { transform: translateY(110%); opacity: 0; }
127
+ [data-enigma-toaster][data-position^="top"] [data-enigma-toast] { transform: translateY(-110%); opacity: 0; }
128
+ /* Scoped to the toaster so it OUTRANKS the enter rules above, which are scoped the same
129
+ way: at equal specificity the later rule wins, and a mounted toast that lost this one sat
130
+ at its entry offset forever - visible, and 110% of its own height off the bottom. */
131
+ [data-enigma-toaster] [data-enigma-toast][data-mounted] { opacity: 1; transform: translateY(0); }
132
+
133
+ /* Collapsed: the ones behind are clipped to the FRONT toast's height and scaled back, so
134
+ the stack reads as depth rather than as a list. Their content is hidden but present -
135
+ removing it would make the box jump when the stack expands. */
136
+ [data-enigma-toaster]:not([data-expanded]) [data-enigma-toast][data-mounted]:not([data-front]) {
137
+ height: var(--enigma-toast-front-height);
138
+ transform: translateY(calc(var(--enigma-toast-before) * var(--enigma-toast-peek) * -1))
139
+ scale(calc(1 - var(--enigma-toast-before) * var(--enigma-toast-scale-step)));
140
+ }
141
+ [data-enigma-toaster][data-position^="top"]:not([data-expanded]) [data-enigma-toast][data-mounted]:not([data-front]) {
142
+ transform: translateY(calc(var(--enigma-toast-before) * var(--enigma-toast-peek)))
143
+ scale(calc(1 - var(--enigma-toast-before) * var(--enigma-toast-scale-step)));
144
+ }
145
+ [data-enigma-toaster]:not([data-expanded]) [data-enigma-toast]:not([data-front]) > * { opacity: 0; }
146
+ [data-enigma-toast] > * { transition: opacity 350ms; }
147
+
148
+ /* Expanded: each toast is lifted by the summed height of the ones in front of it. */
149
+ [data-enigma-toaster][data-expanded][data-position^="bottom"] [data-enigma-toast][data-mounted] {
150
+ transform: translateY(calc(var(--enigma-toast-offset) * -1));
151
+ }
152
+ [data-enigma-toaster][data-expanded][data-position^="top"] [data-enigma-toast][data-mounted] {
153
+ transform: translateY(var(--enigma-toast-offset));
154
+ }
155
+
156
+ /* Past the visible count it is still in the DOM, still counted, and not on screen. */
157
+ [data-enigma-toast][data-hidden] { opacity: 0; pointer-events: none; }
158
+
159
+ [data-enigma-toast][data-state="leaving"] { opacity: 0; }
160
+ [data-enigma-toaster]:not([data-expanded]) [data-enigma-toast][data-state="leaving"] { transform: translateY(150%) scale(0.9); }
161
+ [data-enigma-toaster][data-expanded][data-position^="bottom"] [data-enigma-toast][data-state="leaving"] {
162
+ transform: translateY(calc(var(--enigma-toast-offset) * -1 + 110%));
163
+ }
164
+
165
+ /* While a finger is down the toast tracks it exactly: a transition here would lag behind the
166
+ pointer, which reads as a broken gesture rather than a smooth one. */
167
+ [data-enigma-toast][data-swiping] {
168
+ transition: opacity 200ms;
169
+ transform: translateX(var(--enigma-toast-swipe, 0px));
170
+ }
171
+
172
+ [data-enigma-toast]:focus-visible { box-shadow: var(--enigma-toast-shadow), 0 0 0 2px var(--enigma-toast-info); }
173
+
174
+ [data-enigma-toast][data-tone="success"] {
175
+ color: var(--enigma-toast-success);
176
+ background: var(--enigma-toast-success-bg);
177
+ border-color: var(--enigma-toast-success-border);
178
+ }
179
+ [data-enigma-toast][data-tone="error"] {
180
+ color: var(--enigma-toast-error);
181
+ background: var(--enigma-toast-error-bg);
182
+ border-color: var(--enigma-toast-error-border);
183
+ }
184
+ [data-enigma-toast][data-tone="info"] {
185
+ color: var(--enigma-toast-info);
186
+ background: var(--enigma-toast-info-bg);
187
+ border-color: var(--enigma-toast-info-border);
188
+ }
189
+ [data-enigma-toast][data-tone="warning"] {
190
+ color: var(--enigma-toast-warning);
191
+ background: var(--enigma-toast-warning-bg);
192
+ border-color: var(--enigma-toast-warning-border);
193
+ }
194
+
195
+ [data-enigma-toast-icon] { display: flex; flex-shrink: 0; width: 20px; height: 20px; }
196
+ [data-enigma-toast-icon] svg { width: 20px; height: 20px; display: block; }
197
+ [data-enigma-toast-icon][data-tone="loading"] svg { animation: enigma-toast-spin 900ms linear infinite; }
198
+ @keyframes enigma-toast-spin { to { transform: rotate(360deg); } }
199
+
200
+ [data-enigma-toast-content] { display: flex; flex-direction: column; gap: 3px; min-width: 0; flex: 1; }
201
+ [data-enigma-toast-title] { margin: 0; font-weight: 600; line-height: 1.4; }
202
+ [data-enigma-toast-body] { margin: 0; font-size: 12px; line-height: 1.45; opacity: 0.75; }
203
+
204
+ [data-enigma-toast-action] {
205
+ flex-shrink: 0;
206
+ padding: 5px 10px;
207
+ font: inherit;
208
+ font-size: 12px;
209
+ font-weight: 600;
210
+ color: var(--enigma-toast-bg);
211
+ background: currentColor;
212
+ border: 0;
213
+ border-radius: 6px;
214
+ cursor: pointer;
215
+ }
216
+
217
+ [data-enigma-toast-close] {
218
+ flex-shrink: 0;
219
+ display: grid;
220
+ place-items: center;
221
+ width: 20px;
222
+ height: 20px;
223
+ padding: 0;
224
+ color: inherit;
225
+ background: none;
226
+ border: 0;
227
+ border-radius: 5px;
228
+ cursor: pointer;
229
+ /* Shown on hover or focus, so it never covers the message while it is being read - but
230
+ always reachable by keyboard, which display:none would take away. */
231
+ opacity: 0;
232
+ transition: opacity 120ms ease-out;
233
+ }
234
+ [data-enigma-toast]:hover [data-enigma-toast-close],
235
+ [data-enigma-toast-close]:focus-visible { opacity: 0.7; }
236
+ [data-enigma-toast-close]:hover { opacity: 1; }
237
+
238
+ @media (max-width: 600px) {
239
+ [data-enigma-toaster] { right: 14px; left: 14px; width: auto; }
240
+ }
241
+
242
+ @media (prefers-reduced-motion: reduce) {
243
+ /* The movement goes, the state change stays: a toast still appears and still leaves. */
244
+ [data-enigma-toast], [data-enigma-toast] > * { transition: opacity 200ms !important; }
245
+ [data-enigma-toast][data-mounted] { transform: none !important; }
246
+ }
247
+ `;