@jsenv/navi 0.29.188 → 0.29.190

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,2556 +0,0 @@
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
- // Constructing an Intl formatter dominates the cost of a call (~19µs vs
1364
- // ~0.4µs to format with a kept instance, node 26 on an M-series Mac) and
1365
- // these formatters run in render loops — a card easily writes half a dozen
1366
- // per render — so every instance is memoized by (constructor, lang,
1367
- // options). lang and each option value come from small closed sets, so the
1368
- // cache stays bounded.
1369
- const intlCache = new Map();
1370
- const memoIntl = (constructorName, lang, options) => {
1371
- let key = `${constructorName}|${Array.isArray(lang) ? lang.join() : lang}`;
1372
- if (options) {
1373
- for (const optionName of Object.keys(options)) {
1374
- key += `|${optionName}:${options[optionName]}`;
1375
- }
1376
- }
1377
- const cached = intlCache.get(key);
1378
- if (cached) {
1379
- return cached;
1380
- }
1381
- const formatter = new Intl[constructorName](lang, options);
1382
- intlCache.set(key, formatter);
1383
- return formatter;
1384
- };
1385
-
1386
- // Our own compact/custom duration notation interpolates raw numbers
1387
- // directly (unlike Intl.DurationFormat, which groups thousands on its own,
1388
- // e.g. "5 400 secondes") — this keeps that consistent without reimplementing
1389
- // locale-aware grouping. Falls back to the raw value as-is for a
1390
- // non-numeric mid-edit value (e.g. "2a"), which Intl.NumberFormat can't
1391
- // format anyway.
1392
- const formatCompactNumber = (value, lang) => {
1393
- const n = Number(value);
1394
- return Number.isFinite(n) ? memoIntl("NumberFormat", lang).format(n) : value;
1395
- };
1396
-
1397
- /**
1398
- * Formats a date as a human-readable day string.
1399
- *
1400
- * @param {Date} date
1401
- * @param {{ lang?: string, format?: "long"|"short"|"narrow"|"numeric"|{ weekday?: "long"|"short"|"narrow"|false, day?: boolean, month?: "long"|"short"|"narrow"|"numeric"|false }, year?: boolean|"auto", now?: Date, timeZone?: string }} [options]
1402
- * A string spells the weekday and the month the same way. An object spells
1403
- * them apart, each key defaulting to `"long"`: a narrow card usually wants
1404
- * the weekday whole (it is the reading anchor) and the month abbreviated (it
1405
- * is where the characters are — "septembre" is 9 of them, "sept." reads the
1406
- * same). `"numeric"` stays a string-only spelling: it drops the weekday and
1407
- * writes the whole date in digits.
1408
- *
1409
- * In the object form, `false` drops a part: `{ day: false, month: false }`
1410
- * writes the weekday alone ("mardi"), `{ month: false }` the weekday and
1411
- * day-of-month ("mardi 18"), `{ weekday: false }` the date without its
1412
- * anchor ("18 juillet"). At least one part must stay — with all three
1413
- * dropped, Intl falls back to its own default date spelling.
1414
- * @param {boolean|"auto"} [options.year=true]
1415
- * Whether the `"numeric"` spelling writes the year: `false` drops it
1416
- * ("30/07", the day/month order still following the locale), `"auto"` drops
1417
- * it only when the date is in the current year (`now`'s year). The spelled
1418
- * formats never write the year, so they ignore it.
1419
- * @param {string} [options.timeZone]
1420
- * IANA zone the instant is worded in ("Europe/Paris"); defaults to the
1421
- * runtime's own zone. The case is a server wording an instant for readers
1422
- * in a known zone — a game at 00:30 Paris must not be dated the previous
1423
- * day just because the process clock runs on UTC. `year: "auto"` reads both
1424
- * years in that zone too.
1425
- *
1426
- * @example
1427
- * formatDay(new Date(), { lang: "fr" }) // "lundi 11 mai" (long, default)
1428
- * formatDay(new Date(), { lang: "fr", format: "short" }) // "lun. 11 mai"
1429
- * formatDay(new Date(), { lang: "fr", format: "narrow" }) // "lu. 11 mai"
1430
- * formatDay(new Date(), { lang: "fr", format: "numeric" }) // "11/05/2026"
1431
- * formatDay(new Date(), { lang: "fr", format: "numeric", year: false }) // "11/05"
1432
- * formatDay(new Date(), { lang: "fr", format: { weekday: "long", month: "short" } }) // "mercredi 2 sept."
1433
- * formatDay(new Date(), { lang: "fr", format: { day: false, month: false } }) // "mercredi"
1434
- * formatDay(new Date(), { lang: "fr", format: { month: false } }) // "mercredi 2"
1435
- */
1436
- const formatDay = (
1437
- date,
1438
- {
1439
- lang = getRuntimeLang(),
1440
- format = "long",
1441
- year = true,
1442
- now = new Date(),
1443
- timeZone,
1444
- } = {},
1445
- ) => {
1446
- if (format === "numeric") {
1447
- const yearWritten =
1448
- year === "auto"
1449
- ? readYear(date, timeZone) !== readYear(now, timeZone)
1450
- : year !== false;
1451
- return memoIntl("DateTimeFormat", lang, {
1452
- day: "2-digit",
1453
- month: "2-digit",
1454
- ...(yearWritten ? { year: "numeric" } : {}),
1455
- timeZone,
1456
- }).format(date);
1457
- }
1458
- const {
1459
- weekday = "long",
1460
- day = true,
1461
- month = "long",
1462
- } = typeof format === "string" ? { weekday: format, month: format } : format;
1463
- // a `false` part is omitted, not passed: Intl rejects false as a value
1464
- return memoIntl("DateTimeFormat", lang, {
1465
- ...(weekday === false ? {} : { weekday }),
1466
- ...(day === false ? {} : { day: "numeric" }),
1467
- ...(month === false ? {} : { month }),
1468
- timeZone,
1469
- }).format(date);
1470
- };
1471
-
1472
- /**
1473
- * Returns the day offset relative to now: -1 (yesterday), 0 (today), 1 (tomorrow), or the
1474
- * actual number of days difference for any other date.
1475
- */
1476
- const getRelativeDay = (date, { now = new Date() } = {}) => {
1477
- const dateKey = toLocalDayKey(date);
1478
-
1479
- const yesterdayDate = new Date(now);
1480
- yesterdayDate.setDate(yesterdayDate.getDate() - 1);
1481
- if (dateKey === toLocalDayKey(yesterdayDate)) {
1482
- return -1;
1483
- }
1484
-
1485
- if (dateKey === toLocalDayKey(now)) {
1486
- return 0;
1487
- }
1488
-
1489
- const tomorrowDate = new Date(now);
1490
- tomorrowDate.setDate(tomorrowDate.getDate() + 1);
1491
- if (dateKey === toLocalDayKey(tomorrowDate)) {
1492
- return 1;
1493
- }
1494
-
1495
- const nowMidnight = new Date(now);
1496
- nowMidnight.setHours(0, 0, 0, 0);
1497
- const dateMidnight = new Date(date);
1498
- dateMidnight.setHours(0, 0, 0, 0);
1499
- return Math.round((dateMidnight - nowMidnight) / DAY);
1500
- };
1501
-
1502
- /**
1503
- * Formats a relative day offset (-1/0/1) as a locale-aware label: "hier", "aujourd'hui", "demain".
1504
- */
1505
- // ── Placeholder helpers ────────────────────────────────────────────────────
1506
- // Derive locale-aware format placeholders from Intl.DateTimeFormat.formatToParts
1507
- // using a sentinel date whose parts are unambiguous (day=28, month=11, year=9999).
1508
- // Per-language token tables cover the most common locales; unknown langs fall
1509
- // back to "dd/mm/yyyy".
1510
-
1511
- const SENTINEL_DATE = new Date(9999, 10, 28); // 28 Nov 9999 — day≠month, both 2-digit
1512
-
1513
- const getToken = (key, lang) =>
1514
- naviI18n(`time.placeholder.${key}`, undefined, { lang });
1515
-
1516
- const formatDatePlaceholder = ({ lang = getRuntimeLang() } = {}) => {
1517
- const parts = memoIntl("DateTimeFormat", lang, {
1518
- day: "numeric",
1519
- month: "numeric",
1520
- year: "numeric",
1521
- }).formatToParts(SENTINEL_DATE);
1522
- return parts
1523
- .map((p) => {
1524
- if (p.type === "day") {
1525
- return getToken("day", lang);
1526
- }
1527
- if (p.type === "month") {
1528
- return getToken("month", lang);
1529
- }
1530
- if (p.type === "year") {
1531
- return getToken("year", lang);
1532
- }
1533
- return p.value;
1534
- })
1535
- .join("");
1536
- };
1537
-
1538
- const formatMonthPlaceholder = ({
1539
- lang = getRuntimeLang(),
1540
- format = "long",
1541
- } = {}) => {
1542
- const parts = memoIntl("DateTimeFormat", lang, {
1543
- month: format,
1544
- year: "numeric",
1545
- }).formatToParts(SENTINEL_DATE);
1546
- return parts
1547
- .map((p) => {
1548
- if (p.type === "month") {
1549
- // Text month formats (long/short/narrow) → dash; numeric → token
1550
- return format === "numeric" ? "–" : getToken("month", lang);
1551
- }
1552
- if (p.type === "year") {
1553
- return getToken("year", lang);
1554
- }
1555
- return p.value;
1556
- })
1557
- .join("");
1558
- };
1559
-
1560
- const formatWeekPlaceholder = ({ lang = getRuntimeLang() } = {}) => {
1561
- return `${getToken("week", lang)} xx / ${getToken(lang)}`;
1562
- };
1563
-
1564
- const formatDatetimePlaceholder = ({
1565
- lang = getRuntimeLang(),
1566
- format = "long",
1567
- } = {}) => {
1568
- const intlOptions =
1569
- format === "long"
1570
- ? {
1571
- weekday: "short",
1572
- day: "numeric",
1573
- month: "long",
1574
- hour: "2-digit",
1575
- minute: "2-digit",
1576
- }
1577
- : format === "narrow"
1578
- ? {
1579
- day: "2-digit",
1580
- month: "2-digit",
1581
- hour: "2-digit",
1582
- minute: "2-digit",
1583
- }
1584
- : {
1585
- day: "numeric",
1586
- month: "short",
1587
- hour: "2-digit",
1588
- minute: "2-digit",
1589
- };
1590
- const parts = memoIntl("DateTimeFormat", lang, intlOptions).formatToParts(
1591
- SENTINEL_DATE,
1592
- );
1593
- let skipNext = false;
1594
- return parts
1595
- .map((p) => {
1596
- if (p.type === "weekday") {
1597
- skipNext = true;
1598
- return "";
1599
- }
1600
- if (p.type === "literal" && skipNext) {
1601
- skipNext = false;
1602
- return "";
1603
- }
1604
- skipNext = false;
1605
- if (p.type === "day") {
1606
- return getToken("day", lang);
1607
- }
1608
- if (p.type === "month") {
1609
- return getToken("month", lang);
1610
- }
1611
- if (p.type === "hour") {
1612
- return getToken("hour", lang);
1613
- }
1614
- if (p.type === "minute") {
1615
- return getToken("minute", lang);
1616
- }
1617
- return p.value;
1618
- })
1619
- .join("")
1620
- .trim();
1621
- };
1622
-
1623
- // ── End placeholder helpers ────────────────────────────────────────────────
1624
-
1625
- const formatDayRelative = (offset, { lang = getRuntimeLang() } = {}) => {
1626
- return memoIntl("RelativeTimeFormat", lang, {
1627
- numeric: "auto",
1628
- }).format(offset, "day");
1629
- };
1630
-
1631
- const formatMonth = (
1632
- date,
1633
- { lang = getRuntimeLang(), format = "long", timeZone } = {},
1634
- ) => {
1635
- return memoIntl("DateTimeFormat", lang, {
1636
- month: format, // "long", "short", or "narrow"
1637
- year: "numeric",
1638
- timeZone,
1639
- }).format(date);
1640
- };
1641
-
1642
- /**
1643
- * Formats a date as "lun. 11 mai, 14:30" (long), "11 mai, 14:30" (short), "11/05, 14:30" (narrow).
1644
- * `timeZone` words the instant in that IANA zone instead of the runtime's own.
1645
- */
1646
- const formatDatetime = (
1647
- date,
1648
- { lang = getRuntimeLang(), format = "long", timeZone } = {},
1649
- ) => {
1650
- if (format === "long") {
1651
- return memoIntl("DateTimeFormat", lang, {
1652
- weekday: "short",
1653
- day: "numeric",
1654
- month: "long",
1655
- hour: "2-digit",
1656
- minute: "2-digit",
1657
- timeZone,
1658
- }).format(date);
1659
- }
1660
- if (format === "narrow") {
1661
- return memoIntl("DateTimeFormat", lang, {
1662
- day: "2-digit",
1663
- month: "2-digit",
1664
- hour: "2-digit",
1665
- minute: "2-digit",
1666
- timeZone,
1667
- }).format(date);
1668
- }
1669
- // "short": no weekday
1670
- return memoIntl("DateTimeFormat", lang, {
1671
- day: "numeric",
1672
- month: "short",
1673
- hour: "2-digit",
1674
- minute: "2-digit",
1675
- timeZone,
1676
- }).format(date);
1677
- };
1678
-
1679
- /**
1680
- * Formats a date as "14:30".
1681
- * `timeZone` words the instant in that IANA zone instead of the runtime's own.
1682
- */
1683
- const formatTime = (
1684
- date,
1685
- { lang = getRuntimeLang(), timeZone } = {},
1686
- ) => {
1687
- return memoIntl("DateTimeFormat", lang, {
1688
- hour: "2-digit",
1689
- minute: "2-digit",
1690
- timeZone,
1691
- }).format(date);
1692
- };
1693
-
1694
- /**
1695
- * Formats a time-of-day the way `<Time type="time">` writes it, as a plain
1696
- * string — for the places a component cannot go (a `title` attribute, a push
1697
- * notification).
1698
- *
1699
- * @param {Date|number|string} value
1700
- * A Date, a ms timestamp, or an "HH:MM"/"HH:MM:SS" string. Only the clock
1701
- * time is read. A nullish value renders the "--:--" placeholder; an
1702
- * unparseable one is returned as-is, stringified.
1703
- * @param {{ lang?: string, format?: "long"|"short"|"narrow"|"compact"|"timestring", pad?: boolean, precision?: "hour"|"minute", timeZone?: string }} [options]
1704
- * The options `<Time type="time">` takes: `"timestring"` is the clock
1705
- * "14:30"; the other formats write the time as a duration-shaped phrase —
1706
- * see {@link formatMinuteDuration}'s `clockStyle` for what `pad` and
1707
- * `precision` shape in `format="compact"`.
1708
- * @param {string} [options.timeZone]
1709
- * IANA zone the instant's clock is read in ("Europe/Paris"); defaults to
1710
- * the runtime's own zone. Only applies to a Date/timestamp — an
1711
- * "HH:MM" string is already a wall-clock reading, there is nothing to
1712
- * move to another zone, so it ignores this.
1713
- *
1714
- * @example
1715
- * formatTimeOfDay(date, { lang: "fr" }) // "14 heures 30" (long, default)
1716
- * formatTimeOfDay(date, { lang: "fr", format: "timestring" }) // "14:30"
1717
- * formatTimeOfDay(date, { lang: "fr", format: "compact" }) // "14h30"
1718
- * formatTimeOfDay(date, { lang: "fr", format: "compact", pad: false }) // "8h30", "8h"
1719
- */
1720
- const formatTimeOfDay = (
1721
- value,
1722
- {
1723
- lang = getRuntimeLang(),
1724
- format = "long",
1725
- pad = true,
1726
- precision = pad ? "minute" : "hour",
1727
- timeZone,
1728
- } = {},
1729
- ) => {
1730
- if (value === undefined || value === null) {
1731
- return "--:--";
1732
- }
1733
- const date = toTimeOfDay(value);
1734
- // toDate turns a non-finite number into an Invalid Date, which is an object
1735
- if (!date || isNaN(date.getTime())) {
1736
- return String(value);
1737
- }
1738
- // An "HH:MM" string is a wall-clock reading, not an instant — re-reading
1739
- // it in another zone would shift what the caller already spelled out.
1740
- const zone = typeof value === "string" ? undefined : timeZone;
1741
- if (format === "timestring") {
1742
- return formatTime(date, { lang, timeZone: zone });
1743
- }
1744
- const { hours, minutes } = readClock(date, zone);
1745
- const totalMinutes = hours * 60 + minutes;
1746
- // clockStyle: this is always a time-of-day here, never a duration — keeps
1747
- // a zero hour instead of dropping it (midnight would otherwise be
1748
- // indistinguishable from an actual 5-minute duration), and in
1749
- // format="compact" also zero-pads a single-digit hour so "5h30"/"0h05"
1750
- // read as "05h30"/"00h05", closer to a "HH:MM" clock.
1751
- if (hours !== 0 || format !== "long") {
1752
- // At midnight, short/narrow/compact keep the "0 h"/"0h" hour part —
1753
- // "0 h et 5 min"/"0h 5min"/"00h05" — rather than substituting a
1754
- // translated "midnight" word, which would look out of place squeezed
1755
- // into these otherwise terse, symbol-based formats.
1756
- return formatMinuteDuration(totalMinutes, {
1757
- lang,
1758
- format,
1759
- clockStyle: true,
1760
- pad,
1761
- precision,
1762
- });
1763
- }
1764
- // Midnight (hour 0) at format="long" can't go through
1765
- // formatMinuteDuration's own default zero-hour handling: it drops a
1766
- // zero-valued unit entirely (by design — a real 5-minute duration should
1767
- // print as "5 minutes", not "0 hours 5 minutes"), so "00:05" would
1768
- // otherwise render identically to an actual 5-minute duration, silently
1769
- // losing the fact that it's midnight. Every other hour keeps at least its
1770
- // own "N hour(s)" wording as a hint that this is a time-of-day — only
1771
- // hour 0 loses that hint entirely.
1772
- const midnightWord = naviI18n("time.midnight", undefined, { lang });
1773
- if (midnightWord === "time.midnight") {
1774
- // No "midnight" translation registered for this language — fall back
1775
- // to this language's own literal "0 heure(s)" wording instead (still
1776
- // better than leaking the untranslated key, or substituting an
1777
- // English word that wouldn't grammatically match the rest of the
1778
- // sentence in whatever language this actually is).
1779
- return formatMinuteDuration(totalMinutes, {
1780
- lang,
1781
- format,
1782
- clockStyle: true,
1783
- });
1784
- }
1785
- // Swap just the "0 heure(s)" part of the Intl-generated duration
1786
- // string for the translated "midnight" word, keeping everything else
1787
- // (the conjunction, the minutes part) exactly as Intl would produce
1788
- // for this locale — formatToParts tags each token with the unit it
1789
- // belongs to, so the swap doesn't need to know the locale's own
1790
- // grammar/word order. Only ever one hour-tagged group per call
1791
- // (hours is always 0 or absent here), but guarded anyway in case a
1792
- // future Intl implementation ever splits it into more parts.
1793
- const parts = memoIntl("DurationFormat", lang, {
1794
- style: "long",
1795
- hoursDisplay: "always",
1796
- }).formatToParts({ hours: 0, minutes });
1797
- let hourGroupReplaced = false;
1798
- return parts
1799
- .map((part) => {
1800
- if (part.unit !== "hour") {
1801
- return part.value;
1802
- }
1803
- if (hourGroupReplaced) {
1804
- return "";
1805
- }
1806
- hourGroupReplaced = true;
1807
- return midnightWord;
1808
- })
1809
- .join("");
1810
- };
1811
-
1812
- /**
1813
- * Formats a span between two times-of-day the way `<TimeRange>` writes it, as
1814
- * a plain string — "8h–10h", "11h30–14h00", "14 heures 30 – 16 heures".
1815
- *
1816
- * Applies `<TimeRange>`'s shared-precision rule: the two bounds are written
1817
- * to the same precision, decided by the pair — any bound with minutes gives
1818
- * minutes to both, zero included ("11h30–14h00", never "11h30–14h").
1819
- *
1820
- * @param {Date|number|string} from
1821
- * @param {Date|number|string} to
1822
- * Each bound accepts what {@link formatTimeOfDay} accepts; a nullish bound
1823
- * renders its "--:--" placeholder.
1824
- * @param {{ lang?: string, format?: "long"|"short"|"narrow"|"compact"|"timestring", pad?: boolean, precision?: "hour"|"minute", separator?: string, timeZone?: string }} [options]
1825
- * `precision` writes both bounds at this precision instead of the one the
1826
- * pair calls for. `separator` defaults to the `"time.range_separator"` navi
1827
- * text (an en dash), tightened against both bounds in `format="compact"` —
1828
- * where the span is one short token — and spaced out otherwise. `timeZone`
1829
- * reads both bounds' clocks in that IANA zone — see {@link formatTimeOfDay}.
1830
- *
1831
- * @example
1832
- * formatTimeRange("08:00", "10:00", { lang: "fr", format: "compact", pad: false }) // "8h–10h"
1833
- * formatTimeRange("11:30", "14:00", { lang: "fr", format: "compact", pad: false }) // "11h30–14h00"
1834
- */
1835
- const formatTimeRange = (
1836
- from,
1837
- to,
1838
- {
1839
- lang = getRuntimeLang(),
1840
- format = "long",
1841
- pad = true,
1842
- timeZone,
1843
- precision = resolveTimeRangePrecision(from, to, { format, pad, timeZone }),
1844
- separator = naviI18n("time.range_separator", undefined, { lang }),
1845
- } = {},
1846
- ) => {
1847
- const boundOptions = { lang, format, pad, precision, timeZone };
1848
- const fromText = formatTimeOfDay(from, boundOptions);
1849
- const toText = formatTimeOfDay(to, boundOptions);
1850
- if (format === "compact") {
1851
- return `${fromText}${separator}${toText}`;
1852
- }
1853
- return `${fromText} ${separator} ${toText}`;
1854
- };
1855
-
1856
- // The two bounds of a span are written to the same precision, decided by the
1857
- // pair: "8h–10h" as long as neither has minutes, "11h30–14h00" as soon as one
1858
- // of them does. Only ever a question for the unpadded compact clock — the
1859
- // padded one always writes "08h00", and the spelled-out formats name their
1860
- // units, leaving no shape for the eye to trip on.
1861
- const resolveTimeRangePrecision = (
1862
- from,
1863
- to,
1864
- { format, pad, timeZone },
1865
- ) => {
1866
- if (pad || format !== "compact") {
1867
- return "minute";
1868
- }
1869
- const hasMinutes = (value) => {
1870
- const date = toTimeOfDay(value);
1871
- if (!date || isNaN(date.getTime())) {
1872
- return false;
1873
- }
1874
- // Same rule as formatTimeOfDay: a string is a wall-clock reading, only
1875
- // an instant is re-read in `timeZone`.
1876
- const zone = typeof value === "string" ? undefined : timeZone;
1877
- return readClock(date, zone).minutes !== 0;
1878
- };
1879
- return hasMinutes(from) || hasMinutes(to) ? "minute" : "hour";
1880
- };
1881
-
1882
- /**
1883
- * Formats a duration expressed in minutes as a human-readable string.
1884
- * "long", "short", "narrow" delegate to Intl.DurationFormat.
1885
- * "compact" uses our own notation that omits the minute symbol when hours are present.
1886
- *
1887
- * @param {number} minutes
1888
- * @param {{ lang?: string, format?: "long"|"short"|"narrow"|"compact", clockStyle?: boolean, pad?: boolean, precision?: "hour"|"minute", forceUnit?: boolean }} [options]
1889
- * @param {boolean} [options.forceUnit=false] - Keep the value in minutes
1890
- * however big it gets ("2160 minutes" instead of "1 jour et 12 heures").
1891
- * Past 24 hours the default promotes to days, which reads better but hides
1892
- * the unit the caller works in.
1893
- * @param {boolean} [options.clockStyle=false] - Set this when `minutes`
1894
- * represents a time-of-day rather than a real duration (used by
1895
- * `<Time type="time">`, see time.jsx's own TimeTime). A clock's "0" is a
1896
- * meaningful hour rather than "no hours": a zero-hours component is
1897
- * normally dropped entirely (a real 5-minute duration should print as
1898
- * "5 minutes", not "0 hours 5 minutes"); this keeps it instead (e.g.
1899
- * "0 h et 5 min"/"0h 5min"/"00h05") so midnight doesn't collapse to
1900
- * something indistinguishable from an actual 5-minute duration.
1901
- * Must not be set for plain duration formatting.
1902
- * @param {boolean} [options.pad=true] - Zero-pad the hour to 2 digits
1903
- * ("08h30" rather than "8h30"). `clockStyle` + `format: "compact"` only.
1904
- * @param {"hour"|"minute"} [options.precision="minute"] - Whether a zero
1905
- * minute is written: `"minute"` keeps it ("10h00"), `"hour"` drops it
1906
- * ("10h"). `clockStyle` + `format: "compact"` only.
1907
- *
1908
- * These last two are the clock's two independent shape choices, and only
1909
- * `format: "compact"` has to make them — the spelled-out formats put their
1910
- * units in words, so "10 heures"/"10 h"/"10h" already reads as a time of
1911
- * day whatever the padding, and they always write the hour bare and drop a
1912
- * zero minute. Padded + minute ("08h00") is the column shape, where every
1913
- * row occupies the same width; bare + hour ("8h", "8h30") is the shape a
1914
- * person speaks. Bare + minute ("8h00") only ever makes sense next to a
1915
- * partner that has minutes of its own — see `<TimeRange>`, which is the
1916
- * only thing that asks for it.
1917
- *
1918
- * @example
1919
- * formatMinuteDuration(90, { lang: "fr" }) // "1 heure 30 minutes" (long, default)
1920
- * formatMinuteDuration(90, { lang: "fr", format: "short" }) // "1 h et 30 min" (Intl short)
1921
- * formatMinuteDuration(90, { lang: "fr", format: "narrow" }) // "1h 30min" (Intl narrow)
1922
- * formatMinuteDuration(90, { lang: "fr", format: "compact" }) // "1h30" (custom, no minute symbol)
1923
- * formatMinuteDuration(45, { lang: "en", format: "compact" }) // "45min"
1924
- * formatMinuteDuration(5, { lang: "fr", format: "narrow", clockStyle: true }) // "0h 5min"
1925
- * formatMinuteDuration(330, { lang: "fr", format: "compact", clockStyle: true }) // "05h30"
1926
- * formatMinuteDuration(600, { lang: "fr", format: "compact", clockStyle: true }) // "10h00"
1927
- * formatMinuteDuration(600, { lang: "fr", format: "compact", clockStyle: true, pad: false }) // "10h00"
1928
- * formatMinuteDuration(600, { lang: "fr", format: "compact", clockStyle: true, pad: false, precision: "hour" }) // "10h"
1929
- * formatMinuteDuration(510, { lang: "fr", format: "compact", clockStyle: true, pad: false, precision: "hour" }) // "8h30"
1930
- * formatMinuteDuration(2160, { lang: "fr" }) // "1 jour et 12 heures"
1931
- * formatMinuteDuration(2160, { lang: "fr", forceUnit: true }) // "2 160 minutes"
1932
- */
1933
- const formatMinuteDuration = (
1934
- minutes,
1935
- {
1936
- lang = getRuntimeLang(),
1937
- format = "long",
1938
- clockStyle = false,
1939
- pad = true,
1940
- precision = "minute",
1941
- forceUnit = false,
1942
- } = {},
1943
- ) => {
1944
- if (minutes < 0) {
1945
- // the d/h/m split below only holds for a positive value; formatting the
1946
- // magnitude and putting the sign back is the only reading that works
1947
- return `-${formatMinuteDuration(-minutes, { lang, format, clockStyle, pad, precision, forceUnit })}`;
1948
- }
1949
- if (forceUnit || (minutes === 0 && !clockStyle)) {
1950
- // a zero has nothing to promote to, and rendering it as an empty string
1951
- // would be indistinguishable from a missing value
1952
- return formatSingleUnit(minutes, "minute", { lang, format });
1953
- }
1954
- const totalHours = Math.floor(minutes / 60);
1955
- const m = minutes % 60;
1956
- // a time of day never goes past 24h, and its hour part is the clock hour
1957
- const d = clockStyle ? 0 : Math.floor(totalHours / 24);
1958
- const h = clockStyle ? totalHours : totalHours % 24;
1959
- if (format !== "compact" && typeof Intl.DurationFormat !== "undefined") {
1960
- const fmt = memoIntl("DurationFormat", lang, {
1961
- style: format, // "long", "short", or "narrow"
1962
- ...(clockStyle ? { hoursDisplay: "always" } : {}),
1963
- });
1964
- const duration = {};
1965
- if (d > 0) {
1966
- duration.days = d;
1967
- }
1968
- if (h > 0 || clockStyle || d > 0) {
1969
- duration.hours = h;
1970
- }
1971
- if (m > 0 || (d === 0 && h === 0)) {
1972
- duration.minutes = m;
1973
- }
1974
- return fmt.format(duration);
1975
- }
1976
- // format="compact": "1j12h", "1h30", "45min", "2h" — no minute symbol when hours are present
1977
- const dSym = naviI18n("time.duration.day_symbol", undefined, { lang });
1978
- const hSym = naviI18n("time.duration.hour_symbol", undefined, { lang });
1979
- const mSym = naviI18n("time.duration.minute_symbol", undefined, { lang });
1980
- const dStr = d > 0 ? `${formatCompactNumber(d, lang)}${dSym}` : "";
1981
- const hStr =
1982
- clockStyle && pad
1983
- ? String(h).padStart(2, "0")
1984
- : formatCompactNumber(h, lang);
1985
- if (d === 0 && h === 0 && !clockStyle) {
1986
- return `${m}${mSym}`;
1987
- }
1988
- if (m === 0) {
1989
- if (clockStyle) {
1990
- // "10h00" on a clock, "2h" for a real 2 hours duration — except at
1991
- // precision "hour", where a clock drops the zero minute too ("10h"),
1992
- // the way one says it out loud
1993
- return precision === "minute" ? `${hStr}${hSym}00` : `${hStr}${hSym}`;
1994
- }
1995
- return h === 0 ? dStr : `${dStr}${hStr}${hSym}`;
1996
- }
1997
- return `${dStr}${hStr}${hSym}${String(m).padStart(2, "0")}`;
1998
- };
1999
-
2000
- // "forceUnit": stay in the unit the value is expressed in, however big it gets
2001
- const formatSingleUnit = (value, unit, { lang, format }) => {
2002
- if (format !== "compact" && typeof Intl.DurationFormat !== "undefined") {
2003
- return memoIntl("DurationFormat", lang, {
2004
- style: format,
2005
- // Intl drops a zero-valued unit, and "0 minute" is the whole point here
2006
- [`${unit}sDisplay`]: "always",
2007
- }).format({
2008
- [`${unit}s`]: value,
2009
- });
2010
- }
2011
- const symbol = naviI18n(`time.duration.${unit}_symbol`, undefined, { lang });
2012
- return `${formatCompactNumber(value, lang)}${symbol}`;
2013
- };
2014
-
2015
- /**
2016
- * Formats a duration expressed in hours (possibly fractional) as a human-readable string.
2017
- * Delegates to {@link formatMinuteDuration} after converting hours to minutes.
2018
- *
2019
- * @param {number} hours
2020
- * @param {{ lang?: string, format?: "long"|"short"|"narrow"|"compact", forceUnit?: boolean }} [options]
2021
- * @param {boolean} [options.forceUnit=false] - Keep the value in hours however
2022
- * big it gets ("36 heures" instead of "1 jour et 12 heures"). Ignored for a
2023
- * fractional value, which has no single-unit spelling.
2024
- *
2025
- * @example
2026
- * formatHourDuration(1.5, { lang: "fr" }) // "1 heure 30 minutes" (long, default)
2027
- * formatHourDuration(1.5, { lang: "fr", format: "compact" }) // "1h30"
2028
- * formatHourDuration(2, { lang: "en", format: "compact" }) // "2h"
2029
- * formatHourDuration(36, { lang: "fr" }) // "1 jour et 12 heures"
2030
- * formatHourDuration(36, { lang: "fr", forceUnit: true }) // "36 heures"
2031
- */
2032
- const formatHourDuration = (hours, options = {}) => {
2033
- const { lang = getRuntimeLang(), format = "long", forceUnit } = options;
2034
- if (hours === 0 || (forceUnit && Number.isInteger(hours))) {
2035
- return formatSingleUnit(hours, "hour", { lang, format });
2036
- }
2037
- // a fractional value has no single-unit spelling, it needs its minutes
2038
- const totalMinutes = Math.round(hours * 60);
2039
- return formatMinuteDuration(totalMinutes, { ...options, forceUnit: false });
2040
- };
2041
-
2042
- /**
2043
- * Formats a duration expressed in seconds as a human-readable string.
2044
- * "long", "short", "narrow" delegate to Intl.DurationFormat.
2045
- * "compact" uses our own symbol-based notation.
2046
- *
2047
- * @param {number} seconds
2048
- * @param {{ lang?: string, format?: "long"|"short"|"narrow"|"compact", forceUnit?: boolean }} [options]
2049
- * @param {boolean} [options.forceUnit=false] - Keep the value in seconds
2050
- * however big it gets ("90 000 secondes" instead of "1 jour et 1 heure").
2051
- *
2052
- * @example
2053
- * formatSecondDuration(90, { lang: "fr" }) // "1 minute 30 secondes" (long, default)
2054
- * formatSecondDuration(90, { lang: "fr", format: "short" }) // "1 min. et 30 s." (Intl short)
2055
- * formatSecondDuration(90, { lang: "fr", format: "narrow" }) // "1min 30s" (Intl narrow)
2056
- * formatSecondDuration(90, { lang: "fr", format: "compact" }) // "1m30s" (custom)
2057
- * formatSecondDuration(45, { lang: "en", format: "compact" }) // "45s"
2058
- */
2059
- const formatSecondDuration = (
2060
- seconds,
2061
- { lang = getRuntimeLang(), format = "long", forceUnit = false } = {},
2062
- ) => {
2063
- if (seconds < 0) {
2064
- // the d/h/m/s split below only holds for a positive value; formatting the
2065
- // magnitude and putting the sign back is the only reading that works
2066
- return `-${formatSecondDuration(-seconds, { lang, format, forceUnit })}`;
2067
- }
2068
- if (forceUnit || seconds === 0) {
2069
- return formatSingleUnit(seconds, "second", { lang, format });
2070
- }
2071
- const totalHours = Math.floor(seconds / 3600);
2072
- const d = Math.floor(totalHours / 24);
2073
- const h = totalHours % 24;
2074
- const m = Math.floor((seconds % 3600) / 60);
2075
- const s = seconds % 60;
2076
- if (format !== "compact" && typeof Intl.DurationFormat !== "undefined") {
2077
- const fmt = memoIntl("DurationFormat", lang, { style: format });
2078
- const duration = {};
2079
- if (d > 0) duration.days = d;
2080
- if (h > 0) duration.hours = h;
2081
- if (m > 0) duration.minutes = m;
2082
- if (s > 0 || (d === 0 && h === 0 && m === 0)) duration.seconds = s;
2083
- return fmt.format(duration);
2084
- }
2085
- // compact: "1d1h30m45s", "1h30m45s", "1m30s", "45s"
2086
- const dSym = naviI18n("time.duration.day_symbol", undefined, { lang });
2087
- const hSym = naviI18n("time.duration.hour_symbol", undefined, { lang });
2088
- const mSym = naviI18n("time.duration.minute_symbol", undefined, { lang });
2089
- const sSym = naviI18n("time.duration.second_symbol", undefined, { lang });
2090
- const parts = [];
2091
- // h/m/s are bounded by construction (never need grouping); d can be
2092
- // arbitrarily large for a long duration.
2093
- if (d > 0) parts.push(`${formatCompactNumber(d, lang)}${dSym}`);
2094
- if (h > 0) parts.push(`${h}${hSym}`);
2095
- if (m > 0) parts.push(`${m}${mSym}`);
2096
- if (s > 0 || parts.length === 0) parts.push(`${s}${sSym}`);
2097
- return parts.join("");
2098
- };
2099
-
2100
- /**
2101
- * Formats a duration object as a human-readable string.
2102
- * Reads the parts directly — no conversion to seconds — so years/months/days
2103
- * are preserved as-is and non-numeric mid-edit values (e.g. "2a") are rendered
2104
- * with their unit symbol rather than being stringified.
2105
- *
2106
- * @param {string|number|{ years?: any, months?: any, weeks?: any, days?: any,
2107
- * hours?: any, minutes?: any, seconds?: any, milliseconds?: any }} duration -
2108
- * A string goes through {@link parseDuration} ("PT1H30M", "1h30"), a number
2109
- * is read as seconds. Each unit is written with the value it carries: 90
2110
- * minutes reads "90 minutes", never "1 heure 30" — the variants that
2111
- * promote a count into bigger units are {@link formatMinuteDuration},
2112
- * {@link formatHourDuration} and {@link formatSecondDuration}.
2113
- * @param {{ lang?: string, format?: "long"|"short"|"narrow"|"compact" }} [options]
2114
- *
2115
- * @example
2116
- * formatDuration({ hours: 2, minutes: 15 }, { lang: "fr" }) // "2 heures 15 minutes" (long, default)
2117
- * formatDuration({ hours: 2, minutes: 15 }, { lang: "fr", format: "short" }) // "2 h et 15 min" (Intl short)
2118
- * formatDuration({ hours: 2, minutes: 15 }, { lang: "fr", format: "narrow" }) // "2h 15min" (Intl narrow)
2119
- * formatDuration({ hours: 2, minutes: 15 }, { lang: "fr", format: "compact" }) // "2h15" (custom, no minute symbol)
2120
- * formatDuration({ minutes: 45 }, { lang: "fr", format: "compact" }) // "45min"
2121
- * formatDuration({ hours: 0, minutes: 0 }, { lang: "fr" }) // "0 minute"
2122
- * formatDuration({ hours: "2a", minutes: "15" }, { lang: "fr", format: "compact" }) // "2ah15"
2123
- */
2124
- const formatDuration = (
2125
- duration,
2126
- { lang = getRuntimeLang(), format = "long" } = {},
2127
- ) => {
2128
- if (typeof duration === "string") {
2129
- duration = parseDuration(duration) ?? {};
2130
- } else if (typeof duration === "number") {
2131
- duration = { seconds: duration };
2132
- }
2133
- const has = (key) => duration[key] !== undefined && duration[key] !== null;
2134
-
2135
- // "long" and "narrow" delegate to Intl.DurationFormat when available and all values are numeric.
2136
- //
2137
- // "short" always uses our own compact symbols ("2h15", "45min") because:
2138
- // 1. We omit the minute symbol when hours are also present ("2h15" not "2h 15 min"),
2139
- // which Intl.DurationFormat style:"narrow" does not do.
2140
- // 2. Non-numeric mid-edit values (e.g. { hours: "2a" }) must render as-is with their
2141
- // unit symbol — Intl.DurationFormat only accepts integers.
2142
- if (format !== "compact" && typeof Intl.DurationFormat !== "undefined") {
2143
- const intlDuration = {};
2144
- let allNumeric = true;
2145
- let hasNegative = false;
2146
- let hasPositive = false;
2147
- for (const key of [
2148
- "years",
2149
- "months",
2150
- "weeks",
2151
- "days",
2152
- "hours",
2153
- "minutes",
2154
- "seconds",
2155
- "milliseconds",
2156
- ]) {
2157
- if (!has(key)) {
2158
- continue;
2159
- }
2160
- const n = Number(duration[key]);
2161
- if (!isFinite(n)) {
2162
- allNumeric = false;
2163
- break;
2164
- }
2165
- if (n < 0) {
2166
- hasNegative = true;
2167
- } else if (n > 0) {
2168
- hasPositive = true;
2169
- }
2170
- intlDuration[key] = n;
2171
- }
2172
- // Temporal requires all components to share the same sign.
2173
- // Mixed-sign values (e.g. { hours: -1, minutes: 15 }) throw a RangeError.
2174
- if (
2175
- allNumeric &&
2176
- Object.keys(intlDuration).length > 0 &&
2177
- !(hasNegative && hasPositive)
2178
- ) {
2179
- if (!hasNegative && !hasPositive) {
2180
- return formatSingleUnit(0, smallestUnitOf(intlDuration), {
2181
- lang,
2182
- format,
2183
- });
2184
- }
2185
- return memoIntl("DurationFormat", lang, { style: format }).format(
2186
- intlDuration,
2187
- );
2188
- }
2189
- // Fall through to compact notation when values are non-numeric or mixed-sign
2190
- }
2191
-
2192
- // A component explicitly present but numerically zero (e.g. the demo's own
2193
- // { hours: 0, minutes: 5 }) conveys no information for a genuine duration
2194
- // — same convention formatMinuteDuration/formatSecondDuration already
2195
- // follow (checking h > 0/m > 0, not merely "was a value passed") — so
2196
- // it's dropped here too, regardless of whether the caller included the
2197
- // key at all. Non-numeric mid-edit values (e.g. "2a") still count as
2198
- // present — Number("2a") is NaN, never === 0 — so those keep rendering
2199
- // as-is with their own unit symbol. When every component is zero there is
2200
- // nothing left to drop, so the zero itself is rendered — see below.
2201
- const hasNonZero = (key) => has(key) && Number(duration[key]) !== 0;
2202
-
2203
- const sym = (key) =>
2204
- naviI18n(`time.duration.${key}_symbol`, undefined, { lang });
2205
- const parts = [];
2206
-
2207
- if (hasNonZero("years")) {
2208
- parts.push(`${formatCompactNumber(duration.years, lang)}${sym("year")}`);
2209
- }
2210
- if (hasNonZero("months")) {
2211
- parts.push(`${formatCompactNumber(duration.months, lang)}${sym("month")}`);
2212
- }
2213
- if (hasNonZero("weeks")) {
2214
- parts.push(`${formatCompactNumber(duration.weeks, lang)}${sym("week")}`);
2215
- }
2216
- if (hasNonZero("days")) {
2217
- parts.push(`${formatCompactNumber(duration.days, lang)}${sym("day")}`);
2218
- }
2219
-
2220
- // Hours + minutes: when both present, pad minutes to 2 digits after the h
2221
- // symbol — minutes stays a plain 2-digit pad (it's always 0-59 by
2222
- // convention), only hours goes through grouping.
2223
- const hSym = sym("hour");
2224
- const mSym = sym("minute");
2225
- if (hasNonZero("hours") && hasNonZero("minutes")) {
2226
- parts.push(
2227
- `${formatCompactNumber(duration.hours, lang)}${hSym}${String(duration.minutes).padStart(2, "0")}`,
2228
- );
2229
- } else if (hasNonZero("hours")) {
2230
- parts.push(`${formatCompactNumber(duration.hours, lang)}${hSym}`);
2231
- } else if (hasNonZero("minutes")) {
2232
- parts.push(`${formatCompactNumber(duration.minutes, lang)}${mSym}`);
2233
- }
2234
-
2235
- if (hasNonZero("seconds")) {
2236
- parts.push(
2237
- `${formatCompactNumber(duration.seconds, lang)}${sym("second")}`,
2238
- );
2239
- }
2240
- if (hasNonZero("milliseconds")) {
2241
- parts.push(
2242
- `${formatCompactNumber(duration.milliseconds, lang)}${sym("millisecond")}`,
2243
- );
2244
- }
2245
- if (parts.length > 0) {
2246
- return parts.join("");
2247
- }
2248
- // everything was zero: say so in the smallest unit the caller mentioned,
2249
- // rather than a bare "0" whose unit the reader has to guess
2250
- const smallestUnit = smallestUnitOf(duration);
2251
- return smallestUnit ? `0${sym(smallestUnit)}` : "0";
2252
- };
2253
-
2254
- const UNIT_KEYS = [
2255
- "years",
2256
- "months",
2257
- "weeks",
2258
- "days",
2259
- "hours",
2260
- "minutes",
2261
- "seconds",
2262
- "milliseconds",
2263
- ];
2264
- const smallestUnitOf = (duration) => {
2265
- for (const key of [...UNIT_KEYS].reverse()) {
2266
- if (duration[key] !== undefined && duration[key] !== null) {
2267
- return key.slice(0, -1); // "seconds" -> "second"
2268
- }
2269
- }
2270
- return null;
2271
- };
2272
-
2273
- /**
2274
- * Formats a date relative to now: "il y a 3 jours", "dans 2 heures", etc.
2275
- */
2276
- const formatTimeAgo = (
2277
- date,
2278
- { lang = getRuntimeLang(), now = new Date(), bare, format = "long" } = {},
2279
- ) => {
2280
- const rtf = memoIntl("RelativeTimeFormat", lang, {
2281
- numeric: "auto",
2282
- style: format,
2283
- });
2284
- const nowMs = now instanceof Date ? now.getTime() : now;
2285
- const diff = date.getTime() - nowMs;
2286
- const absDiff = Math.abs(diff);
2287
-
2288
- let value;
2289
- let unit;
2290
- if (absDiff < MINUTE) {
2291
- value = Math.round(diff / 1000);
2292
- unit = "second";
2293
- } else if (absDiff < HOUR) {
2294
- value = Math.round(diff / MINUTE);
2295
- unit = "minute";
2296
- } else if (absDiff < DAY) {
2297
- value = Math.round(diff / HOUR);
2298
- unit = "hour";
2299
- } else if (absDiff < 7 * DAY) {
2300
- value = Math.round(diff / DAY);
2301
- unit = "day";
2302
- } else if (absDiff < 30 * DAY) {
2303
- value = Math.round(diff / (7 * DAY));
2304
- unit = "week";
2305
- } else if (absDiff < YEAR) {
2306
- value = Math.round(diff / (30 * DAY));
2307
- unit = "month";
2308
- } else {
2309
- value = Math.round(diff / YEAR);
2310
- unit = "year";
2311
- }
2312
-
2313
- if (!bare || value >= 0) {
2314
- return rtf.format(value, unit);
2315
- }
2316
- // Drop the leading past-tense literal ("il y a ", "ago ") — keep only integer + unit.
2317
- const parts = rtf.formatToParts(value, unit);
2318
- const integerIndex = parts.findIndex((p) => p.type === "integer");
2319
- return parts
2320
- .slice(integerIndex)
2321
- .map((p) => p.value)
2322
- .join("")
2323
- .trim();
2324
- };
2325
-
2326
- /**
2327
- * Formats a timed event with an optional duration window.
2328
- *
2329
- * States:
2330
- * - Future (now < start) → "dans 1 heure 30", "demain à 15h", …
2331
- * - Ongoing (start ≤ now < start+dur) → "En cours"
2332
- * - Past (now ≥ start+dur) → relative ("il y a 2 heures", …)
2333
- *
2334
- * @param {Date|number} start Start of the event (Date or ms timestamp)
2335
- * @param {number} durationMs Duration in milliseconds (0 = instant event)
2336
- * @param {{ lang?: string, now?: Date|number, bare?: boolean, format?: "long"|"short"|"narrow" }} options
2337
- *
2338
- * @example
2339
- * // 90 min from now
2340
- * formatTimeRelative(Date.now() + 90 * 60_000, 0, { lang: "fr" }) // "dans 1 heure 30"
2341
- * // currently happening (30 min window)
2342
- * formatTimeRelative(Date.now() - 5 * 60_000, 30 * 60_000, { lang: "fr" }) // "En cours"
2343
- * // ended 2 hours ago
2344
- * formatTimeRelative(Date.now() - 3 * 3_600_000, 3_600_000, { lang: "fr" }) // "il y a 2 heures"
2345
- * // short format
2346
- * formatTimeRelative(Date.now() - 3 * 3_600_000, 0, { lang: "fr", format: "short" }) // "il y a 3 h"
2347
- */
2348
- const formatTimeRelative = (
2349
- start,
2350
- durationMs = 0,
2351
- { lang = getRuntimeLang(), now = new Date(), bare, format = "long" } = {},
2352
- ) => {
2353
- const startMs = start instanceof Date ? start.getTime() : Number(start);
2354
- const endMs = startMs + durationMs;
2355
- const nowMs = now instanceof Date ? now.getTime() : Number(now);
2356
-
2357
- if (nowMs >= startMs && nowMs < endMs) {
2358
- return getOngoingText(lang);
2359
- }
2360
- if (nowMs >= endMs) {
2361
- const refDate = endMs > startMs ? new Date(endMs) : new Date(startMs);
2362
- return formatTimeAgo(refDate, { lang, now, bare, format });
2363
- }
2364
-
2365
- const diff = startMs - nowMs;
2366
- return formatFuture(new Date(startMs), diff, { lang, now, format });
2367
- };
2368
-
2369
- const formatFuture = (date, diff, { lang, now, format = "long" }) => {
2370
- const rtf = memoIntl("RelativeTimeFormat", lang, {
2371
- numeric: "auto",
2372
- style: format,
2373
- });
2374
- const nowDate = now instanceof Date ? now : new Date(now);
2375
-
2376
- // < 1 min
2377
- if (diff < MINUTE) {
2378
- return getLessThanMinuteText(lang);
2379
- }
2380
-
2381
- // < 1 hour → "dans X minutes"
2382
- if (diff < HOUR) {
2383
- return rtf.format(Math.ceil(diff / MINUTE), "minute");
2384
- }
2385
-
2386
- // 1h to 2h → "dans 1 heure 30"
2387
- if (diff < 2 * HOUR) {
2388
- const hours = Math.floor(diff / HOUR);
2389
- const minutes = Math.round((diff % HOUR) / MINUTE);
2390
- if (minutes === 0) {
2391
- return rtf.format(hours, "hour");
2392
- }
2393
- const duration = formatMinuteDuration(hours * 60 + minutes, {
2394
- lang,
2395
- format,
2396
- });
2397
- const template = naviI18n("time.in_duration", undefined, { lang });
2398
- if (template !== "time.in_duration") {
2399
- return template.replace("[duration]", duration);
2400
- }
2401
- return `in ${duration}`;
2402
- }
2403
-
2404
- // < 6h → "dans X heures" (precise enough, skip tomorrow label)
2405
- if (diff < 6 * HOUR) {
2406
- return rtf.format(Math.round(diff / HOUR), "hour");
2407
- }
2408
-
2409
- // Tomorrow (calendar day) and within ~30h → "demain à 15h"
2410
- const tomorrowDate = new Date(nowDate);
2411
- tomorrowDate.setDate(tomorrowDate.getDate() + 1);
2412
- if (diff < 30 * HOUR && toLocalDayKey(date) === toLocalDayKey(tomorrowDate)) {
2413
- return formatTomorrowAt(date, lang);
2414
- }
2415
-
2416
- // < 24h → "dans X heures"
2417
- if (diff < DAY) {
2418
- return rtf.format(Math.round(diff / HOUR), "hour");
2419
- }
2420
-
2421
- // < 7 days → "dans X jours"
2422
- if (diff < 7 * DAY) {
2423
- return rtf.format(Math.round(diff / DAY), "day");
2424
- }
2425
-
2426
- // < 30 days → "dans X semaines"
2427
- if (diff < 30 * DAY) {
2428
- return rtf.format(Math.round(diff / (7 * DAY)), "week");
2429
- }
2430
-
2431
- // months (Intl handles "le mois prochain" when value = 1)
2432
- if (diff < YEAR) {
2433
- return rtf.format(Math.round(diff / (30 * DAY)), "month");
2434
- }
2435
-
2436
- return rtf.format(Math.round(diff / YEAR), "year");
2437
- };
2438
-
2439
- const formatTomorrowAt = (date, lang) => {
2440
- const dayLabel = memoIntl("RelativeTimeFormat", lang, {
2441
- numeric: "auto",
2442
- }).format(1, "day");
2443
- const hasMinutes = date.getMinutes() !== 0;
2444
- const timeLabel = memoIntl("DateTimeFormat", lang, {
2445
- hour: "numeric",
2446
- ...(hasMinutes ? { minute: "2-digit" } : {}),
2447
- }).format(date);
2448
- const atTemplate = naviI18n("time.tomorrow_at", undefined, {
2449
- lang,
2450
- });
2451
- // atTemplate is e.g. "[day] à [time]" — replace placeholders
2452
- if (atTemplate !== "time.tomorrow_at") {
2453
- return atTemplate.replace("[day]", dayLabel).replace("[time]", timeLabel);
2454
- }
2455
- // fallback: concatenate with a space
2456
- return `${dayLabel} ${timeLabel}`;
2457
- };
2458
-
2459
- const getLessThanMinuteText = (lang) => {
2460
- return naviI18n("time.less_than_minute", undefined, { lang });
2461
- };
2462
-
2463
- const getOngoingText = (lang) => {
2464
- return naviI18n("time.ongoing", undefined, { lang });
2465
- };
2466
-
2467
- const MINUTE = 60_000;
2468
- const HOUR = 60 * MINUTE;
2469
- const DAY = 24 * HOUR;
2470
- const YEAR = 365 * DAY;
2471
-
2472
- // Compares calendar days in local time (ignores the clock time)
2473
- const toLocalDayKey = (date) => {
2474
- return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
2475
- };
2476
-
2477
- /**
2478
- * Coerces what `<Time>` accepts as a value — a Date, a ms timestamp, a
2479
- * parseable string — into a Date, or null when it cannot. `parseString`
2480
- * lets a caller claim the string forms it recognizes ("HH:MM" for a
2481
- * time-of-day, "YYYY-MM" for a month…) before the generic ones apply.
2482
- */
2483
- const toDate = (value, parseString) => {
2484
- if (value instanceof Date) {
2485
- return value;
2486
- }
2487
- if (typeof value === "number") {
2488
- return new Date(value);
2489
- }
2490
- if (typeof value === "string") {
2491
- if (parseString) {
2492
- return parseString(value);
2493
- }
2494
- // "YYYY-MM-DD" — use local midnight to avoid UTC shift
2495
- if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
2496
- const d = new Date(`${value}T00:00:00`);
2497
- return isNaN(d.getTime()) ? null : d;
2498
- }
2499
- // ISO / other parseable strings
2500
- const d = new Date(value);
2501
- return isNaN(d.getTime()) ? null : d;
2502
- }
2503
- return null;
2504
- };
2505
-
2506
- const toTimeOfDay = (value) => {
2507
- return toDate(value, (string) => {
2508
- if (/^\d{2}:\d{2}(?::\d{2})?$/.test(string)) {
2509
- const d = new Date(`1970-01-01T${string}`);
2510
- return isNaN(d.getTime()) ? null : d;
2511
- }
2512
- return null;
2513
- });
2514
- };
2515
-
2516
- // Reads the wall-clock hour/minute of an instant — in the runtime's own zone
2517
- // by default, in `timeZone` when given. A Date only carries local getters, so
2518
- // another zone's clock has to come out of Intl parts (hourCycle h23 so
2519
- // midnight reads hour 0, never 24).
2520
- const readClock = (date, timeZone) => {
2521
- if (!timeZone) {
2522
- return { hours: date.getHours(), minutes: date.getMinutes() };
2523
- }
2524
- const parts = memoIntl("DateTimeFormat", "en", {
2525
- timeZone,
2526
- hour: "numeric",
2527
- minute: "numeric",
2528
- hourCycle: "h23",
2529
- }).formatToParts(date);
2530
- let hours = 0;
2531
- let minutes = 0;
2532
- for (const part of parts) {
2533
- if (part.type === "hour") {
2534
- hours = Number(part.value);
2535
- } else if (part.type === "minute") {
2536
- minutes = Number(part.value);
2537
- }
2538
- }
2539
- return { hours, minutes };
2540
- };
2541
-
2542
- // Reads the calendar year of an instant in `timeZone` (the runtime's own zone
2543
- // when not given) — what formatDay's `year: "auto"` compares.
2544
- const readYear = (date, timeZone) => {
2545
- if (!timeZone) {
2546
- return date.getFullYear();
2547
- }
2548
- return Number(
2549
- memoIntl("DateTimeFormat", "en", { timeZone, year: "numeric" }).format(
2550
- date,
2551
- ),
2552
- );
2553
- };
2554
-
2555
- export { formatDatePlaceholder, formatDatetime, formatDatetimePlaceholder, formatDay, formatDayRelative, formatDuration, formatHourDuration, formatMinuteDuration, formatMonth, formatMonthPlaceholder, formatSecondDuration, formatTime, formatTimeOfDay, formatTimeRange, formatTimeRelative, formatWeekPlaceholder, getRelativeDay, resolveTimeRangePrecision, toDate, toTimeOfDay };
2556
- //# sourceMappingURL=jsenv_navi_format_time.js.map