@nanahoshi/mona-ui 0.23.1 → 0.23.2

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.
@@ -1,5 +1,5 @@
1
1
  import * as i0 from '@angular/core';
2
- import { InjectionToken, inject, signal, computed, Injectable, ElementRef, DestroyRef, makeEnvironmentProviders } from '@angular/core';
2
+ import { InjectionToken, inject, signal, computed, Injectable, isDevMode, ElementRef, DestroyRef, makeEnvironmentProviders } from '@angular/core';
3
3
  import { Directionality } from '@angular/cdk/bidi';
4
4
  import { DateTime } from 'luxon';
5
5
 
@@ -13,6 +13,45 @@ const MONA_I18N_CONFIG = new InjectionToken("MONA_I18N_CONFIG", {
13
13
  factory: () => ({})
14
14
  });
15
15
 
16
+ /**
17
+ * Locale id used whenever a supplied id cannot be turned into a valid BCP 47 tag.
18
+ * Matches the id of `MONA_DEFAULT_LOCALE`.
19
+ */
20
+ const MONA_FALLBACK_LOCALE_ID = "en-US";
21
+ const canonicalLocaleCache = new Map();
22
+ /**
23
+ * Converts a locale id to a canonical BCP 47 tag, or returns `null` when it is not a valid tag.
24
+ *
25
+ * POSIX/Java style ids are accepted: `en_US` becomes `en-US`, and an encoding or modifier suffix
26
+ * (`de_DE.UTF-8`, `de_DE@euro`) is dropped.
27
+ */
28
+ function tryCanonicalizeLocaleId(localeId) {
29
+ const raw = (localeId ?? "").trim();
30
+ if (!raw) {
31
+ return null;
32
+ }
33
+ if (canonicalLocaleCache.has(raw)) {
34
+ return canonicalLocaleCache.get(raw) ?? null;
35
+ }
36
+ let canonical = null;
37
+ try {
38
+ const tag = raw.replace(/[.@].*$/, "").replace(/_/g, "-");
39
+ canonical = Intl.getCanonicalLocales(tag)[0] ?? null;
40
+ }
41
+ catch {
42
+ canonical = null;
43
+ }
44
+ canonicalLocaleCache.set(raw, canonical);
45
+ return canonical;
46
+ }
47
+ /**
48
+ * Converts a locale id to a canonical BCP 47 tag. Ids that are empty or not valid tags resolve to
49
+ * {@link MONA_FALLBACK_LOCALE_ID}, so every formatter receives the same, valid locale.
50
+ */
51
+ function canonicalizeLocaleId(localeId) {
52
+ return tryCanonicalizeLocaleId(localeId) ?? MONA_FALLBACK_LOCALE_ID;
53
+ }
54
+
16
55
  function isPlainObject(value) {
17
56
  return value !== null && typeof value === "object" && !Array.isArray(value);
18
57
  }
@@ -43,6 +82,9 @@ function mergeMessages(fallback, localeMessages, overrideMessages) {
43
82
  }
44
83
 
45
84
  class MonaI18nService {
85
+ #config;
86
+ #locale;
87
+ #overrides;
46
88
  constructor() {
47
89
  this.#config = inject(MONA_I18N_CONFIG);
48
90
  this.#locale = signal(this.#config.locale ?? MONA_DEFAULT_LOCALE, /* @ts-ignore */
@@ -52,12 +94,14 @@ class MonaI18nService {
52
94
  this.direction = computed(() => this.#locale().direction, /* @ts-ignore */
53
95
  ...(ngDevMode ? [{ debugName: "direction" }] : /* istanbul ignore next */ []));
54
96
  this.locale = this.#locale.asReadonly();
55
- this.localeId = computed(() => this.#locale().id, /* @ts-ignore */
97
+ /**
98
+ * Canonical BCP 47 id of the active locale. POSIX ids such as `en_US` are converted to `en-US`,
99
+ * and invalid ids resolve to `MONA_FALLBACK_LOCALE_ID` so formatters always receive a valid tag.
100
+ */
101
+ this.localeId = computed(() => canonicalizeLocaleId(this.#locale().id), /* @ts-ignore */
56
102
  ...(ngDevMode ? [{ debugName: "localeId" }] : /* istanbul ignore next */ []));
103
+ warnOnInvalidLocaleId(this.#locale().id);
57
104
  }
58
- #config;
59
- #locale;
60
- #overrides;
61
105
  clearMessages() {
62
106
  this.#overrides.set({});
63
107
  }
@@ -75,6 +119,7 @@ class MonaI18nService {
75
119
  this.#overrides.set(messages);
76
120
  }
77
121
  use(locale) {
122
+ warnOnInvalidLocaleId(locale.id);
78
123
  this.#locale.set(locale);
79
124
  }
80
125
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.6", ngImport: i0, type: MonaI18nService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
@@ -85,7 +130,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.6", ngImpor
85
130
  args: [{
86
131
  providedIn: "root"
87
132
  }]
88
- }] });
133
+ }], ctorParameters: () => [] });
134
+ function warnOnInvalidLocaleId(localeId) {
135
+ if (isDevMode() && tryCanonicalizeLocaleId(localeId) == null) {
136
+ console.warn(`[mona-ui] "${localeId}" is not a valid BCP 47 locale id. Falling back to "${MONA_FALLBACK_LOCALE_ID}".`);
137
+ }
138
+ }
89
139
 
90
140
  /**
91
141
  * Reads semantic document direction for a connected browser element using `:dir(rtl)` and `:dir(ltr)`.
@@ -366,6 +416,12 @@ function provideMonaI18n(config = {}) {
366
416
  ]);
367
417
  }
368
418
 
419
+ /** Full-width forms of printable ASCII ("!" to "~"), offset from ASCII by U+FEE0. */
420
+ const FULLWIDTH_ASCII_START = 0xff01;
421
+ const FULLWIDTH_ASCII_END = 0xff5e;
422
+ const FULLWIDTH_ASCII_OFFSET = 0xfee0;
423
+ const DECIMAL_DIGIT_REGEX = /^\p{Nd}$/u;
424
+ const asciiDigitCache = new Map();
369
425
  const numberSymbolsCache = new Map();
370
426
  const numberFormatterCache = new Map();
371
427
  const numberGroupingCache = new Map();
@@ -376,7 +432,8 @@ function serializeOptions(options) {
376
432
  const keys = Object.keys(options).sort();
377
433
  return keys.map(k => `${k}:${String(options[k])}`).join(";");
378
434
  }
379
- function getNumberGroupingPattern(localeId) {
435
+ function getNumberGroupingPattern(locale) {
436
+ const localeId = canonicalizeLocaleId(locale);
380
437
  let pattern = numberGroupingCache.get(localeId);
381
438
  if (!pattern) {
382
439
  try {
@@ -439,7 +496,8 @@ function getNumberGroupingPattern(localeId) {
439
496
  }
440
497
  return pattern;
441
498
  }
442
- function getNumberSymbols(localeId) {
499
+ function getNumberSymbols(locale) {
500
+ const localeId = canonicalizeLocaleId(locale);
443
501
  let symbols = numberSymbolsCache.get(localeId);
444
502
  if (!symbols) {
445
503
  const digitMap = new Map();
@@ -482,7 +540,7 @@ function getNumberSymbols(localeId) {
482
540
  return symbols;
483
541
  }
484
542
  function getNumberFormatter(locale, options) {
485
- const cleanLocale = (locale ?? "").trim() || "en-US";
543
+ const cleanLocale = canonicalizeLocaleId(locale);
486
544
  const key = `${cleanLocale}:${serializeOptions(options)}`;
487
545
  let formatter = numberFormatterCache.get(key);
488
546
  if (!formatter) {
@@ -499,6 +557,30 @@ function getNumberFormatter(locale, options) {
499
557
  function formatNumber(value, locale, options) {
500
558
  return getNumberFormatter(locale, options).format(value);
501
559
  }
560
+ /**
561
+ * ASCII value of a Unicode decimal digit (\p{Nd}), or null for any other character. Unicode encodes every
562
+ * decimal digit set as a contiguous 0-9 run, so the value is the distance from the start of the run.
563
+ */
564
+ function toAsciiDigit(char) {
565
+ let digit = asciiDigitCache.get(char);
566
+ if (digit === undefined) {
567
+ digit = null;
568
+ if (DECIMAL_DIGIT_REGEX.test(char)) {
569
+ const code = char.codePointAt(0);
570
+ let runStart = code;
571
+ while (DECIMAL_DIGIT_REGEX.test(String.fromCodePoint(runStart - 1))) {
572
+ runStart--;
573
+ }
574
+ digit = String((code - runStart) % 10);
575
+ }
576
+ asciiDigitCache.set(char, digit);
577
+ }
578
+ return digit;
579
+ }
580
+ /**
581
+ * Converts digits of any script to ASCII digits. Full-width forms typed by CJK input methods
582
+ * ("12.5", "-3", "1,000") are folded to ASCII first.
583
+ */
502
584
  function normalizeLocalizedDigits(text, localeId) {
503
585
  if (!text) {
504
586
  return text;
@@ -506,27 +588,11 @@ function normalizeLocalizedDigits(text, localeId) {
506
588
  const symbols = localeId ? getNumberSymbols(localeId) : null;
507
589
  let result = "";
508
590
  for (const char of text) {
509
- if (symbols?.digits.has(char)) {
510
- result += symbols.digits.get(char);
511
- }
512
- else {
513
- const code = char.charCodeAt(0);
514
- if (code >= 0x0660 && code <= 0x0669) {
515
- result += String(code - 0x0660);
516
- }
517
- else if (code >= 0x06f0 && code <= 0x06f9) {
518
- result += String(code - 0x06f0);
519
- }
520
- else if (code >= 0x09e6 && code <= 0x09ef) {
521
- result += String(code - 0x09e6);
522
- }
523
- else if (code >= 0x0966 && code <= 0x096f) {
524
- result += String(code - 0x0966);
525
- }
526
- else {
527
- result += char;
528
- }
529
- }
591
+ const code = char.codePointAt(0);
592
+ const folded = code >= FULLWIDTH_ASCII_START && code <= FULLWIDTH_ASCII_END
593
+ ? String.fromCharCode(code - FULLWIDTH_ASCII_OFFSET)
594
+ : char;
595
+ result += symbols?.digits.get(folded) ?? toAsciiDigit(folded) ?? folded;
530
596
  }
531
597
  return result;
532
598
  }
@@ -548,6 +614,21 @@ function normalizeLocalizedInput(text, localeId) {
548
614
  cleaned = normalizeLocalizedDigits(cleaned, localeId);
549
615
  return cleaned;
550
616
  }
617
+ /**
618
+ * Decimal separators accepted while editing: the locale's own, plus "." and "," so a separator typed on a
619
+ * keyboard layout that does not match the locale still works. An alternate is dropped when it is the locale's
620
+ * group separator; otherwise "1,234" typed in en-US (or "1.234" in de-DE) would be read as 1.234.
621
+ */
622
+ function getEditDecimalSeparators(symbols) {
623
+ const primary = symbols.decimal || ".";
624
+ const separators = new Set([primary]);
625
+ for (const alternate of [".", ","]) {
626
+ if (alternate !== symbols.group) {
627
+ separators.add(alternate);
628
+ }
629
+ }
630
+ return separators;
631
+ }
551
632
  function parseLocalizedNumber(text, localeId, options) {
552
633
  if (text == null) {
553
634
  return null;
@@ -699,27 +780,7 @@ function parseLocalizedNumber(text, localeId, options) {
699
780
  if (/[\s\u00A0\u202F]/.test(cleaned)) {
700
781
  return null;
701
782
  }
702
- const primaryDec = symbols.decimal || ".";
703
- const candidateSeparators = new Set();
704
- candidateSeparators.add(primaryDec);
705
- if (primaryDec === ".") {
706
- candidateSeparators.add(",");
707
- }
708
- else if (primaryDec === ",") {
709
- candidateSeparators.add(".");
710
- }
711
- else {
712
- candidateSeparators.add(".");
713
- candidateSeparators.add(",");
714
- }
715
- if (primaryDec === "\u066B") {
716
- candidateSeparators.add(".");
717
- candidateSeparators.add(",");
718
- }
719
- if (primaryDec === "/") {
720
- candidateSeparators.add(".");
721
- candidateSeparators.add(",");
722
- }
783
+ const candidateSeparators = getEditDecimalSeparators(symbols);
723
784
  let foundSep = null;
724
785
  let sepIndex = -1;
725
786
  for (let i = 0; i < cleaned.length; i++) {
@@ -878,25 +939,7 @@ function validateLocalizedNumberEdit(text, localeId, options) {
878
939
  if (normalized === "") {
879
940
  return { valid: true, complete: false, value: null };
880
941
  }
881
- // Identify allowed decimal separators
882
- const primaryDec = symbols.decimal || ".";
883
- // Candidate separators: primary decimal, plus alternate '.' or ','
884
- const candidateSeparators = new Set();
885
- candidateSeparators.add(primaryDec);
886
- if (primaryDec === ".") {
887
- candidateSeparators.add(",");
888
- }
889
- else if (primaryDec === ",") {
890
- candidateSeparators.add(".");
891
- }
892
- else {
893
- candidateSeparators.add(".");
894
- candidateSeparators.add(",");
895
- }
896
- if (primaryDec === "\u066B") {
897
- candidateSeparators.add(".");
898
- candidateSeparators.add(",");
899
- }
942
+ const candidateSeparators = getEditDecimalSeparators(symbols);
900
943
  // Count how many separators from the candidate set appear
901
944
  let foundSep = null;
902
945
  let sepIndex = -1;
@@ -957,22 +1000,20 @@ function validateLocalizedNumberEdit(text, localeId, options) {
957
1000
  /**
958
1001
  * Normalizes a locale string to ensure it uses the Gregorian calendar system
959
1002
  * and doesn't contain a conflicting -u-ca-* extension.
1003
+ * Empty or malformed ids resolve to `MONA_FALLBACK_LOCALE_ID` (en-US).
960
1004
  */
961
1005
  function normalizeGregorianLocale(localeId) {
962
- const trimmed = (localeId ?? "").trim();
963
- if (!trimmed) {
964
- return trimmed;
965
- }
1006
+ const canonical = canonicalizeLocaleId(localeId);
966
1007
  try {
967
1008
  if (typeof Intl !== "undefined" && typeof Intl.Locale === "function") {
968
- const loc = new Intl.Locale(trimmed, { calendar: "gregory" });
1009
+ const loc = new Intl.Locale(canonical, { calendar: "gregory" });
969
1010
  return loc.toString();
970
1011
  }
971
1012
  }
972
1013
  catch {
973
- // Fallback safely if localeId is malformed
1014
+ // Canonical tags are always accepted; keep the canonical id if Intl.Locale still refuses it
974
1015
  }
975
- return trimmed;
1016
+ return canonical;
976
1017
  }
977
1018
  /**
978
1019
  * Creates a Luxon DateTime from a Date instance, reconfigured to the specified locale
@@ -1023,17 +1064,29 @@ function stripBidiControls(value) {
1023
1064
  function sanitizeDateText(value) {
1024
1065
  return stripBidiControls(value).replace(/[\u00A0\u2009\u202F]/g, " ");
1025
1066
  }
1067
+ /**
1068
+ * Luxon throws for formats that mix incompatible fields (e.g. ordinal and month/day tokens). Callers treat
1069
+ * such input as unparsable, so the error becomes an invalid DateTime.
1070
+ */
1071
+ function fromFormatSafe(text, format, options) {
1072
+ try {
1073
+ return DateTime.fromFormat(text, format, options);
1074
+ }
1075
+ catch (error) {
1076
+ return DateTime.invalid("unparsable", error instanceof Error ? error.message : String(error));
1077
+ }
1078
+ }
1026
1079
  /**
1027
1080
  * Parses a date string using the specified format and locale, forcing Gregorian calendar.
1028
1081
  * Localized digits are normalized first to support locales that use native numbering systems.
1029
1082
  */
1030
1083
  function parseGregorianDate(text, format, localeId) {
1031
1084
  const locale = normalizeGregorianLocale(localeId);
1032
- let dt = DateTime.fromFormat(text, format, { locale, outputCalendar: "gregory" });
1085
+ let dt = fromFormatSafe(text, format, { locale, outputCalendar: "gregory" });
1033
1086
  if (!dt.isValid) {
1034
1087
  const sanitizedText = sanitizeDateText(text);
1035
1088
  const sanitizedFormat = sanitizeDateText(format);
1036
- dt = DateTime.fromFormat(sanitizedText, sanitizedFormat, { locale, outputCalendar: "gregory" });
1089
+ dt = fromFormatSafe(sanitizedText, sanitizedFormat, { locale, outputCalendar: "gregory" });
1037
1090
  if (!dt.isValid) {
1038
1091
  let normalizedText = normalizeLocalizedDigits(sanitizedText, locale);
1039
1092
  let latnLocale = locale;
@@ -1045,20 +1098,20 @@ function parseGregorianDate(text, format, localeId) {
1045
1098
  catch {
1046
1099
  // fallback
1047
1100
  }
1048
- dt = DateTime.fromFormat(normalizedText, sanitizedFormat, { locale: latnLocale, outputCalendar: "gregory" });
1101
+ dt = fromFormatSafe(normalizedText, sanitizedFormat, { locale: latnLocale, outputCalendar: "gregory" });
1049
1102
  if (!dt.isValid) {
1050
- dt = DateTime.fromFormat(normalizedText, sanitizedFormat, { locale, outputCalendar: "gregory" });
1103
+ dt = fromFormatSafe(normalizedText, sanitizedFormat, { locale, outputCalendar: "gregory" });
1051
1104
  }
1052
1105
  if (!dt.isValid) {
1053
1106
  const effectiveLocale = latnLocale !== locale ? latnLocale : locale;
1054
1107
  if (sanitizedFormat.includes(". ") && !normalizedText.includes(". ")) {
1055
1108
  const textWithDotSpaces = normalizedText.replace(/\.(?!\s|$)/g, ". ");
1056
- dt = DateTime.fromFormat(textWithDotSpaces, sanitizedFormat, {
1109
+ dt = fromFormatSafe(textWithDotSpaces, sanitizedFormat, {
1057
1110
  locale: effectiveLocale,
1058
1111
  outputCalendar: "gregory"
1059
1112
  });
1060
1113
  if (!dt.isValid && effectiveLocale !== locale) {
1061
- dt = DateTime.fromFormat(textWithDotSpaces, sanitizedFormat, {
1114
+ dt = fromFormatSafe(textWithDotSpaces, sanitizedFormat, {
1062
1115
  locale,
1063
1116
  outputCalendar: "gregory"
1064
1117
  });
@@ -1071,12 +1124,12 @@ function parseGregorianDate(text, format, localeId) {
1071
1124
  if (sanitizedFormat.includes("dd. ") &&
1072
1125
  /(\b\d{4}\.\s*\d{1,2}\.\s*\d{1,2})\.?\s+/.test(normalizedText)) {
1073
1126
  const withDotBeforeTime = normalizedText.replace(/(\b\d{4}\.\s*\d{1,2}\.\s*\d{1,2})\.?\s+/, (_match, datePart) => `${datePart}. `);
1074
- dt = DateTime.fromFormat(withDotBeforeTime, sanitizedFormat, {
1127
+ dt = fromFormatSafe(withDotBeforeTime, sanitizedFormat, {
1075
1128
  locale: effectiveLocale,
1076
1129
  outputCalendar: "gregory"
1077
1130
  });
1078
1131
  if (!dt.isValid && effectiveLocale !== locale) {
1079
- dt = DateTime.fromFormat(withDotBeforeTime, sanitizedFormat, {
1132
+ dt = fromFormatSafe(withDotBeforeTime, sanitizedFormat, {
1080
1133
  locale,
1081
1134
  outputCalendar: "gregory"
1082
1135
  });
@@ -1089,12 +1142,12 @@ function parseGregorianDate(text, format, localeId) {
1089
1142
  const trimmedText = normalizedText.trim();
1090
1143
  if (trimmedFormat.endsWith(".")) {
1091
1144
  const textWithDot = trimmedText.endsWith(".") ? trimmedText : trimmedText + ".";
1092
- dt = DateTime.fromFormat(textWithDot, trimmedFormat, {
1145
+ dt = fromFormatSafe(textWithDot, trimmedFormat, {
1093
1146
  locale: effectiveLocale,
1094
1147
  outputCalendar: "gregory"
1095
1148
  });
1096
1149
  if (!dt.isValid && effectiveLocale !== locale) {
1097
- dt = DateTime.fromFormat(textWithDot, trimmedFormat, {
1150
+ dt = fromFormatSafe(textWithDot, trimmedFormat, {
1098
1151
  locale,
1099
1152
  outputCalendar: "gregory"
1100
1153
  });
@@ -4560,8 +4613,15 @@ const dateFormatCache = new Map();
4560
4613
  const timeFormatCache = new Map();
4561
4614
  const dateTimeFormatCache = new Map();
4562
4615
  const firstDayCache = new Map();
4616
+ /**
4617
+ * Turns an Intl literal part into Luxon format text. ASCII letters would otherwise be read as format tokens
4618
+ * (the "h" in fr-CA "21 h 30", the "klo" in fi-FI "15.09.2026 klo 21.30"), so letter runs are quoted and
4619
+ * apostrophes are doubled.
4620
+ */
4563
4621
  function normalizeEditableLiteral(value) {
4564
- return sanitizeDateText(value);
4622
+ return sanitizeDateText(value)
4623
+ .replace(/'/g, "''")
4624
+ .replace(/[A-Za-z]+/g, run => `'${run}'`);
4565
4625
  }
4566
4626
  /**
4567
4627
  * Derives a localized numeric date format (e.g. "yyyy/MM/dd" or "dd/MM/yyyy") for the given locale.
@@ -4721,7 +4781,7 @@ function getLocaleDateTimeInputFormat(localeId, options) {
4721
4781
  * Intl.Locale.getWeekInfo(), or CLDR fallbacks.
4722
4782
  */
4723
4783
  function getLocaleFirstDayOfWeek(localeId) {
4724
- const normalizedLocaleId = (localeId ?? "").trim();
4784
+ const normalizedLocaleId = canonicalizeLocaleId(localeId);
4725
4785
  let firstDay = firstDayCache.get(normalizedLocaleId);
4726
4786
  if (!firstDay) {
4727
4787
  firstDay = resolveLocaleFirstDayOfWeek(normalizedLocaleId);
@@ -4870,5 +4930,5 @@ function generatePseudoLocale(catalogs, options) {
4870
4930
  * Generated bundle index. Do not edit.
4871
4931
  */
4872
4932
 
4873
- export { BIDI_CONTROL_REGEX, MONA_DEFAULT_LOCALE, MONA_I18N_CONFIG, MonaDirectionService, MonaI18nService, formatGregorianDateToLocaleString, formatNumber, generatePseudoLocale, getLocaleDateInputFormat, getLocaleDateTimeInputFormat, getLocaleFirstDayOfWeek, getLocaleTimeInputFormat, getNumberFormatter, getNumberGroupingPattern, getNumberSymbols, gregorianDateTime, gregorianDateTimeFromObject, injectComponentDirection, mergeMessages, mergeTwo, normalizeGregorianLocale, normalizeLocalizedDigits, normalizeLocalizedInput, normalizeLocalizedMinus, observeComponentDirection, parseGregorianDate, parseLocalizedNumber, provideMonaI18n, pseudoLocalize, pseudoLocalizeCatalog, resolveComponentDirection, sanitizeDateText, stripBidiControls, validateLocalizedNumber, validateLocalizedNumberEdit };
4933
+ export { BIDI_CONTROL_REGEX, MONA_DEFAULT_LOCALE, MONA_I18N_CONFIG, MonaDirectionService, MonaI18nService, formatGregorianDateToLocaleString, formatNumber, generatePseudoLocale, getEditDecimalSeparators, getLocaleDateInputFormat, getLocaleDateTimeInputFormat, getLocaleFirstDayOfWeek, getLocaleTimeInputFormat, getNumberFormatter, getNumberGroupingPattern, getNumberSymbols, gregorianDateTime, gregorianDateTimeFromObject, injectComponentDirection, mergeMessages, mergeTwo, normalizeGregorianLocale, normalizeLocalizedDigits, normalizeLocalizedInput, normalizeLocalizedMinus, observeComponentDirection, parseGregorianDate, parseLocalizedNumber, provideMonaI18n, pseudoLocalize, pseudoLocalizeCatalog, resolveComponentDirection, sanitizeDateText, stripBidiControls, validateLocalizedNumber, validateLocalizedNumberEdit };
4874
4934
  //# sourceMappingURL=nanahoshi-mona-ui-i18n.mjs.map
@@ -10,7 +10,7 @@ import { ButtonDirective } from '@nanahoshi/mona-ui/button';
10
10
  import { themeControlSurfaceClasses, rxTimeout } from '@nanahoshi/mona-ui/internal';
11
11
  import { Subject, filter, switchMap, of, tap, delay, concatMap, timer, takeUntil, distinctUntilChanged, map } from 'rxjs';
12
12
  import { twMerge } from 'tailwind-merge';
13
- import { MonaI18nService, formatNumber, validateLocalizedNumber, validateLocalizedNumberEdit } from '@nanahoshi/mona-ui/i18n';
13
+ import { MonaI18nService, formatNumber, validateLocalizedNumber, getNumberSymbols, validateLocalizedNumberEdit } from '@nanahoshi/mona-ui/i18n';
14
14
  import { TextBoxDirective } from '@nanahoshi/mona-ui/text-box';
15
15
  import { cva } from 'class-variance-authority';
16
16
 
@@ -521,6 +521,28 @@ class NumericTextBoxComponent {
521
521
  }
522
522
  this.#pendingPaste = true;
523
523
  }
524
+ /**
525
+ * The numpad decimal key types "." or "," depending on the keyboard layout, not the locale. Insert the
526
+ * locale's decimal separator instead, so a de-DE layout used with an en-US locale still types a decimal.
527
+ */
528
+ insertLocaleDecimal(event) {
529
+ const localeId = this.#i18n.localeId();
530
+ const decimal = getNumberSymbols(localeId).decimal;
531
+ if (event.key.length !== 1 || event.key === decimal || event.ctrlKey || event.altKey || event.metaKey) {
532
+ return;
533
+ }
534
+ event.preventDefault();
535
+ const inputElement = this.valueTextBoxRef().nativeElement;
536
+ const { value, selectionStart, selectionEnd } = inputElement;
537
+ const start = selectionStart ?? value.length;
538
+ const end = selectionEnd ?? value.length;
539
+ const proposedValue = value.slice(0, start) + decimal + value.slice(end);
540
+ if (!validateLocalizedNumberEdit(proposedValue, localeId, { decimals: this.decimals() }).valid) {
541
+ return;
542
+ }
543
+ inputElement.setRangeText(decimal, start, end, "end");
544
+ inputElement.dispatchEvent(new Event("input", { bubbles: true }));
545
+ }
524
546
  parseValue(value) {
525
547
  const raw = value == null ? "" : value;
526
548
  this.rawInputValue.set(raw);
@@ -611,6 +633,10 @@ class NumericTextBoxComponent {
611
633
  if (event.key === "ArrowDown") {
612
634
  event.preventDefault();
613
635
  this.decrease();
636
+ return;
637
+ }
638
+ if (event.code === "NumpadDecimal") {
639
+ this.insertLocaleDecimal(event);
614
640
  }
615
641
  });
616
642
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanahoshi/mona-ui",
3
- "version": "0.23.1",
3
+ "version": "0.23.2",
4
4
  "description": "A very light Angular UI framework",
5
5
  "author": "Nanahoshi",
6
6
  "repository": {
@@ -493,7 +493,12 @@ declare class MonaI18nService {
493
493
  #private;
494
494
  readonly direction: Signal<_nanahoshi_mona_ui_i18n.MonaTextDirection>;
495
495
  readonly locale: Signal<MonaLocale>;
496
+ /**
497
+ * Canonical BCP 47 id of the active locale. POSIX ids such as `en_US` are converted to `en-US`,
498
+ * and invalid ids resolve to `MONA_FALLBACK_LOCALE_ID` so formatters always receive a valid tag.
499
+ */
496
500
  readonly localeId: Signal<string>;
501
+ constructor();
497
502
  clearMessages(): void;
498
503
  componentMessages<K extends keyof MonaLocaleMessages>(namespace: K, fallback: MonaLocaleMessages[K]): Signal<MonaLocaleMessages[K]>;
499
504
  patchMessages(messages: DeepPartial<MonaLocaleMessages>): void;
@@ -537,13 +542,23 @@ interface NumberGroupingPattern {
537
542
  readonly groupSeparator: string;
538
543
  readonly minimumGroupedIntegerDigits: number;
539
544
  }
540
- declare function getNumberGroupingPattern(localeId: string): NumberGroupingPattern;
541
- declare function getNumberSymbols(localeId: string): NumberSymbols;
545
+ declare function getNumberGroupingPattern(locale: string): NumberGroupingPattern;
546
+ declare function getNumberSymbols(locale: string): NumberSymbols;
542
547
  declare function getNumberFormatter(locale: string, options?: Intl.NumberFormatOptions): Intl.NumberFormat;
543
548
  declare function formatNumber(value: number, locale: string, options?: Intl.NumberFormatOptions): string;
549
+ /**
550
+ * Converts digits of any script to ASCII digits. Full-width forms typed by CJK input methods
551
+ * ("12.5", "-3", "1,000") are folded to ASCII first.
552
+ */
544
553
  declare function normalizeLocalizedDigits(text: string, localeId?: string): string;
545
554
  declare function normalizeLocalizedMinus(text: string, symbols?: NumberSymbols): string;
546
555
  declare function normalizeLocalizedInput(text: string, localeId: string): string;
556
+ /**
557
+ * Decimal separators accepted while editing: the locale's own, plus "." and "," so a separator typed on a
558
+ * keyboard layout that does not match the locale still works. An alternate is dropped when it is the locale's
559
+ * group separator; otherwise "1,234" typed in en-US (or "1.234" in de-DE) would be read as 1.234.
560
+ */
561
+ declare function getEditDecimalSeparators(symbols: NumberSymbols): ReadonlySet<string>;
547
562
  type LocalizedNumberParseMode = "edit" | "locale";
548
563
  interface ParseLocalizedNumberOptions {
549
564
  /**
@@ -551,7 +566,7 @@ interface ParseLocalizedNumberOptions {
551
566
  * Only the locale's canonical decimal separator is treated as decimal, and only
552
567
  * canonical grouping separators are treated as grouping.
553
568
  * When "edit", allows alternate decimal separators (such as '.' in comma-decimal locales)
554
- * during interactive input entry.
569
+ * during interactive input entry, unless the alternate is the locale's group separator.
555
570
  */
556
571
  mode?: LocalizedNumberParseMode;
557
572
  /**
@@ -583,6 +598,7 @@ declare function validateLocalizedNumberEdit(text: string | null | undefined, lo
583
598
  /**
584
599
  * Normalizes a locale string to ensure it uses the Gregorian calendar system
585
600
  * and doesn't contain a conflicting -u-ca-* extension.
601
+ * Empty or malformed ids resolve to `MONA_FALLBACK_LOCALE_ID` (en-US).
586
602
  */
587
603
  declare function normalizeGregorianLocale(localeId: string): string;
588
604
  /**
@@ -678,5 +694,5 @@ declare function pseudoLocalizeCatalog<T extends object>(catalog: T, options?: P
678
694
  */
679
695
  declare function generatePseudoLocale(catalogs: DeepPartial<MonaLocaleMessages>, options?: GeneratePseudoLocaleOptions): MonaLocale;
680
696
 
681
- export { BIDI_CONTROL_REGEX, MONA_DEFAULT_LOCALE, MONA_I18N_CONFIG, MonaDirectionService, MonaI18nService, formatGregorianDateToLocaleString, formatNumber, generatePseudoLocale, getLocaleDateInputFormat, getLocaleDateTimeInputFormat, getLocaleFirstDayOfWeek, getLocaleTimeInputFormat, getNumberFormatter, getNumberGroupingPattern, getNumberSymbols, gregorianDateTime, gregorianDateTimeFromObject, injectComponentDirection, mergeMessages, mergeTwo, normalizeGregorianLocale, normalizeLocalizedDigits, normalizeLocalizedInput, normalizeLocalizedMinus, observeComponentDirection, parseGregorianDate, parseLocalizedNumber, provideMonaI18n, pseudoLocalize, pseudoLocalizeCatalog, resolveComponentDirection, sanitizeDateText, stripBidiControls, validateLocalizedNumber, validateLocalizedNumberEdit };
697
+ export { BIDI_CONTROL_REGEX, MONA_DEFAULT_LOCALE, MONA_I18N_CONFIG, MonaDirectionService, MonaI18nService, formatGregorianDateToLocaleString, formatNumber, generatePseudoLocale, getEditDecimalSeparators, getLocaleDateInputFormat, getLocaleDateTimeInputFormat, getLocaleFirstDayOfWeek, getLocaleTimeInputFormat, getNumberFormatter, getNumberGroupingPattern, getNumberSymbols, gregorianDateTime, gregorianDateTimeFromObject, injectComponentDirection, mergeMessages, mergeTwo, normalizeGregorianLocale, normalizeLocalizedDigits, normalizeLocalizedInput, normalizeLocalizedMinus, observeComponentDirection, parseGregorianDate, parseLocalizedNumber, provideMonaI18n, pseudoLocalize, pseudoLocalizeCatalog, resolveComponentDirection, sanitizeDateText, stripBidiControls, validateLocalizedNumber, validateLocalizedNumberEdit };
682
698
  export type { CalendarMessages, DatePickerMessages, DateTimePickerMessages, DeepPartial, DialogMessages, DirectionObserverHandle, FilterMessages, GeneratePseudoLocaleOptions, LocaleFirstDayOfWeek, LocaleTimeFormatOptions, LocalizedNumberEditValidationResult, LocalizedNumberParseMode, LocalizedNumberValidationResult, MonaAutoCompleteMessages, MonaBreadcrumbMessages, MonaButtonGroupMessages, MonaCalendarMessages, MonaCardMessages, MonaChartMessages, MonaChipMessages, MonaColorGradientMessages, MonaColorPaletteMessages, MonaColorPickerMessages, MonaComboBoxMessages, MonaDatePickerMessages, MonaDateTimePickerMessages, MonaDialogMessages, MonaDropdownListMessages, MonaDropdownsMessages, MonaEditorMessages, MonaFilterMessages, MonaGridMessages, MonaI18nConfig, MonaListBoxMessages, MonaListMessages, MonaLocale, MonaLocaleMessages, MonaMessageNamespace, MonaMultiSelectMessages, MonaNotificationMessages, MonaNumericTextBoxMessages, MonaOtpInputMessages, MonaPagerMessages, MonaRatingMessages, MonaScrollViewMessages, MonaSheetMessages, MonaSliderMessages, MonaSpinnerMessages, MonaSplitButtonMessages, MonaSplitterMessages, MonaStepperMessages, MonaTabsMessages, MonaTextBoxMessages, MonaTextDirection, MonaTimePickerMessages, MonaTimeSelectorMessages, MonaTreeViewMessages, MonaWindowMessages, NotificationMessages, NumberGroupingPattern, NumberSymbols, ParseLocalizedNumberOptions, PseudoLocalizationOptions, SheetMessages, TimePickerMessages, TimeSelectorMessages, ValidateLocalizedNumberEditOptions, WindowMessages };
@@ -146,6 +146,11 @@ declare class NumericTextBoxComponent implements NumericTextboxVariantInputs, Fo
146
146
  private formatEditValue;
147
147
  private formatValueForDisplay;
148
148
  protected onPaste(event: ClipboardEvent): void;
149
+ /**
150
+ * The numpad decimal key types "." or "," depending on the keyboard layout, not the locale. Insert the
151
+ * locale's decimal separator instead, so a de-DE layout used with an en-US locale still types a decimal.
152
+ */
153
+ private insertLocaleDecimal;
149
154
  private parseValue;
150
155
  private setBeforeInputSubscription;
151
156
  private setInputFocusSubscription;