@jsenv/navi 0.29.160 → 0.29.162
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/jsenv_navi.js +2162 -2036
- package/dist/jsenv_navi.js.map +18 -4466
- package/docs/error_handling.md +18 -0
- package/docs/offline.md +7 -0
- package/package.json +1 -1
package/dist/jsenv_navi.js
CHANGED
|
@@ -565,1882 +565,847 @@ effect(() => {
|
|
|
565
565
|
});
|
|
566
566
|
|
|
567
567
|
/**
|
|
568
|
-
*
|
|
568
|
+
* Interpolates a template string, replacing `[key]` placeholders with values.
|
|
569
569
|
*
|
|
570
|
-
*
|
|
571
|
-
*
|
|
572
|
-
*
|
|
573
|
-
*
|
|
574
|
-
* gets wrong. So nothing is guessed. The error is let go, and whoever displays
|
|
575
|
-
* it SAYS so by marking it; what nobody ever took is reported as unhandled.
|
|
570
|
+
* Usable on its own — no i18n instance required — whenever a sentence should
|
|
571
|
+
* stay readable as one string instead of being cut into JSX expressions or
|
|
572
|
+
* concatenations. `<Interpolate>` is the JSX form of this function, and
|
|
573
|
+
* `createI18n` runs every translation through it. See `docs/i18n.md`.
|
|
576
574
|
*
|
|
577
|
-
*
|
|
578
|
-
*
|
|
579
|
-
*
|
|
575
|
+
* `[]` was chosen as the placeholder delimiter (rather than `{}` or `{{}}`)
|
|
576
|
+
* because it does not conflict with JSX syntax, JavaScript template literals,
|
|
577
|
+
* or common punctuation in translated strings.
|
|
580
578
|
*
|
|
581
|
-
*
|
|
579
|
+
* @param {string} template
|
|
580
|
+
* e.g. `"Hello [name], you have [count] messages"`. A non-string is returned
|
|
581
|
+
* untouched, as is any template when `replacements` is missing.
|
|
582
|
+
* @param {object} [replacements]
|
|
583
|
+
* Values keyed by placeholder name. A key can be:
|
|
584
|
+
* - a direct name — `[name]` ← `{ name: "Alice" }`
|
|
585
|
+
* - a dot-path — `[item.label]` ← `{ item: { label: "Book" } }` (a literal
|
|
586
|
+
* `"item.label"` key wins over the path)
|
|
587
|
+
*
|
|
588
|
+
* A value that is a function is called at that point, so an expensive or
|
|
589
|
+
* lazily-known replacement is only computed when the placeholder is actually
|
|
590
|
+
* present in this language's template.
|
|
591
|
+
*
|
|
592
|
+
* A placeholder with no matching value is left in the output as-is
|
|
593
|
+
* (`"[name]"`), making the gap visible rather than silently empty.
|
|
594
|
+
* @param {object} [options]
|
|
595
|
+
* @param {boolean} [options.allowJsx=false]
|
|
596
|
+
* Allow VNode replacements (what `<Interpolate>` passes). Without it, a VNode
|
|
597
|
+
* value warns and is coerced to a string.
|
|
598
|
+
* @returns {string|import("preact").VNode}
|
|
599
|
+
* A plain string when every replacement is a string, a Preact fragment when
|
|
600
|
+
* at least one VNode was interpolated with `allowJsx`.
|
|
582
601
|
*/
|
|
602
|
+
const interpolateText = (
|
|
603
|
+
template,
|
|
604
|
+
replacements,
|
|
605
|
+
{ allowJsx = false } = {},
|
|
606
|
+
) => {
|
|
607
|
+
if (!replacements || typeof template !== "string") {
|
|
608
|
+
return template;
|
|
609
|
+
}
|
|
610
|
+
const parts = template.split(/(\[[^\]]+\])/);
|
|
611
|
+
let hasVnode = false;
|
|
612
|
+
const resolved = [];
|
|
613
|
+
for (const part of parts) {
|
|
614
|
+
const match = part.match(/^\[([^\]]+)\]$/);
|
|
615
|
+
if (!match) {
|
|
616
|
+
resolved.push(part);
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
619
|
+
const key = match[1];
|
|
620
|
+
let value = resolveValue(replacements, key, part);
|
|
621
|
+
if (typeof value === "function") {
|
|
622
|
+
value = value();
|
|
623
|
+
}
|
|
624
|
+
if (isValidElement(value)) {
|
|
625
|
+
if (allowJsx) {
|
|
626
|
+
hasVnode = true;
|
|
627
|
+
} else {
|
|
628
|
+
console.warn(
|
|
629
|
+
`interpolateText: VNode passed for placeholder [${match[1]}] but allowJsx is false — value coerced to string`,
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
resolved.push(value);
|
|
634
|
+
}
|
|
635
|
+
if (!hasVnode) {
|
|
636
|
+
return resolved.join("");
|
|
637
|
+
}
|
|
638
|
+
// h(Fragment) instead of JSX (<>{resolved}</>) to keep this file as .js
|
|
639
|
+
return h(Fragment, null, resolved);
|
|
640
|
+
};
|
|
583
641
|
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
642
|
+
// Resolves a placeholder key against the replacements object.
|
|
643
|
+
// 1. Direct lookup: replacements["item.name"]
|
|
644
|
+
// 2. Dot-path lookup: replacements["item"]["name"]
|
|
645
|
+
// 3. Fallback: the original placeholder string (e.g. "[item.name]")
|
|
646
|
+
const resolveValue = (replacements, key, fallback) => {
|
|
647
|
+
if (key in replacements) {
|
|
648
|
+
return replacements[key];
|
|
649
|
+
}
|
|
650
|
+
const dotIndex = key.indexOf(".");
|
|
651
|
+
if (dotIndex !== -1) {
|
|
652
|
+
const head = key.slice(0, dotIndex);
|
|
653
|
+
const tail = key.slice(dotIndex + 1);
|
|
654
|
+
const parent = replacements[head];
|
|
655
|
+
if (parent && typeof parent === "object") {
|
|
656
|
+
const nested = parent[tail];
|
|
657
|
+
if (nested !== undefined) {
|
|
658
|
+
return nested;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
587
661
|
}
|
|
662
|
+
return fallback;
|
|
588
663
|
};
|
|
589
664
|
|
|
590
|
-
const
|
|
591
|
-
|
|
665
|
+
const DEFAULT_LANG = "en";
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* The browser's own language preferences, most preferred first — read from
|
|
669
|
+
* `navigator.languages` (falling back to the single `navigator.language`,
|
|
670
|
+
* then to DEFAULT_LANG when neither is available, e.g. during SSR). Kept as
|
|
671
|
+
* its own signal, independent from what this app actually supports — see
|
|
672
|
+
* `supportedLanguagesSignal` below for the allow-list that filters it, and
|
|
673
|
+
* `languagesSignal` for the final, ready-to-use combination of the two (plus
|
|
674
|
+
* `preferredLanguageSignal`).
|
|
675
|
+
*/
|
|
676
|
+
const getRuntimeLanguages = () => {
|
|
677
|
+
if (typeof window === "undefined") {
|
|
678
|
+
return [DEFAULT_LANG];
|
|
679
|
+
}
|
|
680
|
+
const { navigator } = window;
|
|
681
|
+
if (typeof navigator === "undefined") {
|
|
682
|
+
return [DEFAULT_LANG];
|
|
683
|
+
}
|
|
684
|
+
const { languages } = navigator;
|
|
685
|
+
if (Array.isArray(languages) && languages.length > 0) {
|
|
686
|
+
return languages;
|
|
687
|
+
}
|
|
688
|
+
const { language } = navigator;
|
|
689
|
+
if (typeof language === "string") {
|
|
690
|
+
return [language];
|
|
691
|
+
}
|
|
692
|
+
return [DEFAULT_LANG];
|
|
592
693
|
};
|
|
593
694
|
|
|
695
|
+
const runtimeLanguagesSignal = signal(getRuntimeLanguages());
|
|
696
|
+
|
|
697
|
+
if (typeof window !== "undefined") {
|
|
698
|
+
window.addEventListener("languagechange", () => {
|
|
699
|
+
runtimeLanguagesSignal.value = getRuntimeLanguages();
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
|
|
594
703
|
/**
|
|
595
|
-
*
|
|
596
|
-
*
|
|
704
|
+
* The languages this app actually offers, e.g. `["en", "fr"]` — an allow-list
|
|
705
|
+
* `languagesSignal` below filters everything else against (runtime languages the
|
|
706
|
+
* browser reports, and `preferredLanguageSignal`'s own override), so a site
|
|
707
|
+
* that only supports English/French never ends up resolving to German just
|
|
708
|
+
* because that happens to be the browser's or the user's own preference.
|
|
597
709
|
*
|
|
598
|
-
*
|
|
599
|
-
*
|
|
600
|
-
* boundary that displays it or reaches window on its own — `preact/debug`
|
|
601
|
-
* re-throws every error a boundary caught, and an unbounded one aborts the
|
|
602
|
-
* render loudly. Reporting it here as well would be a second voice saying the
|
|
603
|
-
* same thing, always the wrong one, since this module cannot see which of those
|
|
604
|
-
* happened.
|
|
710
|
+
* `null` (the default) means no restriction at all: every language the
|
|
711
|
+
* browser/user prefers is allowed through.
|
|
605
712
|
*/
|
|
606
|
-
const
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
713
|
+
const supportedLanguagesSignal = signal(null);
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* @param {string[]|null} languages - e.g. `["en", "fr"]`. Pass `null`/`[]`
|
|
717
|
+
* to lift the restriction again (allow everything).
|
|
718
|
+
*/
|
|
719
|
+
const setSupportedLanguages = (languages) => {
|
|
720
|
+
supportedLanguagesSignal.value =
|
|
721
|
+
languages && languages.length ? languages : null;
|
|
611
722
|
};
|
|
612
723
|
|
|
613
724
|
/**
|
|
614
|
-
*
|
|
725
|
+
* A single language the user explicitly chose (e.g. via an in-app language
|
|
726
|
+
* picker), overriding whatever the browser itself reports — takes priority
|
|
727
|
+
* over `runtimeLanguagesSignal` in `languagesSignal` below, but is still subject
|
|
728
|
+
* to `supportedLanguagesSignal`'s own allow-list.
|
|
615
729
|
*
|
|
616
|
-
*
|
|
617
|
-
*
|
|
618
|
-
*
|
|
730
|
+
* Deliberately a single language, not an ordered list: reordering *among*
|
|
731
|
+
* several preferred languages is real complexity real users essentially
|
|
732
|
+
* never want — the practical need `languagesSignal` needs to serve is "let this
|
|
733
|
+
* one user pick their one preferred language instead of the browser's",
|
|
734
|
+
* nothing more.
|
|
735
|
+
*/
|
|
736
|
+
const preferredLanguageSignal = signal(null);
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* @param {string|null} language - BCP 47 tag, e.g. "fr". Pass `null` to
|
|
740
|
+
* go back to following the browser's own language.
|
|
741
|
+
*/
|
|
742
|
+
const setPreferredLanguage = (language) => {
|
|
743
|
+
preferredLanguageSignal.value = language || null;
|
|
744
|
+
};
|
|
745
|
+
|
|
746
|
+
const getPrimarySubtag = (lang) => lang.split("-")[0];
|
|
747
|
+
|
|
748
|
+
const isLanguageSupported = (lang, supportedLanguages) => {
|
|
749
|
+
const primarySubtag = getPrimarySubtag(lang);
|
|
750
|
+
return supportedLanguages.some(
|
|
751
|
+
(supportedLanguage) =>
|
|
752
|
+
getPrimarySubtag(supportedLanguage) === primarySubtag,
|
|
753
|
+
);
|
|
754
|
+
};
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* The ordered, ready-to-use language preference list every navi
|
|
758
|
+
* component/util defaults to (naviI18n, formatNumber, the Time components,
|
|
759
|
+
* validation messages…), live on every read. Combines, in priority order:
|
|
619
760
|
*
|
|
620
|
-
*
|
|
621
|
-
*
|
|
622
|
-
* before its page exists, and that page cannot render until the routing that
|
|
623
|
-
* asked for the data is over. Measured on an offline navigation, the screen
|
|
624
|
-
* displaying the error arrived ~12ms after this deadline — so the app was told
|
|
625
|
-
* it had displayed nothing while it was displaying it.
|
|
761
|
+
* 1. `preferredLanguageSignal` (the user's own explicit pick, if any)
|
|
762
|
+
* 2. `runtimeLanguagesSignal` (the browser's own ordered preferences)
|
|
626
763
|
*
|
|
627
|
-
*
|
|
628
|
-
*
|
|
629
|
-
*
|
|
630
|
-
*
|
|
631
|
-
*
|
|
764
|
+
* then filters the result through `supportedLanguagesSignal` (if set) so
|
|
765
|
+
* only languages this app actually offers ever come out — e.g. a browser
|
|
766
|
+
* preferring `["de", "fr", "en"]` on a site that only supports `["en",
|
|
767
|
+
* "fr"]` resolves to `["fr", "en"]`, never touching German. If filtering
|
|
768
|
+
* would leave nothing at all (none of the browser's/user's preferences are
|
|
769
|
+
* supported), falls back to `supportedLanguagesSignal` itself so callers
|
|
770
|
+
* still get *something* usable rather than an empty array.
|
|
771
|
+
*
|
|
772
|
+
* Consumers that accept either a single lang or an ordered array (this
|
|
773
|
+
* package's own `matchBestLang`/`createI18n`, and native `Intl.NumberFormat`/
|
|
774
|
+
* `Intl.DateTimeFormat`) can pass this straight through: anything not
|
|
775
|
+
* covered by the first entry falls through to the next, rather than
|
|
776
|
+
* jumping straight to an unrelated default like "en".
|
|
632
777
|
*/
|
|
633
|
-
|
|
634
|
-
const
|
|
635
|
-
|
|
636
|
-
|
|
778
|
+
const languagesSignal = computed(() => {
|
|
779
|
+
const preferredLanguage = preferredLanguageSignal.value;
|
|
780
|
+
const runtimeLanguages = runtimeLanguagesSignal.value;
|
|
781
|
+
const supportedLanguages = supportedLanguagesSignal.value;
|
|
782
|
+
|
|
783
|
+
const orderedLanguages = preferredLanguage
|
|
784
|
+
? [preferredLanguage, ...runtimeLanguages]
|
|
785
|
+
: runtimeLanguages;
|
|
786
|
+
const dedupedLanguages = [...new Set(orderedLanguages)];
|
|
787
|
+
|
|
788
|
+
if (!supportedLanguages) {
|
|
789
|
+
return dedupedLanguages;
|
|
790
|
+
}
|
|
791
|
+
const filteredLanguages = dedupedLanguages.filter((lang) =>
|
|
792
|
+
isLanguageSupported(lang, supportedLanguages),
|
|
793
|
+
);
|
|
794
|
+
return filteredLanguages.length > 0 ? filteredLanguages : supportedLanguages;
|
|
795
|
+
});
|
|
637
796
|
|
|
638
797
|
/**
|
|
639
|
-
*
|
|
640
|
-
*
|
|
641
|
-
*
|
|
798
|
+
* Creates a lightweight i18n instance: a central place where an app declares
|
|
799
|
+
* its texts once and reads them back translated into the active language.
|
|
800
|
+
*
|
|
801
|
+
* Worth using even in a single-language app — one registry beats strings
|
|
802
|
+
* scattered across components, and adding a second language later becomes a
|
|
803
|
+
* data change instead of a refactor. See `docs/i18n.md` for how to choose
|
|
804
|
+
* between the two key styles below and how this relates to `naviI18n`.
|
|
805
|
+
*
|
|
806
|
+
* @param {object} [options]
|
|
807
|
+
* @param {string} [options.keyLang]
|
|
808
|
+
* When set, each key also serves as its own translation for `keyLang`.
|
|
809
|
+
* This allows writing keys directly in that language (typically the language
|
|
810
|
+
* the app is written in) so only *other* languages need registering:
|
|
811
|
+
*
|
|
812
|
+
* ```js
|
|
813
|
+
* const i18n = createI18n({ keyLang: "en" });
|
|
814
|
+
* i18n.add("Hello [name]!", { fr: "Bonjour [name] !" });
|
|
815
|
+
* i18n("Hello [name]!", { name: "Alice" }, { lang: "en" }); // "Hello Alice!"
|
|
816
|
+
* i18n("Hello [name]!", { name: "Alice" }, { lang: "fr" }); // "Bonjour Alice !"
|
|
817
|
+
* ```
|
|
818
|
+
*
|
|
819
|
+
* `keyLang` only applies to keys passed to `add()`/`addAll()`; a key never
|
|
820
|
+
* registered stays opaque and comes back as-is.
|
|
821
|
+
*
|
|
822
|
+
* Without `keyLang`, keys are opaque identifiers and every language
|
|
823
|
+
* (including the one the app was written in) must be registered explicitly:
|
|
824
|
+
*
|
|
825
|
+
* ```js
|
|
826
|
+
* const i18n = createI18n();
|
|
827
|
+
* i18n.add("greeting", { en: "Hello [name]!", fr: "Bonjour [name] !" });
|
|
828
|
+
* i18n("greeting", { name: "Alice" }, { lang: "en" }); // "Hello Alice!"
|
|
829
|
+
* ```
|
|
830
|
+
*
|
|
831
|
+
* @param {string} [options.fallbackLang]
|
|
832
|
+
* Language consulted when the active language has no translation for a key
|
|
833
|
+
* — per key, not per language: a partially translated language falls through
|
|
834
|
+
* to `fallbackLang` only for the keys it is missing. Without it, a missing
|
|
835
|
+
* translation returns the key itself.
|
|
836
|
+
*
|
|
837
|
+
* @param {string|string[]} [options.runtimeLang]
|
|
838
|
+
* The active language (BCP 47 tag or ordered array of tags) — named
|
|
839
|
+
* "runtime" rather than "system" because there is no actual access to the
|
|
840
|
+
* OS/user's system language from a browser, only `navigator.languages` (or
|
|
841
|
+
* an explicit override) at runtime. Defaults to `languagesSignal.value`, read
|
|
842
|
+
* fresh on every `format()`/`has()` call (not frozen at creation time) —
|
|
843
|
+
* so overriding the language app-wide via `setPreferredLanguage()`/
|
|
844
|
+
* `setSupportedLanguages()` (see lang_signal.js) is picked up here too.
|
|
845
|
+
* Passing an explicit `runtimeLang` opts out of that and stays fixed for
|
|
846
|
+
* this instance's whole lifetime.
|
|
847
|
+
*
|
|
848
|
+
* ---
|
|
849
|
+
*
|
|
850
|
+
* ## Registration
|
|
851
|
+
*
|
|
852
|
+
* **`i18n.add(key, { lang: "translation" })`** — one key, multiple languages.
|
|
853
|
+
*
|
|
854
|
+
* **`i18n.addAll({ key: { lang: "translation" }, ... })`** — multiple keys at once.
|
|
855
|
+
*
|
|
856
|
+
* **`i18n.addLangKeys(lang, { key: "translation", ... })`** — full language pack
|
|
857
|
+
* (useful when loading a JSON translation file).
|
|
858
|
+
*
|
|
859
|
+
* All three accumulate: registering a key that already exists overwrites that
|
|
860
|
+
* one key and leaves the rest of the language untouched. This is what lets an
|
|
861
|
+
* app override a single built-in navi text without redeclaring the others.
|
|
862
|
+
*
|
|
863
|
+
* A regional variant (e.g. `"fr-CA"`) automatically inherits all keys from its
|
|
864
|
+
* parent (`"fr"`) that it does not explicitly override:
|
|
865
|
+
* ```js
|
|
866
|
+
* i18n.addLangKeys("fr", { hello: "Bonjour !" });
|
|
867
|
+
* i18n.addLangKeys("fr-CA", { hello: "Allo !" }); // other "fr" keys inherited
|
|
868
|
+
* ```
|
|
869
|
+
* Inheritance is resolved at registration time, so register the parent first.
|
|
870
|
+
*
|
|
871
|
+
* ---
|
|
872
|
+
*
|
|
873
|
+
* ## Reading
|
|
874
|
+
*
|
|
875
|
+
* **`i18n(key, values?, { lang? })`** — the translation for `key`, with
|
|
876
|
+
* `[placeholder]` occurrences replaced from `values` (see `interpolateText`).
|
|
877
|
+
* Returns `key` itself when nothing matches, so an untranslated string still
|
|
878
|
+
* renders something readable. `i18n.format` is an alias of this call.
|
|
879
|
+
*
|
|
880
|
+
* **`i18n.has(key, { lang? })`** — whether a translation genuinely exists,
|
|
881
|
+
* i.e. how to tell "no translation" apart from "translation equal to the key".
|
|
882
|
+
*
|
|
883
|
+
* @returns {Function & { add, addAll, addLangKeys, has, format, languageMap }}
|
|
642
884
|
*/
|
|
643
|
-
const
|
|
644
|
-
const
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
885
|
+
const createI18n = ({ keyLang, fallbackLang, runtimeLang } = {}) => {
|
|
886
|
+
const languageMap = new Map();
|
|
887
|
+
// Bumped by addLangKeys — the only thing besides the active lang itself
|
|
888
|
+
// that could change what getActiveLang()/getResolvedFallbackLang() below
|
|
889
|
+
// resolve to, so it's what invalidates their own small caches.
|
|
890
|
+
let languageMapVersion = 0;
|
|
891
|
+
|
|
892
|
+
// Without an explicit runtimeLang, languagesSignal.value is re-read fresh on
|
|
893
|
+
// every call rather than frozen here via languagesSignal.peek() — freezing it
|
|
894
|
+
// would silently ignore setPreferredLanguage()/setSupportedLanguages() (see
|
|
895
|
+
// lang_signal.js) for the rest of this instance's life.
|
|
896
|
+
const hasExplicitRuntimeLang = runtimeLang !== undefined;
|
|
897
|
+
|
|
898
|
+
// matchBestLang does real work (a Map lookup per candidate, a possible
|
|
899
|
+
// "fr-CA" → "fr" split-and-retry loop) — worth skipping on every single
|
|
900
|
+
// format()/has() call in the common case, since what it resolves to only
|
|
901
|
+
// ever changes when languageMap itself changes (addLangKeys) or, for the
|
|
902
|
+
// non-explicit case, when languagesSignal.value itself changes (preferred
|
|
903
|
+
// language, supported languages, or "languagechange" — see lang_signal.js,
|
|
904
|
+
// languagesSignal is a computed() so its reference is stable when none of its
|
|
905
|
+
// own dependencies actually changed) — comparing those two cheaply
|
|
906
|
+
// (===) is enough to know the cached result below is still valid.
|
|
907
|
+
let cachedActiveLang;
|
|
908
|
+
let cachedActiveLangRuntimeLang;
|
|
909
|
+
let cachedActiveLangVersion = -1;
|
|
910
|
+
const getActiveLang = () => {
|
|
911
|
+
const currentRuntimeLang = hasExplicitRuntimeLang
|
|
912
|
+
? runtimeLang
|
|
913
|
+
: languagesSignal.value;
|
|
914
|
+
if (
|
|
915
|
+
cachedActiveLangVersion === languageMapVersion &&
|
|
916
|
+
cachedActiveLangRuntimeLang === currentRuntimeLang
|
|
917
|
+
) {
|
|
918
|
+
return cachedActiveLang;
|
|
648
919
|
}
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
920
|
+
cachedActiveLang = matchBestLang(currentRuntimeLang, languageMap);
|
|
921
|
+
cachedActiveLangVersion = languageMapVersion;
|
|
922
|
+
cachedActiveLangRuntimeLang = currentRuntimeLang;
|
|
923
|
+
return cachedActiveLang;
|
|
924
|
+
};
|
|
925
|
+
|
|
926
|
+
// fallbackLang is a plain, never-reactive option set once at creation —
|
|
927
|
+
// its own resolution only ever needs recomputing when languageMap does.
|
|
928
|
+
let cachedResolvedFallbackLang;
|
|
929
|
+
let cachedResolvedFallbackLangVersion = -1;
|
|
930
|
+
const getResolvedFallbackLang = () => {
|
|
931
|
+
if (!fallbackLang) {
|
|
932
|
+
return null;
|
|
657
933
|
}
|
|
658
|
-
if (
|
|
659
|
-
|
|
934
|
+
if (cachedResolvedFallbackLangVersion === languageMapVersion) {
|
|
935
|
+
return cachedResolvedFallbackLang;
|
|
660
936
|
}
|
|
661
|
-
|
|
937
|
+
cachedResolvedFallbackLang = matchBestLang(fallbackLang, languageMap);
|
|
938
|
+
cachedResolvedFallbackLangVersion = languageMapVersion;
|
|
939
|
+
return cachedResolvedFallbackLang;
|
|
662
940
|
};
|
|
663
941
|
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
942
|
+
const addLangKeys = (lang, translations) => {
|
|
943
|
+
// Accumulate: merge with any existing translations for this lang
|
|
944
|
+
const existing = languageMap.get(lang);
|
|
945
|
+
if (existing) {
|
|
946
|
+
translations = { ...existing, ...translations };
|
|
669
947
|
}
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
948
|
+
// A regional variant inherits all keys not explicitly overridden
|
|
949
|
+
// e.g. "fr-CA" inherits from "fr"
|
|
950
|
+
const dashIndex = lang.indexOf("-");
|
|
951
|
+
if (dashIndex !== -1) {
|
|
952
|
+
const parentLang = lang.slice(0, dashIndex);
|
|
953
|
+
const parentTranslations = languageMap.get(parentLang);
|
|
954
|
+
if (parentTranslations) {
|
|
955
|
+
translations = { ...parentTranslations, ...translations };
|
|
956
|
+
}
|
|
673
957
|
}
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
};
|
|
677
|
-
|
|
678
|
-
const actionPrivatePropertiesWeakMap = new WeakMap();
|
|
679
|
-
const getActionPrivateProperties = (action) => {
|
|
680
|
-
const actionPrivateProperties = actionPrivatePropertiesWeakMap.get(action);
|
|
681
|
-
if (!actionPrivateProperties) {
|
|
682
|
-
throw new Error(`Cannot find action private properties for "${action}"`);
|
|
683
|
-
}
|
|
684
|
-
return actionPrivateProperties;
|
|
685
|
-
};
|
|
686
|
-
const setActionPrivateProperties = (action, properties) => {
|
|
687
|
-
actionPrivatePropertiesWeakMap.set(action, properties);
|
|
688
|
-
};
|
|
689
|
-
|
|
690
|
-
const IDLE = { id: "idle" };
|
|
691
|
-
const RUNNING = { id: "running" };
|
|
692
|
-
const ABORTED = { id: "aborted" };
|
|
693
|
-
const FAILED = { id: "failed" };
|
|
694
|
-
const COMPLETED = { id: "completed" };
|
|
958
|
+
languageMap.set(lang, translations);
|
|
959
|
+
languageMapVersion++;
|
|
960
|
+
};
|
|
695
961
|
|
|
696
|
-
const
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
error: null,
|
|
706
|
-
completed: false,
|
|
707
|
-
data: undefined,
|
|
708
|
-
};
|
|
709
|
-
}
|
|
710
|
-
const {
|
|
711
|
-
paramsSignal,
|
|
712
|
-
runningStateSignal,
|
|
713
|
-
isPrerunSignal,
|
|
714
|
-
errorSignal,
|
|
715
|
-
dataSignal,
|
|
716
|
-
} = action;
|
|
717
|
-
const params = paramsSignal.value;
|
|
718
|
-
const isPrerun = isPrerunSignal.value;
|
|
719
|
-
const runningState = runningStateSignal.value;
|
|
720
|
-
const idle = runningState === IDLE;
|
|
721
|
-
const aborted = runningState === ABORTED;
|
|
722
|
-
const error = errorSignal.value;
|
|
723
|
-
const loading = runningState === RUNNING;
|
|
724
|
-
const completed = runningState === COMPLETED;
|
|
725
|
-
const data = dataSignal.value;
|
|
962
|
+
const add = (key, langTranslations) => {
|
|
963
|
+
if (keyLang && !(keyLang in langTranslations)) {
|
|
964
|
+
// Auto-register the key itself as the translation for keyLang
|
|
965
|
+
addLangKeys(keyLang, { [key]: key });
|
|
966
|
+
}
|
|
967
|
+
for (const [lang, value] of Object.entries(langTranslations)) {
|
|
968
|
+
addLangKeys(lang, { [key]: value });
|
|
969
|
+
}
|
|
970
|
+
};
|
|
726
971
|
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
idle,
|
|
732
|
-
loading,
|
|
733
|
-
aborted,
|
|
734
|
-
error,
|
|
735
|
-
completed,
|
|
736
|
-
data,
|
|
972
|
+
const addAll = (keyMap) => {
|
|
973
|
+
for (const [key, langTranslations] of Object.entries(keyMap)) {
|
|
974
|
+
add(key, langTranslations);
|
|
975
|
+
}
|
|
737
976
|
};
|
|
738
|
-
};
|
|
739
977
|
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
const
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
let routeErrorText = error && error.message ? error.message : error;
|
|
753
|
-
return jsxs("p", {
|
|
754
|
-
className: "action_error",
|
|
755
|
-
children: ["An error occured: ", routeErrorText]
|
|
756
|
-
});
|
|
757
|
-
};
|
|
758
|
-
const renderCompletedDefault = () => null;
|
|
759
|
-
const ActionRenderer = ({
|
|
760
|
-
action,
|
|
761
|
-
children,
|
|
762
|
-
disabled
|
|
763
|
-
}) => {
|
|
764
|
-
import.meta.css = [css$14, "@jsenv/navi/src/action/action_renderer.jsx"];
|
|
765
|
-
if (action === undefined) {
|
|
766
|
-
throw new Error("ActionRenderer requires an action to render, but none was provided.");
|
|
767
|
-
}
|
|
768
|
-
let renderBranches;
|
|
769
|
-
if (typeof children === "function") {
|
|
770
|
-
renderBranches = {
|
|
771
|
-
completed: children
|
|
772
|
-
};
|
|
773
|
-
} else if (isValidElement(children)) {
|
|
774
|
-
renderBranches = {
|
|
775
|
-
always: () => children
|
|
776
|
-
};
|
|
777
|
-
} else if (isPlainObject$2(children)) {
|
|
778
|
-
renderBranches = children;
|
|
779
|
-
} else {
|
|
780
|
-
renderBranches = {
|
|
781
|
-
completed: children
|
|
782
|
-
};
|
|
783
|
-
}
|
|
784
|
-
const {
|
|
785
|
-
idle: renderIdle = renderIdleDefault,
|
|
786
|
-
loading: renderLoading = renderLoadingDefault,
|
|
787
|
-
aborted: renderAborted = renderAbortedDefault,
|
|
788
|
-
error: renderError = renderErrorDefault,
|
|
789
|
-
completed: renderCompleted,
|
|
790
|
-
always: renderAlways
|
|
791
|
-
} = renderBranches;
|
|
792
|
-
const {
|
|
793
|
-
idle,
|
|
794
|
-
loading,
|
|
795
|
-
aborted,
|
|
796
|
-
error,
|
|
797
|
-
completed,
|
|
798
|
-
data
|
|
799
|
-
} = useActionStatus(action);
|
|
800
|
-
const UIRenderedPromise = useUIRenderedPromise(action);
|
|
801
|
-
const [errorBoundary, resetErrorBoundary] = useErrorBoundary();
|
|
802
|
-
useLayoutEffect(() => {
|
|
803
|
-
resetErrorBoundary();
|
|
804
|
-
}, [action, loading, idle, resetErrorBoundary]);
|
|
805
|
-
useLayoutEffect(() => {
|
|
806
|
-
UIRenderedPromise.resolve();
|
|
807
|
-
return () => {
|
|
808
|
-
actionUIRenderedPromiseWeakMap.delete(action);
|
|
809
|
-
};
|
|
810
|
-
}, [action]);
|
|
811
|
-
if (disabled) {
|
|
812
|
-
return null;
|
|
813
|
-
}
|
|
814
|
-
// If renderAlways is provided, it wins and handles all rendering
|
|
815
|
-
if (renderAlways) {
|
|
816
|
-
return renderAlways({
|
|
817
|
-
idle,
|
|
818
|
-
loading,
|
|
819
|
-
aborted,
|
|
820
|
-
completed,
|
|
821
|
-
error,
|
|
822
|
-
data
|
|
823
|
-
});
|
|
824
|
-
}
|
|
825
|
-
if (idle) {
|
|
826
|
-
return renderIdle(action);
|
|
827
|
-
}
|
|
828
|
-
if (errorBoundary) {
|
|
829
|
-
// Displaying it is what makes it handled (see action_error_report.js)
|
|
830
|
-
markErrorAsDisplayedBy(errorBoundary, "<ActionRenderer>");
|
|
831
|
-
return renderError(errorBoundary, "ui_error", action);
|
|
832
|
-
}
|
|
833
|
-
if (aborted) {
|
|
834
|
-
return renderAborted(action);
|
|
835
|
-
}
|
|
836
|
-
let renderCompletedSafe;
|
|
837
|
-
if (renderCompleted) {
|
|
838
|
-
renderCompletedSafe = renderCompleted;
|
|
839
|
-
} else {
|
|
840
|
-
const {
|
|
841
|
-
ui
|
|
842
|
-
} = getActionPrivateProperties(action);
|
|
843
|
-
if (ui.renderCompleted) {
|
|
844
|
-
renderCompletedSafe = ui.renderCompleted;
|
|
845
|
-
} else {
|
|
846
|
-
renderCompletedSafe = renderCompletedDefault;
|
|
978
|
+
const _getTemplate = (key, lang) => {
|
|
979
|
+
// matchBestLang, not matchLang directly: lang can be an array (e.g.
|
|
980
|
+
// languagesSignal.value is always an ordered array — see lang_signal.js) and
|
|
981
|
+
// matchLang alone assumes a plain string, throwing
|
|
982
|
+
// on .split() otherwise.
|
|
983
|
+
const resolvedLang = lang ? matchBestLang(lang, languageMap) : null;
|
|
984
|
+
if (resolvedLang) {
|
|
985
|
+
const translations = languageMap.get(resolvedLang);
|
|
986
|
+
const translated = translations[key];
|
|
987
|
+
if (translated !== undefined) {
|
|
988
|
+
return translated;
|
|
989
|
+
}
|
|
847
990
|
}
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
991
|
+
const resolvedFallbackLang = getResolvedFallbackLang();
|
|
992
|
+
if (resolvedFallbackLang) {
|
|
993
|
+
const fallbackTranslations = languageMap.get(resolvedFallbackLang);
|
|
994
|
+
const fallbackTranslated = fallbackTranslations[key];
|
|
995
|
+
if (fallbackTranslated !== undefined) {
|
|
996
|
+
return fallbackTranslated;
|
|
997
|
+
}
|
|
852
998
|
}
|
|
853
|
-
return
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
markErrorAsDisplayedBy(error, "<ActionRenderer>");
|
|
857
|
-
return renderError(error, "action_error", action);
|
|
858
|
-
}
|
|
859
|
-
return renderCompletedSafe(data, action);
|
|
860
|
-
};
|
|
861
|
-
const defaultPromise = Promise.resolve();
|
|
862
|
-
defaultPromise.resolve = () => {};
|
|
863
|
-
const actionUIRenderedPromiseWeakMap = new WeakMap();
|
|
864
|
-
const useUIRenderedPromise = action => {
|
|
865
|
-
if (!action) {
|
|
866
|
-
return defaultPromise;
|
|
867
|
-
}
|
|
868
|
-
const actionUIRenderedPromise = actionUIRenderedPromiseWeakMap.get(action);
|
|
869
|
-
if (actionUIRenderedPromise) {
|
|
870
|
-
return actionUIRenderedPromise;
|
|
871
|
-
}
|
|
872
|
-
let resolve;
|
|
873
|
-
const promise = new Promise(res => {
|
|
874
|
-
resolve = res;
|
|
875
|
-
});
|
|
876
|
-
promise.resolve = resolve;
|
|
877
|
-
actionUIRenderedPromiseWeakMap.set(action, promise);
|
|
878
|
-
return promise;
|
|
879
|
-
};
|
|
880
|
-
const isPlainObject$2 = obj => {
|
|
881
|
-
if (typeof obj !== "object" || obj === null) {
|
|
882
|
-
return false;
|
|
883
|
-
}
|
|
884
|
-
let proto = obj;
|
|
885
|
-
while (Object.getPrototypeOf(proto) !== null) {
|
|
886
|
-
proto = Object.getPrototypeOf(proto);
|
|
887
|
-
}
|
|
888
|
-
return Object.getPrototypeOf(obj) === proto || Object.getPrototypeOf(obj) === null;
|
|
889
|
-
};
|
|
890
|
-
|
|
891
|
-
const isSignal = (value) => {
|
|
892
|
-
return getSignalType(value) !== null;
|
|
893
|
-
};
|
|
999
|
+
// No translation found — return key as-is (opaque fallback)
|
|
1000
|
+
return key;
|
|
1001
|
+
};
|
|
894
1002
|
|
|
895
|
-
const
|
|
896
|
-
const
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
}
|
|
1003
|
+
const format = (key, values, { lang = getActiveLang() } = {}) => {
|
|
1004
|
+
const template = _getTemplate(key, lang);
|
|
1005
|
+
return interpolateText(template, values);
|
|
1006
|
+
};
|
|
900
1007
|
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
1008
|
+
const has = (key, { lang = getActiveLang() } = {}) => {
|
|
1009
|
+
const resolvedLang = lang ? matchBestLang(lang, languageMap) : null;
|
|
1010
|
+
if (resolvedLang) {
|
|
1011
|
+
const translations = languageMap.get(resolvedLang);
|
|
1012
|
+
if (translations && key in translations) {
|
|
1013
|
+
return true;
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
const resolvedFallbackLang = getResolvedFallbackLang();
|
|
1017
|
+
if (resolvedFallbackLang) {
|
|
1018
|
+
const fallbackTranslations = languageMap.get(resolvedFallbackLang);
|
|
1019
|
+
if (fallbackTranslations && key in fallbackTranslations) {
|
|
1020
|
+
return true;
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
return false;
|
|
1024
|
+
};
|
|
904
1025
|
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
1026
|
+
// The i18n instance is itself a callable function
|
|
1027
|
+
const i18n = (key, values, opts) => format(key, values, opts);
|
|
1028
|
+
i18n.add = add;
|
|
1029
|
+
i18n.addAll = addAll;
|
|
1030
|
+
i18n.addLangKeys = addLangKeys;
|
|
1031
|
+
i18n.has = has;
|
|
1032
|
+
i18n.format = format;
|
|
1033
|
+
i18n.languageMap = languageMap;
|
|
908
1034
|
|
|
909
|
-
return
|
|
1035
|
+
return i18n;
|
|
910
1036
|
};
|
|
911
1037
|
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
maxDepth = 2,
|
|
917
|
-
currentDepth = 0,
|
|
918
|
-
options = {},
|
|
919
|
-
) => {
|
|
920
|
-
const { asFunctionArgs = false } = options;
|
|
921
|
-
const indent = " ".repeat(currentDepth);
|
|
922
|
-
const nextIndent = " ".repeat(currentDepth + 1);
|
|
923
|
-
|
|
924
|
-
if (currentDepth >= maxDepth) {
|
|
925
|
-
return typeof value === "object" && value !== null
|
|
926
|
-
? "[Object]"
|
|
927
|
-
: String(value);
|
|
928
|
-
}
|
|
929
|
-
|
|
930
|
-
if (value === null) {
|
|
931
|
-
return "null";
|
|
932
|
-
}
|
|
933
|
-
if (value === undefined) {
|
|
934
|
-
return "undefined";
|
|
935
|
-
}
|
|
936
|
-
if (typeof value === "string") {
|
|
937
|
-
return `"${value}"`;
|
|
938
|
-
}
|
|
939
|
-
if (typeof value === "number" || typeof value === "boolean") {
|
|
940
|
-
return String(value);
|
|
941
|
-
}
|
|
942
|
-
if (typeof value === "function") {
|
|
943
|
-
return `[Function ${value.name || "anonymous"}]`;
|
|
944
|
-
}
|
|
945
|
-
if (value instanceof Date) {
|
|
946
|
-
return `Date(${value.toISOString()})`;
|
|
947
|
-
}
|
|
948
|
-
if (value instanceof RegExp) {
|
|
949
|
-
return value.toString();
|
|
1038
|
+
// Walk "fr-CA-variant" → "fr-CA" → "fr" until a registered lang is found
|
|
1039
|
+
const matchLang = (lang, languageMap) => {
|
|
1040
|
+
if (languageMap.has(lang)) {
|
|
1041
|
+
return lang;
|
|
950
1042
|
}
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
const
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
// Display arrays with only one element on a single line
|
|
959
|
-
if (value.length === 1) {
|
|
960
|
-
const item = stringifyForDisplay(
|
|
961
|
-
value[0],
|
|
962
|
-
maxDepth,
|
|
963
|
-
currentDepth + 1,
|
|
964
|
-
// Remove asFunctionArgs for nested calls
|
|
965
|
-
{ ...options, asFunctionArgs: false },
|
|
966
|
-
);
|
|
967
|
-
return `${openBracket}${item}${closeBracket}`;
|
|
968
|
-
}
|
|
969
|
-
|
|
970
|
-
if (value.length > MAX_ENTRIES) {
|
|
971
|
-
const preview = value
|
|
972
|
-
.slice(0, MAX_ENTRIES)
|
|
973
|
-
.map(
|
|
974
|
-
(v) =>
|
|
975
|
-
`${nextIndent}${stringifyForDisplay(v, maxDepth, currentDepth + 1, { ...options, asFunctionArgs: false })}`,
|
|
976
|
-
);
|
|
977
|
-
return `${openBracket}\n${preview.join(",\n")},\n${nextIndent}...${value.length - MAX_ENTRIES} more\n${indent}${closeBracket}`;
|
|
1043
|
+
const parts = lang.split("-");
|
|
1044
|
+
while (parts.length > 1) {
|
|
1045
|
+
parts.pop();
|
|
1046
|
+
const candidate = parts.join("-");
|
|
1047
|
+
if (languageMap.has(candidate)) {
|
|
1048
|
+
return candidate;
|
|
978
1049
|
}
|
|
979
|
-
|
|
980
|
-
const items = value.map(
|
|
981
|
-
(v) =>
|
|
982
|
-
`${nextIndent}${stringifyForDisplay(v, maxDepth, currentDepth + 1, { ...options, asFunctionArgs: false })}`,
|
|
983
|
-
);
|
|
984
|
-
return `${openBracket}\n${items.join(",\n")}\n${indent}${closeBracket}`;
|
|
985
1050
|
}
|
|
1051
|
+
return null;
|
|
1052
|
+
};
|
|
986
1053
|
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
// ✅ Inclure les clés avec valeurs undefined/null
|
|
999
|
-
const allEntries = [];
|
|
1000
|
-
for (const [key, val] of entries) {
|
|
1001
|
-
allEntries.push([key, val]);
|
|
1002
|
-
}
|
|
1003
|
-
|
|
1004
|
-
// Ajouter les clés avec undefined (que Object.entries omet)
|
|
1005
|
-
const descriptor = Object.getOwnPropertyDescriptors(value);
|
|
1006
|
-
for (const [key, desc] of Object.entries(descriptor)) {
|
|
1007
|
-
if (desc.value === undefined && !entries.some(([k]) => k === key)) {
|
|
1008
|
-
allEntries.push([key, undefined]);
|
|
1009
|
-
}
|
|
1010
|
-
}
|
|
1011
|
-
|
|
1012
|
-
// Display objects with only one key on a single line
|
|
1013
|
-
if (allEntries.length === 1) {
|
|
1014
|
-
const [key, val] = allEntries[0];
|
|
1015
|
-
const valueStr = stringifyForDisplay(val, maxDepth, currentDepth + 1, {
|
|
1016
|
-
...options,
|
|
1017
|
-
asFunctionArgs: false,
|
|
1018
|
-
});
|
|
1019
|
-
return `{ ${key}: ${valueStr} }`;
|
|
1020
|
-
}
|
|
1021
|
-
|
|
1022
|
-
if (allEntries.length > MAX_ENTRIES) {
|
|
1023
|
-
const preview = allEntries
|
|
1024
|
-
.slice(0, MAX_ENTRIES)
|
|
1025
|
-
.map(
|
|
1026
|
-
([k, v]) =>
|
|
1027
|
-
`${nextIndent}${k}: ${stringifyForDisplay(v, maxDepth, currentDepth + 1, { ...options, asFunctionArgs: false })}`,
|
|
1028
|
-
);
|
|
1029
|
-
return `{\n${preview.join(",\n")},\n${nextIndent}...${allEntries.length - MAX_ENTRIES} more\n${indent}}`;
|
|
1054
|
+
// lang can be a string or an ordered array of preference strings
|
|
1055
|
+
const matchBestLang = (lang, languageMap) => {
|
|
1056
|
+
if (!lang) {
|
|
1057
|
+
return null;
|
|
1058
|
+
}
|
|
1059
|
+
const candidates = Array.isArray(lang) ? lang : [lang];
|
|
1060
|
+
for (const candidate of candidates) {
|
|
1061
|
+
const match = matchLang(candidate, languageMap);
|
|
1062
|
+
if (match) {
|
|
1063
|
+
return match;
|
|
1030
1064
|
}
|
|
1031
|
-
|
|
1032
|
-
const pairs = allEntries.map(
|
|
1033
|
-
([k, v]) =>
|
|
1034
|
-
`${nextIndent}${k}: ${stringifyForDisplay(v, maxDepth, currentDepth + 1, { ...options, asFunctionArgs: false })}`,
|
|
1035
|
-
);
|
|
1036
|
-
return `{\n${pairs.join(",\n")}\n${indent}}`;
|
|
1037
1065
|
}
|
|
1038
|
-
|
|
1039
|
-
return String(value);
|
|
1066
|
+
return null;
|
|
1040
1067
|
};
|
|
1041
1068
|
|
|
1042
|
-
|
|
1043
|
-
*
|
|
1044
|
-
*
|
|
1045
|
-
*
|
|
1046
|
-
* parameter deduplication.
|
|
1069
|
+
/**
|
|
1070
|
+
* The shared i18n instance holding every text @jsenv/navi components display
|
|
1071
|
+
* on their own — validation messages, button labels, empty-list messages,
|
|
1072
|
+
* relative time wording…
|
|
1047
1073
|
*
|
|
1048
|
-
*
|
|
1049
|
-
*
|
|
1050
|
-
*
|
|
1051
|
-
*
|
|
1052
|
-
*
|
|
1053
|
-
* compare (a Set, a Map, an element, a URL), are equal by reference only —
|
|
1054
|
-
* nothing in them says whether two are "the same". Cheap paths run first: reference
|
|
1055
|
-
* equality, then the identity short-circuit below, then array length before
|
|
1056
|
-
* element-by-element.
|
|
1074
|
+
* It is navi's texts, not the application's: an app registers its own texts in
|
|
1075
|
+
* its own `createI18n()` instance and reaches for `naviI18n` only to change
|
|
1076
|
+
* what navi itself says, or to add a language navi does not ship. Keys here are
|
|
1077
|
+
* opaque identifiers (`"list.empty"`), never the English sentence — the
|
|
1078
|
+
* opposite of what an app is advised to do. `docs/i18n.md` explains why.
|
|
1057
1079
|
*
|
|
1058
|
-
*
|
|
1059
|
-
*
|
|
1060
|
-
*
|
|
1061
|
-
*
|
|
1062
|
-
* across a serialization boundary be recognized as one entity — the cases where a
|
|
1063
|
-
* content comparison would be too slow, or too strict to see them as equal. Use
|
|
1064
|
-
* Symbol.for() so the marker is the same symbol across modules/contexts:
|
|
1080
|
+
* The active language is read from `languagesSignal` (see lang_signal.js —
|
|
1081
|
+
* combines the browser's own `navigator.languages`, an optional
|
|
1082
|
+
* `setPreferredLanguage()` user override, and an optional
|
|
1083
|
+
* `setSupportedLanguages()` app-wide allow-list), live on every lookup.
|
|
1065
1084
|
*
|
|
1066
|
-
*
|
|
1067
|
-
*
|
|
1068
|
-
*
|
|
1069
|
-
*
|
|
1085
|
+
* Built-in key namespaces, all overridable — the registrations below are the
|
|
1086
|
+
* exhaustive list, read them to find the exact key to override:
|
|
1087
|
+
* - `"button.*"` — Clear, Reset, Send, Open, Close, Cancel, Confirm…
|
|
1088
|
+
* - `"time.*"` — relative time wording, duration unit symbols, date field placeholders
|
|
1089
|
+
* - `"spin.*"` — the ends of a steppable range
|
|
1090
|
+
* - `"list.*"` — empty/no-match/failed-rows messages
|
|
1091
|
+
* - `"badge_list.*"` — the "+[count] more" overflow badge
|
|
1092
|
+
* - `"constraint.*"` — every field validation message
|
|
1093
|
+
* - `"network_policy.*"` — what an action settles with when the policy kept it from the network
|
|
1094
|
+
*
|
|
1095
|
+
* Unit names get two derived keys, both optional: `<unit>__plural` and
|
|
1096
|
+
* `<unit>__short`. `<Unit>`/`<Quantity>` fall back to the singular when the
|
|
1097
|
+
* derived key is missing, and to `Intl.NumberFormat` when the unit itself is
|
|
1098
|
+
* not registered at all — so only units Intl gets wrong need registering.
|
|
1099
|
+
*
|
|
1100
|
+
* @example
|
|
1101
|
+
* import { naviI18n } from "@jsenv/navi";
|
|
1102
|
+
*
|
|
1103
|
+
* // Override a built-in text:
|
|
1104
|
+
* naviI18n.add("time.ongoing", { fr: "En cours…" });
|
|
1105
|
+
*
|
|
1106
|
+
* // Teach navi a language it does not ship:
|
|
1107
|
+
* naviI18n.addLangKeys("ja", { "list.empty": "項目がありません。" });
|
|
1108
|
+
*
|
|
1109
|
+
* // Register unit translations used by <Quantity>/<Unit>:
|
|
1110
|
+
* naviI18n.addAll({
|
|
1111
|
+
* ticket: { en: "ticket", fr: "billet" },
|
|
1112
|
+
* ticket__plural: { en: "tickets", fr: "billets" },
|
|
1113
|
+
* });
|
|
1070
1114
|
*/
|
|
1115
|
+
const naviI18n = createI18n();
|
|
1071
1116
|
|
|
1117
|
+
naviI18n.addAll({
|
|
1118
|
+
"button.clear": {
|
|
1119
|
+
en: "Clear",
|
|
1120
|
+
fr: "Effacer",
|
|
1121
|
+
},
|
|
1122
|
+
"button.reset": {
|
|
1123
|
+
en: "Reset",
|
|
1124
|
+
fr: "Réinitialiser",
|
|
1125
|
+
},
|
|
1126
|
+
"button.send": {
|
|
1127
|
+
en: "Send",
|
|
1128
|
+
fr: "Envoyer",
|
|
1129
|
+
},
|
|
1130
|
+
"button.open": {
|
|
1131
|
+
en: "Open",
|
|
1132
|
+
fr: "Ouvrir",
|
|
1133
|
+
},
|
|
1134
|
+
"button.close": {
|
|
1135
|
+
en: "Close",
|
|
1136
|
+
fr: "Fermer",
|
|
1137
|
+
},
|
|
1138
|
+
"button.cancel": {
|
|
1139
|
+
en: "Cancel",
|
|
1140
|
+
fr: "Annuler",
|
|
1141
|
+
},
|
|
1142
|
+
"button.define": {
|
|
1143
|
+
en: "Define",
|
|
1144
|
+
fr: "Définir",
|
|
1145
|
+
},
|
|
1146
|
+
"button.confirm": {
|
|
1147
|
+
en: "Confirm",
|
|
1148
|
+
fr: "Confirmer",
|
|
1149
|
+
},
|
|
1150
|
+
"confirm.message": {
|
|
1151
|
+
en: "Are you sure you want to do this?",
|
|
1152
|
+
fr: "Êtes-vous sûr de vouloir faire cette action ?",
|
|
1153
|
+
},
|
|
1154
|
+
"button.more_actions": {
|
|
1155
|
+
en: "More actions",
|
|
1156
|
+
fr: "Autres actions",
|
|
1157
|
+
},
|
|
1158
|
+
"button.remove": {
|
|
1159
|
+
en: "Remove",
|
|
1160
|
+
fr: "Retirer",
|
|
1161
|
+
},
|
|
1162
|
+
});
|
|
1072
1163
|
|
|
1073
|
-
//
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
const aValue = a[lightKey];
|
|
1241
|
-
const bValue = b[lightKey];
|
|
1242
|
-
if (!compareAt(aValue, bValue, lightKey)) {
|
|
1243
|
-
return false;
|
|
1244
|
-
}
|
|
1245
|
-
keySet.delete(lightKey);
|
|
1246
|
-
}
|
|
1247
|
-
for (const key of keySet) {
|
|
1248
|
-
const aValue = a[key];
|
|
1249
|
-
const bValue = b[key];
|
|
1250
|
-
if (!compareAt(aValue, bValue, key)) {
|
|
1251
|
-
return false;
|
|
1252
|
-
}
|
|
1253
|
-
}
|
|
1254
|
-
} else {
|
|
1255
|
-
for (const key of aKeys) {
|
|
1256
|
-
const aValue = a[key];
|
|
1257
|
-
const bValue = b[key];
|
|
1258
|
-
if (!compareAt(aValue, bValue, key)) {
|
|
1259
|
-
return false;
|
|
1260
|
-
}
|
|
1261
|
-
}
|
|
1262
|
-
}
|
|
1263
|
-
return true;
|
|
1264
|
-
};
|
|
1265
|
-
const compareAt = keyComparator
|
|
1266
|
-
? (a, b, keyOrArrayIndex) => keyComparator(a, b, keyOrArrayIndex, compare)
|
|
1267
|
-
: compare;
|
|
1268
|
-
|
|
1269
|
-
return compare(rootA, rootB);
|
|
1270
|
-
};
|
|
1271
|
-
|
|
1272
|
-
const isPlainObject$1 = (value) => {
|
|
1273
|
-
const prototype = Object.getPrototypeOf(value);
|
|
1274
|
-
return prototype === Object.prototype || prototype === null;
|
|
1275
|
-
};
|
|
1276
|
-
|
|
1277
|
-
const debounceSignal = (
|
|
1278
|
-
signalToDebounce,
|
|
1279
|
-
{ delay = 300, deepCompare = true } = {},
|
|
1280
|
-
) => {
|
|
1281
|
-
let timeoutId;
|
|
1282
|
-
let latestValue = signalToDebounce.peek();
|
|
1283
|
-
const debouncedSignal = signal(latestValue);
|
|
1284
|
-
|
|
1285
|
-
effect(() => {
|
|
1286
|
-
const value = signalToDebounce.value;
|
|
1287
|
-
const debouncedValue = debouncedSignal.peek();
|
|
1288
|
-
if (
|
|
1289
|
-
deepCompare
|
|
1290
|
-
? compareTwoJsValues(value, debouncedValue)
|
|
1291
|
-
: value === debouncedValue
|
|
1292
|
-
) {
|
|
1293
|
-
return;
|
|
1294
|
-
}
|
|
1295
|
-
clearTimeout(timeoutId);
|
|
1296
|
-
latestValue = value;
|
|
1297
|
-
timeoutId = setTimeout(() => {
|
|
1298
|
-
debouncedSignal.value = latestValue;
|
|
1299
|
-
}, delay);
|
|
1300
|
-
});
|
|
1301
|
-
|
|
1302
|
-
debouncedSignal.flush = () => {
|
|
1303
|
-
clearTimeout(timeoutId);
|
|
1304
|
-
debouncedSignal.value = latestValue;
|
|
1305
|
-
};
|
|
1306
|
-
// Whoever reads the debounced value gets the previous one during the delay,
|
|
1307
|
-
// with nothing saying a newer one is on its way. This says it.
|
|
1308
|
-
debouncedSignal.settlingSignal = computed(() => {
|
|
1309
|
-
return !compareTwoJsValues(signalToDebounce.value, debouncedSignal.value);
|
|
1310
|
-
});
|
|
1311
|
-
|
|
1312
|
-
return debouncedSignal;
|
|
1313
|
-
};
|
|
1314
|
-
|
|
1315
|
-
const debouncedSignalCache = new WeakMap();
|
|
1316
|
-
|
|
1317
|
-
/**
|
|
1318
|
-
* The one debounced signal for a given source and delay.
|
|
1319
|
-
*
|
|
1320
|
-
* Two callers asking the same question of the same signal must wait on the same
|
|
1321
|
-
* answer: a debounced signal per caller is a timer per caller, and the actions
|
|
1322
|
-
* derived from them are then several instances of what is one request.
|
|
1323
|
-
*/
|
|
1324
|
-
const getDebouncedSignal = (signalToDebounce, delay) => {
|
|
1325
|
-
let byDelay = debouncedSignalCache.get(signalToDebounce);
|
|
1326
|
-
if (!byDelay) {
|
|
1327
|
-
byDelay = new Map();
|
|
1328
|
-
debouncedSignalCache.set(signalToDebounce, byDelay);
|
|
1329
|
-
}
|
|
1330
|
-
const existing = byDelay.get(delay);
|
|
1331
|
-
if (existing) {
|
|
1332
|
-
return existing;
|
|
1333
|
-
}
|
|
1334
|
-
const debouncedSignal = debounceSignal(signalToDebounce, { delay });
|
|
1335
|
-
byDelay.set(delay, debouncedSignal);
|
|
1336
|
-
return debouncedSignal;
|
|
1337
|
-
};
|
|
1338
|
-
|
|
1339
|
-
/**
|
|
1340
|
-
* jsenv/navi - createJsValueWeakMap
|
|
1341
|
-
*
|
|
1342
|
-
* Key/value cache with true ephemeron behavior and deep equality support.
|
|
1343
|
-
*
|
|
1344
|
-
* Features:
|
|
1345
|
-
* - Mutual retention: key keeps value alive, value keeps key alive
|
|
1346
|
-
* - Deep equality: different objects with same content are treated as identical keys
|
|
1347
|
-
* - Automatic GC: entries are eligible for collection when unreferenced
|
|
1348
|
-
* - Iteration support: can iterate over live entries for deep equality lookup
|
|
1349
|
-
*
|
|
1350
|
-
* Implementation:
|
|
1351
|
-
* - Dual WeakMap (key->value, value->key) provides ephemeron behavior
|
|
1352
|
-
* - WeakRef registry enables iteration without preventing GC
|
|
1353
|
-
* - Primitives stored in Map (permanent retention - avoid for keys)
|
|
1354
|
-
*
|
|
1355
|
-
* Use case: Action caching where params (key) and action (value) should have
|
|
1356
|
-
* synchronized lifetimes while allowing natural garbage collection.
|
|
1357
|
-
*/
|
|
1358
|
-
|
|
1164
|
+
// Default built-in translations — apps can override any key via add()
|
|
1165
|
+
naviI18n.addAll({
|
|
1166
|
+
"time.less_than_minute": {
|
|
1167
|
+
en: "in less than a minute",
|
|
1168
|
+
fr: "dans moins d'une minute",
|
|
1169
|
+
de: "in weniger als einer Minute",
|
|
1170
|
+
es: "en menos de un minuto",
|
|
1171
|
+
it: "in meno di un minuto",
|
|
1172
|
+
pt: "em menos de um minuto",
|
|
1173
|
+
nl: "over minder dan een minuut",
|
|
1174
|
+
},
|
|
1175
|
+
"time.ongoing": {
|
|
1176
|
+
en: "Ongoing",
|
|
1177
|
+
fr: "En cours",
|
|
1178
|
+
de: "Laufend",
|
|
1179
|
+
es: "En curso",
|
|
1180
|
+
it: "In corso",
|
|
1181
|
+
pt: "Em andamento",
|
|
1182
|
+
nl: "Bezig",
|
|
1183
|
+
},
|
|
1184
|
+
// [day] and [time] are replaced at runtime with the localized day/time strings
|
|
1185
|
+
"time.tomorrow_at": {
|
|
1186
|
+
en: "[day] at [time]",
|
|
1187
|
+
fr: "[day] à [time]",
|
|
1188
|
+
de: "[day] um [time]",
|
|
1189
|
+
es: "[day] a las [time]",
|
|
1190
|
+
it: "[day] alle [time]",
|
|
1191
|
+
pt: "[day] às [time]",
|
|
1192
|
+
nl: "[day] om [time]",
|
|
1193
|
+
},
|
|
1194
|
+
// [duration] is replaced at runtime with the formatted duration string (e.g. "1h30", "45 min")
|
|
1195
|
+
"time.in_duration": {
|
|
1196
|
+
en: "in [duration]",
|
|
1197
|
+
fr: "dans [duration]",
|
|
1198
|
+
de: "in [duration]",
|
|
1199
|
+
es: "en [duration]",
|
|
1200
|
+
it: "tra [duration]",
|
|
1201
|
+
pt: "em [duration]",
|
|
1202
|
+
nl: "over [duration]",
|
|
1203
|
+
},
|
|
1204
|
+
// Substituted in place of the "0 heure(s)" part of an Intl-generated
|
|
1205
|
+
// duration string when <Time type="time" format="long"> renders midnight
|
|
1206
|
+
// — see time.jsx's own TimeTime for why midnight can't just fall through
|
|
1207
|
+
// to formatMinuteDuration like every other hour does, and how this word
|
|
1208
|
+
// gets spliced in (formatToParts, not string concatenation) so the rest
|
|
1209
|
+
// of the sentence (conjunction, minutes) still comes out in whatever
|
|
1210
|
+
// grammar/word order this language's own Intl.DurationFormat produces.
|
|
1211
|
+
// Languages without an entry here fall back to that language's own
|
|
1212
|
+
// literal "0 heure(s)" wording instead (see TimeTime), never to this key.
|
|
1213
|
+
"time.midnight": {
|
|
1214
|
+
en: "midnight",
|
|
1215
|
+
fr: "minuit",
|
|
1216
|
+
de: "Mitternacht",
|
|
1217
|
+
es: "medianoche",
|
|
1218
|
+
it: "mezzanotte",
|
|
1219
|
+
pt: "meia-noite",
|
|
1220
|
+
nl: "middernacht",
|
|
1221
|
+
},
|
|
1222
|
+
// What <TimeRange> writes between the two bounds of a span — "8h–10h",
|
|
1223
|
+
// "11 mai – 14 mai". An en dash, the mark for a span, not a hyphen.
|
|
1224
|
+
"time.range_separator": {
|
|
1225
|
+
en: "–",
|
|
1226
|
+
fr: "–",
|
|
1227
|
+
de: "–",
|
|
1228
|
+
es: "–",
|
|
1229
|
+
it: "–",
|
|
1230
|
+
pt: "–",
|
|
1231
|
+
nl: "–",
|
|
1232
|
+
},
|
|
1233
|
+
// Compact duration unit symbols used in "1h30", "45min", "2d", etc.
|
|
1234
|
+
"time.duration.year_symbol": {
|
|
1235
|
+
en: "y",
|
|
1236
|
+
fr: "a",
|
|
1237
|
+
de: "J",
|
|
1238
|
+
es: "a",
|
|
1239
|
+
it: "a",
|
|
1240
|
+
pt: "a",
|
|
1241
|
+
nl: "j",
|
|
1242
|
+
ja: "年",
|
|
1243
|
+
zh: "年",
|
|
1244
|
+
ko: "년",
|
|
1245
|
+
},
|
|
1246
|
+
"time.duration.month_symbol": {
|
|
1247
|
+
en: "mo",
|
|
1248
|
+
fr: "mo",
|
|
1249
|
+
de: "Mo",
|
|
1250
|
+
es: "mo",
|
|
1251
|
+
it: "mo",
|
|
1252
|
+
pt: "mo",
|
|
1253
|
+
nl: "mo",
|
|
1254
|
+
ja: "月",
|
|
1255
|
+
zh: "月",
|
|
1256
|
+
ko: "월",
|
|
1257
|
+
},
|
|
1258
|
+
"time.duration.week_symbol": {
|
|
1259
|
+
en: "w",
|
|
1260
|
+
fr: "sem",
|
|
1261
|
+
de: "W",
|
|
1262
|
+
es: "sem",
|
|
1263
|
+
it: "sett",
|
|
1264
|
+
pt: "sem",
|
|
1265
|
+
nl: "w",
|
|
1266
|
+
ja: "週",
|
|
1267
|
+
zh: "周",
|
|
1268
|
+
ko: "주",
|
|
1269
|
+
},
|
|
1270
|
+
"time.duration.day_symbol": {
|
|
1271
|
+
en: "d",
|
|
1272
|
+
fr: "j",
|
|
1273
|
+
de: "T",
|
|
1274
|
+
es: "d",
|
|
1275
|
+
it: "g",
|
|
1276
|
+
pt: "d",
|
|
1277
|
+
nl: "d",
|
|
1278
|
+
ja: "日",
|
|
1279
|
+
zh: "天",
|
|
1280
|
+
ko: "일",
|
|
1281
|
+
},
|
|
1282
|
+
"time.duration.hour_symbol": {
|
|
1283
|
+
en: "h",
|
|
1284
|
+
fr: "h",
|
|
1285
|
+
de: "h",
|
|
1286
|
+
es: "h",
|
|
1287
|
+
it: "h",
|
|
1288
|
+
pt: "h",
|
|
1289
|
+
nl: "u",
|
|
1290
|
+
ja: "時間",
|
|
1291
|
+
zh: "小时",
|
|
1292
|
+
ko: "시간",
|
|
1293
|
+
},
|
|
1294
|
+
"time.duration.minute_symbol": {
|
|
1295
|
+
en: "min",
|
|
1296
|
+
fr: "min",
|
|
1297
|
+
de: "min",
|
|
1298
|
+
es: "min",
|
|
1299
|
+
it: "min",
|
|
1300
|
+
pt: "min",
|
|
1301
|
+
nl: "min",
|
|
1302
|
+
ja: "分",
|
|
1303
|
+
zh: "分",
|
|
1304
|
+
ko: "분",
|
|
1305
|
+
},
|
|
1306
|
+
"time.duration.second_symbol": {
|
|
1307
|
+
en: "s",
|
|
1308
|
+
fr: "s",
|
|
1309
|
+
de: "s",
|
|
1310
|
+
es: "s",
|
|
1311
|
+
it: "s",
|
|
1312
|
+
pt: "s",
|
|
1313
|
+
nl: "s",
|
|
1314
|
+
ja: "秒",
|
|
1315
|
+
zh: "秒",
|
|
1316
|
+
ko: "초",
|
|
1317
|
+
},
|
|
1318
|
+
"time.duration.millisecond_symbol": {
|
|
1319
|
+
en: "ms",
|
|
1320
|
+
fr: "ms",
|
|
1321
|
+
de: "ms",
|
|
1322
|
+
es: "ms",
|
|
1323
|
+
it: "ms",
|
|
1324
|
+
pt: "ms",
|
|
1325
|
+
nl: "ms",
|
|
1326
|
+
ja: "ms",
|
|
1327
|
+
zh: "ms",
|
|
1328
|
+
ko: "ms",
|
|
1329
|
+
},
|
|
1330
|
+
});
|
|
1359
1331
|
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1332
|
+
// Spin messages — the ends of what one steps through, said without naming
|
|
1333
|
+
// what it is made of: the same words fit days, months, pages or sizes.
|
|
1334
|
+
naviI18n.addAll({
|
|
1335
|
+
"spin.previous": {
|
|
1336
|
+
en: "Previous",
|
|
1337
|
+
fr: "Précédent",
|
|
1338
|
+
},
|
|
1339
|
+
"spin.next": {
|
|
1340
|
+
en: "Next",
|
|
1341
|
+
fr: "Suivant",
|
|
1342
|
+
},
|
|
1343
|
+
"spin.nothing_before": {
|
|
1344
|
+
en: "No item before this one.",
|
|
1345
|
+
fr: "Pas d'élément avant celui-ci.",
|
|
1346
|
+
},
|
|
1347
|
+
"spin.nothing_after": {
|
|
1348
|
+
en: "No item after this one.",
|
|
1349
|
+
fr: "Pas d'élément après celui-ci.",
|
|
1350
|
+
},
|
|
1351
|
+
});
|
|
1364
1352
|
|
|
1365
|
-
|
|
1366
|
-
|
|
1353
|
+
// Time spin messages — what a clock writes between an hour and its minutes,
|
|
1354
|
+
// and how the two ends of a span are named.
|
|
1355
|
+
naviI18n.addAll({
|
|
1356
|
+
"time.hour_separator": {
|
|
1357
|
+
en: ":",
|
|
1358
|
+
fr: "h",
|
|
1359
|
+
},
|
|
1360
|
+
"time.hour_label": {
|
|
1361
|
+
en: "Hours",
|
|
1362
|
+
fr: "Heures",
|
|
1363
|
+
},
|
|
1364
|
+
"time.minute_label": {
|
|
1365
|
+
en: "Minutes",
|
|
1366
|
+
fr: "Minutes",
|
|
1367
|
+
},
|
|
1368
|
+
"time_range.from": {
|
|
1369
|
+
en: "From",
|
|
1370
|
+
fr: "De",
|
|
1371
|
+
},
|
|
1372
|
+
"time_range.to": {
|
|
1373
|
+
en: "to",
|
|
1374
|
+
fr: "à",
|
|
1375
|
+
},
|
|
1376
|
+
});
|
|
1367
1377
|
|
|
1368
|
-
|
|
1369
|
-
|
|
1378
|
+
// List messages — override any key to customize list messages
|
|
1379
|
+
naviI18n.addAll({
|
|
1380
|
+
"list.empty": {
|
|
1381
|
+
en: "No items in this list.",
|
|
1382
|
+
fr: "Aucun élément dans cette liste.",
|
|
1383
|
+
},
|
|
1384
|
+
"list.no_match": {
|
|
1385
|
+
en: "No item matches this search.",
|
|
1386
|
+
fr: "Aucun élément ne correspond à cette recherche.",
|
|
1387
|
+
},
|
|
1388
|
+
"list.no_match_rest_shown": {
|
|
1389
|
+
en: "No item matches this search. The rest is shown below.",
|
|
1390
|
+
fr: "Aucun élément ne correspond à cette recherche. Le reste est affiché ci-dessous.",
|
|
1391
|
+
},
|
|
1392
|
+
"list.rows_failed": {
|
|
1393
|
+
en: "These elements could not be loaded.",
|
|
1394
|
+
fr: "Ces élements n'ont pas pu être chargées.",
|
|
1395
|
+
},
|
|
1396
|
+
"list.rows_retry": {
|
|
1397
|
+
en: "Retry",
|
|
1398
|
+
fr: "Réessayer",
|
|
1399
|
+
},
|
|
1400
|
+
});
|
|
1370
1401
|
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
return {
|
|
1380
|
-
*[Symbol.iterator]() {
|
|
1381
|
-
cleanupKeyRegistry();
|
|
1382
|
-
for (const keyRef of keyRegistry) {
|
|
1383
|
-
const key = keyRef.deref();
|
|
1384
|
-
if (key && keyToValue.has(key)) {
|
|
1385
|
-
yield [key, keyToValue.get(key)];
|
|
1386
|
-
}
|
|
1387
|
-
}
|
|
1388
|
-
for (const [k, v] of primitiveCache) {
|
|
1389
|
-
yield [k, v];
|
|
1390
|
-
}
|
|
1391
|
-
},
|
|
1392
|
-
|
|
1393
|
-
get(key) {
|
|
1394
|
-
const isObject =
|
|
1395
|
-
key && (typeof key === "object" || typeof key === "function");
|
|
1396
|
-
if (isObject) {
|
|
1397
|
-
// Fast path: exact key match
|
|
1398
|
-
if (keyToValue.has(key)) {
|
|
1399
|
-
return keyToValue.get(key);
|
|
1400
|
-
}
|
|
1401
|
-
|
|
1402
|
-
// Slow path: deep equality search
|
|
1403
|
-
cleanupKeyRegistry();
|
|
1404
|
-
for (const keyRef of keyRegistry) {
|
|
1405
|
-
const existingKey = keyRef.deref();
|
|
1406
|
-
if (existingKey && compareTwoJsValues(existingKey, key)) {
|
|
1407
|
-
return keyToValue.get(existingKey);
|
|
1408
|
-
}
|
|
1409
|
-
}
|
|
1410
|
-
return undefined;
|
|
1411
|
-
}
|
|
1412
|
-
return primitiveCache.get(key);
|
|
1413
|
-
},
|
|
1414
|
-
|
|
1415
|
-
set(key, value) {
|
|
1416
|
-
const isObject =
|
|
1417
|
-
key && (typeof key === "object" || typeof key === "function");
|
|
1418
|
-
if (isObject) {
|
|
1419
|
-
cleanupKeyRegistry();
|
|
1420
|
-
|
|
1421
|
-
// Remove existing deep-equal key
|
|
1422
|
-
for (const keyRef of keyRegistry) {
|
|
1423
|
-
const existingKey = keyRef.deref();
|
|
1424
|
-
if (existingKey && compareTwoJsValues(existingKey, key)) {
|
|
1425
|
-
const existingValue = keyToValue.get(existingKey);
|
|
1426
|
-
keyToValue.delete(existingKey);
|
|
1427
|
-
valueToKey.delete(existingValue);
|
|
1428
|
-
keyRegistry.delete(keyRef);
|
|
1429
|
-
break;
|
|
1430
|
-
}
|
|
1431
|
-
}
|
|
1432
|
-
|
|
1433
|
-
// Set ephemeron pair
|
|
1434
|
-
keyToValue.set(key, value);
|
|
1435
|
-
valueToKey.set(value, key);
|
|
1436
|
-
keyRegistry.add(new WeakRef(key));
|
|
1437
|
-
} else {
|
|
1438
|
-
primitiveCache.set(key, value);
|
|
1439
|
-
}
|
|
1440
|
-
},
|
|
1441
|
-
|
|
1442
|
-
delete(key) {
|
|
1443
|
-
const isObject =
|
|
1444
|
-
key && (typeof key === "object" || typeof key === "function");
|
|
1445
|
-
if (isObject) {
|
|
1446
|
-
cleanupKeyRegistry();
|
|
1447
|
-
|
|
1448
|
-
// Try exact match first
|
|
1449
|
-
if (keyToValue.has(key)) {
|
|
1450
|
-
const value = keyToValue.get(key);
|
|
1451
|
-
keyToValue.delete(key);
|
|
1452
|
-
valueToKey.delete(value);
|
|
1453
|
-
|
|
1454
|
-
// Remove from registry
|
|
1455
|
-
for (const keyRef of keyRegistry) {
|
|
1456
|
-
if (keyRef.deref() === key) {
|
|
1457
|
-
keyRegistry.delete(keyRef);
|
|
1458
|
-
break;
|
|
1459
|
-
}
|
|
1460
|
-
}
|
|
1461
|
-
return true;
|
|
1462
|
-
}
|
|
1463
|
-
|
|
1464
|
-
// Try deep equality
|
|
1465
|
-
for (const keyRef of keyRegistry) {
|
|
1466
|
-
const existingKey = keyRef.deref();
|
|
1467
|
-
if (existingKey && compareTwoJsValues(existingKey, key)) {
|
|
1468
|
-
const value = keyToValue.get(existingKey);
|
|
1469
|
-
keyToValue.delete(existingKey);
|
|
1470
|
-
valueToKey.delete(value);
|
|
1471
|
-
keyRegistry.delete(keyRef);
|
|
1472
|
-
return true;
|
|
1473
|
-
}
|
|
1474
|
-
}
|
|
1475
|
-
return false;
|
|
1476
|
-
}
|
|
1477
|
-
return primitiveCache.delete(key);
|
|
1478
|
-
},
|
|
1479
|
-
|
|
1480
|
-
getStats: () => {
|
|
1481
|
-
cleanupKeyRegistry();
|
|
1482
|
-
const aliveKeys = Array.from(keyRegistry).filter((ref) =>
|
|
1483
|
-
ref.deref(),
|
|
1484
|
-
).length;
|
|
1485
|
-
|
|
1486
|
-
return {
|
|
1487
|
-
ephemeronPairs: {
|
|
1488
|
-
total: keyRegistry.size,
|
|
1489
|
-
alive: aliveKeys,
|
|
1490
|
-
note: "True ephemeron: key ↔ value mutual retention via dual WeakMap",
|
|
1491
|
-
},
|
|
1492
|
-
primitive: {
|
|
1493
|
-
total: primitiveCache.size,
|
|
1494
|
-
note: "Primitive keys never GC'd",
|
|
1495
|
-
},
|
|
1496
|
-
};
|
|
1497
|
-
},
|
|
1498
|
-
};
|
|
1499
|
-
};
|
|
1500
|
-
|
|
1501
|
-
const MERGE_AS_PRIMITIVE_SYMBOL = Symbol("navi_merge_as_primitive");
|
|
1502
|
-
|
|
1503
|
-
const mergeTwoJsValues = (firstValue, secondValue) => {
|
|
1504
|
-
const firstIsPrimitive =
|
|
1505
|
-
firstValue === null ||
|
|
1506
|
-
typeof firstValue !== "object" ||
|
|
1507
|
-
MERGE_AS_PRIMITIVE_SYMBOL in firstValue;
|
|
1508
|
-
|
|
1509
|
-
if (firstIsPrimitive) {
|
|
1510
|
-
return secondValue;
|
|
1511
|
-
}
|
|
1512
|
-
const secondIsPrimitive =
|
|
1513
|
-
secondValue === null ||
|
|
1514
|
-
typeof secondValue !== "object" ||
|
|
1515
|
-
MERGE_AS_PRIMITIVE_SYMBOL in secondValue;
|
|
1516
|
-
if (secondIsPrimitive) {
|
|
1517
|
-
return secondValue;
|
|
1518
|
-
}
|
|
1519
|
-
const objectMerge = {};
|
|
1520
|
-
const firstKeys = Object.keys(firstValue);
|
|
1521
|
-
const secondKeys = Object.keys(secondValue);
|
|
1522
|
-
let hasChanged = false;
|
|
1523
|
-
|
|
1524
|
-
// First loop: check for keys in first object and recursively merge with second
|
|
1525
|
-
for (const key of firstKeys) {
|
|
1526
|
-
const firstValueForKey = firstValue[key];
|
|
1527
|
-
const secondHasKey = secondKeys.includes(key);
|
|
1528
|
-
|
|
1529
|
-
if (secondHasKey) {
|
|
1530
|
-
const secondValueForKey = secondValue[key];
|
|
1531
|
-
const mergedValue = mergeTwoJsValues(firstValueForKey, secondValueForKey);
|
|
1532
|
-
objectMerge[key] = mergedValue;
|
|
1533
|
-
if (mergedValue !== firstValueForKey) {
|
|
1534
|
-
hasChanged = true;
|
|
1535
|
-
}
|
|
1536
|
-
} else {
|
|
1537
|
-
objectMerge[key] = firstValueForKey;
|
|
1538
|
-
}
|
|
1539
|
-
}
|
|
1540
|
-
|
|
1541
|
-
for (const key of secondKeys) {
|
|
1542
|
-
if (firstKeys.includes(key)) {
|
|
1543
|
-
continue;
|
|
1544
|
-
}
|
|
1545
|
-
objectMerge[key] = secondValue[key];
|
|
1546
|
-
hasChanged = true;
|
|
1547
|
-
}
|
|
1548
|
-
|
|
1549
|
-
if (!hasChanged) {
|
|
1550
|
-
return firstValue;
|
|
1551
|
-
}
|
|
1552
|
-
return objectMerge;
|
|
1553
|
-
};
|
|
1554
|
-
|
|
1555
|
-
/**
|
|
1556
|
-
* Creates an effect that uses WeakRef to prevent garbage collection of referenced values.
|
|
1557
|
-
*
|
|
1558
|
-
* This utility is useful when you want to create reactive effects that watch objects
|
|
1559
|
-
* without preventing those objects from being garbage collected. If any of the referenced
|
|
1560
|
-
* values is collected, the effect automatically disposes itself.
|
|
1561
|
-
*
|
|
1562
|
-
* @param {Array} values - Array of values to create weak references for
|
|
1563
|
-
* @param {Function} callback - Function to call when the effect runs, receives dereferenced values as arguments
|
|
1564
|
-
* @returns {Function} dispose - Function to manually dispose the effect
|
|
1565
|
-
*
|
|
1566
|
-
* @example
|
|
1567
|
-
* ```js
|
|
1568
|
-
* const objectA = { name: "A" };
|
|
1569
|
-
* const objectB = { name: "B" };
|
|
1570
|
-
* const prefixSignal = signal('demo');
|
|
1571
|
-
*
|
|
1572
|
-
* const dispose = weakEffect([objectA, objectB], (a, b) => {
|
|
1573
|
-
* const prefix = prefixSignal.value
|
|
1574
|
-
* console.log(prefix, a.name, b.name);
|
|
1575
|
-
* });
|
|
1576
|
-
*
|
|
1577
|
-
* // Effect will auto-dispose if objectA or objectB where garbage collected
|
|
1578
|
-
* // or can be manually disposed:
|
|
1579
|
-
* dispose();
|
|
1580
|
-
* ```
|
|
1581
|
-
*/
|
|
1582
|
-
const weakEffect = (values, callback) => {
|
|
1583
|
-
const weakRefSet = new Set();
|
|
1584
|
-
for (const value of values) {
|
|
1585
|
-
weakRefSet.add(new WeakRef(value));
|
|
1586
|
-
}
|
|
1587
|
-
const dispose = effect(() => {
|
|
1588
|
-
const values = [];
|
|
1589
|
-
for (const weakRef of weakRefSet) {
|
|
1590
|
-
const value = weakRef.deref();
|
|
1591
|
-
if (value === undefined) {
|
|
1592
|
-
dispose();
|
|
1593
|
-
return;
|
|
1594
|
-
}
|
|
1595
|
-
values.push(value);
|
|
1596
|
-
}
|
|
1597
|
-
callback(...values);
|
|
1598
|
-
});
|
|
1599
|
-
return dispose;
|
|
1600
|
-
};
|
|
1601
|
-
|
|
1602
|
-
/**
|
|
1603
|
-
* Interpolates a template string, replacing `[key]` placeholders with values.
|
|
1604
|
-
*
|
|
1605
|
-
* Usable on its own — no i18n instance required — whenever a sentence should
|
|
1606
|
-
* stay readable as one string instead of being cut into JSX expressions or
|
|
1607
|
-
* concatenations. `<Interpolate>` is the JSX form of this function, and
|
|
1608
|
-
* `createI18n` runs every translation through it. See `docs/i18n.md`.
|
|
1609
|
-
*
|
|
1610
|
-
* `[]` was chosen as the placeholder delimiter (rather than `{}` or `{{}}`)
|
|
1611
|
-
* because it does not conflict with JSX syntax, JavaScript template literals,
|
|
1612
|
-
* or common punctuation in translated strings.
|
|
1613
|
-
*
|
|
1614
|
-
* @param {string} template
|
|
1615
|
-
* e.g. `"Hello [name], you have [count] messages"`. A non-string is returned
|
|
1616
|
-
* untouched, as is any template when `replacements` is missing.
|
|
1617
|
-
* @param {object} [replacements]
|
|
1618
|
-
* Values keyed by placeholder name. A key can be:
|
|
1619
|
-
* - a direct name — `[name]` ← `{ name: "Alice" }`
|
|
1620
|
-
* - a dot-path — `[item.label]` ← `{ item: { label: "Book" } }` (a literal
|
|
1621
|
-
* `"item.label"` key wins over the path)
|
|
1622
|
-
*
|
|
1623
|
-
* A value that is a function is called at that point, so an expensive or
|
|
1624
|
-
* lazily-known replacement is only computed when the placeholder is actually
|
|
1625
|
-
* present in this language's template.
|
|
1626
|
-
*
|
|
1627
|
-
* A placeholder with no matching value is left in the output as-is
|
|
1628
|
-
* (`"[name]"`), making the gap visible rather than silently empty.
|
|
1629
|
-
* @param {object} [options]
|
|
1630
|
-
* @param {boolean} [options.allowJsx=false]
|
|
1631
|
-
* Allow VNode replacements (what `<Interpolate>` passes). Without it, a VNode
|
|
1632
|
-
* value warns and is coerced to a string.
|
|
1633
|
-
* @returns {string|import("preact").VNode}
|
|
1634
|
-
* A plain string when every replacement is a string, a Preact fragment when
|
|
1635
|
-
* at least one VNode was interpolated with `allowJsx`.
|
|
1636
|
-
*/
|
|
1637
|
-
const interpolateText = (
|
|
1638
|
-
template,
|
|
1639
|
-
replacements,
|
|
1640
|
-
{ allowJsx = false } = {},
|
|
1641
|
-
) => {
|
|
1642
|
-
if (!replacements || typeof template !== "string") {
|
|
1643
|
-
return template;
|
|
1644
|
-
}
|
|
1645
|
-
const parts = template.split(/(\[[^\]]+\])/);
|
|
1646
|
-
let hasVnode = false;
|
|
1647
|
-
const resolved = [];
|
|
1648
|
-
for (const part of parts) {
|
|
1649
|
-
const match = part.match(/^\[([^\]]+)\]$/);
|
|
1650
|
-
if (!match) {
|
|
1651
|
-
resolved.push(part);
|
|
1652
|
-
continue;
|
|
1653
|
-
}
|
|
1654
|
-
const key = match[1];
|
|
1655
|
-
let value = resolveValue(replacements, key, part);
|
|
1656
|
-
if (typeof value === "function") {
|
|
1657
|
-
value = value();
|
|
1658
|
-
}
|
|
1659
|
-
if (isValidElement(value)) {
|
|
1660
|
-
if (allowJsx) {
|
|
1661
|
-
hasVnode = true;
|
|
1662
|
-
} else {
|
|
1663
|
-
console.warn(
|
|
1664
|
-
`interpolateText: VNode passed for placeholder [${match[1]}] but allowJsx is false — value coerced to string`,
|
|
1665
|
-
);
|
|
1666
|
-
}
|
|
1667
|
-
}
|
|
1668
|
-
resolved.push(value);
|
|
1669
|
-
}
|
|
1670
|
-
if (!hasVnode) {
|
|
1671
|
-
return resolved.join("");
|
|
1672
|
-
}
|
|
1673
|
-
// h(Fragment) instead of JSX (<>{resolved}</>) to keep this file as .js
|
|
1674
|
-
return h(Fragment, null, resolved);
|
|
1675
|
-
};
|
|
1676
|
-
|
|
1677
|
-
// Resolves a placeholder key against the replacements object.
|
|
1678
|
-
// 1. Direct lookup: replacements["item.name"]
|
|
1679
|
-
// 2. Dot-path lookup: replacements["item"]["name"]
|
|
1680
|
-
// 3. Fallback: the original placeholder string (e.g. "[item.name]")
|
|
1681
|
-
const resolveValue = (replacements, key, fallback) => {
|
|
1682
|
-
if (key in replacements) {
|
|
1683
|
-
return replacements[key];
|
|
1684
|
-
}
|
|
1685
|
-
const dotIndex = key.indexOf(".");
|
|
1686
|
-
if (dotIndex !== -1) {
|
|
1687
|
-
const head = key.slice(0, dotIndex);
|
|
1688
|
-
const tail = key.slice(dotIndex + 1);
|
|
1689
|
-
const parent = replacements[head];
|
|
1690
|
-
if (parent && typeof parent === "object") {
|
|
1691
|
-
const nested = parent[tail];
|
|
1692
|
-
if (nested !== undefined) {
|
|
1693
|
-
return nested;
|
|
1694
|
-
}
|
|
1695
|
-
}
|
|
1696
|
-
}
|
|
1697
|
-
return fallback;
|
|
1698
|
-
};
|
|
1699
|
-
|
|
1700
|
-
const DEFAULT_LANG = "en";
|
|
1701
|
-
|
|
1702
|
-
/**
|
|
1703
|
-
* The browser's own language preferences, most preferred first — read from
|
|
1704
|
-
* `navigator.languages` (falling back to the single `navigator.language`,
|
|
1705
|
-
* then to DEFAULT_LANG when neither is available, e.g. during SSR). Kept as
|
|
1706
|
-
* its own signal, independent from what this app actually supports — see
|
|
1707
|
-
* `supportedLanguagesSignal` below for the allow-list that filters it, and
|
|
1708
|
-
* `languagesSignal` for the final, ready-to-use combination of the two (plus
|
|
1709
|
-
* `preferredLanguageSignal`).
|
|
1710
|
-
*/
|
|
1711
|
-
const getRuntimeLanguages = () => {
|
|
1712
|
-
if (typeof window === "undefined") {
|
|
1713
|
-
return [DEFAULT_LANG];
|
|
1714
|
-
}
|
|
1715
|
-
const { navigator } = window;
|
|
1716
|
-
if (typeof navigator === "undefined") {
|
|
1717
|
-
return [DEFAULT_LANG];
|
|
1718
|
-
}
|
|
1719
|
-
const { languages } = navigator;
|
|
1720
|
-
if (Array.isArray(languages) && languages.length > 0) {
|
|
1721
|
-
return languages;
|
|
1722
|
-
}
|
|
1723
|
-
const { language } = navigator;
|
|
1724
|
-
if (typeof language === "string") {
|
|
1725
|
-
return [language];
|
|
1726
|
-
}
|
|
1727
|
-
return [DEFAULT_LANG];
|
|
1728
|
-
};
|
|
1729
|
-
|
|
1730
|
-
const runtimeLanguagesSignal = signal(getRuntimeLanguages());
|
|
1731
|
-
|
|
1732
|
-
if (typeof window !== "undefined") {
|
|
1733
|
-
window.addEventListener("languagechange", () => {
|
|
1734
|
-
runtimeLanguagesSignal.value = getRuntimeLanguages();
|
|
1735
|
-
});
|
|
1736
|
-
}
|
|
1737
|
-
|
|
1738
|
-
/**
|
|
1739
|
-
* The languages this app actually offers, e.g. `["en", "fr"]` — an allow-list
|
|
1740
|
-
* `languagesSignal` below filters everything else against (runtime languages the
|
|
1741
|
-
* browser reports, and `preferredLanguageSignal`'s own override), so a site
|
|
1742
|
-
* that only supports English/French never ends up resolving to German just
|
|
1743
|
-
* because that happens to be the browser's or the user's own preference.
|
|
1744
|
-
*
|
|
1745
|
-
* `null` (the default) means no restriction at all: every language the
|
|
1746
|
-
* browser/user prefers is allowed through.
|
|
1747
|
-
*/
|
|
1748
|
-
const supportedLanguagesSignal = signal(null);
|
|
1749
|
-
|
|
1750
|
-
/**
|
|
1751
|
-
* @param {string[]|null} languages - e.g. `["en", "fr"]`. Pass `null`/`[]`
|
|
1752
|
-
* to lift the restriction again (allow everything).
|
|
1753
|
-
*/
|
|
1754
|
-
const setSupportedLanguages = (languages) => {
|
|
1755
|
-
supportedLanguagesSignal.value =
|
|
1756
|
-
languages && languages.length ? languages : null;
|
|
1757
|
-
};
|
|
1758
|
-
|
|
1759
|
-
/**
|
|
1760
|
-
* A single language the user explicitly chose (e.g. via an in-app language
|
|
1761
|
-
* picker), overriding whatever the browser itself reports — takes priority
|
|
1762
|
-
* over `runtimeLanguagesSignal` in `languagesSignal` below, but is still subject
|
|
1763
|
-
* to `supportedLanguagesSignal`'s own allow-list.
|
|
1764
|
-
*
|
|
1765
|
-
* Deliberately a single language, not an ordered list: reordering *among*
|
|
1766
|
-
* several preferred languages is real complexity real users essentially
|
|
1767
|
-
* never want — the practical need `languagesSignal` needs to serve is "let this
|
|
1768
|
-
* one user pick their one preferred language instead of the browser's",
|
|
1769
|
-
* nothing more.
|
|
1770
|
-
*/
|
|
1771
|
-
const preferredLanguageSignal = signal(null);
|
|
1772
|
-
|
|
1773
|
-
/**
|
|
1774
|
-
* @param {string|null} language - BCP 47 tag, e.g. "fr". Pass `null` to
|
|
1775
|
-
* go back to following the browser's own language.
|
|
1776
|
-
*/
|
|
1777
|
-
const setPreferredLanguage = (language) => {
|
|
1778
|
-
preferredLanguageSignal.value = language || null;
|
|
1779
|
-
};
|
|
1780
|
-
|
|
1781
|
-
const getPrimarySubtag = (lang) => lang.split("-")[0];
|
|
1782
|
-
|
|
1783
|
-
const isLanguageSupported = (lang, supportedLanguages) => {
|
|
1784
|
-
const primarySubtag = getPrimarySubtag(lang);
|
|
1785
|
-
return supportedLanguages.some(
|
|
1786
|
-
(supportedLanguage) =>
|
|
1787
|
-
getPrimarySubtag(supportedLanguage) === primarySubtag,
|
|
1788
|
-
);
|
|
1789
|
-
};
|
|
1790
|
-
|
|
1791
|
-
/**
|
|
1792
|
-
* The ordered, ready-to-use language preference list every navi
|
|
1793
|
-
* component/util defaults to (naviI18n, formatNumber, the Time components,
|
|
1794
|
-
* validation messages…), live on every read. Combines, in priority order:
|
|
1795
|
-
*
|
|
1796
|
-
* 1. `preferredLanguageSignal` (the user's own explicit pick, if any)
|
|
1797
|
-
* 2. `runtimeLanguagesSignal` (the browser's own ordered preferences)
|
|
1798
|
-
*
|
|
1799
|
-
* then filters the result through `supportedLanguagesSignal` (if set) so
|
|
1800
|
-
* only languages this app actually offers ever come out — e.g. a browser
|
|
1801
|
-
* preferring `["de", "fr", "en"]` on a site that only supports `["en",
|
|
1802
|
-
* "fr"]` resolves to `["fr", "en"]`, never touching German. If filtering
|
|
1803
|
-
* would leave nothing at all (none of the browser's/user's preferences are
|
|
1804
|
-
* supported), falls back to `supportedLanguagesSignal` itself so callers
|
|
1805
|
-
* still get *something* usable rather than an empty array.
|
|
1806
|
-
*
|
|
1807
|
-
* Consumers that accept either a single lang or an ordered array (this
|
|
1808
|
-
* package's own `matchBestLang`/`createI18n`, and native `Intl.NumberFormat`/
|
|
1809
|
-
* `Intl.DateTimeFormat`) can pass this straight through: anything not
|
|
1810
|
-
* covered by the first entry falls through to the next, rather than
|
|
1811
|
-
* jumping straight to an unrelated default like "en".
|
|
1812
|
-
*/
|
|
1813
|
-
const languagesSignal = computed(() => {
|
|
1814
|
-
const preferredLanguage = preferredLanguageSignal.value;
|
|
1815
|
-
const runtimeLanguages = runtimeLanguagesSignal.value;
|
|
1816
|
-
const supportedLanguages = supportedLanguagesSignal.value;
|
|
1817
|
-
|
|
1818
|
-
const orderedLanguages = preferredLanguage
|
|
1819
|
-
? [preferredLanguage, ...runtimeLanguages]
|
|
1820
|
-
: runtimeLanguages;
|
|
1821
|
-
const dedupedLanguages = [...new Set(orderedLanguages)];
|
|
1822
|
-
|
|
1823
|
-
if (!supportedLanguages) {
|
|
1824
|
-
return dedupedLanguages;
|
|
1825
|
-
}
|
|
1826
|
-
const filteredLanguages = dedupedLanguages.filter((lang) =>
|
|
1827
|
-
isLanguageSupported(lang, supportedLanguages),
|
|
1828
|
-
);
|
|
1829
|
-
return filteredLanguages.length > 0 ? filteredLanguages : supportedLanguages;
|
|
1830
|
-
});
|
|
1831
|
-
|
|
1832
|
-
/**
|
|
1833
|
-
* Creates a lightweight i18n instance: a central place where an app declares
|
|
1834
|
-
* its texts once and reads them back translated into the active language.
|
|
1835
|
-
*
|
|
1836
|
-
* Worth using even in a single-language app — one registry beats strings
|
|
1837
|
-
* scattered across components, and adding a second language later becomes a
|
|
1838
|
-
* data change instead of a refactor. See `docs/i18n.md` for how to choose
|
|
1839
|
-
* between the two key styles below and how this relates to `naviI18n`.
|
|
1840
|
-
*
|
|
1841
|
-
* @param {object} [options]
|
|
1842
|
-
* @param {string} [options.keyLang]
|
|
1843
|
-
* When set, each key also serves as its own translation for `keyLang`.
|
|
1844
|
-
* This allows writing keys directly in that language (typically the language
|
|
1845
|
-
* the app is written in) so only *other* languages need registering:
|
|
1846
|
-
*
|
|
1847
|
-
* ```js
|
|
1848
|
-
* const i18n = createI18n({ keyLang: "en" });
|
|
1849
|
-
* i18n.add("Hello [name]!", { fr: "Bonjour [name] !" });
|
|
1850
|
-
* i18n("Hello [name]!", { name: "Alice" }, { lang: "en" }); // "Hello Alice!"
|
|
1851
|
-
* i18n("Hello [name]!", { name: "Alice" }, { lang: "fr" }); // "Bonjour Alice !"
|
|
1852
|
-
* ```
|
|
1853
|
-
*
|
|
1854
|
-
* `keyLang` only applies to keys passed to `add()`/`addAll()`; a key never
|
|
1855
|
-
* registered stays opaque and comes back as-is.
|
|
1856
|
-
*
|
|
1857
|
-
* Without `keyLang`, keys are opaque identifiers and every language
|
|
1858
|
-
* (including the one the app was written in) must be registered explicitly:
|
|
1859
|
-
*
|
|
1860
|
-
* ```js
|
|
1861
|
-
* const i18n = createI18n();
|
|
1862
|
-
* i18n.add("greeting", { en: "Hello [name]!", fr: "Bonjour [name] !" });
|
|
1863
|
-
* i18n("greeting", { name: "Alice" }, { lang: "en" }); // "Hello Alice!"
|
|
1864
|
-
* ```
|
|
1865
|
-
*
|
|
1866
|
-
* @param {string} [options.fallbackLang]
|
|
1867
|
-
* Language consulted when the active language has no translation for a key
|
|
1868
|
-
* — per key, not per language: a partially translated language falls through
|
|
1869
|
-
* to `fallbackLang` only for the keys it is missing. Without it, a missing
|
|
1870
|
-
* translation returns the key itself.
|
|
1871
|
-
*
|
|
1872
|
-
* @param {string|string[]} [options.runtimeLang]
|
|
1873
|
-
* The active language (BCP 47 tag or ordered array of tags) — named
|
|
1874
|
-
* "runtime" rather than "system" because there is no actual access to the
|
|
1875
|
-
* OS/user's system language from a browser, only `navigator.languages` (or
|
|
1876
|
-
* an explicit override) at runtime. Defaults to `languagesSignal.value`, read
|
|
1877
|
-
* fresh on every `format()`/`has()` call (not frozen at creation time) —
|
|
1878
|
-
* so overriding the language app-wide via `setPreferredLanguage()`/
|
|
1879
|
-
* `setSupportedLanguages()` (see lang_signal.js) is picked up here too.
|
|
1880
|
-
* Passing an explicit `runtimeLang` opts out of that and stays fixed for
|
|
1881
|
-
* this instance's whole lifetime.
|
|
1882
|
-
*
|
|
1883
|
-
* ---
|
|
1884
|
-
*
|
|
1885
|
-
* ## Registration
|
|
1886
|
-
*
|
|
1887
|
-
* **`i18n.add(key, { lang: "translation" })`** — one key, multiple languages.
|
|
1888
|
-
*
|
|
1889
|
-
* **`i18n.addAll({ key: { lang: "translation" }, ... })`** — multiple keys at once.
|
|
1890
|
-
*
|
|
1891
|
-
* **`i18n.addLangKeys(lang, { key: "translation", ... })`** — full language pack
|
|
1892
|
-
* (useful when loading a JSON translation file).
|
|
1893
|
-
*
|
|
1894
|
-
* All three accumulate: registering a key that already exists overwrites that
|
|
1895
|
-
* one key and leaves the rest of the language untouched. This is what lets an
|
|
1896
|
-
* app override a single built-in navi text without redeclaring the others.
|
|
1897
|
-
*
|
|
1898
|
-
* A regional variant (e.g. `"fr-CA"`) automatically inherits all keys from its
|
|
1899
|
-
* parent (`"fr"`) that it does not explicitly override:
|
|
1900
|
-
* ```js
|
|
1901
|
-
* i18n.addLangKeys("fr", { hello: "Bonjour !" });
|
|
1902
|
-
* i18n.addLangKeys("fr-CA", { hello: "Allo !" }); // other "fr" keys inherited
|
|
1903
|
-
* ```
|
|
1904
|
-
* Inheritance is resolved at registration time, so register the parent first.
|
|
1905
|
-
*
|
|
1906
|
-
* ---
|
|
1907
|
-
*
|
|
1908
|
-
* ## Reading
|
|
1909
|
-
*
|
|
1910
|
-
* **`i18n(key, values?, { lang? })`** — the translation for `key`, with
|
|
1911
|
-
* `[placeholder]` occurrences replaced from `values` (see `interpolateText`).
|
|
1912
|
-
* Returns `key` itself when nothing matches, so an untranslated string still
|
|
1913
|
-
* renders something readable. `i18n.format` is an alias of this call.
|
|
1914
|
-
*
|
|
1915
|
-
* **`i18n.has(key, { lang? })`** — whether a translation genuinely exists,
|
|
1916
|
-
* i.e. how to tell "no translation" apart from "translation equal to the key".
|
|
1917
|
-
*
|
|
1918
|
-
* @returns {Function & { add, addAll, addLangKeys, has, format, languageMap }}
|
|
1919
|
-
*/
|
|
1920
|
-
const createI18n = ({ keyLang, fallbackLang, runtimeLang } = {}) => {
|
|
1921
|
-
const languageMap = new Map();
|
|
1922
|
-
// Bumped by addLangKeys — the only thing besides the active lang itself
|
|
1923
|
-
// that could change what getActiveLang()/getResolvedFallbackLang() below
|
|
1924
|
-
// resolve to, so it's what invalidates their own small caches.
|
|
1925
|
-
let languageMapVersion = 0;
|
|
1926
|
-
|
|
1927
|
-
// Without an explicit runtimeLang, languagesSignal.value is re-read fresh on
|
|
1928
|
-
// every call rather than frozen here via languagesSignal.peek() — freezing it
|
|
1929
|
-
// would silently ignore setPreferredLanguage()/setSupportedLanguages() (see
|
|
1930
|
-
// lang_signal.js) for the rest of this instance's life.
|
|
1931
|
-
const hasExplicitRuntimeLang = runtimeLang !== undefined;
|
|
1932
|
-
|
|
1933
|
-
// matchBestLang does real work (a Map lookup per candidate, a possible
|
|
1934
|
-
// "fr-CA" → "fr" split-and-retry loop) — worth skipping on every single
|
|
1935
|
-
// format()/has() call in the common case, since what it resolves to only
|
|
1936
|
-
// ever changes when languageMap itself changes (addLangKeys) or, for the
|
|
1937
|
-
// non-explicit case, when languagesSignal.value itself changes (preferred
|
|
1938
|
-
// language, supported languages, or "languagechange" — see lang_signal.js,
|
|
1939
|
-
// languagesSignal is a computed() so its reference is stable when none of its
|
|
1940
|
-
// own dependencies actually changed) — comparing those two cheaply
|
|
1941
|
-
// (===) is enough to know the cached result below is still valid.
|
|
1942
|
-
let cachedActiveLang;
|
|
1943
|
-
let cachedActiveLangRuntimeLang;
|
|
1944
|
-
let cachedActiveLangVersion = -1;
|
|
1945
|
-
const getActiveLang = () => {
|
|
1946
|
-
const currentRuntimeLang = hasExplicitRuntimeLang
|
|
1947
|
-
? runtimeLang
|
|
1948
|
-
: languagesSignal.value;
|
|
1949
|
-
if (
|
|
1950
|
-
cachedActiveLangVersion === languageMapVersion &&
|
|
1951
|
-
cachedActiveLangRuntimeLang === currentRuntimeLang
|
|
1952
|
-
) {
|
|
1953
|
-
return cachedActiveLang;
|
|
1954
|
-
}
|
|
1955
|
-
cachedActiveLang = matchBestLang(currentRuntimeLang, languageMap);
|
|
1956
|
-
cachedActiveLangVersion = languageMapVersion;
|
|
1957
|
-
cachedActiveLangRuntimeLang = currentRuntimeLang;
|
|
1958
|
-
return cachedActiveLang;
|
|
1959
|
-
};
|
|
1960
|
-
|
|
1961
|
-
// fallbackLang is a plain, never-reactive option set once at creation —
|
|
1962
|
-
// its own resolution only ever needs recomputing when languageMap does.
|
|
1963
|
-
let cachedResolvedFallbackLang;
|
|
1964
|
-
let cachedResolvedFallbackLangVersion = -1;
|
|
1965
|
-
const getResolvedFallbackLang = () => {
|
|
1966
|
-
if (!fallbackLang) {
|
|
1967
|
-
return null;
|
|
1968
|
-
}
|
|
1969
|
-
if (cachedResolvedFallbackLangVersion === languageMapVersion) {
|
|
1970
|
-
return cachedResolvedFallbackLang;
|
|
1971
|
-
}
|
|
1972
|
-
cachedResolvedFallbackLang = matchBestLang(fallbackLang, languageMap);
|
|
1973
|
-
cachedResolvedFallbackLangVersion = languageMapVersion;
|
|
1974
|
-
return cachedResolvedFallbackLang;
|
|
1975
|
-
};
|
|
1976
|
-
|
|
1977
|
-
const addLangKeys = (lang, translations) => {
|
|
1978
|
-
// Accumulate: merge with any existing translations for this lang
|
|
1979
|
-
const existing = languageMap.get(lang);
|
|
1980
|
-
if (existing) {
|
|
1981
|
-
translations = { ...existing, ...translations };
|
|
1982
|
-
}
|
|
1983
|
-
// A regional variant inherits all keys not explicitly overridden
|
|
1984
|
-
// e.g. "fr-CA" inherits from "fr"
|
|
1985
|
-
const dashIndex = lang.indexOf("-");
|
|
1986
|
-
if (dashIndex !== -1) {
|
|
1987
|
-
const parentLang = lang.slice(0, dashIndex);
|
|
1988
|
-
const parentTranslations = languageMap.get(parentLang);
|
|
1989
|
-
if (parentTranslations) {
|
|
1990
|
-
translations = { ...parentTranslations, ...translations };
|
|
1991
|
-
}
|
|
1992
|
-
}
|
|
1993
|
-
languageMap.set(lang, translations);
|
|
1994
|
-
languageMapVersion++;
|
|
1995
|
-
};
|
|
1996
|
-
|
|
1997
|
-
const add = (key, langTranslations) => {
|
|
1998
|
-
if (keyLang && !(keyLang in langTranslations)) {
|
|
1999
|
-
// Auto-register the key itself as the translation for keyLang
|
|
2000
|
-
addLangKeys(keyLang, { [key]: key });
|
|
2001
|
-
}
|
|
2002
|
-
for (const [lang, value] of Object.entries(langTranslations)) {
|
|
2003
|
-
addLangKeys(lang, { [key]: value });
|
|
2004
|
-
}
|
|
2005
|
-
};
|
|
2006
|
-
|
|
2007
|
-
const addAll = (keyMap) => {
|
|
2008
|
-
for (const [key, langTranslations] of Object.entries(keyMap)) {
|
|
2009
|
-
add(key, langTranslations);
|
|
2010
|
-
}
|
|
2011
|
-
};
|
|
2012
|
-
|
|
2013
|
-
const _getTemplate = (key, lang) => {
|
|
2014
|
-
// matchBestLang, not matchLang directly: lang can be an array (e.g.
|
|
2015
|
-
// languagesSignal.value is always an ordered array — see lang_signal.js) and
|
|
2016
|
-
// matchLang alone assumes a plain string, throwing
|
|
2017
|
-
// on .split() otherwise.
|
|
2018
|
-
const resolvedLang = lang ? matchBestLang(lang, languageMap) : null;
|
|
2019
|
-
if (resolvedLang) {
|
|
2020
|
-
const translations = languageMap.get(resolvedLang);
|
|
2021
|
-
const translated = translations[key];
|
|
2022
|
-
if (translated !== undefined) {
|
|
2023
|
-
return translated;
|
|
2024
|
-
}
|
|
2025
|
-
}
|
|
2026
|
-
const resolvedFallbackLang = getResolvedFallbackLang();
|
|
2027
|
-
if (resolvedFallbackLang) {
|
|
2028
|
-
const fallbackTranslations = languageMap.get(resolvedFallbackLang);
|
|
2029
|
-
const fallbackTranslated = fallbackTranslations[key];
|
|
2030
|
-
if (fallbackTranslated !== undefined) {
|
|
2031
|
-
return fallbackTranslated;
|
|
2032
|
-
}
|
|
2033
|
-
}
|
|
2034
|
-
// No translation found — return key as-is (opaque fallback)
|
|
2035
|
-
return key;
|
|
2036
|
-
};
|
|
2037
|
-
|
|
2038
|
-
const format = (key, values, { lang = getActiveLang() } = {}) => {
|
|
2039
|
-
const template = _getTemplate(key, lang);
|
|
2040
|
-
return interpolateText(template, values);
|
|
2041
|
-
};
|
|
2042
|
-
|
|
2043
|
-
const has = (key, { lang = getActiveLang() } = {}) => {
|
|
2044
|
-
const resolvedLang = lang ? matchBestLang(lang, languageMap) : null;
|
|
2045
|
-
if (resolvedLang) {
|
|
2046
|
-
const translations = languageMap.get(resolvedLang);
|
|
2047
|
-
if (translations && key in translations) {
|
|
2048
|
-
return true;
|
|
2049
|
-
}
|
|
2050
|
-
}
|
|
2051
|
-
const resolvedFallbackLang = getResolvedFallbackLang();
|
|
2052
|
-
if (resolvedFallbackLang) {
|
|
2053
|
-
const fallbackTranslations = languageMap.get(resolvedFallbackLang);
|
|
2054
|
-
if (fallbackTranslations && key in fallbackTranslations) {
|
|
2055
|
-
return true;
|
|
2056
|
-
}
|
|
2057
|
-
}
|
|
2058
|
-
return false;
|
|
2059
|
-
};
|
|
2060
|
-
|
|
2061
|
-
// The i18n instance is itself a callable function
|
|
2062
|
-
const i18n = (key, values, opts) => format(key, values, opts);
|
|
2063
|
-
i18n.add = add;
|
|
2064
|
-
i18n.addAll = addAll;
|
|
2065
|
-
i18n.addLangKeys = addLangKeys;
|
|
2066
|
-
i18n.has = has;
|
|
2067
|
-
i18n.format = format;
|
|
2068
|
-
i18n.languageMap = languageMap;
|
|
2069
|
-
|
|
2070
|
-
return i18n;
|
|
2071
|
-
};
|
|
2072
|
-
|
|
2073
|
-
// Walk "fr-CA-variant" → "fr-CA" → "fr" until a registered lang is found
|
|
2074
|
-
const matchLang = (lang, languageMap) => {
|
|
2075
|
-
if (languageMap.has(lang)) {
|
|
2076
|
-
return lang;
|
|
2077
|
-
}
|
|
2078
|
-
const parts = lang.split("-");
|
|
2079
|
-
while (parts.length > 1) {
|
|
2080
|
-
parts.pop();
|
|
2081
|
-
const candidate = parts.join("-");
|
|
2082
|
-
if (languageMap.has(candidate)) {
|
|
2083
|
-
return candidate;
|
|
2084
|
-
}
|
|
2085
|
-
}
|
|
2086
|
-
return null;
|
|
2087
|
-
};
|
|
2088
|
-
|
|
2089
|
-
// lang can be a string or an ordered array of preference strings
|
|
2090
|
-
const matchBestLang = (lang, languageMap) => {
|
|
2091
|
-
if (!lang) {
|
|
2092
|
-
return null;
|
|
2093
|
-
}
|
|
2094
|
-
const candidates = Array.isArray(lang) ? lang : [lang];
|
|
2095
|
-
for (const candidate of candidates) {
|
|
2096
|
-
const match = matchLang(candidate, languageMap);
|
|
2097
|
-
if (match) {
|
|
2098
|
-
return match;
|
|
2099
|
-
}
|
|
2100
|
-
}
|
|
2101
|
-
return null;
|
|
2102
|
-
};
|
|
2103
|
-
|
|
2104
|
-
/**
|
|
2105
|
-
* The shared i18n instance holding every text @jsenv/navi components display
|
|
2106
|
-
* on their own — validation messages, button labels, empty-list messages,
|
|
2107
|
-
* relative time wording…
|
|
2108
|
-
*
|
|
2109
|
-
* It is navi's texts, not the application's: an app registers its own texts in
|
|
2110
|
-
* its own `createI18n()` instance and reaches for `naviI18n` only to change
|
|
2111
|
-
* what navi itself says, or to add a language navi does not ship. Keys here are
|
|
2112
|
-
* opaque identifiers (`"list.empty"`), never the English sentence — the
|
|
2113
|
-
* opposite of what an app is advised to do. `docs/i18n.md` explains why.
|
|
2114
|
-
*
|
|
2115
|
-
* The active language is read from `languagesSignal` (see lang_signal.js —
|
|
2116
|
-
* combines the browser's own `navigator.languages`, an optional
|
|
2117
|
-
* `setPreferredLanguage()` user override, and an optional
|
|
2118
|
-
* `setSupportedLanguages()` app-wide allow-list), live on every lookup.
|
|
2119
|
-
*
|
|
2120
|
-
* Built-in key namespaces, all overridable — the registrations below are the
|
|
2121
|
-
* exhaustive list, read them to find the exact key to override:
|
|
2122
|
-
* - `"button.*"` — Clear, Reset, Send, Open, Close, Cancel, Confirm…
|
|
2123
|
-
* - `"time.*"` — relative time wording, duration unit symbols, date field placeholders
|
|
2124
|
-
* - `"spin.*"` — the ends of a steppable range
|
|
2125
|
-
* - `"list.*"` — empty/no-match/failed-rows messages
|
|
2126
|
-
* - `"badge_list.*"` — the "+[count] more" overflow badge
|
|
2127
|
-
* - `"constraint.*"` — every field validation message
|
|
2128
|
-
* - `"network_policy.*"` — what an action settles with when the policy kept it from the network
|
|
2129
|
-
*
|
|
2130
|
-
* Unit names get two derived keys, both optional: `<unit>__plural` and
|
|
2131
|
-
* `<unit>__short`. `<Unit>`/`<Quantity>` fall back to the singular when the
|
|
2132
|
-
* derived key is missing, and to `Intl.NumberFormat` when the unit itself is
|
|
2133
|
-
* not registered at all — so only units Intl gets wrong need registering.
|
|
2134
|
-
*
|
|
2135
|
-
* @example
|
|
2136
|
-
* import { naviI18n } from "@jsenv/navi";
|
|
2137
|
-
*
|
|
2138
|
-
* // Override a built-in text:
|
|
2139
|
-
* naviI18n.add("time.ongoing", { fr: "En cours…" });
|
|
2140
|
-
*
|
|
2141
|
-
* // Teach navi a language it does not ship:
|
|
2142
|
-
* naviI18n.addLangKeys("ja", { "list.empty": "項目がありません。" });
|
|
2143
|
-
*
|
|
2144
|
-
* // Register unit translations used by <Quantity>/<Unit>:
|
|
2145
|
-
* naviI18n.addAll({
|
|
2146
|
-
* ticket: { en: "ticket", fr: "billet" },
|
|
2147
|
-
* ticket__plural: { en: "tickets", fr: "billets" },
|
|
2148
|
-
* });
|
|
2149
|
-
*/
|
|
2150
|
-
const naviI18n = createI18n();
|
|
2151
|
-
|
|
2152
|
-
naviI18n.addAll({
|
|
2153
|
-
"button.clear": {
|
|
2154
|
-
en: "Clear",
|
|
2155
|
-
fr: "Effacer",
|
|
2156
|
-
},
|
|
2157
|
-
"button.reset": {
|
|
2158
|
-
en: "Reset",
|
|
2159
|
-
fr: "Réinitialiser",
|
|
2160
|
-
},
|
|
2161
|
-
"button.send": {
|
|
2162
|
-
en: "Send",
|
|
2163
|
-
fr: "Envoyer",
|
|
2164
|
-
},
|
|
2165
|
-
"button.open": {
|
|
2166
|
-
en: "Open",
|
|
2167
|
-
fr: "Ouvrir",
|
|
2168
|
-
},
|
|
2169
|
-
"button.close": {
|
|
2170
|
-
en: "Close",
|
|
2171
|
-
fr: "Fermer",
|
|
2172
|
-
},
|
|
2173
|
-
"button.cancel": {
|
|
2174
|
-
en: "Cancel",
|
|
2175
|
-
fr: "Annuler",
|
|
2176
|
-
},
|
|
2177
|
-
"button.define": {
|
|
2178
|
-
en: "Define",
|
|
2179
|
-
fr: "Définir",
|
|
2180
|
-
},
|
|
2181
|
-
"button.confirm": {
|
|
2182
|
-
en: "Confirm",
|
|
2183
|
-
fr: "Confirmer",
|
|
2184
|
-
},
|
|
2185
|
-
"confirm.message": {
|
|
2186
|
-
en: "Are you sure you want to do this?",
|
|
2187
|
-
fr: "Êtes-vous sûr de vouloir faire cette action ?",
|
|
2188
|
-
},
|
|
2189
|
-
"button.more_actions": {
|
|
2190
|
-
en: "More actions",
|
|
2191
|
-
fr: "Autres actions",
|
|
2192
|
-
},
|
|
2193
|
-
"button.remove": {
|
|
2194
|
-
en: "Remove",
|
|
2195
|
-
fr: "Retirer",
|
|
2196
|
-
},
|
|
2197
|
-
});
|
|
2198
|
-
|
|
2199
|
-
// Default built-in translations — apps can override any key via add()
|
|
2200
|
-
naviI18n.addAll({
|
|
2201
|
-
"time.less_than_minute": {
|
|
2202
|
-
en: "in less than a minute",
|
|
2203
|
-
fr: "dans moins d'une minute",
|
|
2204
|
-
de: "in weniger als einer Minute",
|
|
2205
|
-
es: "en menos de un minuto",
|
|
2206
|
-
it: "in meno di un minuto",
|
|
2207
|
-
pt: "em menos de um minuto",
|
|
2208
|
-
nl: "over minder dan een minuut",
|
|
2209
|
-
},
|
|
2210
|
-
"time.ongoing": {
|
|
2211
|
-
en: "Ongoing",
|
|
2212
|
-
fr: "En cours",
|
|
2213
|
-
de: "Laufend",
|
|
2214
|
-
es: "En curso",
|
|
2215
|
-
it: "In corso",
|
|
2216
|
-
pt: "Em andamento",
|
|
2217
|
-
nl: "Bezig",
|
|
2218
|
-
},
|
|
2219
|
-
// [day] and [time] are replaced at runtime with the localized day/time strings
|
|
2220
|
-
"time.tomorrow_at": {
|
|
2221
|
-
en: "[day] at [time]",
|
|
2222
|
-
fr: "[day] à [time]",
|
|
2223
|
-
de: "[day] um [time]",
|
|
2224
|
-
es: "[day] a las [time]",
|
|
2225
|
-
it: "[day] alle [time]",
|
|
2226
|
-
pt: "[day] às [time]",
|
|
2227
|
-
nl: "[day] om [time]",
|
|
2228
|
-
},
|
|
2229
|
-
// [duration] is replaced at runtime with the formatted duration string (e.g. "1h30", "45 min")
|
|
2230
|
-
"time.in_duration": {
|
|
2231
|
-
en: "in [duration]",
|
|
2232
|
-
fr: "dans [duration]",
|
|
2233
|
-
de: "in [duration]",
|
|
2234
|
-
es: "en [duration]",
|
|
2235
|
-
it: "tra [duration]",
|
|
2236
|
-
pt: "em [duration]",
|
|
2237
|
-
nl: "over [duration]",
|
|
2238
|
-
},
|
|
2239
|
-
// Substituted in place of the "0 heure(s)" part of an Intl-generated
|
|
2240
|
-
// duration string when <Time type="time" format="long"> renders midnight
|
|
2241
|
-
// — see time.jsx's own TimeTime for why midnight can't just fall through
|
|
2242
|
-
// to formatMinuteDuration like every other hour does, and how this word
|
|
2243
|
-
// gets spliced in (formatToParts, not string concatenation) so the rest
|
|
2244
|
-
// of the sentence (conjunction, minutes) still comes out in whatever
|
|
2245
|
-
// grammar/word order this language's own Intl.DurationFormat produces.
|
|
2246
|
-
// Languages without an entry here fall back to that language's own
|
|
2247
|
-
// literal "0 heure(s)" wording instead (see TimeTime), never to this key.
|
|
2248
|
-
"time.midnight": {
|
|
2249
|
-
en: "midnight",
|
|
2250
|
-
fr: "minuit",
|
|
2251
|
-
de: "Mitternacht",
|
|
2252
|
-
es: "medianoche",
|
|
2253
|
-
it: "mezzanotte",
|
|
2254
|
-
pt: "meia-noite",
|
|
2255
|
-
nl: "middernacht",
|
|
2256
|
-
},
|
|
2257
|
-
// What <TimeRange> writes between the two bounds of a span — "8h–10h",
|
|
2258
|
-
// "11 mai – 14 mai". An en dash, the mark for a span, not a hyphen.
|
|
2259
|
-
"time.range_separator": {
|
|
2260
|
-
en: "–",
|
|
2261
|
-
fr: "–",
|
|
2262
|
-
de: "–",
|
|
2263
|
-
es: "–",
|
|
2264
|
-
it: "–",
|
|
2265
|
-
pt: "–",
|
|
2266
|
-
nl: "–",
|
|
2267
|
-
},
|
|
2268
|
-
// Compact duration unit symbols used in "1h30", "45min", "2d", etc.
|
|
2269
|
-
"time.duration.year_symbol": {
|
|
2270
|
-
en: "y",
|
|
2271
|
-
fr: "a",
|
|
2272
|
-
de: "J",
|
|
2273
|
-
es: "a",
|
|
2274
|
-
it: "a",
|
|
2275
|
-
pt: "a",
|
|
2276
|
-
nl: "j",
|
|
2277
|
-
ja: "年",
|
|
2278
|
-
zh: "年",
|
|
2279
|
-
ko: "년",
|
|
2280
|
-
},
|
|
2281
|
-
"time.duration.month_symbol": {
|
|
2282
|
-
en: "mo",
|
|
2283
|
-
fr: "mo",
|
|
2284
|
-
de: "Mo",
|
|
2285
|
-
es: "mo",
|
|
2286
|
-
it: "mo",
|
|
2287
|
-
pt: "mo",
|
|
2288
|
-
nl: "mo",
|
|
2289
|
-
ja: "月",
|
|
2290
|
-
zh: "月",
|
|
2291
|
-
ko: "월",
|
|
2292
|
-
},
|
|
2293
|
-
"time.duration.week_symbol": {
|
|
2294
|
-
en: "w",
|
|
2295
|
-
fr: "sem",
|
|
2296
|
-
de: "W",
|
|
2297
|
-
es: "sem",
|
|
2298
|
-
it: "sett",
|
|
2299
|
-
pt: "sem",
|
|
2300
|
-
nl: "w",
|
|
2301
|
-
ja: "週",
|
|
2302
|
-
zh: "周",
|
|
2303
|
-
ko: "주",
|
|
2304
|
-
},
|
|
2305
|
-
"time.duration.day_symbol": {
|
|
2306
|
-
en: "d",
|
|
2307
|
-
fr: "j",
|
|
2308
|
-
de: "T",
|
|
2309
|
-
es: "d",
|
|
2310
|
-
it: "g",
|
|
2311
|
-
pt: "d",
|
|
2312
|
-
nl: "d",
|
|
2313
|
-
ja: "日",
|
|
2314
|
-
zh: "天",
|
|
2315
|
-
ko: "일",
|
|
2316
|
-
},
|
|
2317
|
-
"time.duration.hour_symbol": {
|
|
2318
|
-
en: "h",
|
|
2319
|
-
fr: "h",
|
|
2320
|
-
de: "h",
|
|
2321
|
-
es: "h",
|
|
2322
|
-
it: "h",
|
|
2323
|
-
pt: "h",
|
|
2324
|
-
nl: "u",
|
|
2325
|
-
ja: "時間",
|
|
2326
|
-
zh: "小时",
|
|
2327
|
-
ko: "시간",
|
|
2328
|
-
},
|
|
2329
|
-
"time.duration.minute_symbol": {
|
|
2330
|
-
en: "min",
|
|
2331
|
-
fr: "min",
|
|
2332
|
-
de: "min",
|
|
2333
|
-
es: "min",
|
|
2334
|
-
it: "min",
|
|
2335
|
-
pt: "min",
|
|
2336
|
-
nl: "min",
|
|
2337
|
-
ja: "分",
|
|
2338
|
-
zh: "分",
|
|
2339
|
-
ko: "분",
|
|
2340
|
-
},
|
|
2341
|
-
"time.duration.second_symbol": {
|
|
2342
|
-
en: "s",
|
|
2343
|
-
fr: "s",
|
|
2344
|
-
de: "s",
|
|
2345
|
-
es: "s",
|
|
2346
|
-
it: "s",
|
|
2347
|
-
pt: "s",
|
|
2348
|
-
nl: "s",
|
|
2349
|
-
ja: "秒",
|
|
2350
|
-
zh: "秒",
|
|
2351
|
-
ko: "초",
|
|
2352
|
-
},
|
|
2353
|
-
"time.duration.millisecond_symbol": {
|
|
2354
|
-
en: "ms",
|
|
2355
|
-
fr: "ms",
|
|
2356
|
-
de: "ms",
|
|
2357
|
-
es: "ms",
|
|
2358
|
-
it: "ms",
|
|
2359
|
-
pt: "ms",
|
|
2360
|
-
nl: "ms",
|
|
2361
|
-
ja: "ms",
|
|
2362
|
-
zh: "ms",
|
|
2363
|
-
ko: "ms",
|
|
2364
|
-
},
|
|
2365
|
-
});
|
|
2366
|
-
|
|
2367
|
-
// Spin messages — the ends of what one steps through, said without naming
|
|
2368
|
-
// what it is made of: the same words fit days, months, pages or sizes.
|
|
2369
|
-
naviI18n.addAll({
|
|
2370
|
-
"spin.previous": {
|
|
2371
|
-
en: "Previous",
|
|
2372
|
-
fr: "Précédent",
|
|
2373
|
-
},
|
|
2374
|
-
"spin.next": {
|
|
2375
|
-
en: "Next",
|
|
2376
|
-
fr: "Suivant",
|
|
2377
|
-
},
|
|
2378
|
-
"spin.nothing_before": {
|
|
2379
|
-
en: "No item before this one.",
|
|
2380
|
-
fr: "Pas d'élément avant celui-ci.",
|
|
2381
|
-
},
|
|
2382
|
-
"spin.nothing_after": {
|
|
2383
|
-
en: "No item after this one.",
|
|
2384
|
-
fr: "Pas d'élément après celui-ci.",
|
|
2385
|
-
},
|
|
2386
|
-
});
|
|
2387
|
-
|
|
2388
|
-
// Time spin messages — what a clock writes between an hour and its minutes,
|
|
2389
|
-
// and how the two ends of a span are named.
|
|
2390
|
-
naviI18n.addAll({
|
|
2391
|
-
"time.hour_separator": {
|
|
2392
|
-
en: ":",
|
|
2393
|
-
fr: "h",
|
|
2394
|
-
},
|
|
2395
|
-
"time.hour_label": {
|
|
2396
|
-
en: "Hours",
|
|
2397
|
-
fr: "Heures",
|
|
2398
|
-
},
|
|
2399
|
-
"time.minute_label": {
|
|
2400
|
-
en: "Minutes",
|
|
2401
|
-
fr: "Minutes",
|
|
2402
|
-
},
|
|
2403
|
-
"time_range.from": {
|
|
2404
|
-
en: "From",
|
|
2405
|
-
fr: "De",
|
|
2406
|
-
},
|
|
2407
|
-
"time_range.to": {
|
|
2408
|
-
en: "to",
|
|
2409
|
-
fr: "à",
|
|
2410
|
-
},
|
|
2411
|
-
});
|
|
2412
|
-
|
|
2413
|
-
// List messages — override any key to customize list messages
|
|
2414
|
-
naviI18n.addAll({
|
|
2415
|
-
"list.empty": {
|
|
2416
|
-
en: "No items in this list.",
|
|
2417
|
-
fr: "Aucun élément dans cette liste.",
|
|
2418
|
-
},
|
|
2419
|
-
"list.no_match": {
|
|
2420
|
-
en: "No item matches this search.",
|
|
2421
|
-
fr: "Aucun élément ne correspond à cette recherche.",
|
|
2422
|
-
},
|
|
2423
|
-
"list.no_match_rest_shown": {
|
|
2424
|
-
en: "No item matches this search. The rest is shown below.",
|
|
2425
|
-
fr: "Aucun élément ne correspond à cette recherche. Le reste est affiché ci-dessous.",
|
|
2426
|
-
},
|
|
2427
|
-
"list.rows_failed": {
|
|
2428
|
-
en: "These elements could not be loaded.",
|
|
2429
|
-
fr: "Ces élements n'ont pas pu être chargées.",
|
|
2430
|
-
},
|
|
2431
|
-
"list.rows_retry": {
|
|
2432
|
-
en: "Retry",
|
|
2433
|
-
fr: "Réessayer",
|
|
2434
|
-
},
|
|
2435
|
-
});
|
|
2436
|
-
|
|
2437
|
-
// Badge list messages
|
|
2438
|
-
naviI18n.addAll({
|
|
2439
|
-
"badge_list.more": {
|
|
2440
|
-
en: "+[count] more",
|
|
2441
|
-
fr: "+[count] de plus",
|
|
2442
|
-
},
|
|
2443
|
-
});
|
|
1402
|
+
// Badge list messages
|
|
1403
|
+
naviI18n.addAll({
|
|
1404
|
+
"badge_list.more": {
|
|
1405
|
+
en: "+[count] more",
|
|
1406
|
+
fr: "+[count] de plus",
|
|
1407
|
+
},
|
|
1408
|
+
});
|
|
2444
1409
|
|
|
2445
1410
|
// Constraint validation messages — override any key to customize error messages
|
|
2446
1411
|
naviI18n.addAll({
|
|
@@ -2916,241 +1881,1377 @@ naviI18n.addAll({
|
|
|
2916
1881
|
},
|
|
2917
1882
|
});
|
|
2918
1883
|
|
|
2919
|
-
// Character class and maxLengthGuard messages. The char class keys are
|
|
2920
|
-
// @jsenv/validity's own ("char_class.slug"), prefixed with "constraint." —
|
|
2921
|
-
// the same sentence refuses a keystroke in a callout and a whole value in a
|
|
2922
|
-
// constraint, so there is one key for both.
|
|
2923
|
-
naviI18n.addAll({
|
|
2924
|
-
// Preset-specific char messages — more informative than the generic fallback
|
|
2925
|
-
"constraint.char_class.numeric": {
|
|
2926
|
-
fr: "Ce champ ne peut contenir que des chiffres.",
|
|
2927
|
-
en: "This field can only contain digits.",
|
|
2928
|
-
},
|
|
2929
|
-
"constraint.char_class.alpha": {
|
|
2930
|
-
fr: "Ce champ ne peut contenir que des lettres.",
|
|
2931
|
-
en: "This field can only contain letters.",
|
|
2932
|
-
},
|
|
2933
|
-
"constraint.char_class.alphanumeric": {
|
|
2934
|
-
fr: "Ce champ ne peut contenir que des lettres et des chiffres.",
|
|
2935
|
-
en: "This field can only contain letters and digits.",
|
|
2936
|
-
},
|
|
2937
|
-
"constraint.char_class.uppercase": {
|
|
2938
|
-
fr: "Ce champ ne peut contenir que des lettres majuscules.",
|
|
2939
|
-
en: "This field can only contain uppercase letters.",
|
|
2940
|
-
},
|
|
2941
|
-
"constraint.char_class.hex": {
|
|
2942
|
-
fr: "Ce champ ne peut contenir que des chiffres hexadécimaux (0-9, A-F).",
|
|
2943
|
-
en: "This field can only contain hexadecimal digits (0-9, A-F).",
|
|
2944
|
-
},
|
|
2945
|
-
"constraint.char_class.slug": {
|
|
2946
|
-
fr: "Ce champ ne peut contenir que des lettres minuscules, des chiffres et des tirets.",
|
|
2947
|
-
en: "This field can only contain lowercase letters, digits, and hyphens.",
|
|
2948
|
-
},
|
|
2949
|
-
// Generic fallback for custom char classes and other presets (tel, card, postal, iban…)
|
|
2950
|
-
"constraint.char_class.no_emoji": {
|
|
2951
|
-
fr: "Ce champ ne peut pas contenir d'emoji.",
|
|
2952
|
-
en: "This field cannot contain emoji.",
|
|
2953
|
-
},
|
|
2954
|
-
"constraint.char_class.default": {
|
|
2955
|
-
fr: "Ce champ ne peut contenir que les caractères autorisés.",
|
|
2956
|
-
en: "This field can only contain allowed characters.",
|
|
2957
|
-
},
|
|
2958
|
-
// maxLength: keydown blocked (one character would exceed the limit)
|
|
2959
|
-
"constraint.guard.max_length.typing": {
|
|
2960
|
-
fr: "Longueur maximale de [max] caractère[s] atteinte.",
|
|
2961
|
-
en: "Maximum length of [max] character[s] reached.",
|
|
2962
|
-
},
|
|
2963
|
-
// maxLength: paste/set truncated to maxLength (autofix always applied)
|
|
2964
|
-
"constraint.guard.max_length.value": {
|
|
2965
|
-
fr: "Ce champ ne peut pas contenir plus de [max] caractère[s], une partie a été tronquée.",
|
|
2966
|
-
en: "This field cannot contain more than [max] character[s]; the value was truncated.",
|
|
2967
|
-
},
|
|
2968
|
-
// maxLengthGuard on a multiple selection: one more item would exceed the limit
|
|
2969
|
-
"constraint.guard.max_length.selection": {
|
|
2970
|
-
fr: "[max] max.",
|
|
2971
|
-
en: "[max] max.",
|
|
2972
|
-
},
|
|
2973
|
-
});
|
|
1884
|
+
// Character class and maxLengthGuard messages. The char class keys are
|
|
1885
|
+
// @jsenv/validity's own ("char_class.slug"), prefixed with "constraint." —
|
|
1886
|
+
// the same sentence refuses a keystroke in a callout and a whole value in a
|
|
1887
|
+
// constraint, so there is one key for both.
|
|
1888
|
+
naviI18n.addAll({
|
|
1889
|
+
// Preset-specific char messages — more informative than the generic fallback
|
|
1890
|
+
"constraint.char_class.numeric": {
|
|
1891
|
+
fr: "Ce champ ne peut contenir que des chiffres.",
|
|
1892
|
+
en: "This field can only contain digits.",
|
|
1893
|
+
},
|
|
1894
|
+
"constraint.char_class.alpha": {
|
|
1895
|
+
fr: "Ce champ ne peut contenir que des lettres.",
|
|
1896
|
+
en: "This field can only contain letters.",
|
|
1897
|
+
},
|
|
1898
|
+
"constraint.char_class.alphanumeric": {
|
|
1899
|
+
fr: "Ce champ ne peut contenir que des lettres et des chiffres.",
|
|
1900
|
+
en: "This field can only contain letters and digits.",
|
|
1901
|
+
},
|
|
1902
|
+
"constraint.char_class.uppercase": {
|
|
1903
|
+
fr: "Ce champ ne peut contenir que des lettres majuscules.",
|
|
1904
|
+
en: "This field can only contain uppercase letters.",
|
|
1905
|
+
},
|
|
1906
|
+
"constraint.char_class.hex": {
|
|
1907
|
+
fr: "Ce champ ne peut contenir que des chiffres hexadécimaux (0-9, A-F).",
|
|
1908
|
+
en: "This field can only contain hexadecimal digits (0-9, A-F).",
|
|
1909
|
+
},
|
|
1910
|
+
"constraint.char_class.slug": {
|
|
1911
|
+
fr: "Ce champ ne peut contenir que des lettres minuscules, des chiffres et des tirets.",
|
|
1912
|
+
en: "This field can only contain lowercase letters, digits, and hyphens.",
|
|
1913
|
+
},
|
|
1914
|
+
// Generic fallback for custom char classes and other presets (tel, card, postal, iban…)
|
|
1915
|
+
"constraint.char_class.no_emoji": {
|
|
1916
|
+
fr: "Ce champ ne peut pas contenir d'emoji.",
|
|
1917
|
+
en: "This field cannot contain emoji.",
|
|
1918
|
+
},
|
|
1919
|
+
"constraint.char_class.default": {
|
|
1920
|
+
fr: "Ce champ ne peut contenir que les caractères autorisés.",
|
|
1921
|
+
en: "This field can only contain allowed characters.",
|
|
1922
|
+
},
|
|
1923
|
+
// maxLength: keydown blocked (one character would exceed the limit)
|
|
1924
|
+
"constraint.guard.max_length.typing": {
|
|
1925
|
+
fr: "Longueur maximale de [max] caractère[s] atteinte.",
|
|
1926
|
+
en: "Maximum length of [max] character[s] reached.",
|
|
1927
|
+
},
|
|
1928
|
+
// maxLength: paste/set truncated to maxLength (autofix always applied)
|
|
1929
|
+
"constraint.guard.max_length.value": {
|
|
1930
|
+
fr: "Ce champ ne peut pas contenir plus de [max] caractère[s], une partie a été tronquée.",
|
|
1931
|
+
en: "This field cannot contain more than [max] character[s]; the value was truncated.",
|
|
1932
|
+
},
|
|
1933
|
+
// maxLengthGuard on a multiple selection: one more item would exceed the limit
|
|
1934
|
+
"constraint.guard.max_length.selection": {
|
|
1935
|
+
fr: "[max] max.",
|
|
1936
|
+
en: "[max] max.",
|
|
1937
|
+
},
|
|
1938
|
+
});
|
|
1939
|
+
|
|
1940
|
+
// Date/time placeholder tokens — shown when no value is selected
|
|
1941
|
+
// Override any key to adapt to your language conventions
|
|
1942
|
+
naviI18n.addAll({
|
|
1943
|
+
"time.placeholder.day": {
|
|
1944
|
+
fr: "jj",
|
|
1945
|
+
en: "dd",
|
|
1946
|
+
de: "TT",
|
|
1947
|
+
es: "dd",
|
|
1948
|
+
it: "gg",
|
|
1949
|
+
pt: "dd",
|
|
1950
|
+
nl: "dd",
|
|
1951
|
+
},
|
|
1952
|
+
"time.placeholder.month": {
|
|
1953
|
+
fr: "mm",
|
|
1954
|
+
en: "mm",
|
|
1955
|
+
de: "MM",
|
|
1956
|
+
es: "mm",
|
|
1957
|
+
it: "mm",
|
|
1958
|
+
pt: "mm",
|
|
1959
|
+
nl: "mm",
|
|
1960
|
+
},
|
|
1961
|
+
"time.placeholder.year": {
|
|
1962
|
+
fr: "aaaa",
|
|
1963
|
+
en: "yyyy",
|
|
1964
|
+
de: "JJJJ",
|
|
1965
|
+
es: "aaaa",
|
|
1966
|
+
it: "aaaa",
|
|
1967
|
+
pt: "aaaa",
|
|
1968
|
+
nl: "jjjj",
|
|
1969
|
+
},
|
|
1970
|
+
"time.placeholder.hour": {
|
|
1971
|
+
fr: "hh",
|
|
1972
|
+
en: "hh",
|
|
1973
|
+
de: "hh",
|
|
1974
|
+
es: "hh",
|
|
1975
|
+
it: "hh",
|
|
1976
|
+
pt: "hh",
|
|
1977
|
+
nl: "uu",
|
|
1978
|
+
},
|
|
1979
|
+
"time.placeholder.minute": {
|
|
1980
|
+
fr: "mm",
|
|
1981
|
+
en: "mm",
|
|
1982
|
+
de: "mm",
|
|
1983
|
+
es: "mm",
|
|
1984
|
+
it: "mm",
|
|
1985
|
+
pt: "mm",
|
|
1986
|
+
nl: "mm",
|
|
1987
|
+
},
|
|
1988
|
+
"time.placeholder.week": {
|
|
1989
|
+
fr: "sem.",
|
|
1990
|
+
en: "wk",
|
|
1991
|
+
de: "KW",
|
|
1992
|
+
es: "sem.",
|
|
1993
|
+
it: "sett.",
|
|
1994
|
+
pt: "sem.",
|
|
1995
|
+
nl: "wk",
|
|
1996
|
+
},
|
|
1997
|
+
});
|
|
1998
|
+
|
|
1999
|
+
const isSignal = (value) => {
|
|
2000
|
+
return getSignalType(value) !== null;
|
|
2001
|
+
};
|
|
2002
|
+
|
|
2003
|
+
const BRAND_SYMBOL = Symbol.for("preact-signals");
|
|
2004
|
+
const getSignalType = (value) => {
|
|
2005
|
+
if (!value || typeof value !== "object") {
|
|
2006
|
+
return null;
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
if (value.brand !== BRAND_SYMBOL) {
|
|
2010
|
+
return null;
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
if (typeof value._fn === "function") {
|
|
2014
|
+
return "computed";
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
return "signal";
|
|
2018
|
+
};
|
|
2019
|
+
|
|
2020
|
+
/*
|
|
2021
|
+
* The network policy: one declaration saying whether a request may go out,
|
|
2022
|
+
* read by the action layer rather than by every callback.
|
|
2023
|
+
*
|
|
2024
|
+
* Under a policy (a truthy reason):
|
|
2025
|
+
* - a resource GET answers with the row its store holds for it — named by its
|
|
2026
|
+
* params, or the one it last completed with — and asks nothing
|
|
2027
|
+
* (resource_graph.js, applyNetworkPolicy); a completed read
|
|
2028
|
+
* asked to rerun stays completed (actions.js, handleActionRequest);
|
|
2029
|
+
* anything else settles with an OfflineError carrying the reason;
|
|
2030
|
+
* - a control bound to a write — or inside a form bound to one — is read-only
|
|
2031
|
+
* and says why (control_hooks.jsx, readonly_constraint.js).
|
|
2032
|
+
*
|
|
2033
|
+
* The reason is a value rather than a boolean because "no network" and
|
|
2034
|
+
* "offline mode" are not said the same way to the user: the error and the
|
|
2035
|
+
* read-only message both carry it, and the app decides the words.
|
|
2036
|
+
*
|
|
2037
|
+
* Only actions declaring a verb (`meta.verb`, which every resource action
|
|
2038
|
+
* has) are subject to the policy: a plain `createAction` may not touch the
|
|
2039
|
+
* network at all, and navi has no way to tell.
|
|
2040
|
+
*/
|
|
2041
|
+
|
|
2042
|
+
const WRITE_VERB_SET = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
2043
|
+
|
|
2044
|
+
const networkPolicySignal = signal({
|
|
2045
|
+
source: null,
|
|
2046
|
+
readOnlyMessage: undefined,
|
|
2047
|
+
});
|
|
2048
|
+
|
|
2049
|
+
/**
|
|
2050
|
+
* Declares whether requests may go out.
|
|
2051
|
+
*
|
|
2052
|
+
* @param {import("@preact/signals").Signal | Function | any} source - where the
|
|
2053
|
+
* reason is read from: a signal (followed live), a function (called on each
|
|
2054
|
+
* read), or a plain value. A falsy reason means "go to the network"; any
|
|
2055
|
+
* truthy value means "do not", and is handed to the `OfflineError` an action
|
|
2056
|
+
* settles with (`error.reason`) so a screen can say which kind of offline it is.
|
|
2057
|
+
* @param {Object} [options]
|
|
2058
|
+
* @param {string | ((reason: any) => string)} [options.readOnlyMessage] - what a
|
|
2059
|
+
* control held back by the policy answers when pressed; defaults to navi's
|
|
2060
|
+
* `constraint.readonly.network_policy` text.
|
|
2061
|
+
* @see docs/offline.md
|
|
2062
|
+
*
|
|
2063
|
+
* @example
|
|
2064
|
+
* const offlineReasonSignal = computed(() =>
|
|
2065
|
+
* offlineChosenSignal.value ? "chosen" : deviceOfflineSignal.value ? "device" : null,
|
|
2066
|
+
* );
|
|
2067
|
+
* setNetworkPolicy(offlineReasonSignal, {
|
|
2068
|
+
* readOnlyMessage: (reason) =>
|
|
2069
|
+
* reason === "device" ? "No network: this cannot be sent." : "Offline mode: this cannot be sent.",
|
|
2070
|
+
* });
|
|
2071
|
+
*/
|
|
2072
|
+
const setNetworkPolicy = (source, { readOnlyMessage } = {}) => {
|
|
2073
|
+
silenceOfflineErrors();
|
|
2074
|
+
networkPolicySignal.value = { source, readOnlyMessage };
|
|
2075
|
+
};
|
|
2076
|
+
|
|
2077
|
+
/**
|
|
2078
|
+
* The policy's reason, `null` when requests may go out. Read in a component
|
|
2079
|
+
* render, it follows the policy as it changes.
|
|
2080
|
+
*/
|
|
2081
|
+
const useNetworkPolicyReason = () => {
|
|
2082
|
+
return readReason(networkPolicySignal.value.source);
|
|
2083
|
+
};
|
|
2084
|
+
|
|
2085
|
+
// For the action layer: the same answer without subscribing whoever asks.
|
|
2086
|
+
const peekNetworkPolicyReason = () => {
|
|
2087
|
+
return untracked(() => readReason(networkPolicySignal.peek().source));
|
|
2088
|
+
};
|
|
2089
|
+
|
|
2090
|
+
const isRerunHeldByNetworkPolicy = (action) => {
|
|
2091
|
+
return action.meta.verb === "GET" && peekNetworkPolicyReason() !== null;
|
|
2092
|
+
};
|
|
2093
|
+
|
|
2094
|
+
const isWriteAction = (action) => {
|
|
2095
|
+
return WRITE_VERB_SET.has(action.meta.verb);
|
|
2096
|
+
};
|
|
2097
|
+
|
|
2098
|
+
const getNetworkPolicyReadOnlyMessage = () => {
|
|
2099
|
+
const { readOnlyMessage } = networkPolicySignal.peek();
|
|
2100
|
+
if (typeof readOnlyMessage === "function") {
|
|
2101
|
+
return readOnlyMessage(peekNetworkPolicyReason());
|
|
2102
|
+
}
|
|
2103
|
+
if (readOnlyMessage) {
|
|
2104
|
+
return readOnlyMessage;
|
|
2105
|
+
}
|
|
2106
|
+
return naviI18n("constraint.readonly.network_policy");
|
|
2107
|
+
};
|
|
2108
|
+
|
|
2109
|
+
/**
|
|
2110
|
+
* The error of a request that never left: the policy said not to.
|
|
2111
|
+
* `reason` is the policy's value at that moment.
|
|
2112
|
+
*/
|
|
2113
|
+
class OfflineError extends Error {
|
|
2114
|
+
constructor(reason, message = naviI18n("network_policy.offline")) {
|
|
2115
|
+
super(message);
|
|
2116
|
+
this.name = "OfflineError";
|
|
2117
|
+
this.reason = reason;
|
|
2118
|
+
// A flag beside the class: the error crosses layers that may copy it, and
|
|
2119
|
+
// instanceof does not survive a copy.
|
|
2120
|
+
this.offline = true;
|
|
2121
|
+
}
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
const isOfflineError = (error) => {
|
|
2125
|
+
return Boolean(error && error.offline);
|
|
2126
|
+
};
|
|
2127
|
+
|
|
2128
|
+
/**
|
|
2129
|
+
* An offline error is not one, and nobody is to be told about it as if it were.
|
|
2130
|
+
*
|
|
2131
|
+
* It says "run with what you have, ask nothing" — a state the app itself
|
|
2132
|
+
* declared, about a request that never left. There is no bug to point at, no
|
|
2133
|
+
* stack worth reading, and a screen is already saying it in words the person
|
|
2134
|
+
* understands.
|
|
2135
|
+
*
|
|
2136
|
+
* navi's own report leaves it alone (action_error_report.js). What is left is
|
|
2137
|
+
* the screen displaying it: it does so by throwing the error to a boundary, and
|
|
2138
|
+
* `preact/debug` re-emits on `window` every error a boundary caught — on
|
|
2139
|
+
* purpose, for React devtools compatibility. Uncancelled, that lands as an
|
|
2140
|
+
* uncaught error in the console, over a page calmly explaining there is no
|
|
2141
|
+
* network. Cancelling the event is what says it is handled: the browser drops
|
|
2142
|
+
* the console line, and the jsenv supervisor skips prevented events too.
|
|
2143
|
+
*
|
|
2144
|
+
* Both shapes a failure travels in are covered, since which one it is depends
|
|
2145
|
+
* on whether the resource callback happened to be async — the throw reaching
|
|
2146
|
+
* `window`, and the rejection nobody caught.
|
|
2147
|
+
*
|
|
2148
|
+
* Scoped to the policy's own error and to nothing else — every other error a
|
|
2149
|
+
* boundary caught, and every other rejection let go, stays exactly as loud as
|
|
2150
|
+
* it is.
|
|
2151
|
+
*/
|
|
2152
|
+
let offlineErrorSilenced = false;
|
|
2153
|
+
const silenceOfflineErrors = () => {
|
|
2154
|
+
if (offlineErrorSilenced || typeof window === "undefined") {
|
|
2155
|
+
return;
|
|
2156
|
+
}
|
|
2157
|
+
offlineErrorSilenced = true;
|
|
2158
|
+
window.addEventListener("error", (errorEvent) => {
|
|
2159
|
+
if (isOfflineError(errorEvent.error)) {
|
|
2160
|
+
errorEvent.preventDefault();
|
|
2161
|
+
}
|
|
2162
|
+
});
|
|
2163
|
+
window.addEventListener("unhandledrejection", (rejectionEvent) => {
|
|
2164
|
+
if (isOfflineError(rejectionEvent.reason)) {
|
|
2165
|
+
rejectionEvent.preventDefault();
|
|
2166
|
+
}
|
|
2167
|
+
});
|
|
2168
|
+
};
|
|
2169
|
+
|
|
2170
|
+
const readReason = (source) => {
|
|
2171
|
+
if (!source) {
|
|
2172
|
+
return null;
|
|
2173
|
+
}
|
|
2174
|
+
let reason;
|
|
2175
|
+
if (isSignal(source)) {
|
|
2176
|
+
reason = source.value;
|
|
2177
|
+
} else if (typeof source === "function") {
|
|
2178
|
+
reason = source();
|
|
2179
|
+
} else {
|
|
2180
|
+
reason = source;
|
|
2181
|
+
}
|
|
2182
|
+
return reason || null;
|
|
2183
|
+
};
|
|
2184
|
+
|
|
2185
|
+
/**
|
|
2186
|
+
* Where an action error goes when nothing displays it.
|
|
2187
|
+
*
|
|
2188
|
+
* An action that fails writes the error into its `errorSignal` and stops there.
|
|
2189
|
+
* It cannot know whether a screen is going to show it: at the instant it fails,
|
|
2190
|
+
* the screen that will is often not even mounted — a route action runs before
|
|
2191
|
+
* its page renders, which is precisely the case a guess made at failure time
|
|
2192
|
+
* gets wrong. So nothing is guessed. The error is let go, and whoever displays
|
|
2193
|
+
* it SAYS so by marking it; what nobody ever took is reported as unhandled.
|
|
2194
|
+
*
|
|
2195
|
+
* The mark is `__handled_by__`, the same one the jsenv supervisor reads to stay
|
|
2196
|
+
* out of the way of an error the app is already showing — one mark, one meaning:
|
|
2197
|
+
* "this is on screen somewhere".
|
|
2198
|
+
*
|
|
2199
|
+
* The whole picture, control errors and validation included: docs/error_handling.md
|
|
2200
|
+
*/
|
|
2201
|
+
|
|
2202
|
+
|
|
2203
|
+
const markErrorAsDisplayedBy = (error, by) => {
|
|
2204
|
+
if (error && typeof error === "object") {
|
|
2205
|
+
error.__handled_by__ = by;
|
|
2206
|
+
}
|
|
2207
|
+
};
|
|
2208
|
+
|
|
2209
|
+
const errorIsDisplayed = (error) => {
|
|
2210
|
+
return Boolean(error && error.__handled_by__);
|
|
2211
|
+
};
|
|
2212
|
+
|
|
2213
|
+
/**
|
|
2214
|
+
* A render has read this error — it is now the render tree's business, not this
|
|
2215
|
+
* module's, and there is nothing left to report.
|
|
2216
|
+
*
|
|
2217
|
+
* Whatever the reader does with it is already covered without any deadline: it
|
|
2218
|
+
* displays it (and marks it), or it throws it, and a thrown error either finds a
|
|
2219
|
+
* boundary that displays it or reaches window on its own — `preact/debug`
|
|
2220
|
+
* re-throws every error a boundary caught, and an unbounded one aborts the
|
|
2221
|
+
* render loudly. Reporting it here as well would be a second voice saying the
|
|
2222
|
+
* same thing, always the wrong one, since this module cannot see which of those
|
|
2223
|
+
* happened.
|
|
2224
|
+
*/
|
|
2225
|
+
const errorTakenByRenderSet = new WeakSet();
|
|
2226
|
+
const markErrorAsTakenByRender = (error) => {
|
|
2227
|
+
if (error && typeof error === "object") {
|
|
2228
|
+
errorTakenByRenderSet.add(error);
|
|
2229
|
+
}
|
|
2230
|
+
};
|
|
2231
|
+
|
|
2232
|
+
/**
|
|
2233
|
+
* When the answer "nobody took it" is final.
|
|
2234
|
+
*
|
|
2235
|
+
* The floor is one macrotask: every render that could take the error — Preact's
|
|
2236
|
+
* queue, a Suspense boundary settling on the failure, the boundary above it —
|
|
2237
|
+
* happens in microtasks.
|
|
2238
|
+
*
|
|
2239
|
+
* That floor is enough for an action failing under a page that is already on
|
|
2240
|
+
* screen, and far too early for a route action: it fails ON the url change,
|
|
2241
|
+
* before its page exists, and that page cannot render until the routing that
|
|
2242
|
+
* asked for the data is over. Measured on an offline navigation, the screen
|
|
2243
|
+
* displaying the error arrived ~12ms after this deadline — so the app was told
|
|
2244
|
+
* it had displayed nothing while it was displaying it.
|
|
2245
|
+
*
|
|
2246
|
+
* The browser integration knows when the document has stopped moving and hands
|
|
2247
|
+
* that over here (see installReportDeadlineExtension); nothing else does, and
|
|
2248
|
+
* this module stays free of the DOM. Waiting longer costs nothing now that a
|
|
2249
|
+
* read is enough to call this off: what still reaches the report was read by no
|
|
2250
|
+
* render at all, and a late report about that is as good as a prompt one.
|
|
2251
|
+
*/
|
|
2252
|
+
let waitForDocumentSettled = null;
|
|
2253
|
+
const installReportDeadlineExtension = (fn) => {
|
|
2254
|
+
waitForDocumentSettled = fn;
|
|
2255
|
+
};
|
|
2256
|
+
|
|
2257
|
+
/**
|
|
2258
|
+
* The failures of one moment: reported, still waiting to know whether anything
|
|
2259
|
+
* took them. A url change fails its route actions together, so they wait here
|
|
2260
|
+
* together — which is what lets the throw below say something about the others.
|
|
2261
|
+
*/
|
|
2262
|
+
const errorPendingDecisionSet = new Set();
|
|
2263
|
+
|
|
2264
|
+
/**
|
|
2265
|
+
* A render stopped mid-way cannot read what came after it.
|
|
2266
|
+
*
|
|
2267
|
+
* `useAsyncData` delegates a failure by throwing it out of the render — that is
|
|
2268
|
+
* how the error reaches the boundary that displays it. The render ends on that
|
|
2269
|
+
* line, so every action the component reads BELOW it is never read, including
|
|
2270
|
+
* one that was going to display its own error. "Nobody read it" is still true
|
|
2271
|
+
* of those, but the reason is no longer that the app forgot: nothing COULD read
|
|
2272
|
+
* them. Left alone, the order of two hook calls decides whether an app is told
|
|
2273
|
+
* it has a bug — a rule nobody can see.
|
|
2274
|
+
*
|
|
2275
|
+
* So the throw says it for them: the failures waiting for the same answer at
|
|
2276
|
+
* that instant are failures no render could reach, and the report has nothing
|
|
2277
|
+
* to say about them. What is on screen is the failure the render was stopped
|
|
2278
|
+
* at, which is the same story.
|
|
2279
|
+
*
|
|
2280
|
+
* This is about the failures that already exist when the render is cut short.
|
|
2281
|
+
* An action of the same page failing LATER — after the page was replaced by
|
|
2282
|
+
* what displays the first failure — is not covered and is still reported: at
|
|
2283
|
+
* that point nothing distinguishes it from an action nobody reads.
|
|
2284
|
+
*/
|
|
2285
|
+
const errorNoRenderCouldReachSet = new WeakSet();
|
|
2286
|
+
const markErrorAsStoppingRender = (error) => {
|
|
2287
|
+
for (const errorPendingDecision of errorPendingDecisionSet) {
|
|
2288
|
+
if (errorPendingDecision !== error) {
|
|
2289
|
+
errorNoRenderCouldReachSet.add(errorPendingDecision);
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
2292
|
+
};
|
|
2293
|
+
|
|
2294
|
+
/**
|
|
2295
|
+
* An offline error is never reported: the app declared the state that produced
|
|
2296
|
+
* it, the request never left, and there is nothing for a developer to fix. It
|
|
2297
|
+
* is data a screen shows, and it stays data whether or not one does (see
|
|
2298
|
+
* network_policy.js, which also keeps it out of the browser console).
|
|
2299
|
+
*/
|
|
2300
|
+
const errorIsAccountedFor = (error) => {
|
|
2301
|
+
return (
|
|
2302
|
+
errorIsDisplayed(error) ||
|
|
2303
|
+
errorTakenByRenderSet.has(error) ||
|
|
2304
|
+
errorNoRenderCouldReachSet.has(error) ||
|
|
2305
|
+
isOfflineError(error)
|
|
2306
|
+
);
|
|
2307
|
+
};
|
|
2308
|
+
|
|
2309
|
+
/**
|
|
2310
|
+
* Rethrown rather than logged: an error nobody took is an unhandled error, and
|
|
2311
|
+
* the runtime already knows what to do with those (window "error" event, jsenv
|
|
2312
|
+
* overlay in dev). Same trick preact/debug uses for the same reason.
|
|
2313
|
+
*/
|
|
2314
|
+
const errorReportedSet = new WeakSet();
|
|
2315
|
+
const reportErrorIfNobodyDisplaysIt = (error, { action } = {}) => {
|
|
2316
|
+
if (error && typeof error === "object") {
|
|
2317
|
+
errorPendingDecisionSet.add(error);
|
|
2318
|
+
}
|
|
2319
|
+
const decide = () => {
|
|
2320
|
+
errorPendingDecisionSet.delete(error);
|
|
2321
|
+
if (errorIsAccountedFor(error)) {
|
|
2322
|
+
return;
|
|
2323
|
+
}
|
|
2324
|
+
if (error && typeof error === "object") {
|
|
2325
|
+
// The same error can reach here from more than one direction (the run
|
|
2326
|
+
// that produced it, the routing promise carrying it): it is one error and
|
|
2327
|
+
// it is reported once.
|
|
2328
|
+
if (errorReportedSet.has(error)) {
|
|
2329
|
+
return;
|
|
2330
|
+
}
|
|
2331
|
+
errorReportedSet.add(error);
|
|
2332
|
+
}
|
|
2333
|
+
if (action && error && typeof error === "object" && !error.action) {
|
|
2334
|
+
error.action = action;
|
|
2335
|
+
}
|
|
2336
|
+
throw error;
|
|
2337
|
+
};
|
|
2338
|
+
|
|
2339
|
+
setTimeout(() => {
|
|
2340
|
+
if (errorIsAccountedFor(error)) {
|
|
2341
|
+
// Already taken within the microtasks that followed the failure: the
|
|
2342
|
+
// common case, and there is nothing to wait for.
|
|
2343
|
+
errorPendingDecisionSet.delete(error);
|
|
2344
|
+
return;
|
|
2345
|
+
}
|
|
2346
|
+
if (waitForDocumentSettled) {
|
|
2347
|
+
waitForDocumentSettled(decide);
|
|
2348
|
+
return;
|
|
2349
|
+
}
|
|
2350
|
+
decide();
|
|
2351
|
+
});
|
|
2352
|
+
};
|
|
2353
|
+
|
|
2354
|
+
const actionPrivatePropertiesWeakMap = new WeakMap();
|
|
2355
|
+
const getActionPrivateProperties = (action) => {
|
|
2356
|
+
const actionPrivateProperties = actionPrivatePropertiesWeakMap.get(action);
|
|
2357
|
+
if (!actionPrivateProperties) {
|
|
2358
|
+
throw new Error(`Cannot find action private properties for "${action}"`);
|
|
2359
|
+
}
|
|
2360
|
+
return actionPrivateProperties;
|
|
2361
|
+
};
|
|
2362
|
+
const setActionPrivateProperties = (action, properties) => {
|
|
2363
|
+
actionPrivatePropertiesWeakMap.set(action, properties);
|
|
2364
|
+
};
|
|
2365
|
+
|
|
2366
|
+
const IDLE = { id: "idle" };
|
|
2367
|
+
const RUNNING = { id: "running" };
|
|
2368
|
+
const ABORTED = { id: "aborted" };
|
|
2369
|
+
const FAILED = { id: "failed" };
|
|
2370
|
+
const COMPLETED = { id: "completed" };
|
|
2371
|
+
|
|
2372
|
+
const useActionStatus = (action) => {
|
|
2373
|
+
if (!action) {
|
|
2374
|
+
return {
|
|
2375
|
+
params: undefined,
|
|
2376
|
+
runningState: IDLE,
|
|
2377
|
+
isPrerun: false,
|
|
2378
|
+
idle: true,
|
|
2379
|
+
loading: false,
|
|
2380
|
+
aborted: false,
|
|
2381
|
+
error: null,
|
|
2382
|
+
completed: false,
|
|
2383
|
+
data: undefined,
|
|
2384
|
+
};
|
|
2385
|
+
}
|
|
2386
|
+
const {
|
|
2387
|
+
paramsSignal,
|
|
2388
|
+
runningStateSignal,
|
|
2389
|
+
isPrerunSignal,
|
|
2390
|
+
errorSignal,
|
|
2391
|
+
dataSignal,
|
|
2392
|
+
} = action;
|
|
2393
|
+
const params = paramsSignal.value;
|
|
2394
|
+
const isPrerun = isPrerunSignal.value;
|
|
2395
|
+
const runningState = runningStateSignal.value;
|
|
2396
|
+
const idle = runningState === IDLE;
|
|
2397
|
+
const aborted = runningState === ABORTED;
|
|
2398
|
+
const error = errorSignal.value;
|
|
2399
|
+
const loading = runningState === RUNNING;
|
|
2400
|
+
const completed = runningState === COMPLETED;
|
|
2401
|
+
const data = dataSignal.value;
|
|
2402
|
+
|
|
2403
|
+
return {
|
|
2404
|
+
params,
|
|
2405
|
+
runningState,
|
|
2406
|
+
isPrerun,
|
|
2407
|
+
idle,
|
|
2408
|
+
loading,
|
|
2409
|
+
aborted,
|
|
2410
|
+
error,
|
|
2411
|
+
completed,
|
|
2412
|
+
data,
|
|
2413
|
+
};
|
|
2414
|
+
};
|
|
2415
|
+
|
|
2416
|
+
installImportMetaCssBuild(import.meta);const css$14 = /* css */`.action_error {
|
|
2417
|
+
background: #fdd;
|
|
2418
|
+
border: 1px solid red;
|
|
2419
|
+
margin-top: 0;
|
|
2420
|
+
margin-bottom: 20px;
|
|
2421
|
+
padding: 20px;
|
|
2422
|
+
}
|
|
2423
|
+
`;
|
|
2424
|
+
const renderIdleDefault = () => null;
|
|
2425
|
+
const renderLoadingDefault = () => null;
|
|
2426
|
+
const renderAbortedDefault = () => null;
|
|
2427
|
+
const renderErrorDefault = error => {
|
|
2428
|
+
let routeErrorText = error && error.message ? error.message : error;
|
|
2429
|
+
return jsxs("p", {
|
|
2430
|
+
className: "action_error",
|
|
2431
|
+
children: ["An error occured: ", routeErrorText]
|
|
2432
|
+
});
|
|
2433
|
+
};
|
|
2434
|
+
const renderCompletedDefault = () => null;
|
|
2435
|
+
const ActionRenderer = ({
|
|
2436
|
+
action,
|
|
2437
|
+
children,
|
|
2438
|
+
disabled
|
|
2439
|
+
}) => {
|
|
2440
|
+
import.meta.css = [css$14, "@jsenv/navi/src/action/action_renderer.jsx"];
|
|
2441
|
+
if (action === undefined) {
|
|
2442
|
+
throw new Error("ActionRenderer requires an action to render, but none was provided.");
|
|
2443
|
+
}
|
|
2444
|
+
let renderBranches;
|
|
2445
|
+
if (typeof children === "function") {
|
|
2446
|
+
renderBranches = {
|
|
2447
|
+
completed: children
|
|
2448
|
+
};
|
|
2449
|
+
} else if (isValidElement(children)) {
|
|
2450
|
+
renderBranches = {
|
|
2451
|
+
always: () => children
|
|
2452
|
+
};
|
|
2453
|
+
} else if (isPlainObject$2(children)) {
|
|
2454
|
+
renderBranches = children;
|
|
2455
|
+
} else {
|
|
2456
|
+
renderBranches = {
|
|
2457
|
+
completed: children
|
|
2458
|
+
};
|
|
2459
|
+
}
|
|
2460
|
+
const {
|
|
2461
|
+
idle: renderIdle = renderIdleDefault,
|
|
2462
|
+
loading: renderLoading = renderLoadingDefault,
|
|
2463
|
+
aborted: renderAborted = renderAbortedDefault,
|
|
2464
|
+
error: renderError = renderErrorDefault,
|
|
2465
|
+
completed: renderCompleted,
|
|
2466
|
+
always: renderAlways
|
|
2467
|
+
} = renderBranches;
|
|
2468
|
+
const {
|
|
2469
|
+
idle,
|
|
2470
|
+
loading,
|
|
2471
|
+
aborted,
|
|
2472
|
+
error,
|
|
2473
|
+
completed,
|
|
2474
|
+
data
|
|
2475
|
+
} = useActionStatus(action);
|
|
2476
|
+
const UIRenderedPromise = useUIRenderedPromise(action);
|
|
2477
|
+
const [errorBoundary, resetErrorBoundary] = useErrorBoundary();
|
|
2478
|
+
useLayoutEffect(() => {
|
|
2479
|
+
resetErrorBoundary();
|
|
2480
|
+
}, [action, loading, idle, resetErrorBoundary]);
|
|
2481
|
+
useLayoutEffect(() => {
|
|
2482
|
+
UIRenderedPromise.resolve();
|
|
2483
|
+
return () => {
|
|
2484
|
+
actionUIRenderedPromiseWeakMap.delete(action);
|
|
2485
|
+
};
|
|
2486
|
+
}, [action]);
|
|
2487
|
+
if (disabled) {
|
|
2488
|
+
return null;
|
|
2489
|
+
}
|
|
2490
|
+
// If renderAlways is provided, it wins and handles all rendering
|
|
2491
|
+
if (renderAlways) {
|
|
2492
|
+
return renderAlways({
|
|
2493
|
+
idle,
|
|
2494
|
+
loading,
|
|
2495
|
+
aborted,
|
|
2496
|
+
completed,
|
|
2497
|
+
error,
|
|
2498
|
+
data
|
|
2499
|
+
});
|
|
2500
|
+
}
|
|
2501
|
+
if (idle) {
|
|
2502
|
+
return renderIdle(action);
|
|
2503
|
+
}
|
|
2504
|
+
if (errorBoundary) {
|
|
2505
|
+
// Displaying it is what makes it handled (see action_error_report.js)
|
|
2506
|
+
markErrorAsDisplayedBy(errorBoundary, "<ActionRenderer>");
|
|
2507
|
+
return renderError(errorBoundary, "ui_error", action);
|
|
2508
|
+
}
|
|
2509
|
+
if (aborted) {
|
|
2510
|
+
return renderAborted(action);
|
|
2511
|
+
}
|
|
2512
|
+
let renderCompletedSafe;
|
|
2513
|
+
if (renderCompleted) {
|
|
2514
|
+
renderCompletedSafe = renderCompleted;
|
|
2515
|
+
} else {
|
|
2516
|
+
const {
|
|
2517
|
+
ui
|
|
2518
|
+
} = getActionPrivateProperties(action);
|
|
2519
|
+
if (ui.renderCompleted) {
|
|
2520
|
+
renderCompletedSafe = ui.renderCompleted;
|
|
2521
|
+
} else {
|
|
2522
|
+
renderCompletedSafe = renderCompletedDefault;
|
|
2523
|
+
}
|
|
2524
|
+
}
|
|
2525
|
+
if (loading) {
|
|
2526
|
+
if (action.canDisplayOldData && data !== undefined) {
|
|
2527
|
+
return renderCompletedSafe(data, action);
|
|
2528
|
+
}
|
|
2529
|
+
return renderLoading(action);
|
|
2530
|
+
}
|
|
2531
|
+
if (error) {
|
|
2532
|
+
markErrorAsDisplayedBy(error, "<ActionRenderer>");
|
|
2533
|
+
return renderError(error, "action_error", action);
|
|
2534
|
+
}
|
|
2535
|
+
return renderCompletedSafe(data, action);
|
|
2536
|
+
};
|
|
2537
|
+
const defaultPromise = Promise.resolve();
|
|
2538
|
+
defaultPromise.resolve = () => {};
|
|
2539
|
+
const actionUIRenderedPromiseWeakMap = new WeakMap();
|
|
2540
|
+
const useUIRenderedPromise = action => {
|
|
2541
|
+
if (!action) {
|
|
2542
|
+
return defaultPromise;
|
|
2543
|
+
}
|
|
2544
|
+
const actionUIRenderedPromise = actionUIRenderedPromiseWeakMap.get(action);
|
|
2545
|
+
if (actionUIRenderedPromise) {
|
|
2546
|
+
return actionUIRenderedPromise;
|
|
2547
|
+
}
|
|
2548
|
+
let resolve;
|
|
2549
|
+
const promise = new Promise(res => {
|
|
2550
|
+
resolve = res;
|
|
2551
|
+
});
|
|
2552
|
+
promise.resolve = resolve;
|
|
2553
|
+
actionUIRenderedPromiseWeakMap.set(action, promise);
|
|
2554
|
+
return promise;
|
|
2555
|
+
};
|
|
2556
|
+
const isPlainObject$2 = obj => {
|
|
2557
|
+
if (typeof obj !== "object" || obj === null) {
|
|
2558
|
+
return false;
|
|
2559
|
+
}
|
|
2560
|
+
let proto = obj;
|
|
2561
|
+
while (Object.getPrototypeOf(proto) !== null) {
|
|
2562
|
+
proto = Object.getPrototypeOf(proto);
|
|
2563
|
+
}
|
|
2564
|
+
return Object.getPrototypeOf(obj) === proto || Object.getPrototypeOf(obj) === null;
|
|
2565
|
+
};
|
|
2566
|
+
|
|
2567
|
+
const MAX_ENTRIES = 5;
|
|
2568
|
+
|
|
2569
|
+
const stringifyForDisplay = (
|
|
2570
|
+
value,
|
|
2571
|
+
maxDepth = 2,
|
|
2572
|
+
currentDepth = 0,
|
|
2573
|
+
options = {},
|
|
2574
|
+
) => {
|
|
2575
|
+
const { asFunctionArgs = false } = options;
|
|
2576
|
+
const indent = " ".repeat(currentDepth);
|
|
2577
|
+
const nextIndent = " ".repeat(currentDepth + 1);
|
|
2578
|
+
|
|
2579
|
+
if (currentDepth >= maxDepth) {
|
|
2580
|
+
return typeof value === "object" && value !== null
|
|
2581
|
+
? "[Object]"
|
|
2582
|
+
: String(value);
|
|
2583
|
+
}
|
|
2584
|
+
|
|
2585
|
+
if (value === null) {
|
|
2586
|
+
return "null";
|
|
2587
|
+
}
|
|
2588
|
+
if (value === undefined) {
|
|
2589
|
+
return "undefined";
|
|
2590
|
+
}
|
|
2591
|
+
if (typeof value === "string") {
|
|
2592
|
+
return `"${value}"`;
|
|
2593
|
+
}
|
|
2594
|
+
if (typeof value === "number" || typeof value === "boolean") {
|
|
2595
|
+
return String(value);
|
|
2596
|
+
}
|
|
2597
|
+
if (typeof value === "function") {
|
|
2598
|
+
return `[Function ${value.name || "anonymous"}]`;
|
|
2599
|
+
}
|
|
2600
|
+
if (value instanceof Date) {
|
|
2601
|
+
return `Date(${value.toISOString()})`;
|
|
2602
|
+
}
|
|
2603
|
+
if (value instanceof RegExp) {
|
|
2604
|
+
return value.toString();
|
|
2605
|
+
}
|
|
2606
|
+
|
|
2607
|
+
if (Array.isArray(value)) {
|
|
2608
|
+
const openBracket = asFunctionArgs ? "(" : "[";
|
|
2609
|
+
const closeBracket = asFunctionArgs ? ")" : "]";
|
|
2610
|
+
|
|
2611
|
+
if (value.length === 0) return `${openBracket}${closeBracket}`;
|
|
2612
|
+
|
|
2613
|
+
// Display arrays with only one element on a single line
|
|
2614
|
+
if (value.length === 1) {
|
|
2615
|
+
const item = stringifyForDisplay(
|
|
2616
|
+
value[0],
|
|
2617
|
+
maxDepth,
|
|
2618
|
+
currentDepth + 1,
|
|
2619
|
+
// Remove asFunctionArgs for nested calls
|
|
2620
|
+
{ ...options, asFunctionArgs: false },
|
|
2621
|
+
);
|
|
2622
|
+
return `${openBracket}${item}${closeBracket}`;
|
|
2623
|
+
}
|
|
2624
|
+
|
|
2625
|
+
if (value.length > MAX_ENTRIES) {
|
|
2626
|
+
const preview = value
|
|
2627
|
+
.slice(0, MAX_ENTRIES)
|
|
2628
|
+
.map(
|
|
2629
|
+
(v) =>
|
|
2630
|
+
`${nextIndent}${stringifyForDisplay(v, maxDepth, currentDepth + 1, { ...options, asFunctionArgs: false })}`,
|
|
2631
|
+
);
|
|
2632
|
+
return `${openBracket}\n${preview.join(",\n")},\n${nextIndent}...${value.length - MAX_ENTRIES} more\n${indent}${closeBracket}`;
|
|
2633
|
+
}
|
|
2634
|
+
|
|
2635
|
+
const items = value.map(
|
|
2636
|
+
(v) =>
|
|
2637
|
+
`${nextIndent}${stringifyForDisplay(v, maxDepth, currentDepth + 1, { ...options, asFunctionArgs: false })}`,
|
|
2638
|
+
);
|
|
2639
|
+
return `${openBracket}\n${items.join(",\n")}\n${indent}${closeBracket}`;
|
|
2640
|
+
}
|
|
2641
|
+
|
|
2642
|
+
if (typeof value === "object") {
|
|
2643
|
+
const signalType = getSignalType(value);
|
|
2644
|
+
if (signalType) {
|
|
2645
|
+
const signalValue = value.peek();
|
|
2646
|
+
const prefix = signalType === "computed" ? "computed" : "signal";
|
|
2647
|
+
return `${prefix}(${stringifyForDisplay(signalValue, maxDepth, currentDepth, { ...options, asFunctionArgs: false })})`;
|
|
2648
|
+
}
|
|
2649
|
+
|
|
2650
|
+
const entries = Object.entries(value);
|
|
2651
|
+
if (entries.length === 0) return "{}";
|
|
2652
|
+
|
|
2653
|
+
// ✅ Inclure les clés avec valeurs undefined/null
|
|
2654
|
+
const allEntries = [];
|
|
2655
|
+
for (const [key, val] of entries) {
|
|
2656
|
+
allEntries.push([key, val]);
|
|
2657
|
+
}
|
|
2658
|
+
|
|
2659
|
+
// Ajouter les clés avec undefined (que Object.entries omet)
|
|
2660
|
+
const descriptor = Object.getOwnPropertyDescriptors(value);
|
|
2661
|
+
for (const [key, desc] of Object.entries(descriptor)) {
|
|
2662
|
+
if (desc.value === undefined && !entries.some(([k]) => k === key)) {
|
|
2663
|
+
allEntries.push([key, undefined]);
|
|
2664
|
+
}
|
|
2665
|
+
}
|
|
2666
|
+
|
|
2667
|
+
// Display objects with only one key on a single line
|
|
2668
|
+
if (allEntries.length === 1) {
|
|
2669
|
+
const [key, val] = allEntries[0];
|
|
2670
|
+
const valueStr = stringifyForDisplay(val, maxDepth, currentDepth + 1, {
|
|
2671
|
+
...options,
|
|
2672
|
+
asFunctionArgs: false,
|
|
2673
|
+
});
|
|
2674
|
+
return `{ ${key}: ${valueStr} }`;
|
|
2675
|
+
}
|
|
2676
|
+
|
|
2677
|
+
if (allEntries.length > MAX_ENTRIES) {
|
|
2678
|
+
const preview = allEntries
|
|
2679
|
+
.slice(0, MAX_ENTRIES)
|
|
2680
|
+
.map(
|
|
2681
|
+
([k, v]) =>
|
|
2682
|
+
`${nextIndent}${k}: ${stringifyForDisplay(v, maxDepth, currentDepth + 1, { ...options, asFunctionArgs: false })}`,
|
|
2683
|
+
);
|
|
2684
|
+
return `{\n${preview.join(",\n")},\n${nextIndent}...${allEntries.length - MAX_ENTRIES} more\n${indent}}`;
|
|
2685
|
+
}
|
|
2686
|
+
|
|
2687
|
+
const pairs = allEntries.map(
|
|
2688
|
+
([k, v]) =>
|
|
2689
|
+
`${nextIndent}${k}: ${stringifyForDisplay(v, maxDepth, currentDepth + 1, { ...options, asFunctionArgs: false })}`,
|
|
2690
|
+
);
|
|
2691
|
+
return `{\n${pairs.join(",\n")}\n${indent}}`;
|
|
2692
|
+
}
|
|
2693
|
+
|
|
2694
|
+
return String(value);
|
|
2695
|
+
};
|
|
2696
|
+
|
|
2697
|
+
/*
|
|
2698
|
+
* Deep structural equality for arbitrary JS values — what `===` can't do but this
|
|
2699
|
+
* codebase constantly needs: memoization cache keys ({ id: 1 } equal to { id: 1 }),
|
|
2700
|
+
* effect/memo dependency checks, signal/store change detection, and action
|
|
2701
|
+
* parameter deduplication.
|
|
2702
|
+
*
|
|
2703
|
+
* Beyond recursive object/array comparison it covers the edge cases `===` gets
|
|
2704
|
+
* "wrong" for equality purposes: NaN equals NaN, Date compared by time value,
|
|
2705
|
+
* cycles don't loop (a set of the pairs being compared guards circular refs),
|
|
2706
|
+
* and same-type is required
|
|
2707
|
+
* before descending. Functions, signals, and objects with nothing enumerable to
|
|
2708
|
+
* compare (a Set, a Map, an element, a URL), are equal by reference only —
|
|
2709
|
+
* nothing in them says whether two are "the same". Cheap paths run first: reference
|
|
2710
|
+
* equality, then the identity short-circuit below, then array length before
|
|
2711
|
+
* element-by-element.
|
|
2712
|
+
*
|
|
2713
|
+
* SYMBOL_IDENTITY. Two *different* object instances can be declared "conceptually
|
|
2714
|
+
* the same" by sharing a SYMBOL_IDENTITY value; the comparison then treats them as
|
|
2715
|
+
* equal with no deep walk. This is what lets a spread copy ({ ...params, extra })
|
|
2716
|
+
* still count as the same params as the original, and lets objects reconstructed
|
|
2717
|
+
* across a serialization boundary be recognized as one entity — the cases where a
|
|
2718
|
+
* content comparison would be too slow, or too strict to see them as equal. Use
|
|
2719
|
+
* Symbol.for() so the marker is the same symbol across modules/contexts:
|
|
2720
|
+
*
|
|
2721
|
+
* const id = Symbol.for("params");
|
|
2722
|
+
* a[SYMBOL_IDENTITY] = id;
|
|
2723
|
+
* b[SYMBOL_IDENTITY] = id;
|
|
2724
|
+
* compareTwoJsValues(a, b); // true immediately, no property walk
|
|
2725
|
+
*/
|
|
2726
|
+
|
|
2727
|
+
|
|
2728
|
+
// Marks objects with a conceptual identity that transcends reference equality —
|
|
2729
|
+
// see the file comment. Symbol.for keeps it one shared symbol across modules.
|
|
2730
|
+
const SYMBOL_IDENTITY = Symbol.for("navi_object_identity");
|
|
2731
|
+
|
|
2732
|
+
/**
|
|
2733
|
+
* Deeply compares two values for structural equality.
|
|
2734
|
+
*
|
|
2735
|
+
* @param {any} rootA - First value.
|
|
2736
|
+
* @param {any} rootB - Second value.
|
|
2737
|
+
* @param {object} [options]
|
|
2738
|
+
* @param {(a: any, b: any, keyOrIndex: any, recurse: (a: any, b: any) => boolean) => boolean} [options.keyComparator]
|
|
2739
|
+
* Custom comparator for object properties / array elements. Receives the internal
|
|
2740
|
+
* `compare` as its last argument so it can defer to the default behavior.
|
|
2741
|
+
* @param {boolean} [options.ignoreArrayOrder=false] - Compare arrays as multisets:
|
|
2742
|
+
* equal when they contain the same elements regardless of order.
|
|
2743
|
+
* @param {Iterable<string>} [options.lightKeySet] - Object keys to compare first —
|
|
2744
|
+
* cheaper or likelier-to-differ ones — to short-circuit before the remaining keys.
|
|
2745
|
+
* @returns {boolean} true if the values are deeply equal.
|
|
2746
|
+
*/
|
|
2747
|
+
const compareTwoJsValues = (
|
|
2748
|
+
rootA,
|
|
2749
|
+
rootB,
|
|
2750
|
+
{ keyComparator, ignoreArrayOrder = false, lightKeySet } = {},
|
|
2751
|
+
) => {
|
|
2752
|
+
const seenSet = new Set();
|
|
2753
|
+
const compare = (a, b) => {
|
|
2754
|
+
if (a === b) {
|
|
2755
|
+
return true;
|
|
2756
|
+
}
|
|
2757
|
+
const aIsIsTruthy = Boolean(a);
|
|
2758
|
+
const bIsTruthy = Boolean(b);
|
|
2759
|
+
if (aIsIsTruthy && !bIsTruthy) {
|
|
2760
|
+
return false;
|
|
2761
|
+
}
|
|
2762
|
+
if (!aIsIsTruthy && !bIsTruthy) {
|
|
2763
|
+
// null, undefined, 0, false, NaN
|
|
2764
|
+
if (isNaN(a) && isNaN(b)) {
|
|
2765
|
+
return true;
|
|
2766
|
+
}
|
|
2767
|
+
return a === b;
|
|
2768
|
+
}
|
|
2769
|
+
const aType = typeof a;
|
|
2770
|
+
const bType = typeof b;
|
|
2771
|
+
if (aType !== bType) {
|
|
2772
|
+
return false;
|
|
2773
|
+
}
|
|
2774
|
+
if (aType === "function") {
|
|
2775
|
+
// Not the same function (reference equality came first), and nothing in
|
|
2776
|
+
// a function says whether two of them do the same thing.
|
|
2777
|
+
return false;
|
|
2778
|
+
}
|
|
2779
|
+
const aIsPrimitive =
|
|
2780
|
+
a === null || (aType !== "object" && aType !== "function");
|
|
2781
|
+
const bIsPrimitive =
|
|
2782
|
+
b === null || (bType !== "object" && bType !== "function");
|
|
2783
|
+
if (aIsPrimitive !== bIsPrimitive) {
|
|
2784
|
+
return false;
|
|
2785
|
+
}
|
|
2786
|
+
if (aIsPrimitive && bIsPrimitive) {
|
|
2787
|
+
return a === b;
|
|
2788
|
+
}
|
|
2789
|
+
// Back on something still being compared: a cycle. No loop, and no answer
|
|
2790
|
+
// either — equal by a route that never ends is not equal.
|
|
2791
|
+
if (seenSet.has(a) || seenSet.has(b)) {
|
|
2792
|
+
return false;
|
|
2793
|
+
}
|
|
2794
|
+
// Held only while a and b are being compared, not for the rest of the
|
|
2795
|
+
// walk: the same object is rightly met again elsewhere — in an unordered
|
|
2796
|
+
// array every element of a is tried against every element of b.
|
|
2797
|
+
seenSet.add(a);
|
|
2798
|
+
seenSet.add(b);
|
|
2799
|
+
const result = compareComposite(a, b);
|
|
2800
|
+
seenSet.delete(a);
|
|
2801
|
+
seenSet.delete(b);
|
|
2802
|
+
return result;
|
|
2803
|
+
};
|
|
2804
|
+
const compareComposite = (a, b) => {
|
|
2805
|
+
const aIsArray = Array.isArray(a);
|
|
2806
|
+
const bIsArray = Array.isArray(b);
|
|
2807
|
+
if (aIsArray !== bIsArray) {
|
|
2808
|
+
return false;
|
|
2809
|
+
}
|
|
2810
|
+
if (aIsArray) {
|
|
2811
|
+
// compare arrays
|
|
2812
|
+
if (a.length !== b.length) {
|
|
2813
|
+
return false;
|
|
2814
|
+
}
|
|
2815
|
+
if (ignoreArrayOrder) {
|
|
2816
|
+
// Unordered array comparison: each element in 'a' must have a match in 'b'
|
|
2817
|
+
const usedIndices = new Set();
|
|
2818
|
+
for (let i = 0; i < a.length; i++) {
|
|
2819
|
+
const aValue = a[i];
|
|
2820
|
+
let foundMatch = false;
|
|
2821
|
+
|
|
2822
|
+
for (let j = 0; j < b.length; j++) {
|
|
2823
|
+
if (usedIndices.has(j)) {
|
|
2824
|
+
continue; // Already matched with another element
|
|
2825
|
+
}
|
|
2826
|
+
const bValue = b[j];
|
|
2827
|
+
if (compareAt(aValue, bValue, i)) {
|
|
2828
|
+
foundMatch = true;
|
|
2829
|
+
usedIndices.add(j);
|
|
2830
|
+
break;
|
|
2831
|
+
}
|
|
2832
|
+
}
|
|
2833
|
+
if (!foundMatch) {
|
|
2834
|
+
return false;
|
|
2835
|
+
}
|
|
2836
|
+
}
|
|
2837
|
+
return true;
|
|
2838
|
+
}
|
|
2839
|
+
// Ordered array comparison (original behavior)
|
|
2840
|
+
let i = 0;
|
|
2841
|
+
while (i < a.length) {
|
|
2842
|
+
const aValue = a[i];
|
|
2843
|
+
const bValue = b[i];
|
|
2844
|
+
if (!compareAt(aValue, bValue, i)) {
|
|
2845
|
+
return false;
|
|
2846
|
+
}
|
|
2847
|
+
i++;
|
|
2848
|
+
}
|
|
2849
|
+
return true;
|
|
2850
|
+
}
|
|
2851
|
+
// compare objects
|
|
2852
|
+
const aIdentity = a[SYMBOL_IDENTITY];
|
|
2853
|
+
const bIdentity = b[SYMBOL_IDENTITY];
|
|
2854
|
+
if (
|
|
2855
|
+
aIdentity === bIdentity &&
|
|
2856
|
+
SYMBOL_IDENTITY in a &&
|
|
2857
|
+
SYMBOL_IDENTITY in b
|
|
2858
|
+
) {
|
|
2859
|
+
return true;
|
|
2860
|
+
}
|
|
2861
|
+
// Date objects must be compared by time value, not by enumerable keys (which are empty)
|
|
2862
|
+
const aIsDate = a instanceof Date;
|
|
2863
|
+
const bIsDate = b instanceof Date;
|
|
2864
|
+
if (aIsDate !== bIsDate) {
|
|
2865
|
+
return false;
|
|
2866
|
+
}
|
|
2867
|
+
if (aIsDate) {
|
|
2868
|
+
return a.getTime() === b.getTime();
|
|
2869
|
+
}
|
|
2870
|
+
// A signal is a source of truth, not a value: two of them holding the same
|
|
2871
|
+
// thing right now say nothing about the next tick, and one may change while
|
|
2872
|
+
// the other does not. Their internals (the current value, a version
|
|
2873
|
+
// counter) are enumerable, so without this they would be walked and two
|
|
2874
|
+
// unrelated signals would pass for one — a params signal owned by one
|
|
2875
|
+
// screen would then be handed the action bound to another's.
|
|
2876
|
+
if (isSignal(a) || isSignal(b)) {
|
|
2877
|
+
return false;
|
|
2878
|
+
}
|
|
2879
|
+
const aKeys = Object.keys(a);
|
|
2880
|
+
const bKeys = Object.keys(b);
|
|
2881
|
+
if (aKeys.length !== bKeys.length) {
|
|
2882
|
+
return false;
|
|
2883
|
+
}
|
|
2884
|
+
if (aKeys.length === 0 && (!isPlainObject$1(a) || !isPlainObject$1(b))) {
|
|
2885
|
+
// A Set, a Map, an element, a URL: nothing enumerable to tell two of them
|
|
2886
|
+
// apart, so they are the same one or they are not — and they are not,
|
|
2887
|
+
// reference equality came first.
|
|
2888
|
+
return false;
|
|
2889
|
+
}
|
|
2890
|
+
if (lightKeySet) {
|
|
2891
|
+
// compare light keys first, then remaining keys
|
|
2892
|
+
// (optimization for cases where some keys are more likely to differ and/or faster to compare)
|
|
2893
|
+
const keySet = new Set(aKeys);
|
|
2894
|
+
for (const lightKey of lightKeySet) {
|
|
2895
|
+
const aValue = a[lightKey];
|
|
2896
|
+
const bValue = b[lightKey];
|
|
2897
|
+
if (!compareAt(aValue, bValue, lightKey)) {
|
|
2898
|
+
return false;
|
|
2899
|
+
}
|
|
2900
|
+
keySet.delete(lightKey);
|
|
2901
|
+
}
|
|
2902
|
+
for (const key of keySet) {
|
|
2903
|
+
const aValue = a[key];
|
|
2904
|
+
const bValue = b[key];
|
|
2905
|
+
if (!compareAt(aValue, bValue, key)) {
|
|
2906
|
+
return false;
|
|
2907
|
+
}
|
|
2908
|
+
}
|
|
2909
|
+
} else {
|
|
2910
|
+
for (const key of aKeys) {
|
|
2911
|
+
const aValue = a[key];
|
|
2912
|
+
const bValue = b[key];
|
|
2913
|
+
if (!compareAt(aValue, bValue, key)) {
|
|
2914
|
+
return false;
|
|
2915
|
+
}
|
|
2916
|
+
}
|
|
2917
|
+
}
|
|
2918
|
+
return true;
|
|
2919
|
+
};
|
|
2920
|
+
const compareAt = keyComparator
|
|
2921
|
+
? (a, b, keyOrArrayIndex) => keyComparator(a, b, keyOrArrayIndex, compare)
|
|
2922
|
+
: compare;
|
|
2974
2923
|
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
naviI18n.addAll({
|
|
2978
|
-
"time.placeholder.day": {
|
|
2979
|
-
fr: "jj",
|
|
2980
|
-
en: "dd",
|
|
2981
|
-
de: "TT",
|
|
2982
|
-
es: "dd",
|
|
2983
|
-
it: "gg",
|
|
2984
|
-
pt: "dd",
|
|
2985
|
-
nl: "dd",
|
|
2986
|
-
},
|
|
2987
|
-
"time.placeholder.month": {
|
|
2988
|
-
fr: "mm",
|
|
2989
|
-
en: "mm",
|
|
2990
|
-
de: "MM",
|
|
2991
|
-
es: "mm",
|
|
2992
|
-
it: "mm",
|
|
2993
|
-
pt: "mm",
|
|
2994
|
-
nl: "mm",
|
|
2995
|
-
},
|
|
2996
|
-
"time.placeholder.year": {
|
|
2997
|
-
fr: "aaaa",
|
|
2998
|
-
en: "yyyy",
|
|
2999
|
-
de: "JJJJ",
|
|
3000
|
-
es: "aaaa",
|
|
3001
|
-
it: "aaaa",
|
|
3002
|
-
pt: "aaaa",
|
|
3003
|
-
nl: "jjjj",
|
|
3004
|
-
},
|
|
3005
|
-
"time.placeholder.hour": {
|
|
3006
|
-
fr: "hh",
|
|
3007
|
-
en: "hh",
|
|
3008
|
-
de: "hh",
|
|
3009
|
-
es: "hh",
|
|
3010
|
-
it: "hh",
|
|
3011
|
-
pt: "hh",
|
|
3012
|
-
nl: "uu",
|
|
3013
|
-
},
|
|
3014
|
-
"time.placeholder.minute": {
|
|
3015
|
-
fr: "mm",
|
|
3016
|
-
en: "mm",
|
|
3017
|
-
de: "mm",
|
|
3018
|
-
es: "mm",
|
|
3019
|
-
it: "mm",
|
|
3020
|
-
pt: "mm",
|
|
3021
|
-
nl: "mm",
|
|
3022
|
-
},
|
|
3023
|
-
"time.placeholder.week": {
|
|
3024
|
-
fr: "sem.",
|
|
3025
|
-
en: "wk",
|
|
3026
|
-
de: "KW",
|
|
3027
|
-
es: "sem.",
|
|
3028
|
-
it: "sett.",
|
|
3029
|
-
pt: "sem.",
|
|
3030
|
-
nl: "wk",
|
|
3031
|
-
},
|
|
3032
|
-
});
|
|
2924
|
+
return compare(rootA, rootB);
|
|
2925
|
+
};
|
|
3033
2926
|
|
|
3034
|
-
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
* Under a policy (a truthy reason):
|
|
3039
|
-
* - a resource GET answers with the row its store holds for it — named by its
|
|
3040
|
-
* params, or the one it last completed with — and asks nothing
|
|
3041
|
-
* (resource_graph.js, applyNetworkPolicy); a completed read
|
|
3042
|
-
* asked to rerun stays completed (actions.js, handleActionRequest);
|
|
3043
|
-
* anything else settles with an OfflineError carrying the reason;
|
|
3044
|
-
* - a control bound to a write — or inside a form bound to one — is read-only
|
|
3045
|
-
* and says why (control_hooks.jsx, readonly_constraint.js).
|
|
3046
|
-
*
|
|
3047
|
-
* The reason is a value rather than a boolean because "no network" and
|
|
3048
|
-
* "offline mode" are not said the same way to the user: the error and the
|
|
3049
|
-
* read-only message both carry it, and the app decides the words.
|
|
3050
|
-
*
|
|
3051
|
-
* Only actions declaring a verb (`meta.verb`, which every resource action
|
|
3052
|
-
* has) are subject to the policy: a plain `createAction` may not touch the
|
|
3053
|
-
* network at all, and navi has no way to tell.
|
|
3054
|
-
*/
|
|
2927
|
+
const isPlainObject$1 = (value) => {
|
|
2928
|
+
const prototype = Object.getPrototypeOf(value);
|
|
2929
|
+
return prototype === Object.prototype || prototype === null;
|
|
2930
|
+
};
|
|
3055
2931
|
|
|
3056
|
-
const
|
|
2932
|
+
const debounceSignal = (
|
|
2933
|
+
signalToDebounce,
|
|
2934
|
+
{ delay = 300, deepCompare = true } = {},
|
|
2935
|
+
) => {
|
|
2936
|
+
let timeoutId;
|
|
2937
|
+
let latestValue = signalToDebounce.peek();
|
|
2938
|
+
const debouncedSignal = signal(latestValue);
|
|
3057
2939
|
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
2940
|
+
effect(() => {
|
|
2941
|
+
const value = signalToDebounce.value;
|
|
2942
|
+
const debouncedValue = debouncedSignal.peek();
|
|
2943
|
+
if (
|
|
2944
|
+
deepCompare
|
|
2945
|
+
? compareTwoJsValues(value, debouncedValue)
|
|
2946
|
+
: value === debouncedValue
|
|
2947
|
+
) {
|
|
2948
|
+
return;
|
|
2949
|
+
}
|
|
2950
|
+
clearTimeout(timeoutId);
|
|
2951
|
+
latestValue = value;
|
|
2952
|
+
timeoutId = setTimeout(() => {
|
|
2953
|
+
debouncedSignal.value = latestValue;
|
|
2954
|
+
}, delay);
|
|
2955
|
+
});
|
|
2956
|
+
|
|
2957
|
+
debouncedSignal.flush = () => {
|
|
2958
|
+
clearTimeout(timeoutId);
|
|
2959
|
+
debouncedSignal.value = latestValue;
|
|
2960
|
+
};
|
|
2961
|
+
// Whoever reads the debounced value gets the previous one during the delay,
|
|
2962
|
+
// with nothing saying a newer one is on its way. This says it.
|
|
2963
|
+
debouncedSignal.settlingSignal = computed(() => {
|
|
2964
|
+
return !compareTwoJsValues(signalToDebounce.value, debouncedSignal.value);
|
|
2965
|
+
});
|
|
2966
|
+
|
|
2967
|
+
return debouncedSignal;
|
|
2968
|
+
};
|
|
2969
|
+
|
|
2970
|
+
const debouncedSignalCache = new WeakMap();
|
|
3062
2971
|
|
|
3063
2972
|
/**
|
|
3064
|
-
*
|
|
3065
|
-
*
|
|
3066
|
-
* @param {import("@preact/signals").Signal | Function | any} source - where the
|
|
3067
|
-
* reason is read from: a signal (followed live), a function (called on each
|
|
3068
|
-
* read), or a plain value. A falsy reason means "go to the network"; any
|
|
3069
|
-
* truthy value means "do not", and is handed to the `OfflineError` an action
|
|
3070
|
-
* settles with (`error.reason`) so a screen can say which kind of offline it is.
|
|
3071
|
-
* @param {Object} [options]
|
|
3072
|
-
* @param {string | ((reason: any) => string)} [options.readOnlyMessage] - what a
|
|
3073
|
-
* control held back by the policy answers when pressed; defaults to navi's
|
|
3074
|
-
* `constraint.readonly.network_policy` text.
|
|
3075
|
-
* @see docs/offline.md
|
|
2973
|
+
* The one debounced signal for a given source and delay.
|
|
3076
2974
|
*
|
|
3077
|
-
*
|
|
3078
|
-
*
|
|
3079
|
-
*
|
|
3080
|
-
* );
|
|
3081
|
-
* setNetworkPolicy(offlineReasonSignal, {
|
|
3082
|
-
* readOnlyMessage: (reason) =>
|
|
3083
|
-
* reason === "device" ? "No network: this cannot be sent." : "Offline mode: this cannot be sent.",
|
|
3084
|
-
* });
|
|
2975
|
+
* Two callers asking the same question of the same signal must wait on the same
|
|
2976
|
+
* answer: a debounced signal per caller is a timer per caller, and the actions
|
|
2977
|
+
* derived from them are then several instances of what is one request.
|
|
3085
2978
|
*/
|
|
3086
|
-
const
|
|
3087
|
-
|
|
2979
|
+
const getDebouncedSignal = (signalToDebounce, delay) => {
|
|
2980
|
+
let byDelay = debouncedSignalCache.get(signalToDebounce);
|
|
2981
|
+
if (!byDelay) {
|
|
2982
|
+
byDelay = new Map();
|
|
2983
|
+
debouncedSignalCache.set(signalToDebounce, byDelay);
|
|
2984
|
+
}
|
|
2985
|
+
const existing = byDelay.get(delay);
|
|
2986
|
+
if (existing) {
|
|
2987
|
+
return existing;
|
|
2988
|
+
}
|
|
2989
|
+
const debouncedSignal = debounceSignal(signalToDebounce, { delay });
|
|
2990
|
+
byDelay.set(delay, debouncedSignal);
|
|
2991
|
+
return debouncedSignal;
|
|
3088
2992
|
};
|
|
3089
2993
|
|
|
3090
2994
|
/**
|
|
3091
|
-
*
|
|
3092
|
-
*
|
|
2995
|
+
* jsenv/navi - createJsValueWeakMap
|
|
2996
|
+
*
|
|
2997
|
+
* Key/value cache with true ephemeron behavior and deep equality support.
|
|
2998
|
+
*
|
|
2999
|
+
* Features:
|
|
3000
|
+
* - Mutual retention: key keeps value alive, value keeps key alive
|
|
3001
|
+
* - Deep equality: different objects with same content are treated as identical keys
|
|
3002
|
+
* - Automatic GC: entries are eligible for collection when unreferenced
|
|
3003
|
+
* - Iteration support: can iterate over live entries for deep equality lookup
|
|
3004
|
+
*
|
|
3005
|
+
* Implementation:
|
|
3006
|
+
* - Dual WeakMap (key->value, value->key) provides ephemeron behavior
|
|
3007
|
+
* - WeakRef registry enables iteration without preventing GC
|
|
3008
|
+
* - Primitives stored in Map (permanent retention - avoid for keys)
|
|
3009
|
+
*
|
|
3010
|
+
* Use case: Action caching where params (key) and action (value) should have
|
|
3011
|
+
* synchronized lifetimes while allowing natural garbage collection.
|
|
3093
3012
|
*/
|
|
3094
|
-
const useNetworkPolicyReason = () => {
|
|
3095
|
-
return readReason(networkPolicySignal.value.source);
|
|
3096
|
-
};
|
|
3097
3013
|
|
|
3098
|
-
// For the action layer: the same answer without subscribing whoever asks.
|
|
3099
|
-
const peekNetworkPolicyReason = () => {
|
|
3100
|
-
return untracked(() => readReason(networkPolicySignal.peek().source));
|
|
3101
|
-
};
|
|
3102
3014
|
|
|
3103
|
-
const
|
|
3104
|
-
|
|
3105
|
-
|
|
3015
|
+
const createJsValueWeakMap = () => {
|
|
3016
|
+
// Core ephemeron maps for mutual retention
|
|
3017
|
+
const keyToValue = new WeakMap(); // key -> value
|
|
3018
|
+
const valueToKey = new WeakMap(); // value -> key
|
|
3106
3019
|
|
|
3107
|
-
|
|
3108
|
-
|
|
3020
|
+
// Registry for iteration/deep equality (holds WeakRefs)
|
|
3021
|
+
const keyRegistry = new Set(); // Set of WeakRef(key)
|
|
3022
|
+
|
|
3023
|
+
// Primitive cache
|
|
3024
|
+
const primitiveCache = new Map();
|
|
3025
|
+
|
|
3026
|
+
function cleanupKeyRegistry() {
|
|
3027
|
+
for (const keyRef of keyRegistry) {
|
|
3028
|
+
if (keyRef.deref() === undefined) {
|
|
3029
|
+
keyRegistry.delete(keyRef);
|
|
3030
|
+
}
|
|
3031
|
+
}
|
|
3032
|
+
}
|
|
3033
|
+
|
|
3034
|
+
return {
|
|
3035
|
+
*[Symbol.iterator]() {
|
|
3036
|
+
cleanupKeyRegistry();
|
|
3037
|
+
for (const keyRef of keyRegistry) {
|
|
3038
|
+
const key = keyRef.deref();
|
|
3039
|
+
if (key && keyToValue.has(key)) {
|
|
3040
|
+
yield [key, keyToValue.get(key)];
|
|
3041
|
+
}
|
|
3042
|
+
}
|
|
3043
|
+
for (const [k, v] of primitiveCache) {
|
|
3044
|
+
yield [k, v];
|
|
3045
|
+
}
|
|
3046
|
+
},
|
|
3047
|
+
|
|
3048
|
+
get(key) {
|
|
3049
|
+
const isObject =
|
|
3050
|
+
key && (typeof key === "object" || typeof key === "function");
|
|
3051
|
+
if (isObject) {
|
|
3052
|
+
// Fast path: exact key match
|
|
3053
|
+
if (keyToValue.has(key)) {
|
|
3054
|
+
return keyToValue.get(key);
|
|
3055
|
+
}
|
|
3056
|
+
|
|
3057
|
+
// Slow path: deep equality search
|
|
3058
|
+
cleanupKeyRegistry();
|
|
3059
|
+
for (const keyRef of keyRegistry) {
|
|
3060
|
+
const existingKey = keyRef.deref();
|
|
3061
|
+
if (existingKey && compareTwoJsValues(existingKey, key)) {
|
|
3062
|
+
return keyToValue.get(existingKey);
|
|
3063
|
+
}
|
|
3064
|
+
}
|
|
3065
|
+
return undefined;
|
|
3066
|
+
}
|
|
3067
|
+
return primitiveCache.get(key);
|
|
3068
|
+
},
|
|
3069
|
+
|
|
3070
|
+
set(key, value) {
|
|
3071
|
+
const isObject =
|
|
3072
|
+
key && (typeof key === "object" || typeof key === "function");
|
|
3073
|
+
if (isObject) {
|
|
3074
|
+
cleanupKeyRegistry();
|
|
3075
|
+
|
|
3076
|
+
// Remove existing deep-equal key
|
|
3077
|
+
for (const keyRef of keyRegistry) {
|
|
3078
|
+
const existingKey = keyRef.deref();
|
|
3079
|
+
if (existingKey && compareTwoJsValues(existingKey, key)) {
|
|
3080
|
+
const existingValue = keyToValue.get(existingKey);
|
|
3081
|
+
keyToValue.delete(existingKey);
|
|
3082
|
+
valueToKey.delete(existingValue);
|
|
3083
|
+
keyRegistry.delete(keyRef);
|
|
3084
|
+
break;
|
|
3085
|
+
}
|
|
3086
|
+
}
|
|
3087
|
+
|
|
3088
|
+
// Set ephemeron pair
|
|
3089
|
+
keyToValue.set(key, value);
|
|
3090
|
+
valueToKey.set(value, key);
|
|
3091
|
+
keyRegistry.add(new WeakRef(key));
|
|
3092
|
+
} else {
|
|
3093
|
+
primitiveCache.set(key, value);
|
|
3094
|
+
}
|
|
3095
|
+
},
|
|
3096
|
+
|
|
3097
|
+
delete(key) {
|
|
3098
|
+
const isObject =
|
|
3099
|
+
key && (typeof key === "object" || typeof key === "function");
|
|
3100
|
+
if (isObject) {
|
|
3101
|
+
cleanupKeyRegistry();
|
|
3102
|
+
|
|
3103
|
+
// Try exact match first
|
|
3104
|
+
if (keyToValue.has(key)) {
|
|
3105
|
+
const value = keyToValue.get(key);
|
|
3106
|
+
keyToValue.delete(key);
|
|
3107
|
+
valueToKey.delete(value);
|
|
3108
|
+
|
|
3109
|
+
// Remove from registry
|
|
3110
|
+
for (const keyRef of keyRegistry) {
|
|
3111
|
+
if (keyRef.deref() === key) {
|
|
3112
|
+
keyRegistry.delete(keyRef);
|
|
3113
|
+
break;
|
|
3114
|
+
}
|
|
3115
|
+
}
|
|
3116
|
+
return true;
|
|
3117
|
+
}
|
|
3118
|
+
|
|
3119
|
+
// Try deep equality
|
|
3120
|
+
for (const keyRef of keyRegistry) {
|
|
3121
|
+
const existingKey = keyRef.deref();
|
|
3122
|
+
if (existingKey && compareTwoJsValues(existingKey, key)) {
|
|
3123
|
+
const value = keyToValue.get(existingKey);
|
|
3124
|
+
keyToValue.delete(existingKey);
|
|
3125
|
+
valueToKey.delete(value);
|
|
3126
|
+
keyRegistry.delete(keyRef);
|
|
3127
|
+
return true;
|
|
3128
|
+
}
|
|
3129
|
+
}
|
|
3130
|
+
return false;
|
|
3131
|
+
}
|
|
3132
|
+
return primitiveCache.delete(key);
|
|
3133
|
+
},
|
|
3134
|
+
|
|
3135
|
+
getStats: () => {
|
|
3136
|
+
cleanupKeyRegistry();
|
|
3137
|
+
const aliveKeys = Array.from(keyRegistry).filter((ref) =>
|
|
3138
|
+
ref.deref(),
|
|
3139
|
+
).length;
|
|
3140
|
+
|
|
3141
|
+
return {
|
|
3142
|
+
ephemeronPairs: {
|
|
3143
|
+
total: keyRegistry.size,
|
|
3144
|
+
alive: aliveKeys,
|
|
3145
|
+
note: "True ephemeron: key ↔ value mutual retention via dual WeakMap",
|
|
3146
|
+
},
|
|
3147
|
+
primitive: {
|
|
3148
|
+
total: primitiveCache.size,
|
|
3149
|
+
note: "Primitive keys never GC'd",
|
|
3150
|
+
},
|
|
3151
|
+
};
|
|
3152
|
+
},
|
|
3153
|
+
};
|
|
3109
3154
|
};
|
|
3110
3155
|
|
|
3111
|
-
const
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3156
|
+
const MERGE_AS_PRIMITIVE_SYMBOL = Symbol("navi_merge_as_primitive");
|
|
3157
|
+
|
|
3158
|
+
const mergeTwoJsValues = (firstValue, secondValue) => {
|
|
3159
|
+
const firstIsPrimitive =
|
|
3160
|
+
firstValue === null ||
|
|
3161
|
+
typeof firstValue !== "object" ||
|
|
3162
|
+
MERGE_AS_PRIMITIVE_SYMBOL in firstValue;
|
|
3163
|
+
|
|
3164
|
+
if (firstIsPrimitive) {
|
|
3165
|
+
return secondValue;
|
|
3115
3166
|
}
|
|
3116
|
-
|
|
3117
|
-
|
|
3167
|
+
const secondIsPrimitive =
|
|
3168
|
+
secondValue === null ||
|
|
3169
|
+
typeof secondValue !== "object" ||
|
|
3170
|
+
MERGE_AS_PRIMITIVE_SYMBOL in secondValue;
|
|
3171
|
+
if (secondIsPrimitive) {
|
|
3172
|
+
return secondValue;
|
|
3118
3173
|
}
|
|
3119
|
-
|
|
3120
|
-
|
|
3174
|
+
const objectMerge = {};
|
|
3175
|
+
const firstKeys = Object.keys(firstValue);
|
|
3176
|
+
const secondKeys = Object.keys(secondValue);
|
|
3177
|
+
let hasChanged = false;
|
|
3121
3178
|
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
3179
|
+
// First loop: check for keys in first object and recursively merge with second
|
|
3180
|
+
for (const key of firstKeys) {
|
|
3181
|
+
const firstValueForKey = firstValue[key];
|
|
3182
|
+
const secondHasKey = secondKeys.includes(key);
|
|
3183
|
+
|
|
3184
|
+
if (secondHasKey) {
|
|
3185
|
+
const secondValueForKey = secondValue[key];
|
|
3186
|
+
const mergedValue = mergeTwoJsValues(firstValueForKey, secondValueForKey);
|
|
3187
|
+
objectMerge[key] = mergedValue;
|
|
3188
|
+
if (mergedValue !== firstValueForKey) {
|
|
3189
|
+
hasChanged = true;
|
|
3190
|
+
}
|
|
3191
|
+
} else {
|
|
3192
|
+
objectMerge[key] = firstValueForKey;
|
|
3193
|
+
}
|
|
3134
3194
|
}
|
|
3135
|
-
}
|
|
3136
3195
|
|
|
3137
|
-
const
|
|
3138
|
-
|
|
3139
|
-
|
|
3196
|
+
for (const key of secondKeys) {
|
|
3197
|
+
if (firstKeys.includes(key)) {
|
|
3198
|
+
continue;
|
|
3199
|
+
}
|
|
3200
|
+
objectMerge[key] = secondValue[key];
|
|
3201
|
+
hasChanged = true;
|
|
3202
|
+
}
|
|
3140
3203
|
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
return null;
|
|
3204
|
+
if (!hasChanged) {
|
|
3205
|
+
return firstValue;
|
|
3144
3206
|
}
|
|
3145
|
-
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3207
|
+
return objectMerge;
|
|
3208
|
+
};
|
|
3209
|
+
|
|
3210
|
+
/**
|
|
3211
|
+
* Creates an effect that uses WeakRef to prevent garbage collection of referenced values.
|
|
3212
|
+
*
|
|
3213
|
+
* This utility is useful when you want to create reactive effects that watch objects
|
|
3214
|
+
* without preventing those objects from being garbage collected. If any of the referenced
|
|
3215
|
+
* values is collected, the effect automatically disposes itself.
|
|
3216
|
+
*
|
|
3217
|
+
* @param {Array} values - Array of values to create weak references for
|
|
3218
|
+
* @param {Function} callback - Function to call when the effect runs, receives dereferenced values as arguments
|
|
3219
|
+
* @returns {Function} dispose - Function to manually dispose the effect
|
|
3220
|
+
*
|
|
3221
|
+
* @example
|
|
3222
|
+
* ```js
|
|
3223
|
+
* const objectA = { name: "A" };
|
|
3224
|
+
* const objectB = { name: "B" };
|
|
3225
|
+
* const prefixSignal = signal('demo');
|
|
3226
|
+
*
|
|
3227
|
+
* const dispose = weakEffect([objectA, objectB], (a, b) => {
|
|
3228
|
+
* const prefix = prefixSignal.value
|
|
3229
|
+
* console.log(prefix, a.name, b.name);
|
|
3230
|
+
* });
|
|
3231
|
+
*
|
|
3232
|
+
* // Effect will auto-dispose if objectA or objectB where garbage collected
|
|
3233
|
+
* // or can be manually disposed:
|
|
3234
|
+
* dispose();
|
|
3235
|
+
* ```
|
|
3236
|
+
*/
|
|
3237
|
+
const weakEffect = (values, callback) => {
|
|
3238
|
+
const weakRefSet = new Set();
|
|
3239
|
+
for (const value of values) {
|
|
3240
|
+
weakRefSet.add(new WeakRef(value));
|
|
3152
3241
|
}
|
|
3153
|
-
|
|
3242
|
+
const dispose = effect(() => {
|
|
3243
|
+
const values = [];
|
|
3244
|
+
for (const weakRef of weakRefSet) {
|
|
3245
|
+
const value = weakRef.deref();
|
|
3246
|
+
if (value === undefined) {
|
|
3247
|
+
dispose();
|
|
3248
|
+
return;
|
|
3249
|
+
}
|
|
3250
|
+
values.push(value);
|
|
3251
|
+
}
|
|
3252
|
+
callback(...values);
|
|
3253
|
+
});
|
|
3254
|
+
return dispose;
|
|
3154
3255
|
};
|
|
3155
3256
|
|
|
3156
3257
|
const SYMBOL_OBJECT_SIGNAL = Symbol.for("navi_object_signal");
|
|
@@ -17659,6 +17760,12 @@ const useActionAsyncData = (action, {
|
|
|
17659
17760
|
}
|
|
17660
17761
|
// Not marked: nothing is displayed yet — the boundary that catches this is
|
|
17661
17762
|
// what says so, and only if it has something to show.
|
|
17763
|
+
//
|
|
17764
|
+
// And this render ends here: an action read further down — the one that was
|
|
17765
|
+
// going to display its own error — is never reached. Said out loud, so the
|
|
17766
|
+
// report does not blame the app for a failure nothing could take (see
|
|
17767
|
+
// action_error_report.js).
|
|
17768
|
+
markErrorAsStoppingRender(actionError);
|
|
17662
17769
|
throw actionError;
|
|
17663
17770
|
}
|
|
17664
17771
|
|
|
@@ -38892,7 +38999,7 @@ const TextAnchor = ({
|
|
|
38892
38999
|
className: "navi_text_anchor",
|
|
38893
39000
|
"aria-hidden": "true",
|
|
38894
39001
|
hidden: true,
|
|
38895
|
-
children: "
|
|
39002
|
+
children: ""
|
|
38896
39003
|
})]
|
|
38897
39004
|
});
|
|
38898
39005
|
};
|
|
@@ -39301,7 +39408,7 @@ const REGULAR_SPACE = jsx("span", {
|
|
|
39301
39408
|
const FAKE_SPACE = jsx("span", {
|
|
39302
39409
|
"data-navi-space": "",
|
|
39303
39410
|
style: "padding-left: 0.25em",
|
|
39304
|
-
children: "
|
|
39411
|
+
children: ""
|
|
39305
39412
|
});
|
|
39306
39413
|
const CustomWidthSpace = ({
|
|
39307
39414
|
value,
|
|
@@ -39324,13 +39431,13 @@ const CustomWidthSpace = ({
|
|
|
39324
39431
|
children: " "
|
|
39325
39432
|
}), jsx("span", {
|
|
39326
39433
|
style: `padding-left: ${value}`,
|
|
39327
|
-
children: "
|
|
39434
|
+
children: ""
|
|
39328
39435
|
})]
|
|
39329
39436
|
});
|
|
39330
39437
|
}
|
|
39331
39438
|
return jsx("span", {
|
|
39332
39439
|
style: `padding-left: ${value}`,
|
|
39333
|
-
children: "
|
|
39440
|
+
children: ""
|
|
39334
39441
|
});
|
|
39335
39442
|
};
|
|
39336
39443
|
|
|
@@ -39929,7 +40036,7 @@ const Icon = ({
|
|
|
39929
40036
|
ref: textRef,
|
|
39930
40037
|
children: [jsx("span", {
|
|
39931
40038
|
style: "user-select:none",
|
|
39932
|
-
children: "
|
|
40039
|
+
children: ""
|
|
39933
40040
|
}), innerChildren]
|
|
39934
40041
|
})
|
|
39935
40042
|
});
|
|
@@ -54026,7 +54133,7 @@ const TimeRelative = ({
|
|
|
54026
54133
|
if (children === undefined) {
|
|
54027
54134
|
return jsx(TimeText, {
|
|
54028
54135
|
...props,
|
|
54029
|
-
children: "
|
|
54136
|
+
children: "–"
|
|
54030
54137
|
});
|
|
54031
54138
|
}
|
|
54032
54139
|
const date = toDate(children);
|
|
@@ -61821,6 +61928,7 @@ const css$x = /* css */`@layer navi {
|
|
|
61821
61928
|
--x-corner-top-right-radius: initial;
|
|
61822
61929
|
--x-corner-bottom-right-radius: initial;
|
|
61823
61930
|
--x-corner-bottom-left-radius: initial;
|
|
61931
|
+
box-sizing: border-box;
|
|
61824
61932
|
width: inherit;
|
|
61825
61933
|
min-width: inherit;
|
|
61826
61934
|
max-width: var(--list-max-width, inherit);
|
|
@@ -62143,6 +62251,14 @@ const css$x = /* css */`@layer navi {
|
|
|
62143
62251
|
animation: none;
|
|
62144
62252
|
}
|
|
62145
62253
|
`;
|
|
62254
|
+
|
|
62255
|
+
/* A padding on a list is space between its frame and its rows, so it belongs on
|
|
62256
|
+
the scroll box and not on the frame around it: on the frame it insets the
|
|
62257
|
+
scroll box as a whole, and the scrollbar — which is drawn at the edge of what
|
|
62258
|
+
scrolls — comes off the list's edge by that same amount, floating in the
|
|
62259
|
+
middle of the padding. On the scroll box the scrollbar stays against the
|
|
62260
|
+
border and the padding is what separates the rows from it. */
|
|
62261
|
+
const LIST_PADDING_PROP_SET = new Set(["padding", "paddingX", "paddingY", "paddingTop", "paddingRight", "paddingBottom", "paddingLeft"]);
|
|
62146
62262
|
const ListUI = props => {
|
|
62147
62263
|
import.meta.css = [css$x, "@jsenv/navi/src/control/list/list.jsx"];
|
|
62148
62264
|
const {
|
|
@@ -62184,6 +62300,13 @@ const ListUI = props => {
|
|
|
62184
62300
|
virtual,
|
|
62185
62301
|
...rest
|
|
62186
62302
|
} = props;
|
|
62303
|
+
const scrollBoxPaddingProps = {};
|
|
62304
|
+
for (const name of LIST_PADDING_PROP_SET) {
|
|
62305
|
+
if (name in rest) {
|
|
62306
|
+
scrollBoxPaddingProps[name] = rest[name];
|
|
62307
|
+
delete rest[name];
|
|
62308
|
+
}
|
|
62309
|
+
}
|
|
62187
62310
|
// Accept a string (e.g. from an HTML attribute: renderBudget="50") the
|
|
62188
62311
|
// same way a bare number would work — arithmetic below (renderBudget / 2,
|
|
62189
62312
|
// start + renderBudget, etc.) would silently misbehave on a raw string
|
|
@@ -62338,7 +62461,7 @@ const ListUI = props => {
|
|
|
62338
62461
|
children: [jsx("span", {
|
|
62339
62462
|
className: "navi_list_error_icon",
|
|
62340
62463
|
"aria-hidden": "true",
|
|
62341
|
-
children: "
|
|
62464
|
+
children: "⚠"
|
|
62342
62465
|
}), jsx("span", {
|
|
62343
62466
|
children: error === true ? "Something went wrong." : error
|
|
62344
62467
|
})]
|
|
@@ -62441,6 +62564,7 @@ const ListUI = props => {
|
|
|
62441
62564
|
overflow: overflow,
|
|
62442
62565
|
overflowX: overflowX,
|
|
62443
62566
|
overflowY: overflowY,
|
|
62567
|
+
scrollBoxPaddingProps: scrollBoxPaddingProps,
|
|
62444
62568
|
children: content
|
|
62445
62569
|
})
|
|
62446
62570
|
});
|
|
@@ -62678,6 +62802,7 @@ const ListContent = ({
|
|
|
62678
62802
|
overflow,
|
|
62679
62803
|
overflowX,
|
|
62680
62804
|
overflowY,
|
|
62805
|
+
scrollBoxPaddingProps,
|
|
62681
62806
|
children
|
|
62682
62807
|
}) => {
|
|
62683
62808
|
const listProps = useContext(BoxForwardedPropsContext);
|
|
@@ -62686,6 +62811,7 @@ const ListContent = ({
|
|
|
62686
62811
|
overflow: overflow,
|
|
62687
62812
|
overflowX: overflowX,
|
|
62688
62813
|
overflowY: overflowY,
|
|
62814
|
+
...scrollBoxPaddingProps,
|
|
62689
62815
|
children: jsx(UnorderedList, {
|
|
62690
62816
|
role: role,
|
|
62691
62817
|
fallback: fallback,
|
|
@@ -64703,7 +64829,7 @@ const ListItemReal = props => {
|
|
|
64703
64829
|
children: [jsx("span", {
|
|
64704
64830
|
className: "navi_list_error_icon",
|
|
64705
64831
|
"aria-hidden": "true",
|
|
64706
|
-
children: "
|
|
64832
|
+
children: "⚠"
|
|
64707
64833
|
}), jsx("span", {
|
|
64708
64834
|
className: "navi_list_item_error_message",
|
|
64709
64835
|
children: error === true ? "Something went wrong." : error
|
|
@@ -65586,7 +65712,7 @@ const ListItemsFailure = ({
|
|
|
65586
65712
|
children: [jsx("span", {
|
|
65587
65713
|
className: "navi_list_error_icon",
|
|
65588
65714
|
"aria-hidden": "true",
|
|
65589
|
-
children: "
|
|
65715
|
+
children: "⚠"
|
|
65590
65716
|
}), jsx("span", {
|
|
65591
65717
|
className: "navi_list_item_error_message",
|
|
65592
65718
|
children: error && error.message ? error.message : naviI18n("list.rows_failed")
|
|
@@ -77747,7 +77873,7 @@ const ButtonCopyToClipboard = ({
|
|
|
77747
77873
|
className: "navi_copied_notif",
|
|
77748
77874
|
"aria-hidden": copied ? "false" : "true",
|
|
77749
77875
|
opacity: copied ? 1 : 0,
|
|
77750
|
-
children: "
|
|
77876
|
+
children: "Copié !"
|
|
77751
77877
|
}), jsx(Button, {
|
|
77752
77878
|
className: "navi_copy_button",
|
|
77753
77879
|
flex: "y",
|