@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.
- package/dist/dev/jsenv_navi.js +975 -2821
- package/dist/dev/jsenv_navi.js.map +9 -18
- package/dist/jsenv_navi.js +974 -1615
- package/dist/jsenv_navi.js.map +9 -16
- package/docs/AI_INSTRUCTIONS.md +5 -0
- package/docs/i18n.md +4 -1
- package/docs/interactions.md +67 -0
- package/package.json +3 -7
- package/dist/jsenv_navi_format_time.js +0 -2556
- package/dist/jsenv_navi_format_time.js.map +0 -20
|
@@ -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
|