ngwr 13.0.0 → 14.0.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 (200) hide show
  1. package/README.md +13 -6
  2. package/color-picker/styles/_index.scss +43 -5
  3. package/fesm2022/ngwr-alert.mjs +5 -5
  4. package/fesm2022/ngwr-alert.mjs.map +1 -1
  5. package/fesm2022/ngwr-back-top.mjs +11 -1
  6. package/fesm2022/ngwr-back-top.mjs.map +1 -1
  7. package/fesm2022/ngwr-blur-text.mjs +13 -2
  8. package/fesm2022/ngwr-blur-text.mjs.map +1 -1
  9. package/fesm2022/ngwr-border-glow.mjs +12 -3
  10. package/fesm2022/ngwr-border-glow.mjs.map +1 -1
  11. package/fesm2022/ngwr-breadcrumbs.mjs +14 -3
  12. package/fesm2022/ngwr-breadcrumbs.mjs.map +1 -1
  13. package/fesm2022/ngwr-button.mjs +4 -4
  14. package/fesm2022/ngwr-button.mjs.map +1 -1
  15. package/fesm2022/ngwr-calendar-testing.mjs +15 -0
  16. package/fesm2022/ngwr-calendar-testing.mjs.map +1 -1
  17. package/fesm2022/ngwr-calendar.mjs +51 -5
  18. package/fesm2022/ngwr-calendar.mjs.map +1 -1
  19. package/fesm2022/ngwr-circular-text.mjs +20 -1
  20. package/fesm2022/ngwr-circular-text.mjs.map +1 -1
  21. package/fesm2022/ngwr-collapse.mjs.map +1 -1
  22. package/fesm2022/ngwr-color-picker-testing.mjs +23 -7
  23. package/fesm2022/ngwr-color-picker-testing.mjs.map +1 -1
  24. package/fesm2022/ngwr-color-picker.mjs +89 -10
  25. package/fesm2022/ngwr-color-picker.mjs.map +1 -1
  26. package/fesm2022/ngwr-command-palette.mjs +27 -3
  27. package/fesm2022/ngwr-command-palette.mjs.map +1 -1
  28. package/fesm2022/ngwr-context-menu.mjs +13 -3
  29. package/fesm2022/ngwr-context-menu.mjs.map +1 -1
  30. package/fesm2022/ngwr-date-adapters-fns.mjs +101 -33
  31. package/fesm2022/ngwr-date-adapters-fns.mjs.map +1 -1
  32. package/fesm2022/ngwr-date-picker-testing.mjs +10 -2
  33. package/fesm2022/ngwr-date-picker-testing.mjs.map +1 -1
  34. package/fesm2022/ngwr-date-picker.mjs +103 -13
  35. package/fesm2022/ngwr-date-picker.mjs.map +1 -1
  36. package/fesm2022/ngwr-date.mjs +480 -79
  37. package/fesm2022/ngwr-date.mjs.map +1 -1
  38. package/fesm2022/ngwr-dialog.mjs +22 -16
  39. package/fesm2022/ngwr-dialog.mjs.map +1 -1
  40. package/fesm2022/ngwr-directives.mjs +5 -2
  41. package/fesm2022/ngwr-directives.mjs.map +1 -1
  42. package/fesm2022/ngwr-drag-drop.mjs +30 -2
  43. package/fesm2022/ngwr-drag-drop.mjs.map +1 -1
  44. package/fesm2022/ngwr-drawer.mjs +24 -18
  45. package/fesm2022/ngwr-drawer.mjs.map +1 -1
  46. package/fesm2022/ngwr-dropdown.mjs +8 -3
  47. package/fesm2022/ngwr-dropdown.mjs.map +1 -1
  48. package/fesm2022/ngwr-event-calendar-testing.mjs.map +1 -1
  49. package/fesm2022/ngwr-event-calendar.mjs +1 -1
  50. package/fesm2022/ngwr-event-calendar.mjs.map +1 -1
  51. package/fesm2022/ngwr-file-upload.mjs +52 -12
  52. package/fesm2022/ngwr-file-upload.mjs.map +1 -1
  53. package/fesm2022/ngwr-fuzzy-text.mjs +21 -4
  54. package/fesm2022/ngwr-fuzzy-text.mjs.map +1 -1
  55. package/fesm2022/ngwr-gradient-text.mjs +9 -2
  56. package/fesm2022/ngwr-gradient-text.mjs.map +1 -1
  57. package/fesm2022/ngwr-i18n-en.mjs +63 -1
  58. package/fesm2022/ngwr-i18n-en.mjs.map +1 -1
  59. package/fesm2022/ngwr-i18n-ru.mjs +28 -1
  60. package/fesm2022/ngwr-i18n-ru.mjs.map +1 -1
  61. package/fesm2022/ngwr-i18n.mjs +150 -22
  62. package/fesm2022/ngwr-i18n.mjs.map +1 -1
  63. package/fesm2022/ngwr-icon-adapters-feather.mjs +11 -0
  64. package/fesm2022/ngwr-icon-adapters-feather.mjs.map +1 -1
  65. package/fesm2022/ngwr-icon-adapters-lucide.mjs +22 -2
  66. package/fesm2022/ngwr-icon-adapters-lucide.mjs.map +1 -1
  67. package/fesm2022/ngwr-icon.mjs +474 -37
  68. package/fesm2022/ngwr-icon.mjs.map +1 -1
  69. package/fesm2022/ngwr-input-number.mjs +3 -3
  70. package/fesm2022/ngwr-input-number.mjs.map +1 -1
  71. package/fesm2022/ngwr-input-otp.mjs +21 -3
  72. package/fesm2022/ngwr-input-otp.mjs.map +1 -1
  73. package/fesm2022/ngwr-input.mjs +14 -7
  74. package/fesm2022/ngwr-input.mjs.map +1 -1
  75. package/fesm2022/ngwr-knob-testing.mjs.map +1 -1
  76. package/fesm2022/ngwr-knob.mjs +20 -2
  77. package/fesm2022/ngwr-knob.mjs.map +1 -1
  78. package/fesm2022/ngwr-loading-bar-router.mjs +73 -0
  79. package/fesm2022/ngwr-loading-bar-router.mjs.map +1 -0
  80. package/fesm2022/ngwr-loading-bar.mjs +21 -32
  81. package/fesm2022/ngwr-loading-bar.mjs.map +1 -1
  82. package/fesm2022/ngwr-markdown.mjs +63 -11
  83. package/fesm2022/ngwr-markdown.mjs.map +1 -1
  84. package/fesm2022/ngwr-mention.mjs +104 -14
  85. package/fesm2022/ngwr-mention.mjs.map +1 -1
  86. package/fesm2022/ngwr-overlay.mjs +36 -7
  87. package/fesm2022/ngwr-overlay.mjs.map +1 -1
  88. package/fesm2022/ngwr-pagination-testing.mjs +11 -10
  89. package/fesm2022/ngwr-pagination-testing.mjs.map +1 -1
  90. package/fesm2022/ngwr-pagination.mjs +65 -30
  91. package/fesm2022/ngwr-pagination.mjs.map +1 -1
  92. package/fesm2022/ngwr-popconfirm.mjs +3 -2
  93. package/fesm2022/ngwr-popconfirm.mjs.map +1 -1
  94. package/fesm2022/ngwr-popover.mjs +2 -1
  95. package/fesm2022/ngwr-popover.mjs.map +1 -1
  96. package/fesm2022/ngwr-qr.mjs +38 -1
  97. package/fesm2022/ngwr-qr.mjs.map +1 -1
  98. package/fesm2022/ngwr-select.mjs +93 -34
  99. package/fesm2022/ngwr-select.mjs.map +1 -1
  100. package/fesm2022/ngwr-shiny-text.mjs +10 -3
  101. package/fesm2022/ngwr-shiny-text.mjs.map +1 -1
  102. package/fesm2022/ngwr-splitter-testing.mjs.map +1 -1
  103. package/fesm2022/ngwr-squircle.mjs +2 -1
  104. package/fesm2022/ngwr-squircle.mjs.map +1 -1
  105. package/fesm2022/ngwr-statistic.mjs +35 -4
  106. package/fesm2022/ngwr-statistic.mjs.map +1 -1
  107. package/fesm2022/ngwr-table.mjs +35 -12
  108. package/fesm2022/ngwr-table.mjs.map +1 -1
  109. package/fesm2022/ngwr-tabs-router.mjs +158 -0
  110. package/fesm2022/ngwr-tabs-router.mjs.map +1 -0
  111. package/fesm2022/ngwr-tabs-testing.mjs +13 -11
  112. package/fesm2022/ngwr-tabs-testing.mjs.map +1 -1
  113. package/fesm2022/ngwr-tabs.mjs +104 -12
  114. package/fesm2022/ngwr-tabs.mjs.map +1 -1
  115. package/fesm2022/ngwr-textarea.mjs +20 -1
  116. package/fesm2022/ngwr-textarea.mjs.map +1 -1
  117. package/fesm2022/ngwr-tour.mjs +1 -1
  118. package/fesm2022/ngwr-tour.mjs.map +1 -1
  119. package/fesm2022/ngwr-transfer.mjs +2 -2
  120. package/fesm2022/ngwr-transfer.mjs.map +1 -1
  121. package/fesm2022/ngwr-tree.mjs +18 -2
  122. package/fesm2022/ngwr-tree.mjs.map +1 -1
  123. package/fesm2022/ngwr-utils.mjs +143 -1
  124. package/fesm2022/ngwr-utils.mjs.map +1 -1
  125. package/fesm2022/ngwr-version.mjs +1 -1
  126. package/fesm2022/ngwr-version.mjs.map +1 -1
  127. package/fesm2022/ngwr-window-testing.mjs +10 -2
  128. package/fesm2022/ngwr-window-testing.mjs.map +1 -1
  129. package/fesm2022/ngwr-window.mjs +4 -4
  130. package/fesm2022/ngwr-window.mjs.map +1 -1
  131. package/i18n/en.json +33 -6
  132. package/i18n/ru.json +33 -6
  133. package/llms-full.txt +20 -11
  134. package/llms.txt +47 -16
  135. package/mcp/catalog.js +3 -3
  136. package/mcp/providers.js +11 -0
  137. package/package.json +11 -3
  138. package/schematics/migrations/v13/index.js +101 -0
  139. package/schematics/migrations/v14/index.js +422 -0
  140. package/schematics/migrations.json +10 -0
  141. package/schematics/page/templates.js +1 -1
  142. package/schematics/use/symbol-map.json +1 -0
  143. package/skills/ngwr/SKILL.md +4 -1
  144. package/skills/ngwr/references/catalog.md +2 -0
  145. package/skills/ngwr/references/setup.md +4 -0
  146. package/stepper/styles/_index.scss +19 -1
  147. package/textarea/styles/_index.scss +14 -6
  148. package/toast/styles/_index.scss +12 -4
  149. package/types/ngwr-alert.d.ts +3 -3
  150. package/types/ngwr-blur-text.d.ts +8 -0
  151. package/types/ngwr-breadcrumbs.d.ts +1 -0
  152. package/types/ngwr-button.d.ts +2 -2
  153. package/types/ngwr-calendar-testing.d.ts +21 -4
  154. package/types/ngwr-calendar.d.ts +37 -0
  155. package/types/ngwr-circular-text.d.ts +9 -0
  156. package/types/ngwr-collapse.d.ts +19 -15
  157. package/types/ngwr-color-picker-testing.d.ts +10 -4
  158. package/types/ngwr-color-picker.d.ts +70 -2
  159. package/types/ngwr-command-palette.d.ts +14 -0
  160. package/types/ngwr-context-menu.d.ts +1 -1
  161. package/types/ngwr-date-adapters-fns.d.ts +71 -29
  162. package/types/ngwr-date-picker-testing.d.ts +206 -185
  163. package/types/ngwr-date-picker.d.ts +7 -0
  164. package/types/ngwr-date.d.ts +143 -13
  165. package/types/ngwr-drag-drop.d.ts +19 -0
  166. package/types/ngwr-dropdown.d.ts +1 -1
  167. package/types/ngwr-event-calendar-testing.d.ts +12 -9
  168. package/types/ngwr-file-upload.d.ts +31 -3
  169. package/types/ngwr-fuzzy-text.d.ts +5 -0
  170. package/types/ngwr-i18n.d.ts +89 -7
  171. package/types/ngwr-icon-adapters-feather.d.ts +11 -0
  172. package/types/ngwr-icon.d.ts +42 -1
  173. package/types/ngwr-input-number.d.ts +1 -1
  174. package/types/ngwr-input-otp.d.ts +3 -0
  175. package/types/ngwr-input.d.ts +11 -4
  176. package/types/ngwr-knob-testing.d.ts +8 -4
  177. package/types/ngwr-knob.d.ts +18 -0
  178. package/types/ngwr-loading-bar-router.d.ts +35 -0
  179. package/types/ngwr-loading-bar.d.ts +19 -9
  180. package/types/ngwr-mention.d.ts +13 -0
  181. package/types/ngwr-pagination-testing.d.ts +11 -10
  182. package/types/ngwr-pagination.d.ts +39 -10
  183. package/types/ngwr-popconfirm.d.ts +1 -1
  184. package/types/ngwr-popover.d.ts +1 -1
  185. package/types/ngwr-select.d.ts +65 -10
  186. package/types/ngwr-shiny-text.d.ts +1 -0
  187. package/types/ngwr-splitter-testing.d.ts +9 -4
  188. package/types/ngwr-squircle.d.ts +1 -1
  189. package/types/ngwr-statistic.d.ts +22 -2
  190. package/types/ngwr-table.d.ts +24 -5
  191. package/types/ngwr-tabs-router.d.ts +67 -0
  192. package/types/ngwr-tabs-testing.d.ts +12 -10
  193. package/types/ngwr-tabs.d.ts +103 -6
  194. package/types/ngwr-textarea.d.ts +9 -0
  195. package/types/ngwr-tree.d.ts +7 -0
  196. package/types/ngwr-utils.d.ts +79 -1
  197. package/types/ngwr-version.d.ts +1 -1
  198. package/types/ngwr-window-testing.d.ts +11 -3
  199. package/types/ngwr-window.d.ts +11 -4
  200. package/window/styles/_index.scss +1 -1
@@ -1,6 +1,5 @@
1
1
  import * as i0 from '@angular/core';
2
- import { InjectionToken, inject, PLATFORM_ID, Service, makeEnvironmentProviders } from '@angular/core';
3
- import { isPlatformBrowser } from '@angular/common';
2
+ import { InjectionToken, inject, LOCALE_ID, Service, makeEnvironmentProviders } from '@angular/core';
4
3
 
5
4
  /**
6
5
  * @license
@@ -42,31 +41,38 @@ class WrDateAdapter {
42
41
  */
43
42
  /**
44
43
  * BCP 47 locale tag the {@link WrDateAdapter} implementations read. Defaults to
45
- * `navigator.language` in the browser and `'en-US'` on the server; override via
46
- * {@link provideWrDateAdapter}'s `locale` option.
44
+ * Angular's `LOCALE_ID`; override via {@link provideWrDateAdapter}'s `locale`
45
+ * option.
46
+ *
47
+ * **It used to default to `navigator.language`, and that was wrong in the
48
+ * documented case.** The provider's own first example is the no-argument call,
49
+ * so the path a reader copies was the broken one: an app that set
50
+ * `LOCALE_ID: 'ru-RU'` and translated every string still rendered `March 2026`,
51
+ * `SUN MON TUE …` and a week starting on Sunday, because the calendar was
52
+ * asking the browser rather than the application. `navigator.language` is the
53
+ * user's browser preference; `LOCALE_ID` is what the app says it is, and only
54
+ * the app can be right about that. Reading the browser also made a prerendered
55
+ * page and its hydrated self disagree — the server has no `navigator.language`
56
+ * to match. An app that genuinely wants the browser's tag can still say so:
57
+ * `provideWrDateAdapter({ locale: navigator.language })`.
58
+ *
59
+ * The token is a plain string, resolved once. It is therefore the *pinned* date
60
+ * locale, not a live one — `WrI18n.use()` moves catalog text and cannot move
61
+ * this. `WrI18n`'s class docs carry the full precedence and `use()` warns about
62
+ * the gap in dev mode.
47
63
  *
48
64
  * **How far it reaches depends on the adapter, and the difference matters.**
49
- * The luxon adapter threads it through everything — every `DateTime` is
50
- * constructed with it, and `format` / `parse` call `setLocale`. The date-fns
51
- * adapter reads it only for locale-DERIVED defaults (first day of week); its
52
- * `format` and `parse` pass no locale to date-fns, so they stay on that
53
- * library's module default. Setting this token and expecting French month names
54
- * out of `WrDateFnsAdapter` gets English ones, and the parse side of that
55
- * mismatch is silent rather than loud — see the adapter's own class docs for the
56
- * lever (`setDefaultOptions`).
65
+ * The native and luxon adapters thread it through everything. The date-fns
66
+ * adapter cannot: date-fns resolves its patterns against a `Locale` OBJECT, not
67
+ * a tag, so this token drives only the parts that go through `Intl` (the month
68
+ * and weekday names) and the OBJECT drives `format` / `parse`. Pass it as
69
+ * `provideWrDateFnsAdapter({ dateFnsLocale: ru })` — with only a tag the calendar
70
+ * headings localize and the field does not, which the adapter warns about once
71
+ * in dev mode.
57
72
  */
58
73
  const WR_DATE_LOCALE = new InjectionToken('WR_DATE_LOCALE', {
59
74
  providedIn: 'root',
60
- factory: () => {
61
- // Node 21+ also defines `navigator`, and reports the *build machine's*
62
- // locale — so a `typeof` probe silently bakes that locale into
63
- // prerendered output (wrong month names, wrong first day of week).
64
- // Ask the platform instead.
65
- if (isPlatformBrowser(inject(PLATFORM_ID)) && navigator.language) {
66
- return navigator.language;
67
- }
68
- return 'en-US';
69
- },
75
+ factory: () => inject(LOCALE_ID),
70
76
  });
71
77
 
72
78
  /**
@@ -83,6 +89,90 @@ const NAMED_KEYS = new Set([
83
89
  'shortDateTime',
84
90
  'mediumDateTime',
85
91
  ]);
92
+ /**
93
+ * Bidi control marks ICU embeds in a right-to-left date pattern — ar-SA writes its
94
+ * separator as U+200F followed by a slash. No keyboard produces one, so both the
95
+ * pattern and the typed string drop them before they are compared.
96
+ */
97
+ const BIDI_RE = /[\u200e\u200f\u061c\u2066-\u2069]/g;
98
+ const REGEX_META_RE = /[.*+?^${}()|[\]\\]/g;
99
+ function stripBidi(value) {
100
+ return value.replace(BIDI_RE, '');
101
+ }
102
+ function escapeRegex(value) {
103
+ return value.replace(REGEX_META_RE, '\\$&');
104
+ }
105
+ /**
106
+ * One literal run of an `Intl` pattern, as a regex fragment.
107
+ *
108
+ * Whitespace inside a literal is advisory, not exact: ICU separates `02:30` from `PM`
109
+ * with a NARROW NO-BREAK SPACE (U+202F) and no keyboard emits one, so every whitespace
110
+ * run matches `\s*`. A literal that is nothing BUT whitespace keeps `\s+` — relaxing it
111
+ * there would let `15 2026` also read as `152026`, which is how a lenient parser invents
112
+ * a date nobody typed.
113
+ */
114
+ function literalToRegex(literal) {
115
+ const text = stripBidi(literal);
116
+ if (!text)
117
+ return '';
118
+ if (!text.trim())
119
+ return '\\s+';
120
+ return text.split(/\s+/).map(escapeRegex).join('\\s*');
121
+ }
122
+ /** Longest first, so `March` is never consumed as `Mar` with `ch` left over. */
123
+ function alternation(names) {
124
+ return [...new Set(names)]
125
+ .sort((a, b) => b.length - a.length)
126
+ .map(name => escapeRegex(stripBidi(name)))
127
+ .join('|');
128
+ }
129
+ /**
130
+ * The ten digits a locale's numbering system writes, index 0 to 9 — `null` when the
131
+ * system is algorithmic (roman, hebrew) and there is no digit-by-digit reading to do.
132
+ */
133
+ function numeralsFor(locale, numberingSystem) {
134
+ let formatter;
135
+ try {
136
+ formatter = new Intl.NumberFormat(locale, { numberingSystem, useGrouping: false });
137
+ }
138
+ catch {
139
+ return null;
140
+ }
141
+ const digits = [];
142
+ for (let i = 0; i < 10; i++) {
143
+ const digit = formatter.format(i);
144
+ if ([...digit].length !== 1 || digits.includes(digit))
145
+ return null;
146
+ digits.push(digit);
147
+ }
148
+ return digits;
149
+ }
150
+ /** A character class accepting the locale's own digits AND ASCII ones. */
151
+ function digitClass(numerals) {
152
+ const extra = numerals
153
+ .filter(digit => digit < '0' || digit > '9')
154
+ .map(digit => `\\u${digit.codePointAt(0).toString(16).padStart(4, '0')}`)
155
+ .join('');
156
+ return `[0-9${extra}]`;
157
+ }
158
+ /** `١٥` to `15`, and ASCII through unchanged. `NaN` for anything else. */
159
+ function digitsToNumber(text, numerals) {
160
+ let out = '';
161
+ for (const ch of text) {
162
+ if (ch >= '0' && ch <= '9') {
163
+ out += ch;
164
+ continue;
165
+ }
166
+ const index = numerals.indexOf(ch);
167
+ if (index < 0)
168
+ return Number.NaN;
169
+ out += String(index);
170
+ }
171
+ return out ? Number(out) : Number.NaN;
172
+ }
173
+ function isAllDigits(text, numerals) {
174
+ return text.length > 0 && !Number.isNaN(digitsToNumber(text, numerals));
175
+ }
86
176
  const INTL_OPTIONS = {
87
177
  shortDate: { year: 'numeric', month: 'numeric', day: 'numeric' },
88
178
  mediumDate: { year: 'numeric', month: 'short', day: 'numeric' },
@@ -107,24 +197,30 @@ function pad(n, width = 2) {
107
197
  function isNamedFormat(value) {
108
198
  return NAMED_KEYS.has(value);
109
199
  }
110
- function tokenToRegex(token) {
200
+ /**
201
+ * `digits` is a character class rather than `\d`, and it always accepts ASCII as well as
202
+ * the locale's own numerals. The token formatter WRITES ASCII, so the round trip never
203
+ * needed more — but a reader on an Arabic keyboard types `١٥` into the same field, and
204
+ * `\d` made that silently unparseable with no feedback anywhere.
205
+ */
206
+ function tokenToRegex(token, digits) {
111
207
  switch (token) {
112
208
  case 'yyyy':
113
- return '(\\d{4})';
209
+ return `(${digits}{4})`;
114
210
  case 'yy':
115
- return '(\\d{2})';
211
+ return `(${digits}{2})`;
116
212
  case 'MM':
117
213
  case 'dd':
118
214
  case 'HH':
119
215
  case 'hh':
120
216
  case 'mm':
121
217
  case 'ss':
122
- return '(\\d{2})';
218
+ return `(${digits}{2})`;
123
219
  case 'M':
124
220
  case 'd':
125
221
  case 'H':
126
222
  case 'h':
127
- return '(\\d{1,2})';
223
+ return `(${digits}{1,2})`;
128
224
  case 'a':
129
225
  return '(am|pm|AM|PM)';
130
226
  default:
@@ -238,14 +334,28 @@ class WrNativeDateAdapter extends WrDateAdapter {
238
334
  }
239
335
  return this.formatWithTokens(date, formatKeyOrString);
240
336
  }
337
+ /**
338
+ * Read back what {@link format} wrote.
339
+ *
340
+ * A named format used to delegate to `new Date(raw)`, which understands only
341
+ * Anglo-American forms — so the field could not read its own output anywhere else.
342
+ * `new Date('15.3.2026')` and `new Date('14:30')` are both `Invalid Date`, while
343
+ * `new Date('1')` is 1 January 2001: retyping a de-DE date committed the year 2001 on
344
+ * the first keystroke and then refused every one after it, and `mode="time"` collapsed
345
+ * to midnight in EVERY locale, en-US included. The named branch now builds its parser
346
+ * from the SAME `Intl.DateTimeFormat` that printed the string, so the round trip holds
347
+ * by construction rather than by luck.
348
+ *
349
+ * Returns `null` rather than a guess whenever the string does not match — the caller's
350
+ * committed value is then left exactly as it was, which is the contract
351
+ * `wr-input-number` follows for unparseable text.
352
+ */
241
353
  parse(value, formatKeyOrString) {
242
354
  const raw = value?.trim();
243
355
  if (!raw)
244
356
  return null;
245
- // Named formats fall back to `new Date()` parsing — best-effort, locale-fragile.
246
357
  if (isNamedFormat(formatKeyOrString)) {
247
- const d = new Date(raw);
248
- return this.isValid(d) ? d : null;
358
+ return this.parseNamed(raw, formatKeyOrString);
249
359
  }
250
360
  return this.parseWithTokens(raw, formatKeyOrString);
251
361
  }
@@ -277,16 +387,32 @@ class WrNativeDateAdapter extends WrDateAdapter {
277
387
  * can be this simple.
278
388
  */
279
389
  formatters = new Map();
390
+ /**
391
+ * Every `Intl` call this adapter makes pins `calendar: 'gregory'`, and that is a
392
+ * correctness fix rather than a preference.
393
+ *
394
+ * The value type is a JS `Date`, every accessor on this class (`getYear`,
395
+ * `getMonth`, `getDate`) is Gregorian, `createDate` builds a Gregorian date and the
396
+ * calendar grid draws Gregorian months. A locale whose DEFAULT calendar is something
397
+ * else — `fa-IR` resolves to `persian`, `th-TH` to `buddhist`, `ar-SA` to
398
+ * `islamic-umalqura` in several engines — printed a field the grid beside it could
399
+ * never agree with (`۱۴۰۴/۱۲/۲۴` over a grid headed 2026), and no parser can turn
400
+ * that back into the `Date` it came from. Pinning the calendar makes format and parse
401
+ * describe the same one.
402
+ */
403
+ intlOptions(options) {
404
+ return { ...options, calendar: 'gregory' };
405
+ }
280
406
  intl(key) {
281
407
  let found = this.formatters.get(key);
282
408
  if (!found) {
283
- found = new Intl.DateTimeFormat(this.locale, INTL_OPTIONS[key]);
409
+ found = new Intl.DateTimeFormat(this.locale, this.intlOptions(INTL_OPTIONS[key]));
284
410
  this.formatters.set(key, found);
285
411
  }
286
412
  return found;
287
413
  }
288
414
  getDayOfWeekNames(style) {
289
- const formatter = new Intl.DateTimeFormat(this.locale, { weekday: style });
415
+ const formatter = new Intl.DateTimeFormat(this.locale, this.intlOptions({ weekday: style }));
290
416
  const first = this.getFirstDayOfWeek();
291
417
  // Pick a reference Sunday and walk seven days.
292
418
  const base = new Date(2024, 0, 7); // Sunday 7 Jan 2024
@@ -298,15 +424,96 @@ class WrNativeDateAdapter extends WrDateAdapter {
298
424
  }
299
425
  return out;
300
426
  }
427
+ /**
428
+ * Month names in the STANDALONE (nominative) form — what a calendar heading wants.
429
+ * Inside a date the same name often declines; {@link monthNames} serves that form to
430
+ * the token formatter, and this method deliberately keeps the heading one.
431
+ */
301
432
  getMonthNames(style) {
302
- const formatter = new Intl.DateTimeFormat(this.locale, { month: style });
303
- const out = [];
304
- for (let i = 0; i < 12; i++) {
305
- out.push(formatter.format(new Date(2024, i, 15)));
433
+ return this.monthNames(style, 'standalone');
434
+ }
435
+ numeralsCache = null;
436
+ /** The locale's own ten digits, cached — empty when its system is algorithmic. */
437
+ localeNumerals() {
438
+ this.numeralsCache ??=
439
+ numeralsFor(this.locale, new Intl.NumberFormat(this.locale).resolvedOptions().numberingSystem) ?? [];
440
+ return this.numeralsCache;
441
+ }
442
+ dayPeriodCache = null;
443
+ /**
444
+ * `[am, pm]` as the LOCALE writes them — `PM`, `م`, `午後`.
445
+ *
446
+ * The `a` token used to return a hard-coded lowercase `pm`, byte for byte identical in
447
+ * every locale, which made it the one token in the pattern language that could not be
448
+ * localised at all. `Intl` is the source rather than the i18n catalog because
449
+ * `ngwr/date` deliberately depends on nothing: the panel next to it reads
450
+ * `datePicker.am` / `datePicker.pm`, so an app that wants its own wording overrides it
451
+ * there and passes a 24-hour pattern here.
452
+ */
453
+ dayPeriods() {
454
+ if (this.dayPeriodCache)
455
+ return this.dayPeriodCache;
456
+ let periods = ['AM', 'PM'];
457
+ try {
458
+ const formatter = new Intl.DateTimeFormat(this.locale, this.intlOptions({ hour: 'numeric', hour12: true }));
459
+ const read = (hour) => formatter.formatToParts(new Date(2024, 0, 15, hour)).find(p => p.type === 'dayPeriod')?.value ?? '';
460
+ const [am, pm] = [read(9), read(21)];
461
+ if (am && pm && am !== pm)
462
+ periods = [am, pm];
463
+ }
464
+ catch {
465
+ // No `Intl` day period for this locale — the English pair stays.
466
+ }
467
+ this.dayPeriodCache = periods;
468
+ return periods;
469
+ }
470
+ monthNameCache = new Map();
471
+ /**
472
+ * `context: 'format'` asks `Intl` for the month name as it appears BESIDE A DAY, which
473
+ * is where a case language stops using the nominative: ru writes `15 марта`, not
474
+ * `15 март`, and fi writes `maaliskuuta`, not `maaliskuu`. The name comes out of a
475
+ * `{ day, month }` formatter's parts, because a `{ month }` formatter alone can only
476
+ * ever answer with the standalone form.
477
+ *
478
+ * Some locales write the month NUMERICALLY in that position — fi abbreviates `15.3.`,
479
+ * ja writes `3` with `月` as a separate literal — and there the standalone name is the
480
+ * only name there is, so a purely numeric answer falls back to it. Without that guard
481
+ * `dd MMMM yyyy` would render `15 3 2026` in Japanese.
482
+ */
483
+ monthNames(style, context) {
484
+ const cacheKey = `${style}:${context}`;
485
+ const cached = this.monthNameCache.get(cacheKey);
486
+ if (cached)
487
+ return cached;
488
+ const standaloneFormatter = new Intl.DateTimeFormat(this.locale, this.intlOptions({ month: style }));
489
+ const standalone = [];
490
+ for (let i = 0; i < 12; i++)
491
+ standalone.push(standaloneFormatter.format(new Date(2024, i, 15)));
492
+ let out = standalone;
493
+ if (context === 'format') {
494
+ const numerals = numeralsFor(this.locale, standaloneFormatter.resolvedOptions().numberingSystem) ?? [];
495
+ const inDate = new Intl.DateTimeFormat(this.locale, this.intlOptions({ day: 'numeric', month: style }));
496
+ out = standalone.map((name, i) => {
497
+ const part = inDate.formatToParts(new Date(2024, i, 15)).find(p => p.type === 'month')?.value;
498
+ if (!part || isAllDigits(part, numerals))
499
+ return name;
500
+ return part;
501
+ });
306
502
  }
503
+ this.monthNameCache.set(cacheKey, out);
307
504
  return out;
308
505
  }
309
506
  // Internals: token-based format / parse
507
+ /** Whether a token pattern also writes the day, which decides the month's form. */
508
+ hasDayToken(pattern) {
509
+ let found = false;
510
+ pattern.replace(PART_RE, (part, quoted) => {
511
+ if (quoted === undefined && (part === 'd' || part === 'dd'))
512
+ found = true;
513
+ return part;
514
+ });
515
+ return found;
516
+ }
310
517
  formatWithTokens(date, pattern) {
311
518
  const year = date.getFullYear();
312
519
  const month = date.getMonth();
@@ -315,8 +522,11 @@ class WrNativeDateAdapter extends WrDateAdapter {
315
522
  const hours12 = hours24 % 12 === 0 ? 12 : hours24 % 12;
316
523
  const minutes = date.getMinutes();
317
524
  const seconds = date.getSeconds();
318
- const monthNames = this.getMonthNames('long');
319
- const monthShort = this.getMonthNames('short');
525
+ // A pattern that writes the day wants the month declined for that position — `MMMM`
526
+ // used to hand `15 март 2026` to a Russian field, which reads as machine output.
527
+ const context = this.hasDayToken(pattern) ? 'format' : 'standalone';
528
+ const monthNames = this.monthNames('long', context);
529
+ const monthShort = this.monthNames('short', context);
320
530
  return pattern.replace(PART_RE, (part, quoted) => {
321
531
  // A quoted run is emitted as-is; `''` is how a single quote is written.
322
532
  if (quoted !== undefined)
@@ -352,15 +562,19 @@ class WrNativeDateAdapter extends WrDateAdapter {
352
562
  case 'ss':
353
563
  return pad(seconds);
354
564
  case 'a':
355
- return hours24 < 12 ? 'am' : 'pm';
565
+ return this.dayPeriods()[hours24 < 12 ? 0 : 1];
356
566
  }
357
567
  return token;
358
568
  });
359
569
  }
360
570
  parseWithTokens(value, pattern) {
361
571
  const tokens = [];
362
- const monthLong = this.getMonthNames('long');
363
- const monthShort = this.getMonthNames('short');
572
+ // Both forms are accepted on the way IN: the formatter writes the declined one beside
573
+ // a day (`15 марта`), and a heading or an older stored string carries the nominative.
574
+ const monthLong = [this.monthNames('long', 'format'), this.monthNames('long', 'standalone')];
575
+ const monthShort = [this.monthNames('short', 'format'), this.monthNames('short', 'standalone')];
576
+ const numerals = this.localeNumerals();
577
+ const digits = digitClass(numerals);
364
578
  const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
365
579
  const regex = escaped.replace(PART_RE, (part, quoted) => {
366
580
  if (quoted !== undefined) {
@@ -369,83 +583,266 @@ class WrNativeDateAdapter extends WrDateAdapter {
369
583
  return quoted === '' ? "'" : quoted;
370
584
  }
371
585
  tokens.push(part);
372
- return tokenToRegex(part);
586
+ // `a` is the one token whose text is locale-dependent, so its alternation is built
587
+ // here rather than in the pure `tokenToRegex`: the marker this adapter now PRINTS
588
+ // has to be one the same adapter can read.
589
+ if (part === 'a')
590
+ return `(${alternation([...this.dayPeriods(), 'am', 'pm'])})`;
591
+ return tokenToRegex(part, digits);
373
592
  });
374
- const match = new RegExp(`^${regex}$`).exec(value);
593
+ const match = new RegExp(`^${regex}$`, 'i').exec(value);
375
594
  if (!match)
376
595
  return null;
377
- let year = new Date().getFullYear();
378
- let month = 0;
379
- let day = 1;
380
- let hours = 0;
381
- let minutes = 0;
382
- let seconds = 0;
383
- let isPm = false;
384
- let hadHour12 = false;
385
- let hour12 = 0;
596
+ const parts = {};
386
597
  for (let i = 0; i < tokens.length; i++) {
387
598
  const token = tokens[i];
388
599
  const raw = match[i + 1];
389
- const n = Number(raw);
600
+ const n = digitsToNumber(raw, numerals);
390
601
  switch (token) {
391
602
  case 'yyyy':
392
- year = n;
603
+ parts.year = n;
393
604
  break;
394
605
  case 'yy':
395
- year = 2000 + n;
606
+ parts.year = 2000 + n;
396
607
  break;
397
608
  case 'MM':
398
609
  case 'M':
399
- month = n - 1;
610
+ parts.month = n - 1;
400
611
  break;
401
612
  case 'MMM':
402
613
  case 'MMMM': {
403
- const names = token === 'MMM' ? monthShort : monthLong;
404
- const found = names.findIndex(name => name.toLowerCase() === raw.toLowerCase());
405
- if (found < 0)
614
+ const sets = token === 'MMM' ? monthShort : monthLong;
615
+ const wanted = raw.toLowerCase();
616
+ const found = sets
617
+ .map(names => names.findIndex(name => name.toLowerCase() === wanted))
618
+ .find(index => index >= 0);
619
+ if (found === undefined)
406
620
  return null;
407
- month = found;
621
+ parts.month = found;
408
622
  break;
409
623
  }
410
624
  case 'dd':
411
625
  case 'd':
412
- day = n;
626
+ parts.day = n;
413
627
  break;
414
628
  case 'HH':
415
629
  case 'H':
416
- hours = n;
630
+ parts.hours = n;
417
631
  break;
418
632
  case 'hh':
419
633
  case 'h':
420
- hadHour12 = true;
421
- hour12 = n;
634
+ parts.hour12 = n;
422
635
  break;
423
636
  case 'mm':
424
- minutes = n;
637
+ parts.minutes = n;
425
638
  break;
426
639
  case 'ss':
427
- seconds = n;
640
+ parts.seconds = n;
428
641
  break;
429
- case 'a':
430
- isPm = raw.toLowerCase() === 'pm';
642
+ case 'a': {
643
+ const marker = raw.toLowerCase();
644
+ parts.isPm = marker === 'pm' || marker === this.dayPeriods()[1].toLowerCase();
431
645
  break;
646
+ }
432
647
  }
433
648
  }
434
- if (hadHour12) {
435
- hours = (hour12 % 12) + (isPm ? 12 : 0);
649
+ return this.compose(parts);
650
+ }
651
+ /**
652
+ * Build the parser for one named format out of the very formatter that prints it.
653
+ *
654
+ * `formatToParts` is the only honest source for a locale's field ORDER and its
655
+ * literals — ru writes `dd.MM.y` with a two-digit month while its own
656
+ * `{ month: 'numeric' }` request says one digit, ja puts the year first, and ar-SA
657
+ * separates fields with U+200F. Names (a month, AM/PM) become an alternation of the
658
+ * real strings; numbers accept the locale's digits AND ASCII, because a physical
659
+ * keyboard produces the latter whatever the display shows.
660
+ *
661
+ * Returns `null` — meaning "this format cannot be read back" — rather than guessing,
662
+ * for a field type nothing here asks for (an era, a weekday) or a numbering system
663
+ * with no digit-by-digit reading (roman numerals). `parse` then refuses, which leaves
664
+ * the caller's committed value untouched.
665
+ */
666
+ buildNamedParser(key) {
667
+ const formatter = this.intl(key);
668
+ const resolved = formatter.resolvedOptions();
669
+ const numerals = numeralsFor(this.locale, resolved.numberingSystem);
670
+ if (!numerals)
671
+ return null;
672
+ const digits = digitClass(numerals);
673
+ // Distinct values in every field, so nothing below can read one field's reference
674
+ // value out of another's position.
675
+ const reference = new Date(2024, 0, 15, 13, 45, 56);
676
+ const fields = [];
677
+ const fragments = [];
678
+ for (const part of formatter.formatToParts(reference)) {
679
+ switch (part.type) {
680
+ case 'literal':
681
+ fragments.push({ source: literalToRegex(part.value), literal: true });
682
+ break;
683
+ case 'year':
684
+ fields.push({ kind: 'year' });
685
+ // Exactly two digits or exactly four — the shapes a year is actually written
686
+ // in. `{1,4}` would let the third keystroke of `15.3.2026` read as the year 2
687
+ // and commit it, which is a smaller version of the defect this replaces.
688
+ fragments.push({ source: `(${digits}{2}|${digits}{4})`, literal: false });
689
+ break;
690
+ case 'month': {
691
+ const names = this.namedMonthNames(formatter);
692
+ if (names.every(name => isAllDigits(name, numerals))) {
693
+ fields.push({ kind: 'month' });
694
+ fragments.push({ source: `(${digits}{1,2})`, literal: false });
695
+ }
696
+ else {
697
+ fields.push({ kind: 'monthName', names });
698
+ fragments.push({ source: `(${alternation(names)})`, literal: false });
699
+ }
700
+ break;
701
+ }
702
+ case 'day':
703
+ fields.push({ kind: 'day' });
704
+ fragments.push({ source: `(${digits}{1,2})`, literal: false });
705
+ break;
706
+ case 'hour':
707
+ fields.push({ kind: 'hour' });
708
+ fragments.push({ source: `(${digits}{1,2})`, literal: false });
709
+ break;
710
+ case 'minute':
711
+ fields.push({ kind: 'minute' });
712
+ fragments.push({ source: `(${digits}{1,2})`, literal: false });
713
+ break;
714
+ case 'second':
715
+ fields.push({ kind: 'second' });
716
+ fragments.push({ source: `(${digits}{1,2})`, literal: false });
717
+ break;
718
+ case 'dayPeriod': {
719
+ const names = this.dayPeriodNames(formatter);
720
+ fields.push({ kind: 'dayPeriod', names });
721
+ fragments.push({ source: `(${alternation(names)})`, literal: false });
722
+ break;
723
+ }
724
+ default:
725
+ return null;
726
+ }
727
+ }
728
+ if (!fields.length)
729
+ return null;
730
+ // A trailing literal is optional. ru's medium date ends ` г.` and fi's short date
731
+ // ends with a bare `.`; someone retyping the field types the fields and stops, and
732
+ // refusing that would reintroduce the very "cannot read its own output" defect for
733
+ // the one part of the string that carries no information.
734
+ const source = fragments
735
+ .map((fragment, i) => i === fragments.length - 1 && fragment.literal && fragment.source ? `(?:${fragment.source})?` : fragment.source)
736
+ .join('');
737
+ return { regex: new RegExp(`^${source}$`, 'iu'), fields, numerals, hourCycle: resolved.hourCycle };
738
+ }
739
+ /** The twelve month strings THIS formatter writes, in its own style and context. */
740
+ namedMonthNames(formatter) {
741
+ const out = [];
742
+ for (let i = 0; i < 12; i++) {
743
+ out.push(formatter.formatToParts(new Date(2024, i, 15)).find(p => p.type === 'month')?.value ?? '');
744
+ }
745
+ return out;
746
+ }
747
+ /** `[am, pm]` as THIS formatter writes them — `ص` / `م` in Arabic, not AM / PM. */
748
+ dayPeriodNames(formatter) {
749
+ const read = (hour) => formatter.formatToParts(new Date(2024, 0, 15, hour, 30)).find(p => p.type === 'dayPeriod')?.value ?? '';
750
+ return [read(9), read(21)];
751
+ }
752
+ namedParsers = new Map();
753
+ parseNamed(raw, key) {
754
+ let parser = this.namedParsers.get(key);
755
+ if (parser === undefined) {
756
+ parser = this.buildNamedParser(key);
757
+ this.namedParsers.set(key, parser);
436
758
  }
437
- // `new Date(2025, 12, 45)` does not fail — it rolls forward into the next year — and
438
- // `isValid` is happy with the result, so a text field turned nonsense into a confident
439
- // wrong answer. The clock parts are range-checked directly; the month and the day are
440
- // checked by comparing what came back, which catches every rollover including a month
441
- // of 12 or -1.
759
+ if (!parser)
760
+ return null;
761
+ const match = parser.regex.exec(stripBidi(raw));
762
+ if (!match)
763
+ return null;
764
+ const parts = {};
765
+ const twelveHour = parser.fields.some(field => field.kind === 'dayPeriod');
766
+ for (let i = 0; i < parser.fields.length; i++) {
767
+ const field = parser.fields[i];
768
+ const captured = match[i + 1] ?? '';
769
+ if (field.kind === 'monthName' || field.kind === 'dayPeriod') {
770
+ const wanted = stripBidi(captured).toLowerCase();
771
+ const index = (field.names ?? []).findIndex(name => stripBidi(name).toLowerCase() === wanted);
772
+ if (index < 0)
773
+ return null;
774
+ if (field.kind === 'monthName')
775
+ parts.month = index;
776
+ else
777
+ parts.isPm = index === 1;
778
+ continue;
779
+ }
780
+ const n = digitsToNumber(captured, parser.numerals);
781
+ if (Number.isNaN(n))
782
+ return null;
783
+ switch (field.kind) {
784
+ case 'year':
785
+ // Two digits mean this century, the same reading the `yy` token gives — the
786
+ // picker's own doc promises `1/5/25` cleans up to 2025.
787
+ parts.year = captured.length <= 2 ? 2000 + n : n;
788
+ break;
789
+ case 'month':
790
+ parts.month = n - 1;
791
+ break;
792
+ case 'day':
793
+ parts.day = n;
794
+ break;
795
+ case 'hour':
796
+ if (twelveHour)
797
+ parts.hour12 = n;
798
+ // `hourCycle: 'h24'` numbers midnight 24 rather than 0.
799
+ else
800
+ parts.hours = n === 24 && parser.hourCycle === 'h24' ? 0 : n;
801
+ break;
802
+ case 'minute':
803
+ parts.minutes = n;
804
+ break;
805
+ case 'second':
806
+ parts.seconds = n;
807
+ break;
808
+ }
809
+ }
810
+ return this.compose(parts);
811
+ }
812
+ /**
813
+ * Assemble whatever was read into a `Date`, or refuse.
814
+ *
815
+ * `new Date(2025, 12, 45)` does not fail — it rolls forward into the next year — and
816
+ * `isValid` is happy with the result, so a text field used to turn nonsense into a
817
+ * confident wrong answer. The clock parts are range-checked directly; the month and the
818
+ * day are checked by comparing what came back, which catches every rollover including a
819
+ * month of 12 or -1.
820
+ */
821
+ compose(parts) {
822
+ const now = new Date();
823
+ const year = parts.year ?? now.getFullYear();
824
+ const month = parts.month ?? now.getMonth();
825
+ let hours = parts.hours ?? 0;
826
+ if (parts.hour12 !== undefined)
827
+ hours = (parts.hour12 % 12) + (parts.isPm ? 12 : 0);
828
+ const minutes = parts.minutes ?? 0;
829
+ const seconds = parts.seconds ?? 0;
442
830
  if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59 || seconds < 0 || seconds > 59)
443
831
  return null;
832
+ // A time-only format carries no date, and the date it should imply is TODAY — the
833
+ // previous default was 1 January, so a picker in `mode="time"` moved the model back
834
+ // to New Year's Day as a side effect of reading a clock. The default is clamped to
835
+ // the month it lands in, because an unclamped 31st would roll a February string
836
+ // forward and the rollover check below would then refuse a well-formed string.
837
+ const monthLength = new Date(year, month + 1, 0).getDate();
838
+ const day = parts.day ?? Math.min(now.getDate(), monthLength);
444
839
  const out = new Date(year, month, day, hours, minutes, seconds);
840
+ // Same trap `createDate` documents: a year under 100 maps onto 1900 + year unless it
841
+ // is written back explicitly, so `0025-08-11` used to parse as 1925.
842
+ if (year >= 0 && year < 100)
843
+ out.setFullYear(year);
445
844
  if (!this.isValid(out))
446
845
  return null;
447
- // The day is range-checked by construction: anything the calendar rolled over comes
448
- // back as a different month or a different day.
449
846
  if (out.getMonth() !== month || out.getDate() !== day)
450
847
  return null;
451
848
  return out;
@@ -467,12 +864,16 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.4", ngImpor
467
864
  * Register the date adapter and locale used by the calendar / date picker /
468
865
  * time picker. Call once at bootstrap.
469
866
  *
867
+ * Omit `locale` and dates follow Angular's `LOCALE_ID`, which is what the app
868
+ * already says it is. Pass one only to pin dates to something else — see
869
+ * {@link WR_DATE_LOCALE}, and `WrI18n` for how the three locale inputs rank.
870
+ *
470
871
  * @example
471
872
  * ```ts
472
873
  * bootstrapApplication(AppComponent, {
473
874
  * providers: [
474
- * provideWrDateAdapter(), // native, browser locale
475
- * // provideWrDateAdapter({ locale: 'ru-RU' }),
875
+ * provideWrDateAdapter(), // native adapter, LOCALE_ID
876
+ * // provideWrDateAdapter({ locale: 'ru-RU' }), // pin the dates
476
877
  * // provideWrDateAdapter({ adapter: MyDateFnsAdapter, locale: 'fr' }),
477
878
  * ],
478
879
  * });