@enigmax/primitives 0.17.0 → 0.18.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.
Files changed (89) hide show
  1. package/dist/button-CaXaqG_K.d.ts +63 -0
  2. package/dist/chunk-2QFTRNAZ.js +88 -0
  3. package/dist/chunk-6BGBYUSZ.js +114 -0
  4. package/dist/chunk-AU3H5WIY.js +107 -0
  5. package/dist/chunk-F25CGNQC.js +19 -0
  6. package/dist/chunk-HC2ME5PU.js +168 -0
  7. package/dist/chunk-HS3X3XCW.js +43 -0
  8. package/dist/chunk-IIT6U7LM.js +249 -0
  9. package/dist/chunk-IXVMRVD4.js +96 -0
  10. package/dist/chunk-KJINGUQN.js +188 -0
  11. package/dist/chunk-LKA2UG4P.js +542 -0
  12. package/dist/chunk-MMQPZGSU.js +161 -0
  13. package/dist/chunk-OCMI7R6H.js +79 -0
  14. package/dist/chunk-QYMUIW5I.js +28 -0
  15. package/dist/chunk-S653GLSF.js +17 -0
  16. package/dist/chunk-SNYUBXWQ.js +149 -0
  17. package/dist/chunk-U3V4EHOB.js +41 -0
  18. package/dist/chunk-UOSSNUSC.js +309 -0
  19. package/dist/chunk-WSB655JB.js +47 -0
  20. package/dist/chunk-XNNQRA35.js +31 -0
  21. package/dist/chunk-XQHCZAPJ.js +102 -0
  22. package/dist/chunk-ZCUFYBPB.js +154 -0
  23. package/dist/chunk-ZWR2EXHQ.js +55 -0
  24. package/dist/flags-BBJc9unY.d.ts +133 -0
  25. package/dist/index-D-ARvWpQ.d.ts +306 -0
  26. package/dist/index-DNXPtY9X.d.ts +144 -0
  27. package/dist/index.d.ts +16 -602
  28. package/dist/index.js +9 -1
  29. package/dist/input-BwXjFenq.d.ts +77 -0
  30. package/dist/marquee-CJ3Uwy3E.d.ts +81 -0
  31. package/dist/network-D2LsBG_k.d.ts +39 -0
  32. package/dist/next/index.d.ts +20 -3
  33. package/dist/next/index.js +23 -3
  34. package/dist/notifications-BpVV6sel.d.ts +70 -0
  35. package/dist/password-3DRQYAYQ.js +2 -0
  36. package/dist/password-C8lG4Zm9.d.ts +71 -0
  37. package/dist/password-FB2CUEKJ.js +1 -0
  38. package/dist/react/button.d.ts +75 -0
  39. package/dist/react/button.js +4 -0
  40. package/dist/react/flag.d.ts +37 -0
  41. package/dist/react/flag.js +3 -0
  42. package/dist/react/index.d.ts +28 -312
  43. package/dist/react/index.js +22 -2
  44. package/dist/react/input.d.ts +4 -0
  45. package/dist/react/input.js +3 -0
  46. package/dist/react/marquee.d.ts +44 -0
  47. package/dist/react/marquee.js +3 -0
  48. package/dist/react/network.d.ts +20 -0
  49. package/dist/react/network.js +3 -0
  50. package/dist/react/notifications.d.ts +17 -0
  51. package/dist/react/notifications.js +3 -0
  52. package/dist/react/palette.d.ts +3 -0
  53. package/dist/react/palette.js +4 -0
  54. package/dist/react/relative-time.d.ts +21 -0
  55. package/dist/react/relative-time.js +3 -0
  56. package/dist/react/search.d.ts +30 -0
  57. package/dist/react/search.js +3 -0
  58. package/dist/react/slot.d.ts +47 -0
  59. package/dist/react/slot.js +2 -0
  60. package/dist/react/toast.d.ts +40 -0
  61. package/dist/react/toast.js +4 -0
  62. package/dist/react-router/index.d.ts +20 -3
  63. package/dist/react-router/index.js +23 -3
  64. package/dist/relative-time-YpRTG7YH.d.ts +106 -0
  65. package/dist/search-P4OHCRXD.js +50 -0
  66. package/package.json +52 -2
  67. package/registry.json +175 -18
  68. package/src/core/flags.ts +88 -42
  69. package/src/core/input-icons.ts +34 -0
  70. package/src/core/input.ts +5 -21
  71. package/src/core/palette.ts +0 -0
  72. package/src/index.ts +2 -2
  73. package/src/react/button.tsx +70 -4
  74. package/src/react/flag.tsx +18 -6
  75. package/src/react/index.ts +40 -5
  76. package/src/react/input/icon.tsx +17 -0
  77. package/src/react/input/index.tsx +307 -0
  78. package/src/react/input/password.tsx +174 -0
  79. package/src/react/input/search.tsx +82 -0
  80. package/src/react/input/types.ts +146 -0
  81. package/src/react/input/write-value.ts +18 -0
  82. package/src/react/palette/context.ts +60 -0
  83. package/src/react/palette/index.tsx +66 -0
  84. package/src/react/palette/root.tsx +621 -0
  85. package/src/react/slot.tsx +91 -0
  86. package/src/react/use-button.ts +3 -1
  87. package/dist/chunk-4GLYRF4B.js +0 -1259
  88. package/dist/chunk-JFNND6P4.js +0 -749
  89. package/src/react/input.tsx +0 -429
package/src/core/flags.ts CHANGED
@@ -8,12 +8,14 @@
8
8
  * out of them looks broken to those readers, with nothing in the code to explain why.
9
9
  * There is no font stack that fixes it, so the fix is not a font: it is an image.
10
10
  *
11
- * Two upstream sets, both SVG, both installable or served from a CDN:
11
+ * Three sets, all SVG, all served by enigma itself:
12
12
  *
13
- * - `rect` / `square` - lipis/flag-icons (`flags/4x3`, `flags/1x1`), the classic
14
- * rectangular set, 271 flags plus subdivisions.
15
- * - `circle` - HatScripts/circle-flags (`flags/*.svg` on its gh-pages branch), 445 round
16
- * flags, which is what a flag next to an avatar or inside a chip usually wants.
13
+ * - `rect` (4:3) and `square` (1:1) - the classic rectangular artwork.
14
+ * - `circle` - round, which is what a flag next to an avatar or inside a chip wants.
15
+ *
16
+ * They are served from enigma's own tree rather than from anyone else's, because a
17
+ * component whose default source is a third party is a component that breaks when that
18
+ * third party moves, renames a branch, or goes away.
17
19
  *
18
20
  * Nothing here touches the DOM, so it renders on a server.
19
21
  */
@@ -21,26 +23,24 @@
21
23
  export type FlagShape = "rect" | "square" | "circle";
22
24
 
23
25
  /**
24
- * `svg` is the only format the two upstreams publish, so it is the only one a CDN can
25
- * serve. `png` and `webp` exist for a LOCAL set (`enigma add flags --flags local
26
- * --flag-formats webp`), which rasterises what it downloads, and for a mirror of your own.
26
+ * `svg` is what the sets are stored as, so it is what a remote source can serve. `png` and
27
+ * `webp` exist for a LOCAL set (`enigma add flags --flags local --flag-formats webp`), which
28
+ * rasterises what it downloads, and for a mirror of your own.
27
29
  */
28
30
  export type FlagFormat = "svg" | "png" | "webp";
29
31
 
30
32
  /**
31
33
  * Where the files come from.
32
34
  *
33
- * - `"cdn"` - jsDelivr, straight from the two upstream projects. Nothing to install.
35
+ * - `"cdn"` - enigma's own artwork, over a CDN. Nothing to install.
34
36
  * - `"local"` - files under `basePath` in your own public directory.
35
- * - any URL - a mirror of your own with the same layout as the local one
37
+ * - any URL - a mirror of your own with the same layout
36
38
  * (`<base>/<shape>/<code>.<format>`), which is exactly the tree the downloader writes.
37
39
  */
38
40
  export type FlagSource = "cdn" | "local" | (string & {});
39
41
 
40
- /** Pinned so an upgrade upstream is a decision here, never a surprise in production. */
41
- export const FLAG_ICONS_VERSION = "7.5.0";
42
- /** circle-flags publishes from a branch, not a tag; gh-pages is its default branch. */
43
- export const CIRCLE_FLAGS_REF = "gh-pages";
42
+ /** enigma's own artwork, over a CDN. Every source shares this layout. */
43
+ export const FLAG_CDN = "https://cdn.jsdelivr.net/gh/FJRG2007/enigma@main/assets/flags";
44
44
 
45
45
  export interface FlagOptions {
46
46
  shape?: FlagShape;
@@ -50,10 +50,16 @@ export interface FlagOptions {
50
50
  basePath?: string;
51
51
  /** Rendered HEIGHT in px. The width follows the shape's ratio. Default 16. */
52
52
  size?: number;
53
+ /**
54
+ * Language the automatic name is written in. Left out, it follows the document's own
55
+ * `lang`, which is what a translated page wants.
56
+ */
57
+ locale?: string;
53
58
  }
54
59
 
55
- export interface FlagConfig extends Required<Omit<FlagOptions, "size">> {
60
+ export interface FlagConfig extends Required<Omit<FlagOptions, "size" | "locale">> {
56
61
  size: number;
62
+ locale?: string;
57
63
  }
58
64
 
59
65
  const DEFAULTS: FlagConfig = {
@@ -96,12 +102,14 @@ const EMOJI_FLAG = /^[\u{1F1E6}-\u{1F1FF}]{2}$/u;
96
102
  * A BCP 47 tag whose region is spelled the way the standard spells it: UPPERCASE. That
97
103
  * casing is the whole discriminator, and it has to be read before the value is lowercased -
98
104
  * `es-ES` is Spanish-in-Spain and means the flag `es`, while `es-ct` is a subdivision code
99
- * both sets publish (Catalonia) and means the file `es-ct`. Lowercasing first makes those
105
+ * the sets publish (Catalonia) and means the file `es-ct`. Lowercasing first makes those
100
106
  * two the same string, and then one of them is always wrong.
101
107
  */
102
108
  const LOCALE_TAG = /^([A-Za-z]{2,3})[-_]([A-Z]{2})$/;
103
- /** What the two sets actually name their files: `es`, `gb-eng`, `au-nsw`, `easter_island`. */
109
+ /** What the sets actually name their files: `es`, `gb-eng`, `au-nsw`, `easter_island`. */
104
110
  const FILE_CODE = /^[a-z]{2,}(?:[-_][a-z0-9]+)*$/;
111
+ /** A plain country: exactly the shape `Intl.DisplayNames` can put a name to. */
112
+ const COUNTRY_CODE = /^[a-z]{2}$/;
105
113
 
106
114
  /**
107
115
  * Anything a codebase calls a country into the file name the sets use, or null.
@@ -128,21 +136,48 @@ export function normalizeFlagCode(value: string | null | undefined): string | nu
128
136
  return FILE_CODE.test(code) ? code : null;
129
137
  }
130
138
 
139
+ /**
140
+ * The country's name, in the reader's language, or null.
141
+ *
142
+ * `Intl.DisplayNames` is the whole implementation: the names are in the runtime already,
143
+ * translated, and maintained by whoever maintains the platform - so this ships no table of
144
+ * 250 country names to go stale, and a Spanish page says "España" without being told.
145
+ *
146
+ * Null for anything it cannot name, and that is deliberate for SUBDIVISIONS: `gb-eng` is
147
+ * England, and falling back to the region (`gb`, "United Kingdom") would put a confidently
148
+ * wrong name on the flag. A name nobody can source is worse than no name.
149
+ */
150
+ export function flagName(code: string | null | undefined, locale?: string): string | null {
151
+ const normalized = normalizeFlagCode(code);
152
+ if (!normalized || !COUNTRY_CODE.test(normalized)) return null;
153
+ const region = normalized.toUpperCase();
154
+ try {
155
+ const names = new Intl.DisplayNames([locale ?? documentLocale() ?? "en"], { type: "region" });
156
+ const name = names.of(region);
157
+ // It echoes the input back when it has no name for the code.
158
+ return !name || name === region ? null : name;
159
+ } catch {
160
+ return null;
161
+ }
162
+ }
163
+
164
+ /** The page's own language, which is what a translated page wants the name written in. */
165
+ function documentLocale(): string | undefined {
166
+ if (typeof document === "undefined") return undefined;
167
+ return document.documentElement.lang || undefined;
168
+ }
169
+
131
170
  /** Everything a renderer needs, computed in one pass. Null when the code is unusable. */
132
171
  export interface FlagView {
133
- /** The upstream file name, e.g. `es`, `gb-eng`. */
172
+ /** The file name, e.g. `es`, `gb-eng`. */
134
173
  code: string;
135
174
  src: string;
136
175
  shape: FlagShape;
137
176
  format: FlagFormat;
138
177
  width: number;
139
178
  height: number;
140
- }
141
-
142
- function cdnUrl(code: string, shape: FlagShape): string {
143
- if (shape === "circle") return `https://cdn.jsdelivr.net/gh/HatScripts/circle-flags@${CIRCLE_FLAGS_REF}/flags/${code}.svg`;
144
- const size = shape === "square" ? "1x1" : "4x3";
145
- return `https://cdn.jsdelivr.net/npm/flag-icons@${FLAG_ICONS_VERSION}/flags/${size}/${code}.svg`;
179
+ /** The automatic name, when there is one. */
180
+ name: string | null;
146
181
  }
147
182
 
148
183
  /** One warning per process, not one per flag: a list of 200 would print 200 times. */
@@ -151,32 +186,32 @@ let warnedAboutFormat = false;
151
186
  /**
152
187
  * The URL for one flag. Null when the code cannot be resolved.
153
188
  *
154
- * A raster format asked of the CDN falls back to SVG rather than producing a 404: neither
155
- * upstream publishes anything but SVG, so `format: "webp"` is only meaningful against a
156
- * local set or a mirror. It says so once, in development, instead of silently disagreeing
157
- * with what the call site asked for.
189
+ * A raster format asked of the CDN falls back to SVG rather than producing a 404: the sets
190
+ * are stored as SVG, so `format: "webp"` is only meaningful against a local set or a mirror.
191
+ * It says so once, in development, instead of silently disagreeing with the call site.
158
192
  */
159
193
  export function flagSrc(code: string | null | undefined, options: FlagOptions = {}): string | null {
160
194
  const settings = { ...config, ...options };
161
195
  const normalized = normalizeFlagCode(code);
162
196
  if (!normalized) return null;
163
197
 
164
- const remoteCdn = settings.source === "cdn";
198
+ const remote = settings.source === "cdn";
165
199
  let format = settings.format;
166
- if (remoteCdn && format !== "svg") {
200
+ if (remote && format !== "svg") {
167
201
  if (!warnedAboutFormat && typeof process !== "undefined" && process.env?.NODE_ENV !== "production") {
168
202
  warnedAboutFormat = true;
169
- console.warn(`[enigma/flags] The flag CDNs publish SVG only, so "${format}" was served as SVG. Download a local set for raster formats: enigma add flags --flags local --flag-formats ${format}`);
203
+ console.warn(`[enigma/flags] The flags are served as SVG, so "${format}" was served as SVG. Download a local set for raster formats: enigma add flags --flags local --flag-formats ${format}`);
170
204
  }
171
205
  format = "svg";
172
206
  }
173
- if (remoteCdn) return cdnUrl(normalized, settings.shape);
174
207
 
175
- const base = (settings.source === "local" ? settings.basePath : settings.source).replace(/\/+$/, "");
208
+ // One layout everywhere - `<base>/<shape>/<code>.<format>` - so a mirror is the same
209
+ // string with a different host, and the downloader writes exactly what this reads.
210
+ const base = remote ? FLAG_CDN : (settings.source === "local" ? settings.basePath : settings.source).replace(/\/+$/, "");
176
211
  return `${base}/${settings.shape}/${normalized}.${format}`;
177
212
  }
178
213
 
179
- /** `flagSrc` plus the resolved geometry, for a renderer that has to size the box. */
214
+ /** `flagSrc` plus the resolved geometry and name, for a renderer that has to size the box. */
180
215
  export function flagView(code: string | null | undefined, options: FlagOptions = {}): FlagView | null {
181
216
  const settings = { ...config, ...options };
182
217
  const normalized = normalizeFlagCode(code);
@@ -189,25 +224,36 @@ export function flagView(code: string | null | undefined, options: FlagOptions =
189
224
  shape: settings.shape,
190
225
  format: settings.source === "cdn" ? "svg" : settings.format,
191
226
  width: Math.round(height * FLAG_RATIO[settings.shape]),
192
- height
227
+ height,
228
+ name: flagName(normalized, settings.locale)
193
229
  };
194
230
  }
195
231
 
232
+ export interface FlagAttributeOptions extends FlagOptions {
233
+ /** Your own accessible name, which wins over the automatic one. */
234
+ label?: string;
235
+ /**
236
+ * Beside a country name that is already on screen, the flag is decoration and repeating
237
+ * the name only makes a screen reader say it twice.
238
+ */
239
+ decorative?: boolean;
240
+ }
241
+
196
242
  /**
197
243
  * Attributes for a plain `<img>`, for vanilla, Astro and any template language.
198
244
  *
199
- * `label` is the accessible name and there is no default for it: a flag with no label is
200
- * DECORATIVE (empty alt, `aria-hidden`), which is correct beside a country name and is the
201
- * common case. Inventing one from the code would put "ES" or "es" into a screen reader,
202
- * and a country name guessed in the reader's wrong language is worse than silence.
245
+ * The accessible name is AUTOMATIC: the country's own name, in the reader's language, from
246
+ * `Intl.DisplayNames`. `label` replaces it and `decorative` drops it. Nothing is ever
247
+ * invented - a subdivision the platform cannot name, or a runtime without `Intl`, renders
248
+ * as decoration rather than putting a bare "ES" into a screen reader.
203
249
  */
204
- export function flagAttributes(code: string | null | undefined, options: FlagOptions & { label?: string; } = {}): Record<string, string | number> | null {
250
+ export function flagAttributes(code: string | null | undefined, options: FlagAttributeOptions = {}): Record<string, string | number> | null {
205
251
  const view = flagView(code, options);
206
252
  if (!view) return null;
207
- const label = options.label?.trim();
253
+ const label = options.decorative ? "" : options.label?.trim() || view.name || "";
208
254
  return {
209
255
  src: view.src,
210
- alt: label ?? "",
256
+ alt: label,
211
257
  width: view.width,
212
258
  height: view.height,
213
259
  loading: "lazy",
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The icon shapes both renderers draw.
3
+ *
4
+ * Their own module because the React field imports NOTHING else from the vanilla adapter:
5
+ * pulling `createInput` in for two arrays of path data put the whole imperative renderer
6
+ * into every React bundle, for a component that never calls it.
7
+ */
8
+
9
+ /**
10
+ * The built-in glyphs, as path data.
11
+ *
12
+ * Path data rather than markup because there are two renderers: this file writes an SVG
13
+ * string into a button it created, and the React component builds elements. Keeping the
14
+ * shapes here means one definition, and a theme that replaces an icon replaces it in both.
15
+ * Everything is stroked with `currentColor` at 1em, so an icon inherits the field's text.
16
+ */
17
+ export const INPUT_ICON_PATHS = {
18
+ eye: ["M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z", "M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"],
19
+ eyeOff: [
20
+ "M10.6 5.2A9.8 9.8 0 0 1 12 5c6.5 0 10 7 10 7a17.6 17.6 0 0 1-3.2 4.2M6.2 6.2A17.7 17.7 0 0 0 2 12s3.5 7 10 7a9.6 9.6 0 0 0 4.2-.9",
21
+ "m2 2 20 20",
22
+ "M9.9 9.9a3 3 0 0 0 4.2 4.2"
23
+ ],
24
+ generate: ["m12 3 1.9 4.6L18.5 9.5 13.9 11.4 12 16l-1.9-4.6L5.5 9.5l4.6-1.9Z", "M19 15l.8 2.2 2.2.8-2.2.8-.8 2.2-.8-2.2-2.2-.8 2.2-.8Z"],
25
+ /** Clears a search field. The platform draws one only in WebKit, and not on mobile. */
26
+ clear: ["m6 6 12 12", "m18 6-12 12"],
27
+ search: ["M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14Z", "m20 20-4.2-4.2"]
28
+ } as const;
29
+
30
+ /** The same shapes as a standalone SVG string, for the DOM renderer below. */
31
+ export function iconMarkup(paths: readonly string[]): string {
32
+ const body = paths.map((path) => `<path d="${path}"/>`).join("");
33
+ return `<svg viewBox="0 0 24 24" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
34
+ }
package/src/core/input.ts CHANGED
@@ -73,28 +73,12 @@ export interface InputInstance {
73
73
  }
74
74
 
75
75
  /**
76
- * The built-in glyphs, as path data.
77
- *
78
- * Path data rather than markup because there are two renderers: this file writes an SVG
79
- * string into a button it created, and the React component builds elements. Keeping the
80
- * shapes here means one definition, and a theme that replaces an icon replaces it in both.
81
- * Everything is stroked with `currentColor` at 1em, so an icon inherits the field's text.
76
+ * The icons live in their own module so the React field can take them without taking this
77
+ * whole adapter with it. Re-exported here because they were part of this module's API
78
+ * before the split, and moving an export is a breaking change for no gain.
82
79
  */
83
- export const INPUT_ICON_PATHS = {
84
- eye: ["M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z", "M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"],
85
- eyeOff: [
86
- "M10.6 5.2A9.8 9.8 0 0 1 12 5c6.5 0 10 7 10 7a17.6 17.6 0 0 1-3.2 4.2M6.2 6.2A17.7 17.7 0 0 0 2 12s3.5 7 10 7a9.6 9.6 0 0 0 4.2-.9",
87
- "m2 2 20 20",
88
- "M9.9 9.9a3 3 0 0 0 4.2 4.2"
89
- ],
90
- generate: ["m12 3 1.9 4.6L18.5 9.5 13.9 11.4 12 16l-1.9-4.6L5.5 9.5l4.6-1.9Z", "M19 15l.8 2.2 2.2.8-2.2.8-.8 2.2-.8-2.2-2.2-.8 2.2-.8Z"]
91
- } as const;
92
-
93
- /** The same shapes as a standalone SVG string, for the DOM renderer below. */
94
- export function iconMarkup(paths: readonly string[]): string {
95
- const body = paths.map((path) => `<path d="${path}"/>`).join("");
96
- return `<svg viewBox="0 0 24 24" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
97
- }
80
+ export { INPUT_ICON_PATHS, iconMarkup } from "@/core/input-icons";
81
+ import { INPUT_ICON_PATHS, iconMarkup } from "@/core/input-icons";
98
82
 
99
83
  const EYE = iconMarkup(INPUT_ICON_PATHS.eye);
100
84
  const EYE_OFF = iconMarkup(INPUT_ICON_PATHS.eyeOff);
Binary file
package/src/index.ts CHANGED
@@ -31,13 +31,13 @@ export {
31
31
  flagSrc,
32
32
  flagView,
33
33
  flagAttributes,
34
+ flagName,
34
35
  normalizeFlagCode,
35
36
  configureFlags,
36
37
  flagConfig,
37
38
  resetFlagConfig,
38
39
  FLAG_RATIO,
39
- FLAG_ICONS_VERSION,
40
- CIRCLE_FLAGS_REF,
40
+ FLAG_CDN,
41
41
  type FlagOptions,
42
42
  type FlagConfig,
43
43
  type FlagView,
@@ -1,9 +1,10 @@
1
1
  "use client";
2
2
 
3
+ import { Slot } from "@/react/slot";
3
4
  import { useButton } from "@/react/use-button";
4
5
  import { getLinkComponent } from "@/react/link";
5
6
  import type { ButtonOptions, ButtonState } from "@/core/button";
6
- import { createElement, forwardRef, type ComponentPropsWithoutRef, type ElementType, type ReactNode } from "react";
7
+ import { createElement, forwardRef, isValidElement, type ComponentPropsWithoutRef, type ElementType, type ReactNode } from "react";
7
8
 
8
9
  /**
9
10
  * `<Button>` - the component, for the ninety percent.
@@ -51,6 +52,49 @@ export interface ButtonProps extends ButtonOptions, Omit<ComponentPropsWithoutRe
51
52
  pending?: ReactNode;
52
53
  /** `type` on a real button. Ignored on a link, which has none. */
53
54
  type?: "button" | "submit" | "reset";
55
+ /**
56
+ * Show the shortcut as a key badge after the label, the way Stripe writes
57
+ * "Create invoice n".
58
+ *
59
+ * `"auto"` (the default) shows it when there is a shortcut AND the label renders text:
60
+ * beside a lone glyph on an icon-only button the badge is noise, and it would double the
61
+ * width of the smallest control on the screen. `true` and `false` decide it outright.
62
+ */
63
+ shortcutHint?: boolean | "auto";
64
+ /** Render the badge yourself. Return null to drop it for one button. */
65
+ renderShortcut?: (key: string) => ReactNode;
66
+ /**
67
+ * Put the behaviour on YOUR element instead of ours - a `motion.button`, your design
68
+ * system's button, a router Link, anything.
69
+ *
70
+ * ```tsx
71
+ * <Button asChild cooldown={30_000} onPress={resend}>
72
+ * <motion.button whileTap={{ scale: 0.98 }}>Resend</motion.button>
73
+ * </Button>
74
+ * ```
75
+ *
76
+ * The child owns its own markup, so the shortcut badge is not injected - the child is
77
+ * rendered exactly as written. Read `state.shortcut` from `useButton` to place your own.
78
+ */
79
+ asChild?: boolean;
80
+ }
81
+
82
+ /**
83
+ * Whether a label renders any TEXT.
84
+ *
85
+ * Walked rather than assumed: `<Button shortcut="s"><Icon /></Button>` and
86
+ * `<Button shortcut="s"><Icon /> Save</Button>` differ only in a string buried in the tree,
87
+ * and that string is the whole difference between a hint and a decoration. An element whose
88
+ * text arrives from somewhere this cannot see reports false, so `auto` errs towards the
89
+ * quieter button; `shortcutHint` overrides it in one word.
90
+ */
91
+ function hasText(node: ReactNode): boolean {
92
+ if (node === null || node === undefined || typeof node === "boolean") return false;
93
+ if (typeof node === "string") return node.trim().length > 0;
94
+ if (typeof node === "number") return true;
95
+ if (Array.isArray(node)) return node.some(hasText);
96
+ if (isValidElement(node)) return hasText((node.props as { children?: ReactNode; }).children);
97
+ return false;
54
98
  }
55
99
 
56
100
  export const Button = forwardRef<HTMLElement, ButtonProps>(function Button({
@@ -66,6 +110,9 @@ export const Button = forwardRef<HTMLElement, ButtonProps>(function Button({
66
110
  onClick,
67
111
  onChange,
68
112
  type = "button",
113
+ shortcutHint = "auto",
114
+ renderShortcut,
115
+ asChild = false,
69
116
  ...rest
70
117
  }, ref) {
71
118
  const button = useButton({ href, disabled, loading, cooldown, shortcut, onPress: onPress ?? onClick, onChange });
@@ -75,17 +122,36 @@ export const Button = forwardRef<HTMLElement, ButtonProps>(function Button({
75
122
  // to setLinkComponent - and a plain <a> when nothing was. The package cannot import a
76
123
  // router itself, so registering one is what turns every href into a client navigation
77
124
  // without a word at the call site.
78
- const Tag: ElementType = as ?? (state.element === "a" ? getLinkComponent() : "button");
125
+ const Tag: ElementType = asChild ? Slot : as ?? (state.element === "a" ? getLinkComponent() : "button");
79
126
 
80
127
  // `type="button"` is the default on purpose. A bare <button> inside a form submits it,
81
- // so an action button that forgot it posts the form instead of doing its job.
128
+ // so an action button that forgot it posts the form instead of doing its job. Never
129
+ // injected through a slot: the child may be an anchor or a div, where `type` is either
130
+ // invalid or means something else entirely.
82
131
  const native = Tag === "button" ? { type } : {};
83
132
 
84
133
  const label = typeof children === "function" ? children(state) : children;
134
+ const content = state.loading && pending !== undefined ? pending : label;
135
+
136
+ // Not while it is working: the label has already been replaced by whatever `pending`
137
+ // says, and a key that does nothing right now is not a hint.
138
+ // Never through a slot: it takes exactly one child, and a second one would throw.
139
+ const showHint = !asChild && Boolean(state.shortcut) && !state.loading &&
140
+ (shortcutHint === "auto" ? hasText(content) : shortcutHint);
141
+ const hint = showHint && state.shortcut
142
+ ? renderShortcut?.(state.shortcut) ?? createElement(
143
+ "kbd",
144
+ // Hidden from the accessible name - the button already carries
145
+ // `aria-keyshortcuts`, and "Save n" is not what anyone wants read out.
146
+ { "data-enigma-button-key": "", "aria-hidden": true },
147
+ state.shortcut
148
+ )
149
+ : null;
85
150
 
86
151
  return createElement(
87
152
  Tag,
88
153
  { ref, ...button.props, ...native, ...rest },
89
- state.loading && pending !== undefined ? pending : label
154
+ content,
155
+ hint
90
156
  );
91
157
  });
@@ -7,10 +7,17 @@ export interface FlagProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, "sr
7
7
  /** `es`, `ES`, `en-GB`, `gb-eng`, or the emoji flag you are replacing. */
8
8
  code: string | null | undefined;
9
9
  /**
10
- * The accessible name. Leave it out and the flag is decorative, which is what it is
11
- * beside a country name - the reader hears the name once instead of twice.
10
+ * Your own accessible name. Left out, the country's own name is used, in the reader's
11
+ * language - so a flag standing on its own is never a silent image.
12
12
  */
13
13
  label?: string;
14
+ /**
15
+ * Beside a country name that is already on screen the flag is decoration, and repeating
16
+ * the name only makes a screen reader say it twice.
17
+ */
18
+ decorative?: boolean;
19
+ /** Language the automatic name is written in. Follows the document's `lang` otherwise. */
20
+ locale?: string;
14
21
  shape?: FlagShape;
15
22
  format?: FlagFormat;
16
23
  source?: FlagSource;
@@ -28,8 +35,8 @@ export interface FlagProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, "sr
28
35
  * The defaults come from `configureFlags`, so moving the whole application from the CDN to
29
36
  * a downloaded set is one line at startup and no change at any call site.
30
37
  */
31
- export function Flag({ code, label, shape, format, source, basePath, size, fallback = null, ...rest }: FlagProps): ReactNode {
32
- const options: FlagOptions = { shape, format, source, basePath, size };
38
+ export function Flag({ code, label, decorative = false, locale, shape, format, source, basePath, size, fallback = null, ...rest }: FlagProps): ReactNode {
39
+ const options: FlagOptions = { shape, format, source, basePath, size, locale };
33
40
  // An explicit `undefined` prop must not beat the configured default, so the unset ones
34
41
  // are dropped rather than spread over the config.
35
42
  for (const key of Object.keys(options) as (keyof FlagOptions)[]) {
@@ -39,16 +46,21 @@ export function Flag({ code, label, shape, format, source, basePath, size, fallb
39
46
  const view = flagView(code, options);
40
47
  if (!view) return fallback;
41
48
 
42
- const name = label?.trim();
49
+ // Automatic unless told otherwise: the country's own name, in the reader's language.
50
+ const name = decorative ? "" : label?.trim() || view.name || "";
43
51
  return (
44
52
  <img
45
53
  src={view.src}
46
- alt={name ?? ""}
54
+ alt={name}
47
55
  width={view.width}
48
56
  height={view.height}
49
57
  loading="lazy"
50
58
  decoding="async"
51
59
  aria-hidden={name ? undefined : true}
60
+ // The name comes from the reader's own runtime, so a server render and its
61
+ // hydration can legitimately disagree on the language it is written in. Same
62
+ // escape hatch, and same reason, as the relative timestamp.
63
+ suppressHydrationWarning
52
64
  data-enigma-flag=""
53
65
  data-flag-code={view.code}
54
66
  data-flag-shape={view.shape}
@@ -10,16 +10,23 @@ export { useButton, type UseButtonResult } from "@/react/use-button";
10
10
  export { Button, type ButtonProps } from "@/react/button";
11
11
  export { setLinkComponent, getLinkComponent } from "@/react/link";
12
12
  export { type ButtonOptions, type ButtonState } from "@/core/button";
13
+ export { Input } from "@/react/input";
13
14
  export {
14
- Input,
15
- PasswordStrength,
16
15
  type InputProps,
17
- type PasswordStrengthProps,
16
+ type InputBaseProps,
17
+ type InputType,
18
18
  type FieldAction,
19
19
  type BreachChecker,
20
20
  type BreachState,
21
- type BreachStatus
22
- } from "@/react/input";
21
+ type BreachStatus,
22
+ type PasswordOnlyProps,
23
+ type SearchOnlyProps,
24
+ type PlainOnlyProps
25
+ } from "@/react/input/types";
26
+ // The meter as a standalone component, for a form that renders it somewhere else. Re-exported
27
+ // from the chunk rather than through `Input`, so importing only `Input` leaves it behind.
28
+ export { PasswordStrength, type PasswordStrengthProps } from "@/react/input/password";
29
+ export { Slot, mergeSlotProps, type SlotProps } from "@/react/slot";
23
30
  export {
24
31
  generatePassword,
25
32
  estimatePasswordStrength,
@@ -38,6 +45,7 @@ export {
38
45
  flagSrc,
39
46
  flagView,
40
47
  flagAttributes,
48
+ flagName,
41
49
  normalizeFlagCode,
42
50
  configureFlags,
43
51
  type FlagOptions,
@@ -45,3 +53,30 @@ export {
45
53
  type FlagFormat,
46
54
  type FlagSource
47
55
  } from "@/core/flags";
56
+ export {
57
+ SearchPalette,
58
+ PaletteRoot,
59
+ PaletteTrigger,
60
+ PaletteContent,
61
+ PaletteField,
62
+ PaletteList,
63
+ PaletteItem,
64
+ PaletteFooter,
65
+ usePaletteContext,
66
+ type SearchPaletteProps,
67
+ type PaletteRootProps,
68
+ type PaletteListProps,
69
+ type PaletteSection,
70
+ type PaletteRow,
71
+ type PaletteContextValue
72
+ } from "@/react/palette";
73
+ export {
74
+ createRecentStore,
75
+ recentKey,
76
+ groupRows,
77
+ moveActive,
78
+ shortcutLabel,
79
+ isPaletteShortcut,
80
+ type RecentEntry,
81
+ type RecentStore
82
+ } from "@/core/palette";
@@ -0,0 +1,17 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ /**
4
+ * The built-in glyphs, drawn from the shared path data.
5
+ *
6
+ * Stroked with `currentColor` at 1em, so an icon inherits the field's text colour and size
7
+ * and needs no styling of its own. Replacing one is a `FieldAction` with the same `name`.
8
+ */
9
+ const ICON_PROPS = {
10
+ viewBox: "0 0 24 24", width: "1em", height: "1em", fill: "none",
11
+ stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round",
12
+ "aria-hidden": true
13
+ } as const;
14
+
15
+ export function Icon({ paths }: { paths: readonly string[]; }): ReactNode {
16
+ return <svg {...ICON_PROPS}>{paths.map((path) => <path key={path} d={path} />)}</svg>;
17
+ }