@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.
@@ -565,1882 +565,847 @@ effect(() => {
565
565
  });
566
566
 
567
567
  /**
568
- * Where an action error goes when nothing displays it.
568
+ * Interpolates a template string, replacing `[key]` placeholders with values.
569
569
  *
570
- * An action that fails writes the error into its `errorSignal` and stops there.
571
- * It cannot know whether a screen is going to show it: at the instant it fails,
572
- * the screen that will is often not even mounted — a route action runs before
573
- * its page renders, which is precisely the case a guess made at failure time
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
- * The mark is `__handled_by__`, the same one the jsenv supervisor reads to stay
578
- * out of the way of an error the app is already showing — one mark, one meaning:
579
- * "this is on screen somewhere".
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
- * The whole picture, control errors and validation included: docs/error_handling.md
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
- const markErrorAsDisplayedBy = (error, by) => {
585
- if (error && typeof error === "object") {
586
- error.__handled_by__ = by;
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 errorIsDisplayed = (error) => {
591
- return Boolean(error && error.__handled_by__);
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
- * A render has read this error — it is now the render tree's business, not this
596
- * module's, and there is nothing left to report.
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
- * Whatever the reader does with it is already covered without any deadline: it
599
- * displays it (and marks it), or it throws it, and a thrown error either finds a
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 errorTakenByRenderSet = new WeakSet();
607
- const markErrorAsTakenByRender = (error) => {
608
- if (error && typeof error === "object") {
609
- errorTakenByRenderSet.add(error);
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
- * When the answer "nobody took it" is final.
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
- * The floor is one macrotask: every render that could take the error — Preact's
617
- * queue, a Suspense boundary settling on the failure, the boundary above it —
618
- * happens in microtasks.
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
- * That floor is enough for an action failing under a page that is already on
621
- * screen, and far too early for a route action: it fails ON the url change,
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
- * The browser integration knows when the document has stopped moving and hands
628
- * that over here (see installReportDeadlineExtension); nothing else does, and
629
- * this module stays free of the DOM. Waiting longer costs nothing now that a
630
- * read is enough to call this off: what still reaches the report was read by no
631
- * render at all, and a late report about that is as good as a prompt one.
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
- let waitForDocumentSettled = null;
634
- const installReportDeadlineExtension = (fn) => {
635
- waitForDocumentSettled = fn;
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
- * Rethrown rather than logged: an error nobody took is an unhandled error, and
640
- * the runtime already knows what to do with those (window "error" event, jsenv
641
- * overlay in dev). Same trick preact/debug uses for the same reason.
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 errorReportedSet = new WeakSet();
644
- const reportErrorIfNobodyDisplaysIt = (error, { action } = {}) => {
645
- const decide = () => {
646
- if (errorIsDisplayed(error) || errorTakenByRenderSet.has(error)) {
647
- return;
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
- if (error && typeof error === "object") {
650
- // The same error can reach here from more than one direction (the run
651
- // that produced it, the routing promise carrying it): it is one error and
652
- // it is reported once.
653
- if (errorReportedSet.has(error)) {
654
- return;
655
- }
656
- errorReportedSet.add(error);
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 (action && error && typeof error === "object" && !error.action) {
659
- error.action = action;
934
+ if (cachedResolvedFallbackLangVersion === languageMapVersion) {
935
+ return cachedResolvedFallbackLang;
660
936
  }
661
- throw error;
937
+ cachedResolvedFallbackLang = matchBestLang(fallbackLang, languageMap);
938
+ cachedResolvedFallbackLangVersion = languageMapVersion;
939
+ return cachedResolvedFallbackLang;
662
940
  };
663
941
 
664
- setTimeout(() => {
665
- if (errorIsDisplayed(error) || errorTakenByRenderSet.has(error)) {
666
- // Already taken within the microtasks that followed the failure: the
667
- // common case, and there is nothing to wait for.
668
- return;
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
- if (waitForDocumentSettled) {
671
- waitForDocumentSettled(decide);
672
- return;
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
- decide();
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 useActionStatus = (action) => {
697
- if (!action) {
698
- return {
699
- params: undefined,
700
- runningState: IDLE,
701
- isPrerun: false,
702
- idle: true,
703
- loading: false,
704
- aborted: false,
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
- return {
728
- params,
729
- runningState,
730
- isPrerun,
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
- installImportMetaCssBuild(import.meta);const css$14 = /* css */`.action_error {
741
- background: #fdd;
742
- border: 1px solid red;
743
- margin-top: 0;
744
- margin-bottom: 20px;
745
- padding: 20px;
746
- }
747
- `;
748
- const renderIdleDefault = () => null;
749
- const renderLoadingDefault = () => null;
750
- const renderAbortedDefault = () => null;
751
- const renderErrorDefault = error => {
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
- if (loading) {
850
- if (action.canDisplayOldData && data !== undefined) {
851
- return renderCompletedSafe(data, action);
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 renderLoading(action);
854
- }
855
- if (error) {
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 BRAND_SYMBOL = Symbol.for("preact-signals");
896
- const getSignalType = (value) => {
897
- if (!value || typeof value !== "object") {
898
- return null;
899
- }
1003
+ const format = (key, values, { lang = getActiveLang() } = {}) => {
1004
+ const template = _getTemplate(key, lang);
1005
+ return interpolateText(template, values);
1006
+ };
900
1007
 
901
- if (value.brand !== BRAND_SYMBOL) {
902
- return null;
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
- if (typeof value._fn === "function") {
906
- return "computed";
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 "signal";
1035
+ return i18n;
910
1036
  };
911
1037
 
912
- const MAX_ENTRIES = 5;
913
-
914
- const stringifyForDisplay = (
915
- value,
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
- if (Array.isArray(value)) {
953
- const openBracket = asFunctionArgs ? "(" : "[";
954
- const closeBracket = asFunctionArgs ? ")" : "]";
955
-
956
- if (value.length === 0) return `${openBracket}${closeBracket}`;
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
- if (typeof value === "object") {
988
- const signalType = getSignalType(value);
989
- if (signalType) {
990
- const signalValue = value.peek();
991
- const prefix = signalType === "computed" ? "computed" : "signal";
992
- return `${prefix}(${stringifyForDisplay(signalValue, maxDepth, currentDepth, { ...options, asFunctionArgs: false })})`;
993
- }
994
-
995
- const entries = Object.entries(value);
996
- if (entries.length === 0) return "{}";
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
- * Deep structural equality for arbitrary JS values — what `===` can't do but this
1044
- * codebase constantly needs: memoization cache keys ({ id: 1 } equal to { id: 1 }),
1045
- * effect/memo dependency checks, signal/store change detection, and action
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
- * Beyond recursive object/array comparison it covers the edge cases `===` gets
1049
- * "wrong" for equality purposes: NaN equals NaN, Date compared by time value,
1050
- * cycles don't loop (a set of the pairs being compared guards circular refs),
1051
- * and same-type is required
1052
- * before descending. Functions, signals, and objects with nothing enumerable to
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
- * SYMBOL_IDENTITY. Two *different* object instances can be declared "conceptually
1059
- * the same" by sharing a SYMBOL_IDENTITY value; the comparison then treats them as
1060
- * equal with no deep walk. This is what lets a spread copy ({ ...params, extra })
1061
- * still count as the same params as the original, and lets objects reconstructed
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
- * const id = Symbol.for("params");
1067
- * a[SYMBOL_IDENTITY] = id;
1068
- * b[SYMBOL_IDENTITY] = id;
1069
- * compareTwoJsValues(a, b); // true immediately, no property walk
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
- // Marks objects with a conceptual identity that transcends reference equality —
1074
- // see the file comment. Symbol.for keeps it one shared symbol across modules.
1075
- const SYMBOL_IDENTITY = Symbol.for("navi_object_identity");
1076
-
1077
- /**
1078
- * Deeply compares two values for structural equality.
1079
- *
1080
- * @param {any} rootA - First value.
1081
- * @param {any} rootB - Second value.
1082
- * @param {object} [options]
1083
- * @param {(a: any, b: any, keyOrIndex: any, recurse: (a: any, b: any) => boolean) => boolean} [options.keyComparator]
1084
- * Custom comparator for object properties / array elements. Receives the internal
1085
- * `compare` as its last argument so it can defer to the default behavior.
1086
- * @param {boolean} [options.ignoreArrayOrder=false] - Compare arrays as multisets:
1087
- * equal when they contain the same elements regardless of order.
1088
- * @param {Iterable<string>} [options.lightKeySet] - Object keys to compare first —
1089
- * cheaper or likelier-to-differ ones — to short-circuit before the remaining keys.
1090
- * @returns {boolean} true if the values are deeply equal.
1091
- */
1092
- const compareTwoJsValues = (
1093
- rootA,
1094
- rootB,
1095
- { keyComparator, ignoreArrayOrder = false, lightKeySet } = {},
1096
- ) => {
1097
- const seenSet = new Set();
1098
- const compare = (a, b) => {
1099
- if (a === b) {
1100
- return true;
1101
- }
1102
- const aIsIsTruthy = Boolean(a);
1103
- const bIsTruthy = Boolean(b);
1104
- if (aIsIsTruthy && !bIsTruthy) {
1105
- return false;
1106
- }
1107
- if (!aIsIsTruthy && !bIsTruthy) {
1108
- // null, undefined, 0, false, NaN
1109
- if (isNaN(a) && isNaN(b)) {
1110
- return true;
1111
- }
1112
- return a === b;
1113
- }
1114
- const aType = typeof a;
1115
- const bType = typeof b;
1116
- if (aType !== bType) {
1117
- return false;
1118
- }
1119
- if (aType === "function") {
1120
- // Not the same function (reference equality came first), and nothing in
1121
- // a function says whether two of them do the same thing.
1122
- return false;
1123
- }
1124
- const aIsPrimitive =
1125
- a === null || (aType !== "object" && aType !== "function");
1126
- const bIsPrimitive =
1127
- b === null || (bType !== "object" && bType !== "function");
1128
- if (aIsPrimitive !== bIsPrimitive) {
1129
- return false;
1130
- }
1131
- if (aIsPrimitive && bIsPrimitive) {
1132
- return a === b;
1133
- }
1134
- // Back on something still being compared: a cycle. No loop, and no answer
1135
- // either — equal by a route that never ends is not equal.
1136
- if (seenSet.has(a) || seenSet.has(b)) {
1137
- return false;
1138
- }
1139
- // Held only while a and b are being compared, not for the rest of the
1140
- // walk: the same object is rightly met again elsewhere — in an unordered
1141
- // array every element of a is tried against every element of b.
1142
- seenSet.add(a);
1143
- seenSet.add(b);
1144
- const result = compareComposite(a, b);
1145
- seenSet.delete(a);
1146
- seenSet.delete(b);
1147
- return result;
1148
- };
1149
- const compareComposite = (a, b) => {
1150
- const aIsArray = Array.isArray(a);
1151
- const bIsArray = Array.isArray(b);
1152
- if (aIsArray !== bIsArray) {
1153
- return false;
1154
- }
1155
- if (aIsArray) {
1156
- // compare arrays
1157
- if (a.length !== b.length) {
1158
- return false;
1159
- }
1160
- if (ignoreArrayOrder) {
1161
- // Unordered array comparison: each element in 'a' must have a match in 'b'
1162
- const usedIndices = new Set();
1163
- for (let i = 0; i < a.length; i++) {
1164
- const aValue = a[i];
1165
- let foundMatch = false;
1166
-
1167
- for (let j = 0; j < b.length; j++) {
1168
- if (usedIndices.has(j)) {
1169
- continue; // Already matched with another element
1170
- }
1171
- const bValue = b[j];
1172
- if (compareAt(aValue, bValue, i)) {
1173
- foundMatch = true;
1174
- usedIndices.add(j);
1175
- break;
1176
- }
1177
- }
1178
- if (!foundMatch) {
1179
- return false;
1180
- }
1181
- }
1182
- return true;
1183
- }
1184
- // Ordered array comparison (original behavior)
1185
- let i = 0;
1186
- while (i < a.length) {
1187
- const aValue = a[i];
1188
- const bValue = b[i];
1189
- if (!compareAt(aValue, bValue, i)) {
1190
- return false;
1191
- }
1192
- i++;
1193
- }
1194
- return true;
1195
- }
1196
- // compare objects
1197
- const aIdentity = a[SYMBOL_IDENTITY];
1198
- const bIdentity = b[SYMBOL_IDENTITY];
1199
- if (
1200
- aIdentity === bIdentity &&
1201
- SYMBOL_IDENTITY in a &&
1202
- SYMBOL_IDENTITY in b
1203
- ) {
1204
- return true;
1205
- }
1206
- // Date objects must be compared by time value, not by enumerable keys (which are empty)
1207
- const aIsDate = a instanceof Date;
1208
- const bIsDate = b instanceof Date;
1209
- if (aIsDate !== bIsDate) {
1210
- return false;
1211
- }
1212
- if (aIsDate) {
1213
- return a.getTime() === b.getTime();
1214
- }
1215
- // A signal is a source of truth, not a value: two of them holding the same
1216
- // thing right now say nothing about the next tick, and one may change while
1217
- // the other does not. Their internals (the current value, a version
1218
- // counter) are enumerable, so without this they would be walked and two
1219
- // unrelated signals would pass for one — a params signal owned by one
1220
- // screen would then be handed the action bound to another's.
1221
- if (isSignal(a) || isSignal(b)) {
1222
- return false;
1223
- }
1224
- const aKeys = Object.keys(a);
1225
- const bKeys = Object.keys(b);
1226
- if (aKeys.length !== bKeys.length) {
1227
- return false;
1228
- }
1229
- if (aKeys.length === 0 && (!isPlainObject$1(a) || !isPlainObject$1(b))) {
1230
- // A Set, a Map, an element, a URL: nothing enumerable to tell two of them
1231
- // apart, so they are the same one or they are not — and they are not,
1232
- // reference equality came first.
1233
- return false;
1234
- }
1235
- if (lightKeySet) {
1236
- // compare light keys first, then remaining keys
1237
- // (optimization for cases where some keys are more likely to differ and/or faster to compare)
1238
- const keySet = new Set(aKeys);
1239
- for (const lightKey of lightKeySet) {
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
- const createJsValueWeakMap = () => {
1361
- // Core ephemeron maps for mutual retention
1362
- const keyToValue = new WeakMap(); // key -> value
1363
- const valueToKey = new WeakMap(); // value -> key
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
- // Registry for iteration/deep equality (holds WeakRefs)
1366
- const keyRegistry = new Set(); // Set of WeakRef(key)
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
- // Primitive cache
1369
- const primitiveCache = new Map();
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
- function cleanupKeyRegistry() {
1372
- for (const keyRef of keyRegistry) {
1373
- if (keyRef.deref() === undefined) {
1374
- keyRegistry.delete(keyRef);
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
- // Date/time placeholder tokens — shown when no value is selected
2976
- // Override any key to adapt to your language conventions
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
- * The network policy: one declaration saying whether a request may go out,
3036
- * read by the action layer rather than by every callback.
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 WRITE_VERB_SET = new Set(["POST", "PUT", "PATCH", "DELETE"]);
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
- const networkPolicySignal = signal({
3059
- source: null,
3060
- readOnlyMessage: undefined,
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
- * Declares whether requests may go out.
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
- * @example
3078
- * const offlineReasonSignal = computed(() =>
3079
- * offlineChosenSignal.value ? "chosen" : deviceOfflineSignal.value ? "device" : null,
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 setNetworkPolicy = (source, { readOnlyMessage } = {}) => {
3087
- networkPolicySignal.value = { source, readOnlyMessage };
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
- * The policy's reason, `null` when requests may go out. Read in a component
3092
- * render, it follows the policy as it changes.
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 isRerunHeldByNetworkPolicy = (action) => {
3104
- return action.meta.verb === "GET" && peekNetworkPolicyReason() !== null;
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
- const isWriteAction = (action) => {
3108
- return WRITE_VERB_SET.has(action.meta.verb);
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 getNetworkPolicyReadOnlyMessage = () => {
3112
- const { readOnlyMessage } = networkPolicySignal.peek();
3113
- if (typeof readOnlyMessage === "function") {
3114
- return readOnlyMessage(peekNetworkPolicyReason());
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
- if (readOnlyMessage) {
3117
- return readOnlyMessage;
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
- return naviI18n("constraint.readonly.network_policy");
3120
- };
3174
+ const objectMerge = {};
3175
+ const firstKeys = Object.keys(firstValue);
3176
+ const secondKeys = Object.keys(secondValue);
3177
+ let hasChanged = false;
3121
3178
 
3122
- /**
3123
- * The error of a request that never left: the policy said not to.
3124
- * `reason` is the policy's value at that moment.
3125
- */
3126
- class OfflineError extends Error {
3127
- constructor(reason, message = naviI18n("network_policy.offline")) {
3128
- super(message);
3129
- this.name = "OfflineError";
3130
- this.reason = reason;
3131
- // A flag beside the class: the error crosses layers that may copy it, and
3132
- // instanceof does not survive a copy.
3133
- this.offline = true;
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 isOfflineError = (error) => {
3138
- return Boolean(error && error.offline);
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
- const readReason = (source) => {
3142
- if (!source) {
3143
- return null;
3204
+ if (!hasChanged) {
3205
+ return firstValue;
3144
3206
  }
3145
- let reason;
3146
- if (isSignal(source)) {
3147
- reason = source.value;
3148
- } else if (typeof source === "function") {
3149
- reason = source();
3150
- } else {
3151
- reason = source;
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
- return reason || null;
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: "\u200B"
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: "\u200B"
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: "\u200B"
39434
+ children: "​"
39328
39435
  })]
39329
39436
  });
39330
39437
  }
39331
39438
  return jsx("span", {
39332
39439
  style: `padding-left: ${value}`,
39333
- children: "\u200B"
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: "\u200B"
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: "\u2013"
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: "\u26A0"
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: "\u26A0"
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: "\u26A0"
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: "Copi\xE9 !"
77876
+ children: "Copié !"
77751
77877
  }), jsx(Button, {
77752
77878
  className: "navi_copy_button",
77753
77879
  flex: "y",