@jsenv/navi 0.29.180 → 0.29.182

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.
@@ -0,0 +1,2442 @@
1
+ import { parseDuration } from "@jsenv/validity";
2
+
3
+ // The JSX half of interpolation (VNode detection, fragment assembly) is
4
+ // installed by interpolate.jsx rather than imported: this module sits under
5
+ // createI18n and the pure formatters (format_time.js), which must stay
6
+ // importable where preact is not installed. Until installed, a VNode
7
+ // replacement is neither detected nor assembled — values are joined as
8
+ // strings — which is only reachable by passing a VNode without going through
9
+ // <Interpolate>.
10
+
11
+ /**
12
+ * Interpolates a template string, replacing `[key]` placeholders with values.
13
+ *
14
+ * Usable on its own — no i18n instance required — whenever a sentence should
15
+ * stay readable as one string instead of being cut into JSX expressions or
16
+ * concatenations. `<Interpolate>` is the JSX form of this function, and
17
+ * `createI18n` runs every translation through it. See `docs/i18n.md`.
18
+ *
19
+ * `[]` was chosen as the placeholder delimiter (rather than `{}` or `{{}}`)
20
+ * because it does not conflict with JSX syntax, JavaScript template literals,
21
+ * or common punctuation in translated strings.
22
+ *
23
+ * @param {string} template
24
+ * e.g. `"Hello [name], you have [count] messages"`. A non-string is returned
25
+ * untouched, as is any template when `replacements` is missing.
26
+ * @param {object} [replacements]
27
+ * Values keyed by placeholder name. A key can be:
28
+ * - a direct name — `[name]` ← `{ name: "Alice" }`
29
+ * - a dot-path — `[item.label]` ← `{ item: { label: "Book" } }` (a literal
30
+ * `"item.label"` key wins over the path)
31
+ *
32
+ * A value that is a function is called at that point, so an expensive or
33
+ * lazily-known replacement is only computed when the placeholder is actually
34
+ * present in this language's template.
35
+ *
36
+ * A placeholder with no matching value is left in the output as-is
37
+ * (`"[name]"`), making the gap visible rather than silently empty.
38
+ * @param {object} [options]
39
+ * @param {boolean} [options.allowJsx=false]
40
+ * Allow VNode replacements (what `<Interpolate>` passes). Without it, a VNode
41
+ * value warns and is coerced to a string.
42
+ * @returns {string|import("preact").VNode}
43
+ * A plain string when every replacement is a string, a Preact fragment when
44
+ * at least one VNode was interpolated with `allowJsx`.
45
+ */
46
+ const interpolateText = (
47
+ template,
48
+ replacements,
49
+ { allowJsx = false } = {},
50
+ ) => {
51
+ if (!replacements || typeof template !== "string") {
52
+ return template;
53
+ }
54
+ const parts = template.split(/(\[[^\]]+\])/);
55
+ const resolved = [];
56
+ for (const part of parts) {
57
+ const match = part.match(/^\[([^\]]+)\]$/);
58
+ if (!match) {
59
+ resolved.push(part);
60
+ continue;
61
+ }
62
+ const key = match[1];
63
+ let value = resolveValue(replacements, key, part);
64
+ if (typeof value === "function") {
65
+ value = value();
66
+ }
67
+ resolved.push(value);
68
+ }
69
+ {
70
+ return resolved.join("");
71
+ }
72
+ };
73
+
74
+ // Resolves a placeholder key against the replacements object.
75
+ // 1. Direct lookup: replacements["item.name"]
76
+ // 2. Dot-path lookup: replacements["item"]["name"]
77
+ // 3. Fallback: the original placeholder string (e.g. "[item.name]")
78
+ const resolveValue = (replacements, key, fallback) => {
79
+ if (key in replacements) {
80
+ return replacements[key];
81
+ }
82
+ const dotIndex = key.indexOf(".");
83
+ if (dotIndex !== -1) {
84
+ const head = key.slice(0, dotIndex);
85
+ const tail = key.slice(dotIndex + 1);
86
+ const parent = replacements[head];
87
+ if (parent && typeof parent === "object") {
88
+ const nested = parent[tail];
89
+ if (nested !== undefined) {
90
+ return nested;
91
+ }
92
+ }
93
+ }
94
+ return fallback;
95
+ };
96
+
97
+ /**
98
+ * The language every formatter/i18n call falls back to when it is given no
99
+ * `lang` — an injectable source, deliberately free of any import.
100
+ *
101
+ * This module is the seam that keeps text formatting importable outside the
102
+ * browser (`@jsenv/navi/format_time` from a backend, say): by default the
103
+ * source is the runtime's own locale, exactly what Intl itself would pick.
104
+ * The browser bundle swaps the source for `languagesSignal` (see
105
+ * lang_signal.js), so the fallback follows the user's live language
106
+ * preference — and because the source is read fresh on every call, reading it
107
+ * during a component render subscribes the component the same way reading the
108
+ * signal directly would.
109
+ */
110
+
111
+ let systemLocale;
112
+ let runtimeLangSource = () => {
113
+ systemLocale ??= new Intl.DateTimeFormat().resolvedOptions().locale;
114
+ return systemLocale;
115
+ };
116
+
117
+ const getRuntimeLang = () => runtimeLangSource();
118
+
119
+ /**
120
+ * Creates a lightweight i18n instance: a central place where an app declares
121
+ * its texts once and reads them back translated into the active language.
122
+ *
123
+ * Worth using even in a single-language app — one registry beats strings
124
+ * scattered across components, and adding a second language later becomes a
125
+ * data change instead of a refactor. See `docs/i18n.md` for how to choose
126
+ * between the two key styles below and how this relates to `naviI18n`.
127
+ *
128
+ * @param {object} [options]
129
+ * @param {string} [options.keyLang]
130
+ * When set, each key also serves as its own translation for `keyLang`.
131
+ * This allows writing keys directly in that language (typically the language
132
+ * the app is written in) so only *other* languages need registering:
133
+ *
134
+ * ```js
135
+ * const i18n = createI18n({ keyLang: "en" });
136
+ * i18n.add("Hello [name]!", { fr: "Bonjour [name] !" });
137
+ * i18n("Hello [name]!", { name: "Alice" }, { lang: "en" }); // "Hello Alice!"
138
+ * i18n("Hello [name]!", { name: "Alice" }, { lang: "fr" }); // "Bonjour Alice !"
139
+ * ```
140
+ *
141
+ * `keyLang` only applies to keys passed to `add()`/`addAll()`; a key never
142
+ * registered stays opaque and comes back as-is.
143
+ *
144
+ * Without `keyLang`, keys are opaque identifiers and every language
145
+ * (including the one the app was written in) must be registered explicitly:
146
+ *
147
+ * ```js
148
+ * const i18n = createI18n();
149
+ * i18n.add("greeting", { en: "Hello [name]!", fr: "Bonjour [name] !" });
150
+ * i18n("greeting", { name: "Alice" }, { lang: "en" }); // "Hello Alice!"
151
+ * ```
152
+ *
153
+ * @param {string} [options.fallbackLang]
154
+ * Language consulted when the active language has no translation for a key
155
+ * — per key, not per language: a partially translated language falls through
156
+ * to `fallbackLang` only for the keys it is missing. Without it, a missing
157
+ * translation returns the key itself.
158
+ *
159
+ * @param {string|string[]} [options.runtimeLang]
160
+ * The active language (BCP 47 tag or ordered array of tags) — named
161
+ * "runtime" rather than "system" because there is no actual access to the
162
+ * OS/user's system language from a browser, only `navigator.languages` (or
163
+ * an explicit override) at runtime. Defaults to the shared runtime language
164
+ * source (see runtime_lang.js) — `languagesSignal.value` in a browser
165
+ * bundle, the runtime's own locale elsewhere — read fresh on every
166
+ * `format()`/`has()` call (not frozen at creation time), so overriding the
167
+ * language app-wide via `setPreferredLanguage()`/`setSupportedLanguages()`
168
+ * (see lang_signal.js) is picked up here too.
169
+ * Passing an explicit `runtimeLang` opts out of that and stays fixed for
170
+ * this instance's whole lifetime.
171
+ *
172
+ * ---
173
+ *
174
+ * ## Registration
175
+ *
176
+ * **`i18n.add(key, { lang: "translation" })`** — one key, multiple languages.
177
+ *
178
+ * **`i18n.addAll({ key: { lang: "translation" }, ... })`** — multiple keys at once.
179
+ *
180
+ * **`i18n.addLangKeys(lang, { key: "translation", ... })`** — full language pack
181
+ * (useful when loading a JSON translation file).
182
+ *
183
+ * All three accumulate: registering a key that already exists overwrites that
184
+ * one key and leaves the rest of the language untouched. This is what lets an
185
+ * app override a single built-in navi text without redeclaring the others.
186
+ *
187
+ * A regional variant (e.g. `"fr-CA"`) automatically inherits all keys from its
188
+ * parent (`"fr"`) that it does not explicitly override:
189
+ * ```js
190
+ * i18n.addLangKeys("fr", { hello: "Bonjour !" });
191
+ * i18n.addLangKeys("fr-CA", { hello: "Allo !" }); // other "fr" keys inherited
192
+ * ```
193
+ * Inheritance is resolved at registration time, so register the parent first.
194
+ *
195
+ * ---
196
+ *
197
+ * ## Reading
198
+ *
199
+ * **`i18n(key, values?, { lang? })`** — the translation for `key`, with
200
+ * `[placeholder]` occurrences replaced from `values` (see `interpolateText`).
201
+ * Returns `key` itself when nothing matches, so an untranslated string still
202
+ * renders something readable. `i18n.format` is an alias of this call.
203
+ *
204
+ * **`i18n.has(key, { lang? })`** — whether a translation genuinely exists,
205
+ * i.e. how to tell "no translation" apart from "translation equal to the key".
206
+ *
207
+ * @returns {Function & { add, addAll, addLangKeys, has, format, languageMap }}
208
+ */
209
+ const createI18n = ({ keyLang, fallbackLang, runtimeLang } = {}) => {
210
+ const languageMap = new Map();
211
+ // Bumped by addLangKeys — the only thing besides the active lang itself
212
+ // that could change what getActiveLang()/getResolvedFallbackLang() below
213
+ // resolve to, so it's what invalidates their own small caches.
214
+ let languageMapVersion = 0;
215
+
216
+ // Without an explicit runtimeLang, the runtime language source is re-read
217
+ // fresh on every call rather than frozen here — freezing it would silently
218
+ // ignore setPreferredLanguage()/setSupportedLanguages() (see lang_signal.js)
219
+ // for the rest of this instance's life.
220
+ const hasExplicitRuntimeLang = runtimeLang !== undefined;
221
+
222
+ // matchBestLang does real work (a Map lookup per candidate, a possible
223
+ // "fr-CA" → "fr" split-and-retry loop) — worth skipping on every single
224
+ // format()/has() call in the common case, since what it resolves to only
225
+ // ever changes when languageMap itself changes (addLangKeys) or, for the
226
+ // non-explicit case, when the runtime lang itself changes (preferred
227
+ // language, supported languages, or "languagechange" — see lang_signal.js;
228
+ // its languagesSignal is a computed() so its reference is stable when none
229
+ // of its own dependencies actually changed, and the signal-free fallback is
230
+ // a cached string) — comparing those two cheaply (===) is enough to know
231
+ // the cached result below is still valid.
232
+ let cachedActiveLang;
233
+ let cachedActiveLangRuntimeLang;
234
+ let cachedActiveLangVersion = -1;
235
+ const getActiveLang = () => {
236
+ const currentRuntimeLang = hasExplicitRuntimeLang
237
+ ? runtimeLang
238
+ : getRuntimeLang();
239
+ if (
240
+ cachedActiveLangVersion === languageMapVersion &&
241
+ cachedActiveLangRuntimeLang === currentRuntimeLang
242
+ ) {
243
+ return cachedActiveLang;
244
+ }
245
+ cachedActiveLang = matchBestLang(currentRuntimeLang, languageMap);
246
+ cachedActiveLangVersion = languageMapVersion;
247
+ cachedActiveLangRuntimeLang = currentRuntimeLang;
248
+ return cachedActiveLang;
249
+ };
250
+
251
+ // fallbackLang is a plain, never-reactive option set once at creation —
252
+ // its own resolution only ever needs recomputing when languageMap does.
253
+ let cachedResolvedFallbackLang;
254
+ let cachedResolvedFallbackLangVersion = -1;
255
+ const getResolvedFallbackLang = () => {
256
+ if (!fallbackLang) {
257
+ return null;
258
+ }
259
+ if (cachedResolvedFallbackLangVersion === languageMapVersion) {
260
+ return cachedResolvedFallbackLang;
261
+ }
262
+ cachedResolvedFallbackLang = matchBestLang(fallbackLang, languageMap);
263
+ cachedResolvedFallbackLangVersion = languageMapVersion;
264
+ return cachedResolvedFallbackLang;
265
+ };
266
+
267
+ const addLangKeys = (lang, translations) => {
268
+ // Accumulate: merge with any existing translations for this lang
269
+ const existing = languageMap.get(lang);
270
+ if (existing) {
271
+ translations = { ...existing, ...translations };
272
+ }
273
+ // A regional variant inherits all keys not explicitly overridden
274
+ // e.g. "fr-CA" inherits from "fr"
275
+ const dashIndex = lang.indexOf("-");
276
+ if (dashIndex !== -1) {
277
+ const parentLang = lang.slice(0, dashIndex);
278
+ const parentTranslations = languageMap.get(parentLang);
279
+ if (parentTranslations) {
280
+ translations = { ...parentTranslations, ...translations };
281
+ }
282
+ }
283
+ languageMap.set(lang, translations);
284
+ languageMapVersion++;
285
+ };
286
+
287
+ const add = (key, langTranslations) => {
288
+ if (keyLang && !(keyLang in langTranslations)) {
289
+ // Auto-register the key itself as the translation for keyLang
290
+ addLangKeys(keyLang, { [key]: key });
291
+ }
292
+ for (const [lang, value] of Object.entries(langTranslations)) {
293
+ addLangKeys(lang, { [key]: value });
294
+ }
295
+ };
296
+
297
+ const addAll = (keyMap) => {
298
+ for (const [key, langTranslations] of Object.entries(keyMap)) {
299
+ add(key, langTranslations);
300
+ }
301
+ };
302
+
303
+ const _getTemplate = (key, lang) => {
304
+ // matchBestLang, not matchLang directly: lang can be an array (e.g.
305
+ // languagesSignal.value is always an ordered array — see lang_signal.js) and
306
+ // matchLang alone assumes a plain string, throwing
307
+ // on .split() otherwise.
308
+ const resolvedLang = lang ? matchBestLang(lang, languageMap) : null;
309
+ if (resolvedLang) {
310
+ const translations = languageMap.get(resolvedLang);
311
+ const translated = translations[key];
312
+ if (translated !== undefined) {
313
+ return translated;
314
+ }
315
+ }
316
+ const resolvedFallbackLang = getResolvedFallbackLang();
317
+ if (resolvedFallbackLang) {
318
+ const fallbackTranslations = languageMap.get(resolvedFallbackLang);
319
+ const fallbackTranslated = fallbackTranslations[key];
320
+ if (fallbackTranslated !== undefined) {
321
+ return fallbackTranslated;
322
+ }
323
+ }
324
+ // No translation found — return key as-is (opaque fallback)
325
+ return key;
326
+ };
327
+
328
+ const format = (key, values, { lang = getActiveLang() } = {}) => {
329
+ const template = _getTemplate(key, lang);
330
+ return interpolateText(template, values);
331
+ };
332
+
333
+ const has = (key, { lang = getActiveLang() } = {}) => {
334
+ const resolvedLang = lang ? matchBestLang(lang, languageMap) : null;
335
+ if (resolvedLang) {
336
+ const translations = languageMap.get(resolvedLang);
337
+ if (translations && key in translations) {
338
+ return true;
339
+ }
340
+ }
341
+ const resolvedFallbackLang = getResolvedFallbackLang();
342
+ if (resolvedFallbackLang) {
343
+ const fallbackTranslations = languageMap.get(resolvedFallbackLang);
344
+ if (fallbackTranslations && key in fallbackTranslations) {
345
+ return true;
346
+ }
347
+ }
348
+ return false;
349
+ };
350
+
351
+ // The i18n instance is itself a callable function
352
+ const i18n = (key, values, opts) => format(key, values, opts);
353
+ i18n.add = add;
354
+ i18n.addAll = addAll;
355
+ i18n.addLangKeys = addLangKeys;
356
+ i18n.has = has;
357
+ i18n.format = format;
358
+ i18n.languageMap = languageMap;
359
+
360
+ return i18n;
361
+ };
362
+
363
+ // Walk "fr-CA-variant" → "fr-CA" → "fr" until a registered lang is found
364
+ const matchLang = (lang, languageMap) => {
365
+ if (languageMap.has(lang)) {
366
+ return lang;
367
+ }
368
+ const parts = lang.split("-");
369
+ while (parts.length > 1) {
370
+ parts.pop();
371
+ const candidate = parts.join("-");
372
+ if (languageMap.has(candidate)) {
373
+ return candidate;
374
+ }
375
+ }
376
+ return null;
377
+ };
378
+
379
+ // lang can be a string or an ordered array of preference strings
380
+ const matchBestLang = (lang, languageMap) => {
381
+ if (!lang) {
382
+ return null;
383
+ }
384
+ const candidates = Array.isArray(lang) ? lang : [lang];
385
+ for (const candidate of candidates) {
386
+ const match = matchLang(candidate, languageMap);
387
+ if (match) {
388
+ return match;
389
+ }
390
+ }
391
+ return null;
392
+ };
393
+
394
+ /**
395
+ * The shared i18n instance holding every text @jsenv/navi components display
396
+ * on their own — validation messages, button labels, empty-list messages,
397
+ * relative time wording…
398
+ *
399
+ * It is navi's texts, not the application's: an app registers its own texts in
400
+ * its own `createI18n()` instance and reaches for `naviI18n` only to change
401
+ * what navi itself says, or to add a language navi does not ship. Keys here are
402
+ * opaque identifiers (`"list.empty"`), never the English sentence — the
403
+ * opposite of what an app is advised to do. `docs/i18n.md` explains why.
404
+ *
405
+ * The active language is read from `languagesSignal` (see lang_signal.js —
406
+ * combines the browser's own `navigator.languages`, an optional
407
+ * `setPreferredLanguage()` user override, and an optional
408
+ * `setSupportedLanguages()` app-wide allow-list), live on every lookup.
409
+ *
410
+ * Built-in key namespaces, all overridable — the registrations below are the
411
+ * exhaustive list, read them to find the exact key to override:
412
+ * - `"button.*"` — Clear, Reset, Send, Open, Close, Cancel, Confirm…
413
+ * - `"time.*"` — relative time wording, duration unit symbols, date field placeholders
414
+ * - `"spin.*"` — the ends of a steppable range
415
+ * - `"list.*"` — empty/no-match/failed-rows messages
416
+ * - `"badge_list.*"` — the "+[count] more" overflow badge
417
+ * - `"constraint.*"` — every field validation message
418
+ * - `"network_policy.*"` — what an action settles with when the policy kept it from the network
419
+ *
420
+ * Unit names get two derived keys, both optional: `<unit>__plural` and
421
+ * `<unit>__short`. `<Unit>`/`<Quantity>` fall back to the singular when the
422
+ * derived key is missing, and to `Intl.NumberFormat` when the unit itself is
423
+ * not registered at all — so only units Intl gets wrong need registering.
424
+ *
425
+ * @example
426
+ * import { naviI18n } from "@jsenv/navi";
427
+ *
428
+ * // Override a built-in text:
429
+ * naviI18n.add("time.ongoing", { fr: "En cours…" });
430
+ *
431
+ * // Teach navi a language it does not ship:
432
+ * naviI18n.addLangKeys("ja", { "list.empty": "項目がありません。" });
433
+ *
434
+ * // Register unit translations used by <Quantity>/<Unit>:
435
+ * naviI18n.addAll({
436
+ * ticket: { en: "ticket", fr: "billet" },
437
+ * ticket__plural: { en: "tickets", fr: "billets" },
438
+ * });
439
+ */
440
+ const naviI18n = createI18n();
441
+
442
+ naviI18n.addAll({
443
+ "button.clear": {
444
+ en: "Clear",
445
+ fr: "Effacer",
446
+ },
447
+ "button.reset": {
448
+ en: "Reset",
449
+ fr: "Réinitialiser",
450
+ },
451
+ "button.send": {
452
+ en: "Send",
453
+ fr: "Envoyer",
454
+ },
455
+ "button.open": {
456
+ en: "Open",
457
+ fr: "Ouvrir",
458
+ },
459
+ "button.close": {
460
+ en: "Close",
461
+ fr: "Fermer",
462
+ },
463
+ "button.cancel": {
464
+ en: "Cancel",
465
+ fr: "Annuler",
466
+ },
467
+ "button.define": {
468
+ en: "Define",
469
+ fr: "Définir",
470
+ },
471
+ "button.confirm": {
472
+ en: "Confirm",
473
+ fr: "Confirmer",
474
+ },
475
+ "confirm.message": {
476
+ en: "Are you sure you want to do this?",
477
+ fr: "Êtes-vous sûr de vouloir faire cette action ?",
478
+ },
479
+ "button.more_actions": {
480
+ en: "More actions",
481
+ fr: "Autres actions",
482
+ },
483
+ "button.remove": {
484
+ en: "Remove",
485
+ fr: "Retirer",
486
+ },
487
+ });
488
+
489
+ // Default built-in translations — apps can override any key via add()
490
+ naviI18n.addAll({
491
+ "time.less_than_minute": {
492
+ en: "in less than a minute",
493
+ fr: "dans moins d'une minute",
494
+ de: "in weniger als einer Minute",
495
+ es: "en menos de un minuto",
496
+ it: "in meno di un minuto",
497
+ pt: "em menos de um minuto",
498
+ nl: "over minder dan een minuut",
499
+ },
500
+ "time.ongoing": {
501
+ en: "Ongoing",
502
+ fr: "En cours",
503
+ de: "Laufend",
504
+ es: "En curso",
505
+ it: "In corso",
506
+ pt: "Em andamento",
507
+ nl: "Bezig",
508
+ },
509
+ // [day] and [time] are replaced at runtime with the localized day/time strings
510
+ "time.tomorrow_at": {
511
+ en: "[day] at [time]",
512
+ fr: "[day] à [time]",
513
+ de: "[day] um [time]",
514
+ es: "[day] a las [time]",
515
+ it: "[day] alle [time]",
516
+ pt: "[day] às [time]",
517
+ nl: "[day] om [time]",
518
+ },
519
+ // [duration] is replaced at runtime with the formatted duration string (e.g. "1h30", "45 min")
520
+ "time.in_duration": {
521
+ en: "in [duration]",
522
+ fr: "dans [duration]",
523
+ de: "in [duration]",
524
+ es: "en [duration]",
525
+ it: "tra [duration]",
526
+ pt: "em [duration]",
527
+ nl: "over [duration]",
528
+ },
529
+ // Substituted in place of the "0 heure(s)" part of an Intl-generated
530
+ // duration string when <Time type="time" format="long"> renders midnight
531
+ // — see time.jsx's own TimeTime for why midnight can't just fall through
532
+ // to formatMinuteDuration like every other hour does, and how this word
533
+ // gets spliced in (formatToParts, not string concatenation) so the rest
534
+ // of the sentence (conjunction, minutes) still comes out in whatever
535
+ // grammar/word order this language's own Intl.DurationFormat produces.
536
+ // Languages without an entry here fall back to that language's own
537
+ // literal "0 heure(s)" wording instead (see TimeTime), never to this key.
538
+ "time.midnight": {
539
+ en: "midnight",
540
+ fr: "minuit",
541
+ de: "Mitternacht",
542
+ es: "medianoche",
543
+ it: "mezzanotte",
544
+ pt: "meia-noite",
545
+ nl: "middernacht",
546
+ },
547
+ // What <TimeRange> writes between the two bounds of a span — "8h–10h",
548
+ // "11 mai – 14 mai". An en dash, the mark for a span, not a hyphen.
549
+ "time.range_separator": {
550
+ en: "–",
551
+ fr: "–",
552
+ de: "–",
553
+ es: "–",
554
+ it: "–",
555
+ pt: "–",
556
+ nl: "–",
557
+ },
558
+ // Compact duration unit symbols used in "1h30", "45min", "2d", etc.
559
+ "time.duration.year_symbol": {
560
+ en: "y",
561
+ fr: "a",
562
+ de: "J",
563
+ es: "a",
564
+ it: "a",
565
+ pt: "a",
566
+ nl: "j",
567
+ ja: "年",
568
+ zh: "年",
569
+ ko: "년",
570
+ },
571
+ "time.duration.month_symbol": {
572
+ en: "mo",
573
+ fr: "mo",
574
+ de: "Mo",
575
+ es: "mo",
576
+ it: "mo",
577
+ pt: "mo",
578
+ nl: "mo",
579
+ ja: "月",
580
+ zh: "月",
581
+ ko: "월",
582
+ },
583
+ "time.duration.week_symbol": {
584
+ en: "w",
585
+ fr: "sem",
586
+ de: "W",
587
+ es: "sem",
588
+ it: "sett",
589
+ pt: "sem",
590
+ nl: "w",
591
+ ja: "週",
592
+ zh: "周",
593
+ ko: "주",
594
+ },
595
+ "time.duration.day_symbol": {
596
+ en: "d",
597
+ fr: "j",
598
+ de: "T",
599
+ es: "d",
600
+ it: "g",
601
+ pt: "d",
602
+ nl: "d",
603
+ ja: "日",
604
+ zh: "天",
605
+ ko: "일",
606
+ },
607
+ "time.duration.hour_symbol": {
608
+ en: "h",
609
+ fr: "h",
610
+ de: "h",
611
+ es: "h",
612
+ it: "h",
613
+ pt: "h",
614
+ nl: "u",
615
+ ja: "時間",
616
+ zh: "小时",
617
+ ko: "시간",
618
+ },
619
+ "time.duration.minute_symbol": {
620
+ en: "min",
621
+ fr: "min",
622
+ de: "min",
623
+ es: "min",
624
+ it: "min",
625
+ pt: "min",
626
+ nl: "min",
627
+ ja: "分",
628
+ zh: "分",
629
+ ko: "분",
630
+ },
631
+ "time.duration.second_symbol": {
632
+ en: "s",
633
+ fr: "s",
634
+ de: "s",
635
+ es: "s",
636
+ it: "s",
637
+ pt: "s",
638
+ nl: "s",
639
+ ja: "秒",
640
+ zh: "秒",
641
+ ko: "초",
642
+ },
643
+ "time.duration.millisecond_symbol": {
644
+ en: "ms",
645
+ fr: "ms",
646
+ de: "ms",
647
+ es: "ms",
648
+ it: "ms",
649
+ pt: "ms",
650
+ nl: "ms",
651
+ ja: "ms",
652
+ zh: "ms",
653
+ ko: "ms",
654
+ },
655
+ });
656
+
657
+ // Spin messages — the ends of what one steps through, said without naming
658
+ // what it is made of: the same words fit days, months, pages or sizes.
659
+ naviI18n.addAll({
660
+ "spin.previous": {
661
+ en: "Previous",
662
+ fr: "Précédent",
663
+ },
664
+ "spin.next": {
665
+ en: "Next",
666
+ fr: "Suivant",
667
+ },
668
+ "spin.nothing_before": {
669
+ en: "No item before this one.",
670
+ fr: "Pas d'élément avant celui-ci.",
671
+ },
672
+ "spin.nothing_after": {
673
+ en: "No item after this one.",
674
+ fr: "Pas d'élément après celui-ci.",
675
+ },
676
+ });
677
+
678
+ // Time spin messages — what a clock writes between an hour and its minutes,
679
+ // and how the two ends of a span are named.
680
+ naviI18n.addAll({
681
+ "time.hour_separator": {
682
+ en: ":",
683
+ fr: "h",
684
+ },
685
+ "time.hour_label": {
686
+ en: "Hours",
687
+ fr: "Heures",
688
+ },
689
+ "time.minute_label": {
690
+ en: "Minutes",
691
+ fr: "Minutes",
692
+ },
693
+ "time_range.from": {
694
+ en: "From",
695
+ fr: "De",
696
+ },
697
+ "time_range.to": {
698
+ en: "to",
699
+ fr: "à",
700
+ },
701
+ });
702
+
703
+ // List messages — override any key to customize list messages
704
+ naviI18n.addAll({
705
+ "list.empty": {
706
+ en: "No items in this list.",
707
+ fr: "Aucun élément dans cette liste.",
708
+ },
709
+ "list.no_match": {
710
+ en: "No item matches this search.",
711
+ fr: "Aucun élément ne correspond à cette recherche.",
712
+ },
713
+ "list.no_match_rest_shown": {
714
+ en: "No item matches this search. The rest is shown below.",
715
+ fr: "Aucun élément ne correspond à cette recherche. Le reste est affiché ci-dessous.",
716
+ },
717
+ "list.rows_failed": {
718
+ en: "These elements could not be loaded.",
719
+ fr: "Ces élements n'ont pas pu être chargées.",
720
+ },
721
+ "list.rows_retry": {
722
+ en: "Retry",
723
+ fr: "Réessayer",
724
+ },
725
+ });
726
+
727
+ // Badge list messages
728
+ naviI18n.addAll({
729
+ "badge_list.more": {
730
+ en: "+[count] more",
731
+ fr: "+[count] de plus",
732
+ },
733
+ });
734
+
735
+ // Constraint validation messages — override any key to customize error messages
736
+ naviI18n.addAll({
737
+ "constraint.available": {
738
+ fr: '"[value]" est utilisé. Veuillez entrer une autre valeur.',
739
+ en: '"[value]" is already taken. Please enter a different value.',
740
+ },
741
+ "constraint.required.date": {
742
+ fr: "Veuillez sélectionner une date.",
743
+ en: "Please select a date.",
744
+ },
745
+ "constraint.required.month": {
746
+ fr: "Veuillez sélectionner un mois.",
747
+ en: "Please select a month.",
748
+ },
749
+ "constraint.required.week": {
750
+ fr: "Veuillez sélectionner une semaine.",
751
+ en: "Please select a week.",
752
+ },
753
+ "constraint.required.time": {
754
+ fr: "Veuillez sélectionner une heure.",
755
+ en: "Please select a time.",
756
+ },
757
+ "constraint.required.number": {
758
+ fr: "Veuillez saisir un nombre.",
759
+ en: "Please enter a number.",
760
+ },
761
+ "constraint.required.datetime": {
762
+ fr: "Veuillez sélectionner une date et une heure.",
763
+ en: "Please select a date and time.",
764
+ },
765
+ "constraint.required.color": {
766
+ fr: "Veuillez sélectionner une couleur.",
767
+ en: "Please select a color.",
768
+ },
769
+ "constraint.required.file": {
770
+ fr: "Veuillez sélectionner un fichier.",
771
+ en: "Please select a file.",
772
+ },
773
+ "constraint.required.file.multiple": {
774
+ fr: "Veuillez sélectionner au moins un fichier.",
775
+ en: "Please select at least one file.",
776
+ },
777
+ "constraint.disabled.checkbox": {
778
+ fr: "Cette case est désactivée.",
779
+ en: "This checkbox is disabled.",
780
+ },
781
+ "constraint.disabled.radio": {
782
+ fr: "Cette option est désactivée.",
783
+ en: "This option is disabled.",
784
+ },
785
+ "constraint.disabled.default": {
786
+ fr: "Ce champ est désactivé.",
787
+ en: "This field is disabled.",
788
+ },
789
+ "constraint.readonly.button": {
790
+ fr: "Cette action n'est pas disponible pour l'instant.",
791
+ en: "This action is not available right now.",
792
+ },
793
+ "constraint.readonly.option": {
794
+ fr: "Cette option n'est pas disponible.",
795
+ en: "This option is not available.",
796
+ },
797
+ "constraint.readonly.selection": {
798
+ fr: "La sélection ne peut plus être modifiée.",
799
+ en: "This selection cannot be changed.",
800
+ },
801
+ "constraint.readonly.choice": {
802
+ fr: "Ce choix ne peut plus être changé.",
803
+ en: "This choice cannot be changed.",
804
+ },
805
+ "constraint.readonly.item": {
806
+ fr: "Cet élément n'est pas disponible.",
807
+ en: "This item is not available.",
808
+ },
809
+ "constraint.readonly.default": {
810
+ fr: "Cet élément est en lecture seule et ne peut pas être modifié.",
811
+ en: "This element is read-only and cannot be modified.",
812
+ },
813
+ "constraint.readonly.awaiting_change": {
814
+ fr: "Cette action attend une modification.",
815
+ en: "This action is waiting for a change.",
816
+ },
817
+ // parallelGuard: the surface already has as many runs in flight as it allows
818
+ "constraint.readonly.parallel_guard": {
819
+ fr: "[max] action[s] déjà en cours, attendez qu'une se termine.",
820
+ en: "[max] action[s] already in progress, wait for one to finish.",
821
+ },
822
+ "constraint.readonly.network_policy": {
823
+ fr: "Hors ligne : ça ne peut pas partir.",
824
+ en: "Offline: this cannot be sent.",
825
+ },
826
+ "network_policy.offline": {
827
+ fr: "Hors ligne : rien n'a été demandé.",
828
+ en: "Offline: nothing was requested.",
829
+ },
830
+ "constraint.busy.button": {
831
+ fr: "Cette action est en cours...",
832
+ en: "This action is in progress...",
833
+ },
834
+ // What a ROW is waiting on: the row as a thing the list holds, joining it,
835
+ // leaving it or being saved. Said "à la liste" / "to the list" on purpose —
836
+ // in a selectable list, "en cours d'ajout" alone reads as "being added to
837
+ // the selection", which is the sentence below and a different event.
838
+ "constraint.busy.item": {
839
+ fr: "Cet élément est en cours de synchronisation.",
840
+ en: "This item is being synchronized.",
841
+ },
842
+ "constraint.busy.item.adding": {
843
+ fr: "Cet élément est en cours d'ajout à la liste.",
844
+ en: "This item is being added to the list.",
845
+ },
846
+ "constraint.busy.item.removing": {
847
+ fr: "Cet élément est en cours de retrait de la liste.",
848
+ en: "This item is being removed from the list.",
849
+ },
850
+ "constraint.busy.item.updating": {
851
+ fr: "Cet élément est en cours de mise à jour.",
852
+ en: "This item is being updated.",
853
+ },
854
+ // What the LIST is waiting on: the choice just made, on its way. The subject
855
+ // is the selection, never a row — the row this is shown on is the one the
856
+ // user just pressed, which is not the one being committed.
857
+ "constraint.busy.selection": {
858
+ fr: "La sélection est en cours d'enregistrement...",
859
+ en: "This selection is being saved...",
860
+ },
861
+ "constraint.busy.choice": {
862
+ fr: "Le choix est en cours d'enregistrement...",
863
+ en: "This choice is being saved...",
864
+ },
865
+ "constraint.busy.default": {
866
+ fr: "Cet élément est occupé.",
867
+ en: "This element is busy.",
868
+ },
869
+ "constraint.one_of.no_match": {
870
+ fr: "Aucune suggestion ne correspond à votre saisie.",
871
+ en: "No suggestion matches your input.",
872
+ },
873
+ "constraint.one_of.default": {
874
+ fr: "Veuillez choisir une valeur parmi les suggestions.",
875
+ en: "Please choose a value from the suggestions.",
876
+ },
877
+ "constraint.same_as.password": {
878
+ fr: "Ce mot de passe doit être identique au précédent.",
879
+ en: "This password must match the previous one.",
880
+ },
881
+ "constraint.same_as.email": {
882
+ fr: "Cette adresse e-mail doit être identique a la précédente.",
883
+ en: "This email address must match the previous one.",
884
+ },
885
+ "constraint.same_as.default": {
886
+ fr: "Ce champ doit être identique au précédent.",
887
+ en: "This field must match the previous one.",
888
+ },
889
+ "constraint.time_after.default": {
890
+ fr: "L'heure de fin ne peut pas être avant l'heure de début.",
891
+ en: "The end time cannot be before the start time.",
892
+ },
893
+ "constraint.time_after.min_duration": {
894
+ fr: "La plage doit durer au moins <strong>[duration]</strong> minutes.",
895
+ en: "The span must last at least <strong>[duration]</strong> minutes.",
896
+ },
897
+ "constraint.required.checkbox": {
898
+ fr: "Veuillez cocher cette case.",
899
+ en: "Please check this box.",
900
+ },
901
+ "constraint.required.checkbox_group": {
902
+ fr: "Veuillez sélectionner au moins une option.",
903
+ en: "Please select at least one option.",
904
+ },
905
+ "constraint.required.radio": {
906
+ fr: "Veuillez sélectionner une option.",
907
+ en: "Please select an option.",
908
+ },
909
+ "constraint.required.password": {
910
+ fr: "Veuillez saisir un mot de passe.",
911
+ en: "Please enter a password.",
912
+ },
913
+ "constraint.required.password.confirm": {
914
+ fr: "Veuillez confirmer le mot de passe.",
915
+ en: "Please confirm the password.",
916
+ },
917
+ "constraint.required.email": {
918
+ fr: "Veuillez saisir une adresse e-mail.",
919
+ en: "Please enter an email address.",
920
+ },
921
+ "constraint.required.email.confirm": {
922
+ fr: "Veuillez confirmer l'adresse e-mail.",
923
+ en: "Please confirm the email address.",
924
+ },
925
+ "constraint.required.confirm": {
926
+ fr: "Veuillez confirmer le champ précédent.",
927
+ en: "Please confirm the previous field.",
928
+ },
929
+ "constraint.required.default": {
930
+ fr: "Veuillez remplir ce champ.",
931
+ en: "Please fill in this field.",
932
+ },
933
+ "constraint.pattern.password": {
934
+ fr: "Ce mot de passe ne correspond pas au format requis.",
935
+ en: "This password does not match the required format.",
936
+ },
937
+ "constraint.pattern.email": {
938
+ fr: "Cette adresse e-mail ne correspond pas au format requis.",
939
+ en: "This email address does not match the required format.",
940
+ },
941
+ "constraint.pattern.default": {
942
+ fr: "Ce champ ne correspond pas au format requis.",
943
+ en: "This field does not match the required format.",
944
+ },
945
+ "constraint.type.email.at": {
946
+ fr: 'Veuillez inclure "@" dans l\'adresse e-mail. Il manque un symbole "@" dans [value].',
947
+ en: 'Please include "@" in the email address. "@" is missing in [value].',
948
+ },
949
+ "constraint.type.email.invalid": {
950
+ fr: "Veuillez saisir une adresse e-mail valide.",
951
+ en: "Please enter a valid email address.",
952
+ },
953
+ "constraint.min_length.singular.password": {
954
+ fr: "Ce mot de passe doit contenir au moins [min] caractère (il contient actuellement un seul caractère).",
955
+ en: "This password must contain at least [min] character (it currently contains only one character).",
956
+ },
957
+ "constraint.min_length.singular.email": {
958
+ fr: "Cette adresse e-mail doit contenir au moins [min] caractère (il contient actuellement un seul caractère).",
959
+ en: "This email address must contain at least [min] character (it currently contains only one character).",
960
+ },
961
+ "constraint.min_length.singular.default": {
962
+ fr: "Ce champ doit contenir au moins [min] caractère (il contient actuellement un seul caractère).",
963
+ en: "This field must contain at least [min] character (it currently contains only one character).",
964
+ },
965
+ "constraint.min_length.plural.password": {
966
+ fr: "Ce mot de passe doit contenir au moins [min] caractères (il contient actuellement [count] caractères).",
967
+ en: "This password must contain at least [min] characters (it currently contains [count] characters).",
968
+ },
969
+ "constraint.min_length.plural.email": {
970
+ fr: "Cette adresse e-mail doit contenir au moins [min] caractères (il contient actuellement [count] caractères).",
971
+ en: "This email address must contain at least [min] characters (it currently contains [count] characters).",
972
+ },
973
+ "constraint.min_length.plural.default": {
974
+ fr: "Ce champ doit contenir au moins [min] caractères (il contient actuellement [count] caractères).",
975
+ en: "This field must contain at least [min] characters (it currently contains [count] characters).",
976
+ },
977
+ "constraint.max_length.password": {
978
+ fr: "Ce mot de passe doit contenir au maximum [max] caractères (il contient actuellement [count] caractères).",
979
+ en: "This password must contain at most [max] characters (it currently contains [count] characters).",
980
+ },
981
+ "constraint.max_length.email": {
982
+ fr: "Cette adresse e-mail doit contenir au maximum [max] caractères (il contient actuellement [count] caractères).",
983
+ en: "This email address must contain at most [max] characters (it currently contains [count] characters).",
984
+ },
985
+ "constraint.max_length.default": {
986
+ fr: "Ce champ doit contenir au maximum [max] caractères (il contient actuellement [count] caractères).",
987
+ en: "This field must contain at most [max] characters (it currently contains [count] characters).",
988
+ },
989
+ "constraint.max_length.selection": {
990
+ fr: "Sélectionnez au maximum [max] choix ([count] actuellement).",
991
+ en: "Select at most [max] choices ([count] currently).",
992
+ },
993
+ "constraint.type.number.default": {
994
+ fr: "Ce champ doit être un nombre.",
995
+ en: "This field must be a number.",
996
+ },
997
+ "constraint.type.hour.default": {
998
+ fr: "Ce champ doit contenir un nombre d'heures.",
999
+ en: "This field must contain a number of hours.",
1000
+ },
1001
+ "constraint.type.minute.default": {
1002
+ fr: "Ce champ doit contenir un nombre de minutes.",
1003
+ en: "This field must contain a number of minutes.",
1004
+ },
1005
+ "constraint.type.second.default": {
1006
+ fr: "Ce champ doit contenir un nombre de secondes.",
1007
+ en: "This field must contain a number of seconds.",
1008
+ },
1009
+ "constraint.type.percentage.default": {
1010
+ fr: "Ce champ doit contenir un pourcentage.",
1011
+ en: "This field must contain a percentage.",
1012
+ },
1013
+ "constraint.min.number.default": {
1014
+ fr: "Ce nombre doit être <strong>[min]</strong> ou plus.",
1015
+ en: "This number must be <strong>[min]</strong> or greater.",
1016
+ },
1017
+ "constraint.min.hour.default": {
1018
+ fr: "Le nombre d'heures doit être <strong>[min]</strong> ou plus.",
1019
+ en: "The number of hours must be <strong>[min]</strong> or greater.",
1020
+ },
1021
+ "constraint.min.minute.default": {
1022
+ fr: "Le nombre de minutes doit être <strong>[min]</strong> ou plus.",
1023
+ en: "The number of minutes must be <strong>[min]</strong> or greater.",
1024
+ },
1025
+ "constraint.min.second.default": {
1026
+ fr: "Le nombre de secondes doit être <strong>[min]</strong> ou plus.",
1027
+ en: "The number of seconds must be <strong>[min]</strong> or greater.",
1028
+ },
1029
+ "constraint.min.percentage.default": {
1030
+ fr: "Le pourcentage doit être <strong>[min]</strong> ou plus.",
1031
+ en: "The percentage must be <strong>[min]</strong> or greater.",
1032
+ },
1033
+ "constraint.min.duration.default": {
1034
+ fr: "La durée doit être d'au moins <strong>[min]</strong>.",
1035
+ en: "The duration must be at least <strong>[min]</strong>.",
1036
+ },
1037
+ "constraint.max.duration.default": {
1038
+ fr: "La durée ne doit pas dépasser <strong>[max]</strong>.",
1039
+ en: "The duration must not exceed <strong>[max]</strong>.",
1040
+ },
1041
+ "constraint.step.duration.default": {
1042
+ fr: "La durée doit être un multiple de <strong>[step]</strong> (par ex. <strong>[before]</strong> ou <strong>[after]</strong>).",
1043
+ en: "The duration must be a multiple of <strong>[step]</strong> (e.g. <strong>[before]</strong> or <strong>[after]</strong>).",
1044
+ },
1045
+ "constraint.min.time.default": {
1046
+ fr: "L'heure doit être <strong>[min]</strong> ou plus.",
1047
+ en: "The time must be <strong>[min]</strong> or later.",
1048
+ },
1049
+ "constraint.min.date.today.default": {
1050
+ fr: "La date doit être aujourd'hui ou dans le futur.",
1051
+ en: "The date must be today or in the future.",
1052
+ },
1053
+ "constraint.min.date.default": {
1054
+ fr: "La date doit être à partir du <strong>[min]</strong>.",
1055
+ en: "The date must be on or after <strong>[min]</strong>.",
1056
+ },
1057
+ "constraint.max.date.today.default": {
1058
+ fr: "La date doit être aujourd'hui ou dans le passé.",
1059
+ en: "The date must be today or in the past.",
1060
+ },
1061
+ "constraint.max.date.default": {
1062
+ fr: "La date doit être au plus tard le <strong>[max]</strong>.",
1063
+ en: "The date must be on or before <strong>[max]</strong>.",
1064
+ },
1065
+ "constraint.max.number.default": {
1066
+ fr: "Max <strong>[max]</strong>.",
1067
+ en: "Max <strong>[max]</strong>.",
1068
+ },
1069
+ "constraint.max.hour.default": {
1070
+ fr: "Max <strong>[max]</strong> heures.",
1071
+ en: "Max <strong>[max]</strong> hours.",
1072
+ },
1073
+ "constraint.max.minute.default": {
1074
+ fr: "Max <strong>[max]</strong> minutes.",
1075
+ en: "Max <strong>[max]</strong> minutes.",
1076
+ },
1077
+ "constraint.max.second.default": {
1078
+ fr: "Max <strong>[max]</strong> secondes.",
1079
+ en: "Max <strong>[max]</strong> secondes.",
1080
+ },
1081
+ "constraint.max.percentage.default": {
1082
+ fr: "Max <strong>[max]</strong>%.",
1083
+ en: "Max <strong>[max]</strong>%.",
1084
+ },
1085
+ "constraint.step.number.default": {
1086
+ fr: "Ce nombre doit être un multiple de <strong>[step]</strong> (par ex. <strong>[before]</strong> ou <strong>[after]</strong>).",
1087
+ en: "This number must be a multiple of <strong>[step]</strong> (e.g. <strong>[before]</strong> or <strong>[after]</strong>).",
1088
+ },
1089
+ "constraint.step.hour.default": {
1090
+ fr: "Le nombre d'heures doit être un multiple de <strong>[step]</strong> (par ex. <strong>[before]</strong> ou <strong>[after]</strong>).",
1091
+ en: "The number of hours must be a multiple of <strong>[step]</strong> (e.g. <strong>[before]</strong> or <strong>[after]</strong>).",
1092
+ },
1093
+ "constraint.step.minute.default": {
1094
+ fr: "Le nombre de minutes doit être un multiple de <strong>[step]</strong> (par ex. <strong>[before]</strong> ou <strong>[after]</strong>).",
1095
+ en: "The number of minutes must be a multiple of <strong>[step]</strong> (e.g. <strong>[before]</strong> or <strong>[after]</strong>).",
1096
+ },
1097
+ "constraint.step.second.default": {
1098
+ fr: "Le nombre de secondes doit être un multiple de <strong>[step]</strong> (par ex. <strong>[before]</strong> ou <strong>[after]</strong>).",
1099
+ en: "The number of seconds must be a multiple of <strong>[step]</strong> (e.g. <strong>[before]</strong> or <strong>[after]</strong>).",
1100
+ },
1101
+ "constraint.step.percentage.default": {
1102
+ fr: "Le pourcentage doit être un multiple de <strong>[step]</strong> (par ex. <strong>[before]</strong> ou <strong>[after]</strong>).",
1103
+ en: "The percentage must be a multiple of <strong>[step]</strong> (e.g. <strong>[before]</strong> or <strong>[after]</strong>).",
1104
+ },
1105
+ "constraint.step.time.hour": {
1106
+ fr: "L'heure doit être dans un intervalle de <strong>[step]</strong> heure(s) (par ex. <strong>[before]</strong> ou <strong>[after]</strong>).",
1107
+ en: "The time must be within an interval of <strong>[step]</strong> hour(s) (e.g. <strong>[before]</strong> or <strong>[after]</strong>).",
1108
+ },
1109
+ "constraint.step.time.minute": {
1110
+ fr: "L'heure doit être dans un intervalle de <strong>[step]</strong> minute(s) (par ex. <strong>[before]</strong> ou <strong>[after]</strong>).",
1111
+ en: "The time must be within an interval of <strong>[step]</strong> minute(s) (e.g. <strong>[before]</strong> or <strong>[after]</strong>).",
1112
+ },
1113
+ "constraint.step.time.second": {
1114
+ fr: "L'heure doit être dans un intervalle de <strong>[step]</strong> seconde(s) (par ex. <strong>[before]</strong> ou <strong>[after]</strong>).",
1115
+ en: "The time must be within an interval of <strong>[step]</strong> second(s) (e.g. <strong>[before]</strong> or <strong>[after]</strong>).",
1116
+ },
1117
+ "constraint.step.date.default": {
1118
+ fr: "La date doit correspondre à un intervalle de <strong>[step]</strong> jour(s) (par ex. <strong>[before]</strong> ou <strong>[after]</strong>).",
1119
+ en: "The date must correspond to an interval of <strong>[step]</strong> day(s) (e.g. <strong>[before]</strong> or <strong>[after]</strong>).",
1120
+ },
1121
+ "constraint.max.time.default": {
1122
+ fr: "L'heure doit être <strong>[max]</strong> ou moins.",
1123
+ en: "The time must be <strong>[max]</strong> or earlier.",
1124
+ },
1125
+ "constraint.single_space.start": {
1126
+ fr: "Ce champ ne doit pas commencer par un espace.",
1127
+ en: "This field must not start with a space.",
1128
+ },
1129
+ "constraint.single_space.end": {
1130
+ fr: "Ce champ ne doit pas finir par un espace.",
1131
+ en: "This field must not end with a space.",
1132
+ },
1133
+ "constraint.single_space.consecutive": {
1134
+ fr: "Ce champ ne doit pas contenir plusieurs espaces consécutifs.",
1135
+ en: "This field must not contain consecutive spaces.",
1136
+ },
1137
+ // [sample] is the offending character with its marks — a stack is invisible
1138
+ // as a description and obvious as a sample.
1139
+ "constraint.displayable.stacked_marks.singular": {
1140
+ fr: "Ce champ contient un caractère qui empile plus de <strong>[max]</strong> signes : « [sample] ».",
1141
+ en: "This field contains a character stacking more than <strong>[max]</strong> marks: “[sample]”.",
1142
+ },
1143
+ "constraint.displayable.stacked_marks.plural": {
1144
+ fr: "Ce champ contient [count] caractères qui empilent plus de <strong>[max]</strong> signes (tel que « [sample] »).",
1145
+ en: "This field contains [count] characters stacking more than <strong>[max]</strong> marks (such as “[sample]”).",
1146
+ },
1147
+ "constraint.displayable.invisible": {
1148
+ fr: "Ce champ doit contenir au moins un caractère visible.",
1149
+ en: "This field must contain at least one visible character.",
1150
+ },
1151
+ "constraint.displayable.blank_lines": {
1152
+ fr: "Ce champ ne doit pas contenir plusieurs lignes vides consécutives.",
1153
+ en: "This field must not contain consecutive blank lines.",
1154
+ },
1155
+ "constraint.displayable.dangling_joiner": {
1156
+ fr: "Ce champ contient un caractère de liaison invisible qui ne relie rien.",
1157
+ en: "This field contains an invisible joiner that joins nothing.",
1158
+ },
1159
+ "constraint.no_emoji.default": {
1160
+ fr: "Ce champ ne doit pas contenir d'emoji.",
1161
+ en: "This field must not contain emoji.",
1162
+ },
1163
+ "constraint.max_line_breaks.default": {
1164
+ fr: "Ce champ ne doit pas contenir plus de [max] retour[s] à la ligne.",
1165
+ en: "This field must not contain more than [max] line break[s].",
1166
+ },
1167
+ "constraint.min_lower_letter.password.singular": {
1168
+ fr: "Ce mot de passe doit contenir au moins une lettre minuscule.",
1169
+ en: "This password must contain at least one lowercase letter.",
1170
+ },
1171
+ "constraint.min_lower_letter.password.plural": {
1172
+ fr: "Ce mot de passe doit contenir au moins [min] lettres minuscules.",
1173
+ en: "This password must contain at least [min] lowercase letters.",
1174
+ },
1175
+ "constraint.min_lower_letter.default.singular": {
1176
+ fr: "Ce champ doit contenir au moins une lettre minuscule.",
1177
+ en: "This field must contain at least one lowercase letter.",
1178
+ },
1179
+ "constraint.min_lower_letter.default.plural": {
1180
+ fr: "Ce champ doit contenir au moins [min] lettres minuscules.",
1181
+ en: "This field must contain at least [min] lowercase letters.",
1182
+ },
1183
+ "constraint.min_upper_letter.password.singular": {
1184
+ fr: "Ce mot de passe doit contenir au moins une lettre majuscule.",
1185
+ en: "This password must contain at least one uppercase letter.",
1186
+ },
1187
+ "constraint.min_upper_letter.password.plural": {
1188
+ fr: "Ce mot de passe doit contenir au moins [min] lettres majuscules.",
1189
+ en: "This password must contain at least [min] uppercase letters.",
1190
+ },
1191
+ "constraint.min_upper_letter.default.singular": {
1192
+ fr: "Ce champ doit contenir au moins une lettre majuscule.",
1193
+ en: "This field must contain at least one uppercase letter.",
1194
+ },
1195
+ "constraint.min_upper_letter.default.plural": {
1196
+ fr: "Ce champ doit contenir au moins [min] lettres majuscules.",
1197
+ en: "This field must contain at least [min] uppercase letters.",
1198
+ },
1199
+ "constraint.min_digit.password.singular": {
1200
+ fr: "Ce mot de passe doit contenir au moins un chiffre.",
1201
+ en: "This password must contain at least one digit.",
1202
+ },
1203
+ "constraint.min_digit.password.plural": {
1204
+ fr: "Ce mot de passe doit contenir au moins [min] chiffres.",
1205
+ en: "This password must contain at least [min] digits.",
1206
+ },
1207
+ "constraint.min_digit.default.singular": {
1208
+ fr: "Ce champ doit contenir au moins un chiffre.",
1209
+ en: "This field must contain at least one digit.",
1210
+ },
1211
+ "constraint.min_digit.default.plural": {
1212
+ fr: "Ce champ doit contenir au moins [min] chiffres.",
1213
+ en: "This field must contain at least [min] digits.",
1214
+ },
1215
+ "constraint.min_special_char.password.singular": {
1216
+ fr: "Ce mot de passe doit contenir au moins un caractère spécial. ([charset])",
1217
+ en: "This password must contain at least one special character. ([charset])",
1218
+ },
1219
+ "constraint.min_special_char.password.plural": {
1220
+ fr: "Ce mot de passe doit contenir au moins [min] caractères spéciaux. ([charset])",
1221
+ en: "This password must contain at least [min] special characters. ([charset])",
1222
+ },
1223
+ "constraint.min_special_char.default.singular": {
1224
+ fr: "Ce champ doit contenir au moins un caractère spécial. ([charset])",
1225
+ en: "This field must contain at least one special character. ([charset])",
1226
+ },
1227
+ "constraint.min_special_char.default.plural": {
1228
+ fr: "Ce champ doit contenir au moins [min] caractères spéciaux. ([charset])",
1229
+ en: "This field must contain at least [min] special characters. ([charset])",
1230
+ },
1231
+ });
1232
+
1233
+ // Character class and maxLengthGuard messages. The char class keys are
1234
+ // @jsenv/validity's own ("char_class.slug"), prefixed with "constraint." —
1235
+ // the same sentence refuses a keystroke in a callout and a whole value in a
1236
+ // constraint, so there is one key for both.
1237
+ naviI18n.addAll({
1238
+ // Preset-specific char messages — more informative than the generic fallback
1239
+ "constraint.char_class.numeric": {
1240
+ fr: "Ce champ ne peut contenir que des chiffres.",
1241
+ en: "This field can only contain digits.",
1242
+ },
1243
+ "constraint.char_class.alpha": {
1244
+ fr: "Ce champ ne peut contenir que des lettres.",
1245
+ en: "This field can only contain letters.",
1246
+ },
1247
+ "constraint.char_class.alphanumeric": {
1248
+ fr: "Ce champ ne peut contenir que des lettres et des chiffres.",
1249
+ en: "This field can only contain letters and digits.",
1250
+ },
1251
+ "constraint.char_class.uppercase": {
1252
+ fr: "Ce champ ne peut contenir que des lettres majuscules.",
1253
+ en: "This field can only contain uppercase letters.",
1254
+ },
1255
+ "constraint.char_class.hex": {
1256
+ fr: "Ce champ ne peut contenir que des chiffres hexadécimaux (0-9, A-F).",
1257
+ en: "This field can only contain hexadecimal digits (0-9, A-F).",
1258
+ },
1259
+ "constraint.char_class.slug": {
1260
+ fr: "Ce champ ne peut contenir que des lettres minuscules, des chiffres et des tirets.",
1261
+ en: "This field can only contain lowercase letters, digits, and hyphens.",
1262
+ },
1263
+ // Generic fallback for custom char classes and other presets (tel, card, postal, iban…)
1264
+ "constraint.char_class.no_emoji": {
1265
+ fr: "Ce champ ne peut pas contenir d'emoji.",
1266
+ en: "This field cannot contain emoji.",
1267
+ },
1268
+ "constraint.char_class.default": {
1269
+ fr: "Ce champ ne peut contenir que les caractères autorisés.",
1270
+ en: "This field can only contain allowed characters.",
1271
+ },
1272
+ // maxLength: keydown blocked (one character would exceed the limit)
1273
+ "constraint.guard.max_length.typing": {
1274
+ fr: "Longueur maximale de [max] caractère[s] atteinte.",
1275
+ en: "Maximum length of [max] character[s] reached.",
1276
+ },
1277
+ // maxLength: paste/set truncated to maxLength (autofix always applied)
1278
+ "constraint.guard.max_length.value": {
1279
+ fr: "Ce champ ne peut pas contenir plus de [max] caractère[s], une partie a été tronquée.",
1280
+ en: "This field cannot contain more than [max] character[s]; the value was truncated.",
1281
+ },
1282
+ // maxLengthGuard on a multiple selection: one more item would exceed the limit
1283
+ "constraint.guard.max_length.selection": {
1284
+ fr: "[max] max.",
1285
+ en: "[max] max.",
1286
+ },
1287
+ });
1288
+
1289
+ // Date/time placeholder tokens — shown when no value is selected
1290
+ // Override any key to adapt to your language conventions
1291
+ naviI18n.addAll({
1292
+ "time.placeholder.day": {
1293
+ fr: "jj",
1294
+ en: "dd",
1295
+ de: "TT",
1296
+ es: "dd",
1297
+ it: "gg",
1298
+ pt: "dd",
1299
+ nl: "dd",
1300
+ },
1301
+ "time.placeholder.month": {
1302
+ fr: "mm",
1303
+ en: "mm",
1304
+ de: "MM",
1305
+ es: "mm",
1306
+ it: "mm",
1307
+ pt: "mm",
1308
+ nl: "mm",
1309
+ },
1310
+ "time.placeholder.year": {
1311
+ fr: "aaaa",
1312
+ en: "yyyy",
1313
+ de: "JJJJ",
1314
+ es: "aaaa",
1315
+ it: "aaaa",
1316
+ pt: "aaaa",
1317
+ nl: "jjjj",
1318
+ },
1319
+ "time.placeholder.hour": {
1320
+ fr: "hh",
1321
+ en: "hh",
1322
+ de: "hh",
1323
+ es: "hh",
1324
+ it: "hh",
1325
+ pt: "hh",
1326
+ nl: "uu",
1327
+ },
1328
+ "time.placeholder.minute": {
1329
+ fr: "mm",
1330
+ en: "mm",
1331
+ de: "mm",
1332
+ es: "mm",
1333
+ it: "mm",
1334
+ pt: "mm",
1335
+ nl: "mm",
1336
+ },
1337
+ "time.placeholder.week": {
1338
+ fr: "sem.",
1339
+ en: "wk",
1340
+ de: "KW",
1341
+ es: "sem.",
1342
+ it: "sett.",
1343
+ pt: "sem.",
1344
+ nl: "wk",
1345
+ },
1346
+ });
1347
+
1348
+ /**
1349
+ * Pure vanilla JS time formatting utilities, usable outside the browser.
1350
+ *
1351
+ * Exposed as `@jsenv/navi/format_time` so code that has no preact (a backend
1352
+ * writing dates into push notifications, say) shares the exact same wording
1353
+ * as the `<Time>` components. Everything this module imports must stay free
1354
+ * of preact/@preact/signals. `lang` defaults to the runtime language source
1355
+ * (see runtime_lang.js): `languagesSignal` in a browser bundle — live, and
1356
+ * subscribing a rendering component like any signal read — the runtime's own
1357
+ * locale elsewhere.
1358
+ *
1359
+ * All functions accept an optional `{ now }` parameter for testability.
1360
+ */
1361
+
1362
+
1363
+ // Our own compact/custom duration notation interpolates raw numbers
1364
+ // directly (unlike Intl.DurationFormat, which groups thousands on its own,
1365
+ // e.g. "5 400 secondes") — this keeps that consistent without reimplementing
1366
+ // locale-aware grouping. Falls back to the raw value as-is for a
1367
+ // non-numeric mid-edit value (e.g. "2a"), which Intl.NumberFormat can't
1368
+ // format anyway.
1369
+ const formatCompactNumber = (value, lang) => {
1370
+ const n = Number(value);
1371
+ return Number.isFinite(n) ? new Intl.NumberFormat(lang).format(n) : value;
1372
+ };
1373
+
1374
+ /**
1375
+ * Formats a date as a human-readable day string.
1376
+ *
1377
+ * @param {Date} date
1378
+ * @param {{ lang?: string, format?: "long"|"short"|"narrow"|"numeric"|{ weekday?: "long"|"short"|"narrow", month?: "long"|"short"|"narrow"|"numeric" }, year?: boolean|"auto", now?: Date }} [options]
1379
+ * A string spells the weekday and the month the same way. An object spells
1380
+ * them apart, each key defaulting to `"long"`: a narrow card usually wants
1381
+ * the weekday whole (it is the reading anchor) and the month abbreviated (it
1382
+ * is where the characters are — "septembre" is 9 of them, "sept." reads the
1383
+ * same). `"numeric"` stays a string-only spelling: it drops the weekday and
1384
+ * writes the whole date in digits.
1385
+ * @param {boolean|"auto"} [options.year=true]
1386
+ * Whether the `"numeric"` spelling writes the year: `false` drops it
1387
+ * ("30/07", the day/month order still following the locale), `"auto"` drops
1388
+ * it only when the date is in the current year (`now`'s year). The spelled
1389
+ * formats never write the year, so they ignore it.
1390
+ *
1391
+ * @example
1392
+ * formatDay(new Date(), { lang: "fr" }) // "lundi 11 mai" (long, default)
1393
+ * formatDay(new Date(), { lang: "fr", format: "short" }) // "lun. 11 mai"
1394
+ * formatDay(new Date(), { lang: "fr", format: "narrow" }) // "lu. 11 mai"
1395
+ * formatDay(new Date(), { lang: "fr", format: "numeric" }) // "11/05/2026"
1396
+ * formatDay(new Date(), { lang: "fr", format: "numeric", year: false }) // "11/05"
1397
+ * formatDay(new Date(), { lang: "fr", format: { weekday: "long", month: "short" } }) // "mercredi 2 sept."
1398
+ */
1399
+ const formatDay = (
1400
+ date,
1401
+ {
1402
+ lang = getRuntimeLang(),
1403
+ format = "long",
1404
+ year = true,
1405
+ now = new Date(),
1406
+ } = {},
1407
+ ) => {
1408
+ if (format === "numeric") {
1409
+ const yearWritten =
1410
+ year === "auto"
1411
+ ? date.getFullYear() !== now.getFullYear()
1412
+ : year !== false;
1413
+ return new Intl.DateTimeFormat(lang, {
1414
+ day: "2-digit",
1415
+ month: "2-digit",
1416
+ ...(yearWritten ? { year: "numeric" } : {}),
1417
+ }).format(date);
1418
+ }
1419
+ const { weekday = "long", month = "long" } =
1420
+ typeof format === "string" ? { weekday: format, month: format } : format;
1421
+ return new Intl.DateTimeFormat(lang, {
1422
+ weekday, // "long", "short", or "narrow"
1423
+ day: "numeric",
1424
+ month,
1425
+ }).format(date);
1426
+ };
1427
+
1428
+ /**
1429
+ * Returns the day offset relative to now: -1 (yesterday), 0 (today), 1 (tomorrow), or the
1430
+ * actual number of days difference for any other date.
1431
+ */
1432
+ const getRelativeDay = (date, { now = new Date() } = {}) => {
1433
+ const dateKey = toLocalDayKey(date);
1434
+
1435
+ const yesterdayDate = new Date(now);
1436
+ yesterdayDate.setDate(yesterdayDate.getDate() - 1);
1437
+ if (dateKey === toLocalDayKey(yesterdayDate)) {
1438
+ return -1;
1439
+ }
1440
+
1441
+ if (dateKey === toLocalDayKey(now)) {
1442
+ return 0;
1443
+ }
1444
+
1445
+ const tomorrowDate = new Date(now);
1446
+ tomorrowDate.setDate(tomorrowDate.getDate() + 1);
1447
+ if (dateKey === toLocalDayKey(tomorrowDate)) {
1448
+ return 1;
1449
+ }
1450
+
1451
+ const nowMidnight = new Date(now);
1452
+ nowMidnight.setHours(0, 0, 0, 0);
1453
+ const dateMidnight = new Date(date);
1454
+ dateMidnight.setHours(0, 0, 0, 0);
1455
+ return Math.round((dateMidnight - nowMidnight) / DAY);
1456
+ };
1457
+
1458
+ /**
1459
+ * Formats a relative day offset (-1/0/1) as a locale-aware label: "hier", "aujourd'hui", "demain".
1460
+ */
1461
+ // ── Placeholder helpers ────────────────────────────────────────────────────
1462
+ // Derive locale-aware format placeholders from Intl.DateTimeFormat.formatToParts
1463
+ // using a sentinel date whose parts are unambiguous (day=28, month=11, year=9999).
1464
+ // Per-language token tables cover the most common locales; unknown langs fall
1465
+ // back to "dd/mm/yyyy".
1466
+
1467
+ const SENTINEL_DATE = new Date(9999, 10, 28); // 28 Nov 9999 — day≠month, both 2-digit
1468
+
1469
+ const getToken = (key, lang) =>
1470
+ naviI18n(`time.placeholder.${key}`, undefined, { lang });
1471
+
1472
+ const formatDatePlaceholder = ({ lang = getRuntimeLang() } = {}) => {
1473
+ const parts = new Intl.DateTimeFormat(lang, {
1474
+ day: "numeric",
1475
+ month: "numeric",
1476
+ year: "numeric",
1477
+ }).formatToParts(SENTINEL_DATE);
1478
+ return parts
1479
+ .map((p) => {
1480
+ if (p.type === "day") {
1481
+ return getToken("day", lang);
1482
+ }
1483
+ if (p.type === "month") {
1484
+ return getToken("month", lang);
1485
+ }
1486
+ if (p.type === "year") {
1487
+ return getToken("year", lang);
1488
+ }
1489
+ return p.value;
1490
+ })
1491
+ .join("");
1492
+ };
1493
+
1494
+ const formatMonthPlaceholder = ({
1495
+ lang = getRuntimeLang(),
1496
+ format = "long",
1497
+ } = {}) => {
1498
+ const parts = new Intl.DateTimeFormat(lang, {
1499
+ month: format,
1500
+ year: "numeric",
1501
+ }).formatToParts(SENTINEL_DATE);
1502
+ return parts
1503
+ .map((p) => {
1504
+ if (p.type === "month") {
1505
+ // Text month formats (long/short/narrow) → dash; numeric → token
1506
+ return format === "numeric" ? "–" : getToken("month", lang);
1507
+ }
1508
+ if (p.type === "year") {
1509
+ return getToken("year", lang);
1510
+ }
1511
+ return p.value;
1512
+ })
1513
+ .join("");
1514
+ };
1515
+
1516
+ const formatWeekPlaceholder = ({ lang = getRuntimeLang() } = {}) => {
1517
+ return `${getToken("week", lang)} xx / ${getToken(lang)}`;
1518
+ };
1519
+
1520
+ const formatDatetimePlaceholder = ({
1521
+ lang = getRuntimeLang(),
1522
+ format = "long",
1523
+ } = {}) => {
1524
+ const intlOptions =
1525
+ format === "long"
1526
+ ? {
1527
+ weekday: "short",
1528
+ day: "numeric",
1529
+ month: "long",
1530
+ hour: "2-digit",
1531
+ minute: "2-digit",
1532
+ }
1533
+ : format === "narrow"
1534
+ ? {
1535
+ day: "2-digit",
1536
+ month: "2-digit",
1537
+ hour: "2-digit",
1538
+ minute: "2-digit",
1539
+ }
1540
+ : {
1541
+ day: "numeric",
1542
+ month: "short",
1543
+ hour: "2-digit",
1544
+ minute: "2-digit",
1545
+ };
1546
+ const parts = new Intl.DateTimeFormat(lang, intlOptions).formatToParts(
1547
+ SENTINEL_DATE,
1548
+ );
1549
+ let skipNext = false;
1550
+ return parts
1551
+ .map((p) => {
1552
+ if (p.type === "weekday") {
1553
+ skipNext = true;
1554
+ return "";
1555
+ }
1556
+ if (p.type === "literal" && skipNext) {
1557
+ skipNext = false;
1558
+ return "";
1559
+ }
1560
+ skipNext = false;
1561
+ if (p.type === "day") {
1562
+ return getToken("day", lang);
1563
+ }
1564
+ if (p.type === "month") {
1565
+ return getToken("month", lang);
1566
+ }
1567
+ if (p.type === "hour") {
1568
+ return getToken("hour", lang);
1569
+ }
1570
+ if (p.type === "minute") {
1571
+ return getToken("minute", lang);
1572
+ }
1573
+ return p.value;
1574
+ })
1575
+ .join("")
1576
+ .trim();
1577
+ };
1578
+
1579
+ // ── End placeholder helpers ────────────────────────────────────────────────
1580
+
1581
+ const formatDayRelative = (offset, lang) => {
1582
+ const relativeDay = new Intl.RelativeTimeFormat(lang, {
1583
+ numeric: "auto",
1584
+ }).format(offset, "day");
1585
+ return relativeDay;
1586
+ };
1587
+
1588
+ const formatMonth = (
1589
+ date,
1590
+ { lang = getRuntimeLang(), format = "long" } = {},
1591
+ ) => {
1592
+ return new Intl.DateTimeFormat(lang, {
1593
+ month: format, // "long", "short", or "narrow"
1594
+ year: "numeric",
1595
+ }).format(date);
1596
+ };
1597
+
1598
+ /**
1599
+ * Formats a date as "lun. 11 mai, 14:30" (long), "11 mai, 14:30" (short), "11/05, 14:30" (narrow).
1600
+ */
1601
+ const formatDatetime = (
1602
+ date,
1603
+ { lang = getRuntimeLang(), format = "long" } = {},
1604
+ ) => {
1605
+ if (format === "long") {
1606
+ return new Intl.DateTimeFormat(lang, {
1607
+ weekday: "short",
1608
+ day: "numeric",
1609
+ month: "long",
1610
+ hour: "2-digit",
1611
+ minute: "2-digit",
1612
+ }).format(date);
1613
+ }
1614
+ if (format === "narrow") {
1615
+ return new Intl.DateTimeFormat(lang, {
1616
+ day: "2-digit",
1617
+ month: "2-digit",
1618
+ hour: "2-digit",
1619
+ minute: "2-digit",
1620
+ }).format(date);
1621
+ }
1622
+ // "short": no weekday
1623
+ return new Intl.DateTimeFormat(lang, {
1624
+ day: "numeric",
1625
+ month: "short",
1626
+ hour: "2-digit",
1627
+ minute: "2-digit",
1628
+ }).format(date);
1629
+ };
1630
+
1631
+ /**
1632
+ * Formats a date as "14:30".
1633
+ */
1634
+ const formatTime = (date, lang) => {
1635
+ return new Intl.DateTimeFormat(lang, {
1636
+ hour: "2-digit",
1637
+ minute: "2-digit",
1638
+ }).format(date);
1639
+ };
1640
+
1641
+ /**
1642
+ * Formats a time-of-day the way `<Time type="time">` writes it, as a plain
1643
+ * string — for the places a component cannot go (a `title` attribute, a push
1644
+ * notification).
1645
+ *
1646
+ * @param {Date|number|string} value
1647
+ * A Date, a ms timestamp, or an "HH:MM"/"HH:MM:SS" string. Only the clock
1648
+ * time is read. A nullish value renders the "--:--" placeholder; an
1649
+ * unparseable one is returned as-is, stringified.
1650
+ * @param {{ lang?: string, format?: "long"|"short"|"narrow"|"compact"|"timestring", pad?: boolean, precision?: "hour"|"minute" }} [options]
1651
+ * The options `<Time type="time">` takes: `"timestring"` is the clock
1652
+ * "14:30"; the other formats write the time as a duration-shaped phrase —
1653
+ * see {@link formatMinuteDuration}'s `clockStyle` for what `pad` and
1654
+ * `precision` shape in `format="compact"`.
1655
+ *
1656
+ * @example
1657
+ * formatTimeOfDay(date, { lang: "fr" }) // "14 heures 30" (long, default)
1658
+ * formatTimeOfDay(date, { lang: "fr", format: "timestring" }) // "14:30"
1659
+ * formatTimeOfDay(date, { lang: "fr", format: "compact" }) // "14h30"
1660
+ * formatTimeOfDay(date, { lang: "fr", format: "compact", pad: false }) // "8h30", "8h"
1661
+ */
1662
+ const formatTimeOfDay = (
1663
+ value,
1664
+ {
1665
+ lang = getRuntimeLang(),
1666
+ format = "long",
1667
+ pad = true,
1668
+ precision = pad ? "minute" : "hour",
1669
+ } = {},
1670
+ ) => {
1671
+ if (value === undefined || value === null) {
1672
+ return "--:--";
1673
+ }
1674
+ const date = toTimeOfDay(value);
1675
+ // toDate turns a non-finite number into an Invalid Date, which is an object
1676
+ if (!date || isNaN(date.getTime())) {
1677
+ return String(value);
1678
+ }
1679
+ if (format === "timestring") {
1680
+ return formatTime(date, lang);
1681
+ }
1682
+ const totalMinutes = date.getHours() * 60 + date.getMinutes();
1683
+ // clockStyle: this is always a time-of-day here, never a duration — keeps
1684
+ // a zero hour instead of dropping it (midnight would otherwise be
1685
+ // indistinguishable from an actual 5-minute duration), and in
1686
+ // format="compact" also zero-pads a single-digit hour so "5h30"/"0h05"
1687
+ // read as "05h30"/"00h05", closer to a "HH:MM" clock.
1688
+ if (date.getHours() !== 0 || format !== "long") {
1689
+ // At midnight, short/narrow/compact keep the "0 h"/"0h" hour part —
1690
+ // "0 h et 5 min"/"0h 5min"/"00h05" — rather than substituting a
1691
+ // translated "midnight" word, which would look out of place squeezed
1692
+ // into these otherwise terse, symbol-based formats.
1693
+ return formatMinuteDuration(totalMinutes, {
1694
+ lang,
1695
+ format,
1696
+ clockStyle: true,
1697
+ pad,
1698
+ precision,
1699
+ });
1700
+ }
1701
+ // Midnight (hour 0) at format="long" can't go through
1702
+ // formatMinuteDuration's own default zero-hour handling: it drops a
1703
+ // zero-valued unit entirely (by design — a real 5-minute duration should
1704
+ // print as "5 minutes", not "0 hours 5 minutes"), so "00:05" would
1705
+ // otherwise render identically to an actual 5-minute duration, silently
1706
+ // losing the fact that it's midnight. Every other hour keeps at least its
1707
+ // own "N hour(s)" wording as a hint that this is a time-of-day — only
1708
+ // hour 0 loses that hint entirely.
1709
+ const midnightWord = naviI18n("time.midnight", undefined, { lang });
1710
+ if (midnightWord === "time.midnight") {
1711
+ // No "midnight" translation registered for this language — fall back
1712
+ // to this language's own literal "0 heure(s)" wording instead (still
1713
+ // better than leaking the untranslated key, or substituting an
1714
+ // English word that wouldn't grammatically match the rest of the
1715
+ // sentence in whatever language this actually is).
1716
+ return formatMinuteDuration(totalMinutes, {
1717
+ lang,
1718
+ format,
1719
+ clockStyle: true,
1720
+ });
1721
+ }
1722
+ // Swap just the "0 heure(s)" part of the Intl-generated duration
1723
+ // string for the translated "midnight" word, keeping everything else
1724
+ // (the conjunction, the minutes part) exactly as Intl would produce
1725
+ // for this locale — formatToParts tags each token with the unit it
1726
+ // belongs to, so the swap doesn't need to know the locale's own
1727
+ // grammar/word order. Only ever one hour-tagged group per call
1728
+ // (hours is always 0 or absent here), but guarded anyway in case a
1729
+ // future Intl implementation ever splits it into more parts.
1730
+ const parts = new Intl.DurationFormat(lang, {
1731
+ style: "long",
1732
+ hoursDisplay: "always",
1733
+ }).formatToParts({ hours: 0, minutes: date.getMinutes() });
1734
+ let hourGroupReplaced = false;
1735
+ return parts
1736
+ .map((part) => {
1737
+ if (part.unit !== "hour") {
1738
+ return part.value;
1739
+ }
1740
+ if (hourGroupReplaced) {
1741
+ return "";
1742
+ }
1743
+ hourGroupReplaced = true;
1744
+ return midnightWord;
1745
+ })
1746
+ .join("");
1747
+ };
1748
+
1749
+ /**
1750
+ * Formats a span between two times-of-day the way `<TimeRange>` writes it, as
1751
+ * a plain string — "8h–10h", "11h30–14h00", "14 heures 30 – 16 heures".
1752
+ *
1753
+ * Applies `<TimeRange>`'s shared-precision rule: the two bounds are written
1754
+ * to the same precision, decided by the pair — any bound with minutes gives
1755
+ * minutes to both, zero included ("11h30–14h00", never "11h30–14h").
1756
+ *
1757
+ * @param {Date|number|string} from
1758
+ * @param {Date|number|string} to
1759
+ * Each bound accepts what {@link formatTimeOfDay} accepts; a nullish bound
1760
+ * renders its "--:--" placeholder.
1761
+ * @param {{ lang?: string, format?: "long"|"short"|"narrow"|"compact"|"timestring", pad?: boolean, precision?: "hour"|"minute", separator?: string }} [options]
1762
+ * `precision` writes both bounds at this precision instead of the one the
1763
+ * pair calls for. `separator` defaults to the `"time.range_separator"` navi
1764
+ * text (an en dash), tightened against both bounds in `format="compact"` —
1765
+ * where the span is one short token — and spaced out otherwise.
1766
+ *
1767
+ * @example
1768
+ * formatTimeRange("08:00", "10:00", { lang: "fr", format: "compact", pad: false }) // "8h–10h"
1769
+ * formatTimeRange("11:30", "14:00", { lang: "fr", format: "compact", pad: false }) // "11h30–14h00"
1770
+ */
1771
+ const formatTimeRange = (
1772
+ from,
1773
+ to,
1774
+ {
1775
+ lang = getRuntimeLang(),
1776
+ format = "long",
1777
+ pad = true,
1778
+ precision = resolveTimeRangePrecision(from, to, { format, pad }),
1779
+ separator = naviI18n("time.range_separator", undefined, { lang }),
1780
+ } = {},
1781
+ ) => {
1782
+ const boundOptions = { lang, format, pad, precision };
1783
+ const fromText = formatTimeOfDay(from, boundOptions);
1784
+ const toText = formatTimeOfDay(to, boundOptions);
1785
+ if (format === "compact") {
1786
+ return `${fromText}${separator}${toText}`;
1787
+ }
1788
+ return `${fromText} ${separator} ${toText}`;
1789
+ };
1790
+
1791
+ // The two bounds of a span are written to the same precision, decided by the
1792
+ // pair: "8h–10h" as long as neither has minutes, "11h30–14h00" as soon as one
1793
+ // of them does. Only ever a question for the unpadded compact clock — the
1794
+ // padded one always writes "08h00", and the spelled-out formats name their
1795
+ // units, leaving no shape for the eye to trip on.
1796
+ const resolveTimeRangePrecision = (from, to, { format, pad }) => {
1797
+ if (pad || format !== "compact") {
1798
+ return "minute";
1799
+ }
1800
+ const hasMinutes = (value) => {
1801
+ const date = toTimeOfDay(value);
1802
+ return Boolean(date) && !isNaN(date.getTime()) && date.getMinutes() !== 0;
1803
+ };
1804
+ return hasMinutes(from) || hasMinutes(to) ? "minute" : "hour";
1805
+ };
1806
+
1807
+ /**
1808
+ * Formats a duration expressed in minutes as a human-readable string.
1809
+ * "long", "short", "narrow" delegate to Intl.DurationFormat.
1810
+ * "compact" uses our own notation that omits the minute symbol when hours are present.
1811
+ *
1812
+ * @param {number} minutes
1813
+ * @param {{ lang?: string, format?: "long"|"short"|"narrow"|"compact", clockStyle?: boolean, pad?: boolean, precision?: "hour"|"minute", forceUnit?: boolean }} [options]
1814
+ * @param {boolean} [options.forceUnit=false] - Keep the value in minutes
1815
+ * however big it gets ("2160 minutes" instead of "1 jour et 12 heures").
1816
+ * Past 24 hours the default promotes to days, which reads better but hides
1817
+ * the unit the caller works in.
1818
+ * @param {boolean} [options.clockStyle=false] - Set this when `minutes`
1819
+ * represents a time-of-day rather than a real duration (used by
1820
+ * `<Time type="time">`, see time.jsx's own TimeTime). A clock's "0" is a
1821
+ * meaningful hour rather than "no hours": a zero-hours component is
1822
+ * normally dropped entirely (a real 5-minute duration should print as
1823
+ * "5 minutes", not "0 hours 5 minutes"); this keeps it instead (e.g.
1824
+ * "0 h et 5 min"/"0h 5min"/"00h05") so midnight doesn't collapse to
1825
+ * something indistinguishable from an actual 5-minute duration.
1826
+ * Must not be set for plain duration formatting.
1827
+ * @param {boolean} [options.pad=true] - Zero-pad the hour to 2 digits
1828
+ * ("08h30" rather than "8h30"). `clockStyle` + `format: "compact"` only.
1829
+ * @param {"hour"|"minute"} [options.precision="minute"] - Whether a zero
1830
+ * minute is written: `"minute"` keeps it ("10h00"), `"hour"` drops it
1831
+ * ("10h"). `clockStyle` + `format: "compact"` only.
1832
+ *
1833
+ * These last two are the clock's two independent shape choices, and only
1834
+ * `format: "compact"` has to make them — the spelled-out formats put their
1835
+ * units in words, so "10 heures"/"10 h"/"10h" already reads as a time of
1836
+ * day whatever the padding, and they always write the hour bare and drop a
1837
+ * zero minute. Padded + minute ("08h00") is the column shape, where every
1838
+ * row occupies the same width; bare + hour ("8h", "8h30") is the shape a
1839
+ * person speaks. Bare + minute ("8h00") only ever makes sense next to a
1840
+ * partner that has minutes of its own — see `<TimeRange>`, which is the
1841
+ * only thing that asks for it.
1842
+ *
1843
+ * @example
1844
+ * formatMinuteDuration(90, { lang: "fr" }) // "1 heure 30 minutes" (long, default)
1845
+ * formatMinuteDuration(90, { lang: "fr", format: "short" }) // "1 h et 30 min" (Intl short)
1846
+ * formatMinuteDuration(90, { lang: "fr", format: "narrow" }) // "1h 30min" (Intl narrow)
1847
+ * formatMinuteDuration(90, { lang: "fr", format: "compact" }) // "1h30" (custom, no minute symbol)
1848
+ * formatMinuteDuration(45, { lang: "en", format: "compact" }) // "45min"
1849
+ * formatMinuteDuration(5, { lang: "fr", format: "narrow", clockStyle: true }) // "0h 5min"
1850
+ * formatMinuteDuration(330, { lang: "fr", format: "compact", clockStyle: true }) // "05h30"
1851
+ * formatMinuteDuration(600, { lang: "fr", format: "compact", clockStyle: true }) // "10h00"
1852
+ * formatMinuteDuration(600, { lang: "fr", format: "compact", clockStyle: true, pad: false }) // "10h00"
1853
+ * formatMinuteDuration(600, { lang: "fr", format: "compact", clockStyle: true, pad: false, precision: "hour" }) // "10h"
1854
+ * formatMinuteDuration(510, { lang: "fr", format: "compact", clockStyle: true, pad: false, precision: "hour" }) // "8h30"
1855
+ * formatMinuteDuration(2160, { lang: "fr" }) // "1 jour et 12 heures"
1856
+ * formatMinuteDuration(2160, { lang: "fr", forceUnit: true }) // "2 160 minutes"
1857
+ */
1858
+ const formatMinuteDuration = (
1859
+ minutes,
1860
+ {
1861
+ lang = getRuntimeLang(),
1862
+ format = "long",
1863
+ clockStyle = false,
1864
+ pad = true,
1865
+ precision = "minute",
1866
+ forceUnit = false,
1867
+ } = {},
1868
+ ) => {
1869
+ if (minutes < 0) {
1870
+ // the d/h/m split below only holds for a positive value; formatting the
1871
+ // magnitude and putting the sign back is the only reading that works
1872
+ return `-${formatMinuteDuration(-minutes, { lang, format, clockStyle, pad, precision, forceUnit })}`;
1873
+ }
1874
+ if (forceUnit || (minutes === 0 && !clockStyle)) {
1875
+ // a zero has nothing to promote to, and rendering it as an empty string
1876
+ // would be indistinguishable from a missing value
1877
+ return formatSingleUnit(minutes, "minute", { lang, format });
1878
+ }
1879
+ const totalHours = Math.floor(minutes / 60);
1880
+ const m = minutes % 60;
1881
+ // a time of day never goes past 24h, and its hour part is the clock hour
1882
+ const d = clockStyle ? 0 : Math.floor(totalHours / 24);
1883
+ const h = clockStyle ? totalHours : totalHours % 24;
1884
+ if (format !== "compact" && typeof Intl.DurationFormat !== "undefined") {
1885
+ const fmt = new Intl.DurationFormat(lang, {
1886
+ style: format, // "long", "short", or "narrow"
1887
+ ...(clockStyle ? { hoursDisplay: "always" } : {}),
1888
+ });
1889
+ const duration = {};
1890
+ if (d > 0) {
1891
+ duration.days = d;
1892
+ }
1893
+ if (h > 0 || clockStyle || d > 0) {
1894
+ duration.hours = h;
1895
+ }
1896
+ if (m > 0 || (d === 0 && h === 0)) {
1897
+ duration.minutes = m;
1898
+ }
1899
+ return fmt.format(duration);
1900
+ }
1901
+ // format="compact": "1j12h", "1h30", "45min", "2h" — no minute symbol when hours are present
1902
+ const dSym = naviI18n("time.duration.day_symbol", undefined, { lang });
1903
+ const hSym = naviI18n("time.duration.hour_symbol", undefined, { lang });
1904
+ const mSym = naviI18n("time.duration.minute_symbol", undefined, { lang });
1905
+ const dStr = d > 0 ? `${formatCompactNumber(d, lang)}${dSym}` : "";
1906
+ const hStr =
1907
+ clockStyle && pad
1908
+ ? String(h).padStart(2, "0")
1909
+ : formatCompactNumber(h, lang);
1910
+ if (d === 0 && h === 0 && !clockStyle) {
1911
+ return `${m}${mSym}`;
1912
+ }
1913
+ if (m === 0) {
1914
+ if (clockStyle) {
1915
+ // "10h00" on a clock, "2h" for a real 2 hours duration — except at
1916
+ // precision "hour", where a clock drops the zero minute too ("10h"),
1917
+ // the way one says it out loud
1918
+ return precision === "minute" ? `${hStr}${hSym}00` : `${hStr}${hSym}`;
1919
+ }
1920
+ return h === 0 ? dStr : `${dStr}${hStr}${hSym}`;
1921
+ }
1922
+ return `${dStr}${hStr}${hSym}${String(m).padStart(2, "0")}`;
1923
+ };
1924
+
1925
+ // "forceUnit": stay in the unit the value is expressed in, however big it gets
1926
+ const formatSingleUnit = (value, unit, { lang, format }) => {
1927
+ if (format !== "compact" && typeof Intl.DurationFormat !== "undefined") {
1928
+ return new Intl.DurationFormat(lang, {
1929
+ style: format,
1930
+ // Intl drops a zero-valued unit, and "0 minute" is the whole point here
1931
+ [`${unit}sDisplay`]: "always",
1932
+ }).format({
1933
+ [`${unit}s`]: value,
1934
+ });
1935
+ }
1936
+ const symbol = naviI18n(`time.duration.${unit}_symbol`, undefined, { lang });
1937
+ return `${formatCompactNumber(value, lang)}${symbol}`;
1938
+ };
1939
+
1940
+ /**
1941
+ * Formats a duration expressed in hours (possibly fractional) as a human-readable string.
1942
+ * Delegates to {@link formatMinuteDuration} after converting hours to minutes.
1943
+ *
1944
+ * @param {number} hours
1945
+ * @param {{ lang?: string, format?: "long"|"short"|"narrow"|"compact", forceUnit?: boolean }} [options]
1946
+ * @param {boolean} [options.forceUnit=false] - Keep the value in hours however
1947
+ * big it gets ("36 heures" instead of "1 jour et 12 heures"). Ignored for a
1948
+ * fractional value, which has no single-unit spelling.
1949
+ *
1950
+ * @example
1951
+ * formatHourDuration(1.5, { lang: "fr" }) // "1 heure 30 minutes" (long, default)
1952
+ * formatHourDuration(1.5, { lang: "fr", format: "compact" }) // "1h30"
1953
+ * formatHourDuration(2, { lang: "en", format: "compact" }) // "2h"
1954
+ * formatHourDuration(36, { lang: "fr" }) // "1 jour et 12 heures"
1955
+ * formatHourDuration(36, { lang: "fr", forceUnit: true }) // "36 heures"
1956
+ */
1957
+ const formatHourDuration = (hours, options = {}) => {
1958
+ const { lang = getRuntimeLang(), format = "long", forceUnit } = options;
1959
+ if (hours === 0 || (forceUnit && Number.isInteger(hours))) {
1960
+ return formatSingleUnit(hours, "hour", { lang, format });
1961
+ }
1962
+ // a fractional value has no single-unit spelling, it needs its minutes
1963
+ const totalMinutes = Math.round(hours * 60);
1964
+ return formatMinuteDuration(totalMinutes, { ...options, forceUnit: false });
1965
+ };
1966
+
1967
+ /**
1968
+ * Formats a duration expressed in seconds as a human-readable string.
1969
+ * "long", "short", "narrow" delegate to Intl.DurationFormat.
1970
+ * "compact" uses our own symbol-based notation.
1971
+ *
1972
+ * @param {number} seconds
1973
+ * @param {{ lang?: string, format?: "long"|"short"|"narrow"|"compact", forceUnit?: boolean }} [options]
1974
+ * @param {boolean} [options.forceUnit=false] - Keep the value in seconds
1975
+ * however big it gets ("90 000 secondes" instead of "1 jour et 1 heure").
1976
+ *
1977
+ * @example
1978
+ * formatSecondDuration(90, { lang: "fr" }) // "1 minute 30 secondes" (long, default)
1979
+ * formatSecondDuration(90, { lang: "fr", format: "short" }) // "1 min. et 30 s." (Intl short)
1980
+ * formatSecondDuration(90, { lang: "fr", format: "narrow" }) // "1min 30s" (Intl narrow)
1981
+ * formatSecondDuration(90, { lang: "fr", format: "compact" }) // "1m30s" (custom)
1982
+ * formatSecondDuration(45, { lang: "en", format: "compact" }) // "45s"
1983
+ */
1984
+ const formatSecondDuration = (
1985
+ seconds,
1986
+ { lang = getRuntimeLang(), format = "long", forceUnit = false } = {},
1987
+ ) => {
1988
+ if (seconds < 0) {
1989
+ // the d/h/m/s split below only holds for a positive value; formatting the
1990
+ // magnitude and putting the sign back is the only reading that works
1991
+ return `-${formatSecondDuration(-seconds, { lang, format, forceUnit })}`;
1992
+ }
1993
+ if (forceUnit || seconds === 0) {
1994
+ return formatSingleUnit(seconds, "second", { lang, format });
1995
+ }
1996
+ const totalHours = Math.floor(seconds / 3600);
1997
+ const d = Math.floor(totalHours / 24);
1998
+ const h = totalHours % 24;
1999
+ const m = Math.floor((seconds % 3600) / 60);
2000
+ const s = seconds % 60;
2001
+ if (format !== "compact" && typeof Intl.DurationFormat !== "undefined") {
2002
+ const fmt = new Intl.DurationFormat(lang, { style: format });
2003
+ const duration = {};
2004
+ if (d > 0) duration.days = d;
2005
+ if (h > 0) duration.hours = h;
2006
+ if (m > 0) duration.minutes = m;
2007
+ if (s > 0 || (d === 0 && h === 0 && m === 0)) duration.seconds = s;
2008
+ return fmt.format(duration);
2009
+ }
2010
+ // compact: "1d1h30m45s", "1h30m45s", "1m30s", "45s"
2011
+ const dSym = naviI18n("time.duration.day_symbol", undefined, { lang });
2012
+ const hSym = naviI18n("time.duration.hour_symbol", undefined, { lang });
2013
+ const mSym = naviI18n("time.duration.minute_symbol", undefined, { lang });
2014
+ const sSym = naviI18n("time.duration.second_symbol", undefined, { lang });
2015
+ const parts = [];
2016
+ // h/m/s are bounded by construction (never need grouping); d can be
2017
+ // arbitrarily large for a long duration.
2018
+ if (d > 0) parts.push(`${formatCompactNumber(d, lang)}${dSym}`);
2019
+ if (h > 0) parts.push(`${h}${hSym}`);
2020
+ if (m > 0) parts.push(`${m}${mSym}`);
2021
+ if (s > 0 || parts.length === 0) parts.push(`${s}${sSym}`);
2022
+ return parts.join("");
2023
+ };
2024
+
2025
+ /**
2026
+ * Formats a duration object as a human-readable string.
2027
+ * Reads the parts directly — no conversion to seconds — so years/months/days
2028
+ * are preserved as-is and non-numeric mid-edit values (e.g. "2a") are rendered
2029
+ * with their unit symbol rather than being stringified.
2030
+ *
2031
+ * @param {string|number|{ years?: any, months?: any, weeks?: any, days?: any,
2032
+ * hours?: any, minutes?: any, seconds?: any, milliseconds?: any }} duration -
2033
+ * A string goes through {@link parseDuration} ("PT1H30M", "1h30"), a number
2034
+ * is read as seconds. Each unit is written with the value it carries: 90
2035
+ * minutes reads "90 minutes", never "1 heure 30" — the variants that
2036
+ * promote a count into bigger units are {@link formatMinuteDuration},
2037
+ * {@link formatHourDuration} and {@link formatSecondDuration}.
2038
+ * @param {{ lang?: string, format?: "long"|"short"|"narrow"|"compact" }} [options]
2039
+ *
2040
+ * @example
2041
+ * formatDuration({ hours: 2, minutes: 15 }, { lang: "fr" }) // "2 heures 15 minutes" (long, default)
2042
+ * formatDuration({ hours: 2, minutes: 15 }, { lang: "fr", format: "short" }) // "2 h et 15 min" (Intl short)
2043
+ * formatDuration({ hours: 2, minutes: 15 }, { lang: "fr", format: "narrow" }) // "2h 15min" (Intl narrow)
2044
+ * formatDuration({ hours: 2, minutes: 15 }, { lang: "fr", format: "compact" }) // "2h15" (custom, no minute symbol)
2045
+ * formatDuration({ minutes: 45 }, { lang: "fr", format: "compact" }) // "45min"
2046
+ * formatDuration({ hours: 0, minutes: 0 }, { lang: "fr" }) // "0 minute"
2047
+ * formatDuration({ hours: "2a", minutes: "15" }, { lang: "fr", format: "compact" }) // "2ah15"
2048
+ */
2049
+ const formatDuration = (
2050
+ duration,
2051
+ { lang = getRuntimeLang(), format = "long" } = {},
2052
+ ) => {
2053
+ if (typeof duration === "string") {
2054
+ duration = parseDuration(duration) ?? {};
2055
+ } else if (typeof duration === "number") {
2056
+ duration = { seconds: duration };
2057
+ }
2058
+ const has = (key) => duration[key] !== undefined && duration[key] !== null;
2059
+
2060
+ // "long" and "narrow" delegate to Intl.DurationFormat when available and all values are numeric.
2061
+ //
2062
+ // "short" always uses our own compact symbols ("2h15", "45min") because:
2063
+ // 1. We omit the minute symbol when hours are also present ("2h15" not "2h 15 min"),
2064
+ // which Intl.DurationFormat style:"narrow" does not do.
2065
+ // 2. Non-numeric mid-edit values (e.g. { hours: "2a" }) must render as-is with their
2066
+ // unit symbol — Intl.DurationFormat only accepts integers.
2067
+ if (format !== "compact" && typeof Intl.DurationFormat !== "undefined") {
2068
+ const intlDuration = {};
2069
+ let allNumeric = true;
2070
+ let hasNegative = false;
2071
+ let hasPositive = false;
2072
+ for (const key of [
2073
+ "years",
2074
+ "months",
2075
+ "weeks",
2076
+ "days",
2077
+ "hours",
2078
+ "minutes",
2079
+ "seconds",
2080
+ "milliseconds",
2081
+ ]) {
2082
+ if (!has(key)) {
2083
+ continue;
2084
+ }
2085
+ const n = Number(duration[key]);
2086
+ if (!isFinite(n)) {
2087
+ allNumeric = false;
2088
+ break;
2089
+ }
2090
+ if (n < 0) {
2091
+ hasNegative = true;
2092
+ } else if (n > 0) {
2093
+ hasPositive = true;
2094
+ }
2095
+ intlDuration[key] = n;
2096
+ }
2097
+ // Temporal requires all components to share the same sign.
2098
+ // Mixed-sign values (e.g. { hours: -1, minutes: 15 }) throw a RangeError.
2099
+ if (
2100
+ allNumeric &&
2101
+ Object.keys(intlDuration).length > 0 &&
2102
+ !(hasNegative && hasPositive)
2103
+ ) {
2104
+ if (!hasNegative && !hasPositive) {
2105
+ return formatSingleUnit(0, smallestUnitOf(intlDuration), {
2106
+ lang,
2107
+ format,
2108
+ });
2109
+ }
2110
+ return new Intl.DurationFormat(lang, { style: format }).format(
2111
+ intlDuration,
2112
+ );
2113
+ }
2114
+ // Fall through to compact notation when values are non-numeric or mixed-sign
2115
+ }
2116
+
2117
+ // A component explicitly present but numerically zero (e.g. the demo's own
2118
+ // { hours: 0, minutes: 5 }) conveys no information for a genuine duration
2119
+ // — same convention formatMinuteDuration/formatSecondDuration already
2120
+ // follow (checking h > 0/m > 0, not merely "was a value passed") — so
2121
+ // it's dropped here too, regardless of whether the caller included the
2122
+ // key at all. Non-numeric mid-edit values (e.g. "2a") still count as
2123
+ // present — Number("2a") is NaN, never === 0 — so those keep rendering
2124
+ // as-is with their own unit symbol. When every component is zero there is
2125
+ // nothing left to drop, so the zero itself is rendered — see below.
2126
+ const hasNonZero = (key) => has(key) && Number(duration[key]) !== 0;
2127
+
2128
+ const sym = (key) =>
2129
+ naviI18n(`time.duration.${key}_symbol`, undefined, { lang });
2130
+ const parts = [];
2131
+
2132
+ if (hasNonZero("years")) {
2133
+ parts.push(`${formatCompactNumber(duration.years, lang)}${sym("year")}`);
2134
+ }
2135
+ if (hasNonZero("months")) {
2136
+ parts.push(`${formatCompactNumber(duration.months, lang)}${sym("month")}`);
2137
+ }
2138
+ if (hasNonZero("weeks")) {
2139
+ parts.push(`${formatCompactNumber(duration.weeks, lang)}${sym("week")}`);
2140
+ }
2141
+ if (hasNonZero("days")) {
2142
+ parts.push(`${formatCompactNumber(duration.days, lang)}${sym("day")}`);
2143
+ }
2144
+
2145
+ // Hours + minutes: when both present, pad minutes to 2 digits after the h
2146
+ // symbol — minutes stays a plain 2-digit pad (it's always 0-59 by
2147
+ // convention), only hours goes through grouping.
2148
+ const hSym = sym("hour");
2149
+ const mSym = sym("minute");
2150
+ if (hasNonZero("hours") && hasNonZero("minutes")) {
2151
+ parts.push(
2152
+ `${formatCompactNumber(duration.hours, lang)}${hSym}${String(duration.minutes).padStart(2, "0")}`,
2153
+ );
2154
+ } else if (hasNonZero("hours")) {
2155
+ parts.push(`${formatCompactNumber(duration.hours, lang)}${hSym}`);
2156
+ } else if (hasNonZero("minutes")) {
2157
+ parts.push(`${formatCompactNumber(duration.minutes, lang)}${mSym}`);
2158
+ }
2159
+
2160
+ if (hasNonZero("seconds")) {
2161
+ parts.push(
2162
+ `${formatCompactNumber(duration.seconds, lang)}${sym("second")}`,
2163
+ );
2164
+ }
2165
+ if (hasNonZero("milliseconds")) {
2166
+ parts.push(
2167
+ `${formatCompactNumber(duration.milliseconds, lang)}${sym("millisecond")}`,
2168
+ );
2169
+ }
2170
+ if (parts.length > 0) {
2171
+ return parts.join("");
2172
+ }
2173
+ // everything was zero: say so in the smallest unit the caller mentioned,
2174
+ // rather than a bare "0" whose unit the reader has to guess
2175
+ const smallestUnit = smallestUnitOf(duration);
2176
+ return smallestUnit ? `0${sym(smallestUnit)}` : "0";
2177
+ };
2178
+
2179
+ const UNIT_KEYS = [
2180
+ "years",
2181
+ "months",
2182
+ "weeks",
2183
+ "days",
2184
+ "hours",
2185
+ "minutes",
2186
+ "seconds",
2187
+ "milliseconds",
2188
+ ];
2189
+ const smallestUnitOf = (duration) => {
2190
+ for (const key of [...UNIT_KEYS].reverse()) {
2191
+ if (duration[key] !== undefined && duration[key] !== null) {
2192
+ return key.slice(0, -1); // "seconds" -> "second"
2193
+ }
2194
+ }
2195
+ return null;
2196
+ };
2197
+
2198
+ /**
2199
+ * Formats a date relative to now: "il y a 3 jours", "dans 2 heures", etc.
2200
+ */
2201
+ const formatTimeAgo = (
2202
+ date,
2203
+ { lang = getRuntimeLang(), now = new Date(), bare, format = "long" } = {},
2204
+ ) => {
2205
+ const rtf = new Intl.RelativeTimeFormat(lang, {
2206
+ numeric: "auto",
2207
+ style: format,
2208
+ });
2209
+ const nowMs = now instanceof Date ? now.getTime() : now;
2210
+ const diff = date.getTime() - nowMs;
2211
+ const absDiff = Math.abs(diff);
2212
+
2213
+ let value;
2214
+ let unit;
2215
+ if (absDiff < MINUTE) {
2216
+ value = Math.round(diff / 1000);
2217
+ unit = "second";
2218
+ } else if (absDiff < HOUR) {
2219
+ value = Math.round(diff / MINUTE);
2220
+ unit = "minute";
2221
+ } else if (absDiff < DAY) {
2222
+ value = Math.round(diff / HOUR);
2223
+ unit = "hour";
2224
+ } else if (absDiff < 7 * DAY) {
2225
+ value = Math.round(diff / DAY);
2226
+ unit = "day";
2227
+ } else if (absDiff < 30 * DAY) {
2228
+ value = Math.round(diff / (7 * DAY));
2229
+ unit = "week";
2230
+ } else if (absDiff < YEAR) {
2231
+ value = Math.round(diff / (30 * DAY));
2232
+ unit = "month";
2233
+ } else {
2234
+ value = Math.round(diff / YEAR);
2235
+ unit = "year";
2236
+ }
2237
+
2238
+ if (!bare || value >= 0) {
2239
+ return rtf.format(value, unit);
2240
+ }
2241
+ // Drop the leading past-tense literal ("il y a ", "ago ") — keep only integer + unit.
2242
+ const parts = rtf.formatToParts(value, unit);
2243
+ const integerIndex = parts.findIndex((p) => p.type === "integer");
2244
+ return parts
2245
+ .slice(integerIndex)
2246
+ .map((p) => p.value)
2247
+ .join("")
2248
+ .trim();
2249
+ };
2250
+
2251
+ /**
2252
+ * Formats a timed event with an optional duration window.
2253
+ *
2254
+ * States:
2255
+ * - Future (now < start) → "dans 1 heure 30", "demain à 15h", …
2256
+ * - Ongoing (start ≤ now < start+dur) → "En cours"
2257
+ * - Past (now ≥ start+dur) → relative ("il y a 2 heures", …)
2258
+ *
2259
+ * @param {Date|number} start Start of the event (Date or ms timestamp)
2260
+ * @param {number} durationMs Duration in milliseconds (0 = instant event)
2261
+ * @param {{ lang?: string, now?: Date|number, bare?: boolean, format?: "long"|"short"|"narrow" }} options
2262
+ *
2263
+ * @example
2264
+ * // 90 min from now
2265
+ * formatTimeRelative(Date.now() + 90 * 60_000, 0, { lang: "fr" }) // "dans 1 heure 30"
2266
+ * // currently happening (30 min window)
2267
+ * formatTimeRelative(Date.now() - 5 * 60_000, 30 * 60_000, { lang: "fr" }) // "En cours"
2268
+ * // ended 2 hours ago
2269
+ * formatTimeRelative(Date.now() - 3 * 3_600_000, 3_600_000, { lang: "fr" }) // "il y a 2 heures"
2270
+ * // short format
2271
+ * formatTimeRelative(Date.now() - 3 * 3_600_000, 0, { lang: "fr", format: "short" }) // "il y a 3 h"
2272
+ */
2273
+ const formatTimeRelative = (
2274
+ start,
2275
+ durationMs = 0,
2276
+ { lang = getRuntimeLang(), now = new Date(), bare, format = "long" } = {},
2277
+ ) => {
2278
+ const startMs = start instanceof Date ? start.getTime() : Number(start);
2279
+ const endMs = startMs + durationMs;
2280
+ const nowMs = now instanceof Date ? now.getTime() : Number(now);
2281
+
2282
+ if (nowMs >= startMs && nowMs < endMs) {
2283
+ return getOngoingText(lang);
2284
+ }
2285
+ if (nowMs >= endMs) {
2286
+ const refDate = endMs > startMs ? new Date(endMs) : new Date(startMs);
2287
+ return formatTimeAgo(refDate, { lang, now, bare, format });
2288
+ }
2289
+
2290
+ const diff = startMs - nowMs;
2291
+ return formatFuture(new Date(startMs), diff, { lang, now, format });
2292
+ };
2293
+
2294
+ const formatFuture = (date, diff, { lang, now, format = "long" }) => {
2295
+ const rtf = new Intl.RelativeTimeFormat(lang, {
2296
+ numeric: "auto",
2297
+ style: format,
2298
+ });
2299
+ const nowDate = now instanceof Date ? now : new Date(now);
2300
+
2301
+ // < 1 min
2302
+ if (diff < MINUTE) {
2303
+ return getLessThanMinuteText(lang);
2304
+ }
2305
+
2306
+ // < 1 hour → "dans X minutes"
2307
+ if (diff < HOUR) {
2308
+ return rtf.format(Math.ceil(diff / MINUTE), "minute");
2309
+ }
2310
+
2311
+ // 1h to 2h → "dans 1 heure 30"
2312
+ if (diff < 2 * HOUR) {
2313
+ const hours = Math.floor(diff / HOUR);
2314
+ const minutes = Math.round((diff % HOUR) / MINUTE);
2315
+ if (minutes === 0) {
2316
+ return rtf.format(hours, "hour");
2317
+ }
2318
+ const duration = formatMinuteDuration(hours * 60 + minutes, {
2319
+ lang,
2320
+ format,
2321
+ });
2322
+ const template = naviI18n("time.in_duration", undefined, { lang });
2323
+ if (template !== "time.in_duration") {
2324
+ return template.replace("[duration]", duration);
2325
+ }
2326
+ return `in ${duration}`;
2327
+ }
2328
+
2329
+ // < 6h → "dans X heures" (precise enough, skip tomorrow label)
2330
+ if (diff < 6 * HOUR) {
2331
+ return rtf.format(Math.round(diff / HOUR), "hour");
2332
+ }
2333
+
2334
+ // Tomorrow (calendar day) and within ~30h → "demain à 15h"
2335
+ const tomorrowDate = new Date(nowDate);
2336
+ tomorrowDate.setDate(tomorrowDate.getDate() + 1);
2337
+ if (diff < 30 * HOUR && toLocalDayKey(date) === toLocalDayKey(tomorrowDate)) {
2338
+ return formatTomorrowAt(date, lang);
2339
+ }
2340
+
2341
+ // < 24h → "dans X heures"
2342
+ if (diff < DAY) {
2343
+ return rtf.format(Math.round(diff / HOUR), "hour");
2344
+ }
2345
+
2346
+ // < 7 days → "dans X jours"
2347
+ if (diff < 7 * DAY) {
2348
+ return rtf.format(Math.round(diff / DAY), "day");
2349
+ }
2350
+
2351
+ // < 30 days → "dans X semaines"
2352
+ if (diff < 30 * DAY) {
2353
+ return rtf.format(Math.round(diff / (7 * DAY)), "week");
2354
+ }
2355
+
2356
+ // months (Intl handles "le mois prochain" when value = 1)
2357
+ if (diff < YEAR) {
2358
+ return rtf.format(Math.round(diff / (30 * DAY)), "month");
2359
+ }
2360
+
2361
+ return rtf.format(Math.round(diff / YEAR), "year");
2362
+ };
2363
+
2364
+ const formatTomorrowAt = (date, lang) => {
2365
+ const dayLabel = new Intl.RelativeTimeFormat(lang, {
2366
+ numeric: "auto",
2367
+ }).format(1, "day");
2368
+ const hasMinutes = date.getMinutes() !== 0;
2369
+ const timeLabel = new Intl.DateTimeFormat(lang, {
2370
+ hour: "numeric",
2371
+ ...(hasMinutes ? { minute: "2-digit" } : {}),
2372
+ }).format(date);
2373
+ const atTemplate = naviI18n("time.tomorrow_at", undefined, {
2374
+ lang,
2375
+ });
2376
+ // atTemplate is e.g. "[day] à [time]" — replace placeholders
2377
+ if (atTemplate !== "time.tomorrow_at") {
2378
+ return atTemplate.replace("[day]", dayLabel).replace("[time]", timeLabel);
2379
+ }
2380
+ // fallback: concatenate with a space
2381
+ return `${dayLabel} ${timeLabel}`;
2382
+ };
2383
+
2384
+ const getLessThanMinuteText = (lang) => {
2385
+ return naviI18n("time.less_than_minute", undefined, { lang });
2386
+ };
2387
+
2388
+ const getOngoingText = (lang) => {
2389
+ return naviI18n("time.ongoing", undefined, { lang });
2390
+ };
2391
+
2392
+ const MINUTE = 60_000;
2393
+ const HOUR = 60 * MINUTE;
2394
+ const DAY = 24 * HOUR;
2395
+ const YEAR = 365 * DAY;
2396
+
2397
+ // Compares calendar days in local time (ignores the clock time)
2398
+ const toLocalDayKey = (date) => {
2399
+ return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
2400
+ };
2401
+
2402
+ /**
2403
+ * Coerces what `<Time>` accepts as a value — a Date, a ms timestamp, a
2404
+ * parseable string — into a Date, or null when it cannot. `parseString`
2405
+ * lets a caller claim the string forms it recognizes ("HH:MM" for a
2406
+ * time-of-day, "YYYY-MM" for a month…) before the generic ones apply.
2407
+ */
2408
+ const toDate = (value, parseString) => {
2409
+ if (value instanceof Date) {
2410
+ return value;
2411
+ }
2412
+ if (typeof value === "number") {
2413
+ return new Date(value);
2414
+ }
2415
+ if (typeof value === "string") {
2416
+ if (parseString) {
2417
+ return parseString(value);
2418
+ }
2419
+ // "YYYY-MM-DD" — use local midnight to avoid UTC shift
2420
+ if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
2421
+ const d = new Date(`${value}T00:00:00`);
2422
+ return isNaN(d.getTime()) ? null : d;
2423
+ }
2424
+ // ISO / other parseable strings
2425
+ const d = new Date(value);
2426
+ return isNaN(d.getTime()) ? null : d;
2427
+ }
2428
+ return null;
2429
+ };
2430
+
2431
+ const toTimeOfDay = (value) => {
2432
+ return toDate(value, (string) => {
2433
+ if (/^\d{2}:\d{2}(?::\d{2})?$/.test(string)) {
2434
+ const d = new Date(`1970-01-01T${string}`);
2435
+ return isNaN(d.getTime()) ? null : d;
2436
+ }
2437
+ return null;
2438
+ });
2439
+ };
2440
+
2441
+ export { formatDatePlaceholder, formatDatetime, formatDatetimePlaceholder, formatDay, formatDayRelative, formatDuration, formatHourDuration, formatMinuteDuration, formatMonth, formatMonthPlaceholder, formatSecondDuration, formatTime, formatTimeOfDay, formatTimeRange, formatTimeRelative, formatWeekPlaceholder, getRelativeDay, resolveTimeRangePrecision, toDate, toTimeOfDay };
2442
+ //# sourceMappingURL=jsenv_navi_format_time.js.map