@dnax/ui 0.0.64 → 0.0.65
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/components/QDatePicker.vue +12 -5
- package/components/QTabs.vue +11 -2
- package/components/internal/QDateCalendar.vue +25 -23
- package/index.ts +16 -0
- package/lib/dateI18n.test.ts +91 -0
- package/lib/dateI18n.ts +85 -0
- package/lib/network.test.ts +199 -0
- package/lib/network.ts +147 -0
- package/lib/q.ts +6 -1
- package/lib/styleTokens.test.ts +31 -0
- package/lib/wakeLock.test.ts +219 -0
- package/lib/wakeLock.ts +202 -0
- package/package.json +1 -1
- package/runtime.ts +7 -0
- package/styles/main.css +98 -16
|
@@ -9,6 +9,8 @@ import { icons } from "../lib/icons"
|
|
|
9
9
|
import { cn } from "../lib/utils"
|
|
10
10
|
import { useOverlayBack } from "../lib/overlayBack"
|
|
11
11
|
import { placePopover, type PopoverPlacement } from "../lib/datePicker"
|
|
12
|
+
import { useConfigLang } from "../lib/useComponentProps"
|
|
13
|
+
import { dateLabels, dateLocale } from "../lib/dateI18n"
|
|
12
14
|
import QDateCalendar from "./internal/QDateCalendar.vue"
|
|
13
15
|
|
|
14
16
|
interface Props {
|
|
@@ -43,7 +45,7 @@ interface Props {
|
|
|
43
45
|
width?: string
|
|
44
46
|
/** Titre du panneau (modes à voile : sheet/modal/dialog) */
|
|
45
47
|
title?: string
|
|
46
|
-
/** Raccourci «
|
|
48
|
+
/** Raccourci « aujourd'hui » sous le calendrier (tous les modes) */
|
|
47
49
|
todayBtn?: boolean
|
|
48
50
|
/** En-tête : libellé cliquable ouvrant le choix du mois (tous les modes) */
|
|
49
51
|
monthDropdown?: boolean
|
|
@@ -75,6 +77,11 @@ const emit = defineEmits<{
|
|
|
75
77
|
clear: []
|
|
76
78
|
}>()
|
|
77
79
|
|
|
80
|
+
// Langue de l'app (`QConfigProvider lang`) : format d'affichage et libellés.
|
|
81
|
+
const configLang = useConfigLang()
|
|
82
|
+
const locale = computed(() => dateLocale(configLang.value))
|
|
83
|
+
const labels = computed(() => dateLabels(configLang.value))
|
|
84
|
+
|
|
78
85
|
/** Champ déclencheur (tout sauf `inline`) */
|
|
79
86
|
const isPanelMode = computed(() => props.mode !== "inline")
|
|
80
87
|
/** Panneau ancré au champ : ni voile, ni blocage du scroll, ni retour navigateur */
|
|
@@ -98,7 +105,7 @@ const formatFn = computed(
|
|
|
98
105
|
() =>
|
|
99
106
|
props.format ??
|
|
100
107
|
((d: Date) =>
|
|
101
|
-
new Intl.DateTimeFormat(
|
|
108
|
+
new Intl.DateTimeFormat(locale.value, { day: "2-digit", month: "short", year: "numeric" }).format(d)),
|
|
102
109
|
)
|
|
103
110
|
|
|
104
111
|
const displayText = computed(() => (props.modelValue ? formatFn.value(props.modelValue) : ""))
|
|
@@ -139,7 +146,7 @@ const closePopup = () => {
|
|
|
139
146
|
open.value = false
|
|
140
147
|
}
|
|
141
148
|
|
|
142
|
-
const sheetTitle = computed(() => props.title || props.label ||
|
|
149
|
+
const sheetTitle = computed(() => props.title || props.label || labels.value.selectDate)
|
|
143
150
|
|
|
144
151
|
const sheetStyle = computed<Record<string, string> | undefined>(() =>
|
|
145
152
|
props.width ? { width: props.width, maxWidth: props.width } : undefined,
|
|
@@ -263,7 +270,7 @@ onBeforeUnmount(() => {
|
|
|
263
270
|
v-if="clearable && modelValue && !disable && !readonly"
|
|
264
271
|
class="q-field__clear"
|
|
265
272
|
type="button"
|
|
266
|
-
aria-label="
|
|
273
|
+
:aria-label="labels.clear"
|
|
267
274
|
@click.stop="onClear"
|
|
268
275
|
>
|
|
269
276
|
<Icon :icon="icons.x" aria-hidden="true" />
|
|
@@ -304,7 +311,7 @@ onBeforeUnmount(() => {
|
|
|
304
311
|
<button
|
|
305
312
|
type="button"
|
|
306
313
|
class="q-date-picker__sheet-close"
|
|
307
|
-
:aria-label="
|
|
314
|
+
:aria-label="`${labels.close} ${sheetTitle}`"
|
|
308
315
|
@click="closePopup"
|
|
309
316
|
>
|
|
310
317
|
<Icon :icon="icons.x" aria-hidden="true" />
|
package/components/QTabs.vue
CHANGED
|
@@ -183,6 +183,9 @@ const scrollArrows = (dir: -1 | 1) => {
|
|
|
183
183
|
el.scrollBy(props.vertical ? { top: delta, behavior: "smooth" } : { left: delta, behavior: "smooth" })
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
+
/** Débordement réel → les deux flèches existent (celle du côté bloqué est **désactivée**). */
|
|
187
|
+
const scrollable = computed(() => canScrollStart.value || canScrollEnd.value)
|
|
188
|
+
|
|
186
189
|
// Recalcule la position de l'indicateur (et les flèches) après chaque changement de DOM
|
|
187
190
|
const measure = async () => {
|
|
188
191
|
await nextTick()
|
|
@@ -285,6 +288,10 @@ const tabsClasses = computed(() =>
|
|
|
285
288
|
props.animated && `q-tabs--anim-${props.transition}`,
|
|
286
289
|
props.collapseInactive && "q-tabs--collapse-inactive",
|
|
287
290
|
props.showArrows && "q-tabs--arrows",
|
|
291
|
+
// Fondu des bords : uniquement du côté où il reste du contenu à révéler (le CSS
|
|
292
|
+
// n'utilise qu'un masque alpha → rendu identique en thème clair et sombre).
|
|
293
|
+
props.showArrows && canScrollStart.value && "q-tabs--scroll-start",
|
|
294
|
+
props.showArrows && canScrollEnd.value && "q-tabs--scroll-end",
|
|
288
295
|
),
|
|
289
296
|
)
|
|
290
297
|
</script>
|
|
@@ -299,9 +306,10 @@ const tabsClasses = computed(() =>
|
|
|
299
306
|
:aria-orientation="vertical ? 'vertical' : 'horizontal'"
|
|
300
307
|
>
|
|
301
308
|
<button
|
|
302
|
-
v-if="showArrows &&
|
|
309
|
+
v-if="showArrows && scrollable"
|
|
303
310
|
type="button"
|
|
304
311
|
class="q-tabs__arrow q-tabs__arrow--start"
|
|
312
|
+
:disabled="!canScrollStart"
|
|
305
313
|
:aria-label="vertical ? 'Defiler les onglets vers le haut' : 'Defiler les onglets vers la gauche'"
|
|
306
314
|
@click="scrollArrows(-1)"
|
|
307
315
|
>
|
|
@@ -317,9 +325,10 @@ const tabsClasses = computed(() =>
|
|
|
317
325
|
<div class="q-tabs__indicator" :style="indicatorStyle" aria-hidden="true" />
|
|
318
326
|
</div>
|
|
319
327
|
<button
|
|
320
|
-
v-if="showArrows &&
|
|
328
|
+
v-if="showArrows && scrollable"
|
|
321
329
|
type="button"
|
|
322
330
|
class="q-tabs__arrow q-tabs__arrow--end"
|
|
331
|
+
:disabled="!canScrollEnd"
|
|
323
332
|
:aria-label="vertical ? 'Defiler les onglets vers le bas' : 'Defiler les onglets vers la droite'"
|
|
324
333
|
@click="scrollArrows(1)"
|
|
325
334
|
>
|
|
@@ -4,6 +4,14 @@ import { computed, ref, watch } from "vue"
|
|
|
4
4
|
import { Icon } from "@iconify/vue"
|
|
5
5
|
import { icons } from "../../lib/icons"
|
|
6
6
|
import { cn } from "../../lib/utils"
|
|
7
|
+
import { useConfigLang } from "../../lib/useComponentProps"
|
|
8
|
+
import {
|
|
9
|
+
dateLabels,
|
|
10
|
+
dateLocale,
|
|
11
|
+
monthShortNames,
|
|
12
|
+
monthYearLabel,
|
|
13
|
+
weekdayShortNames,
|
|
14
|
+
} from "../../lib/dateI18n"
|
|
7
15
|
|
|
8
16
|
interface Props {
|
|
9
17
|
modelValue?: Date | null
|
|
@@ -15,7 +23,7 @@ interface Props {
|
|
|
15
23
|
disabledDates?: (d: Date) => boolean
|
|
16
24
|
/** Premier jour de la semaine : 0 = dimanche, 1 = lundi (défaut) */
|
|
17
25
|
firstDayOfWeek?: number
|
|
18
|
-
/**
|
|
26
|
+
/** Libellé du raccourci « aujourd'hui » (texte selon `lang`) */
|
|
19
27
|
todayBtn?: boolean
|
|
20
28
|
/** En-tête : libellé cliquable ouvrant le choix du mois (et de l'année) */
|
|
21
29
|
monthDropdown?: boolean
|
|
@@ -30,6 +38,11 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
30
38
|
|
|
31
39
|
const emit = defineEmits<{ select: [date: Date] }>()
|
|
32
40
|
|
|
41
|
+
// Langue de l'app (`QConfigProvider lang`) : mois, jours et libellés suivent.
|
|
42
|
+
const configLang = useConfigLang()
|
|
43
|
+
const locale = computed(() => dateLocale(configLang.value))
|
|
44
|
+
const labels = computed(() => dateLabels(configLang.value))
|
|
45
|
+
|
|
33
46
|
const today = new Date()
|
|
34
47
|
const viewYear = ref(props.modelValue?.getFullYear() ?? today.getFullYear())
|
|
35
48
|
const viewMonth = ref(props.modelValue?.getMonth() ?? today.getMonth())
|
|
@@ -51,10 +64,7 @@ const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDat
|
|
|
51
64
|
const sameDay = (a: Date, b: Date) =>
|
|
52
65
|
a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
|
|
53
66
|
|
|
54
|
-
const weekdayLabels = computed(() =>
|
|
55
|
-
const names = ["Dim", "Lun", "Mar", "Mer", "Jeu", "Ven", "Sam"] as const
|
|
56
|
-
return Array.from({ length: 7 }, (_, i) => names[(props.firstDayOfWeek + i) % 7])
|
|
57
|
-
})
|
|
67
|
+
const weekdayLabels = computed(() => weekdayShortNames(locale.value, props.firstDayOfWeek))
|
|
58
68
|
|
|
59
69
|
const weeks = computed<(Date | null)[][]>(() => {
|
|
60
70
|
const y = viewYear.value
|
|
@@ -70,18 +80,10 @@ const weeks = computed<(Date | null)[][]>(() => {
|
|
|
70
80
|
return result
|
|
71
81
|
})
|
|
72
82
|
|
|
73
|
-
const monthLabel = computed(() =>
|
|
74
|
-
new Intl.DateTimeFormat(undefined, { month: "long", year: "numeric" }).format(
|
|
75
|
-
new Date(viewYear.value, viewMonth.value, 1),
|
|
76
|
-
),
|
|
77
|
-
)
|
|
83
|
+
const monthLabel = computed(() => monthYearLabel(locale.value, viewYear.value, viewMonth.value))
|
|
78
84
|
|
|
79
|
-
/** Noms courts des 12 mois, dans la langue
|
|
80
|
-
const monthNames = computed(() =>
|
|
81
|
-
Array.from({ length: 12 }, (_, i) =>
|
|
82
|
-
new Intl.DateTimeFormat(undefined, { month: "short" }).format(new Date(2000, i, 1)),
|
|
83
|
-
),
|
|
84
|
-
)
|
|
85
|
+
/** Noms courts des 12 mois, dans la langue de l'app (comme `monthLabel`) */
|
|
86
|
+
const monthNames = computed(() => monthShortNames(locale.value))
|
|
85
87
|
|
|
86
88
|
const prevMonth = () => {
|
|
87
89
|
if (viewMonth.value === 0) {
|
|
@@ -126,7 +128,7 @@ const onSelectDate = (d: Date) => {
|
|
|
126
128
|
emit("select", d)
|
|
127
129
|
}
|
|
128
130
|
|
|
129
|
-
/** Raccourci «
|
|
131
|
+
/** Raccourci « aujourd'hui » : désactivé quand aujourd'hui est hors bornes / désactivé */
|
|
130
132
|
const todaySelectable = computed(() => !isDisabled(startOfDay(new Date())))
|
|
131
133
|
|
|
132
134
|
const selectToday = () => {
|
|
@@ -141,7 +143,7 @@ const selectToday = () => {
|
|
|
141
143
|
<button
|
|
142
144
|
type="button"
|
|
143
145
|
class="q-date-calendar__nav-btn"
|
|
144
|
-
aria-label="
|
|
146
|
+
:aria-label="labels.prevMonth"
|
|
145
147
|
@click="prevMonth"
|
|
146
148
|
>
|
|
147
149
|
<Icon :icon="icons.chevronLeft" aria-hidden="true" />
|
|
@@ -152,7 +154,7 @@ const selectToday = () => {
|
|
|
152
154
|
class="q-date-calendar__nav-label q-date-calendar__nav-label--button"
|
|
153
155
|
:class="{ 'q-date-calendar__nav-label--open': monthPickerOpen }"
|
|
154
156
|
:aria-expanded="monthPickerOpen"
|
|
155
|
-
aria-label="
|
|
157
|
+
:aria-label="labels.chooseMonth"
|
|
156
158
|
@click="monthPickerOpen = !monthPickerOpen"
|
|
157
159
|
>
|
|
158
160
|
<span class="q-date-calendar__nav-text">{{ monthLabel }}</span>
|
|
@@ -162,7 +164,7 @@ const selectToday = () => {
|
|
|
162
164
|
<button
|
|
163
165
|
type="button"
|
|
164
166
|
class="q-date-calendar__nav-btn"
|
|
165
|
-
aria-label="
|
|
167
|
+
:aria-label="labels.nextMonth"
|
|
166
168
|
@click="nextMonth"
|
|
167
169
|
>
|
|
168
170
|
<Icon :icon="icons.chevronRight" aria-hidden="true" />
|
|
@@ -175,7 +177,7 @@ const selectToday = () => {
|
|
|
175
177
|
<button
|
|
176
178
|
type="button"
|
|
177
179
|
class="q-date-calendar__nav-btn"
|
|
178
|
-
aria-label="
|
|
180
|
+
:aria-label="labels.prevYear"
|
|
179
181
|
@click="prevYear"
|
|
180
182
|
>
|
|
181
183
|
<Icon :icon="icons.chevronLeft" aria-hidden="true" />
|
|
@@ -184,7 +186,7 @@ const selectToday = () => {
|
|
|
184
186
|
<button
|
|
185
187
|
type="button"
|
|
186
188
|
class="q-date-calendar__nav-btn"
|
|
187
|
-
aria-label="
|
|
189
|
+
:aria-label="labels.nextYear"
|
|
188
190
|
@click="nextYear"
|
|
189
191
|
>
|
|
190
192
|
<Icon :icon="icons.chevronRight" aria-hidden="true" />
|
|
@@ -239,7 +241,7 @@ const selectToday = () => {
|
|
|
239
241
|
:disabled="!todaySelectable"
|
|
240
242
|
@click="selectToday"
|
|
241
243
|
>
|
|
242
|
-
|
|
244
|
+
{{ labels.today }}
|
|
243
245
|
</button>
|
|
244
246
|
</div>
|
|
245
247
|
</div>
|
package/index.ts
CHANGED
|
@@ -164,6 +164,22 @@ export { screen } from "./lib/screen"
|
|
|
164
164
|
export type { QScreen } from "./lib/screen"
|
|
165
165
|
export { loading } from "./lib/loading"
|
|
166
166
|
export type { LoadingOptions, QLoadingPlugin } from "./lib/loading"
|
|
167
|
+
export { network, createNetwork, resolveOnline, onlineAfterEvent } from "./lib/network"
|
|
168
|
+
export type { QNetwork, NetworkEvent, NetworkOptions, NetworkEventTarget, NetworkNavigator } from "./lib/network"
|
|
169
|
+
export {
|
|
170
|
+
wakeLock,
|
|
171
|
+
createWakeLock,
|
|
172
|
+
isWakeLockCapable,
|
|
173
|
+
isDocumentVisible,
|
|
174
|
+
shouldReacquire,
|
|
175
|
+
} from "./lib/wakeLock"
|
|
176
|
+
export type {
|
|
177
|
+
QWakeLock,
|
|
178
|
+
WakeLockOptions,
|
|
179
|
+
WakeLockApiLike,
|
|
180
|
+
WakeLockSentinelLike,
|
|
181
|
+
WakeLockVisibilityLike,
|
|
182
|
+
} from "./lib/wakeLock"
|
|
167
183
|
export { localStorage, sessionStorage } from "./lib/storage"
|
|
168
184
|
export type { QWebStorage } from "./lib/storage"
|
|
169
185
|
export {
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Libellés / locale des composants de date — pilotés par le `lang` du provider.
|
|
2
|
+
import { describe, expect, it } from "bun:test"
|
|
3
|
+
import {
|
|
4
|
+
dateLabels,
|
|
5
|
+
dateLocale,
|
|
6
|
+
monthShortNames,
|
|
7
|
+
monthYearLabel,
|
|
8
|
+
weekdayShortNames,
|
|
9
|
+
} from "./dateI18n"
|
|
10
|
+
|
|
11
|
+
describe("dateLocale", () => {
|
|
12
|
+
it("mappe la langue de l'app sur une locale BCP 47 déterministe", () => {
|
|
13
|
+
expect(dateLocale("fr")).toBe("fr-FR")
|
|
14
|
+
expect(dateLocale("en")).toBe("en-US")
|
|
15
|
+
})
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
describe("dateLabels", () => {
|
|
19
|
+
it("« Today » devient « Aujourd'hui » en français", () => {
|
|
20
|
+
expect(dateLabels("fr").today).toBe("Aujourd'hui")
|
|
21
|
+
expect(dateLabels("en").today).toBe("Today")
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it("couvre les libellés du panneau et de la navigation", () => {
|
|
25
|
+
expect(dateLabels("fr").selectDate).toBe("Sélectionner une date")
|
|
26
|
+
expect(dateLabels("fr").clear).toBe("Effacer")
|
|
27
|
+
expect(dateLabels("fr").close).toBe("Fermer")
|
|
28
|
+
expect(dateLabels("fr").prevMonth).toBe("Mois précédent")
|
|
29
|
+
expect(dateLabels("fr").nextMonth).toBe("Mois suivant")
|
|
30
|
+
expect(dateLabels("en").selectDate).toBe("Select a date")
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it("retombe sur l'anglais pour une langue inconnue (JS non typé)", () => {
|
|
34
|
+
expect(dateLabels("de" as never).today).toBe("Today")
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe("weekdayShortNames", () => {
|
|
39
|
+
it("suit la locale demandée, pas celle du navigateur", () => {
|
|
40
|
+
expect(weekdayShortNames("fr-FR", 1)[0]).toMatch(/^lun/i)
|
|
41
|
+
expect(weekdayShortNames("en-US", 1)[0]).toMatch(/^mon/i)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it("commence par le jour demandé (0 = dimanche)", () => {
|
|
45
|
+
const monday = weekdayShortNames("en-US", 1)
|
|
46
|
+
const sunday = weekdayShortNames("en-US", 0)
|
|
47
|
+
expect(sunday[0]).toMatch(/^sun/i)
|
|
48
|
+
expect(sunday[0]).toBe(monday[6])
|
|
49
|
+
expect(sunday[6]).toBe(monday[5])
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it("rend 7 jours sans point final ni minuscule initiale", () => {
|
|
53
|
+
const names = weekdayShortNames("fr-FR")
|
|
54
|
+
expect(names).toHaveLength(7)
|
|
55
|
+
for (const name of names) {
|
|
56
|
+
expect(name.endsWith(".")).toBe(false)
|
|
57
|
+
expect(name[0]).toBe(name[0]!.toUpperCase())
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
describe("monthShortNames", () => {
|
|
63
|
+
it("rend les 12 mois dans la langue demandée", () => {
|
|
64
|
+
const fr = monthShortNames("fr-FR")
|
|
65
|
+
const en = monthShortNames("en-US")
|
|
66
|
+
expect(fr).toHaveLength(12)
|
|
67
|
+
expect(en).toEqual([
|
|
68
|
+
"Jan",
|
|
69
|
+
"Feb",
|
|
70
|
+
"Mar",
|
|
71
|
+
"Apr",
|
|
72
|
+
"May",
|
|
73
|
+
"Jun",
|
|
74
|
+
"Jul",
|
|
75
|
+
"Aug",
|
|
76
|
+
"Sep",
|
|
77
|
+
"Oct",
|
|
78
|
+
"Nov",
|
|
79
|
+
"Dec",
|
|
80
|
+
])
|
|
81
|
+
expect(fr[0]).toMatch(/^janv/i)
|
|
82
|
+
expect(fr[11]).toMatch(/^déc/i)
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe("monthYearLabel", () => {
|
|
87
|
+
it("en-tête long dans la langue demandée", () => {
|
|
88
|
+
expect(monthYearLabel("en-US", 2026, 0)).toBe("January 2026")
|
|
89
|
+
expect(monthYearLabel("fr-FR", 2026, 0)).toContain("janvier 2026")
|
|
90
|
+
})
|
|
91
|
+
})
|
package/lib/dateI18n.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Libellés et locale des composants de date (QDatePicker / QDateCalendar).
|
|
2
|
+
//
|
|
3
|
+
// La langue vient du `QConfigProvider` (`lang`, défaut "en") et arrive ici via
|
|
4
|
+
// `useConfigLang()`. On ne s'appuie **pas** sur la locale du navigateur : le rendu
|
|
5
|
+
// doit être déterministe (serveur ↔ client) et suivre la langue choisie dans l'app.
|
|
6
|
+
import type { QAppLang } from "./config"
|
|
7
|
+
|
|
8
|
+
/** Clés d'interface des composants de date. */
|
|
9
|
+
export type DateUiKey =
|
|
10
|
+
| "today"
|
|
11
|
+
| "prevMonth"
|
|
12
|
+
| "nextMonth"
|
|
13
|
+
| "chooseMonth"
|
|
14
|
+
| "prevYear"
|
|
15
|
+
| "nextYear"
|
|
16
|
+
| "selectDate"
|
|
17
|
+
| "clear"
|
|
18
|
+
| "close"
|
|
19
|
+
|
|
20
|
+
const DATE_UI: Record<QAppLang, Record<DateUiKey, string>> = {
|
|
21
|
+
en: {
|
|
22
|
+
today: "Today",
|
|
23
|
+
prevMonth: "Previous month",
|
|
24
|
+
nextMonth: "Next month",
|
|
25
|
+
chooseMonth: "Choose month",
|
|
26
|
+
prevYear: "Previous year",
|
|
27
|
+
nextYear: "Next year",
|
|
28
|
+
selectDate: "Select a date",
|
|
29
|
+
clear: "Clear",
|
|
30
|
+
close: "Close",
|
|
31
|
+
},
|
|
32
|
+
fr: {
|
|
33
|
+
today: "Aujourd'hui",
|
|
34
|
+
prevMonth: "Mois précédent",
|
|
35
|
+
nextMonth: "Mois suivant",
|
|
36
|
+
chooseMonth: "Choisir le mois",
|
|
37
|
+
prevYear: "Année précédente",
|
|
38
|
+
nextYear: "Année suivante",
|
|
39
|
+
selectDate: "Sélectionner une date",
|
|
40
|
+
clear: "Effacer",
|
|
41
|
+
close: "Fermer",
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Locale BCP 47 associée à la langue de l'app (déterministe, contrairement à `undefined`). */
|
|
46
|
+
export function dateLocale(lang: QAppLang): string {
|
|
47
|
+
return lang === "fr" ? "fr-FR" : "en-US"
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Libellés d'interface des composants de date. */
|
|
51
|
+
export function dateLabels(lang: QAppLang): Record<DateUiKey, string> {
|
|
52
|
+
return DATE_UI[lang] ?? DATE_UI.en
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Abréviation : initiale en majuscule, sans point final (`dim.` → `Dim`). */
|
|
56
|
+
function shortName(value: string): string {
|
|
57
|
+
const clean = value.replace(/[.,]+$/, "").trim()
|
|
58
|
+
return clean.charAt(0).toUpperCase() + clean.slice(1)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Jours de la semaine abrégés, en commençant par `firstDayOfWeek`
|
|
63
|
+
* (0 = dimanche, 1 = lundi).
|
|
64
|
+
*/
|
|
65
|
+
export function weekdayShortNames(locale: string, firstDayOfWeek = 1): string[] {
|
|
66
|
+
const fmt = new Intl.DateTimeFormat(locale, { weekday: "short" })
|
|
67
|
+
// 2024-01-07 est un dimanche — référence stable, indépendante de la date du jour.
|
|
68
|
+
const names = Array.from({ length: 7 }, (_, i) =>
|
|
69
|
+
shortName(fmt.format(new Date(2024, 0, 7 + i))),
|
|
70
|
+
)
|
|
71
|
+
const start = ((firstDayOfWeek % 7) + 7) % 7
|
|
72
|
+
return Array.from({ length: 7 }, (_, i) => names[(start + i) % 7]!)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Mois abrégés, dans l'ordre de l'année (`Janv.` / `Jan`). */
|
|
76
|
+
export function monthShortNames(locale: string): string[] {
|
|
77
|
+
const fmt = new Intl.DateTimeFormat(locale, { month: "short" })
|
|
78
|
+
return Array.from({ length: 12 }, (_, i) => shortName(fmt.format(new Date(2024, i, 1))))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** En-tête du calendrier : « janvier 2026 » / « January 2026 ». */
|
|
82
|
+
export function monthYearLabel(locale: string, year: number, month: number): string {
|
|
83
|
+
return new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" })
|
|
84
|
+
.format(new Date(year, month, 1))
|
|
85
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// $q.network — état de connexion : logique pure + fabrique testable hors DOM.
|
|
2
|
+
// (La règle projet interdit le navigateur : on injecte `navigator` et la cible d'événements.)
|
|
3
|
+
import { describe, expect, it } from "bun:test"
|
|
4
|
+
import { effect } from "vue"
|
|
5
|
+
import { createNetwork, onlineAfterEvent, resolveOnline } from "./network"
|
|
6
|
+
|
|
7
|
+
/** Fausse cible d'événements (window) + compteur d'écouteurs. */
|
|
8
|
+
function fakeTarget() {
|
|
9
|
+
const listeners = new Map<string, Set<(event: { type: string }) => void>>()
|
|
10
|
+
return {
|
|
11
|
+
addEventListener(type: string, cb: (event: { type: string }) => void) {
|
|
12
|
+
if (!listeners.has(type)) listeners.set(type, new Set())
|
|
13
|
+
listeners.get(type)!.add(cb)
|
|
14
|
+
},
|
|
15
|
+
removeEventListener(type: string, cb: (event: { type: string }) => void) {
|
|
16
|
+
listeners.get(type)?.delete(cb)
|
|
17
|
+
},
|
|
18
|
+
/** Émet un événement comme le navigateur. */
|
|
19
|
+
emit(type: string) {
|
|
20
|
+
for (const cb of [...(listeners.get(type) ?? [])]) cb({ type })
|
|
21
|
+
},
|
|
22
|
+
/** Nombre d'écouteurs encore attachés pour ce type. */
|
|
23
|
+
count(type: string) {
|
|
24
|
+
return listeners.get(type)?.size ?? 0
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("resolveOnline", () => {
|
|
30
|
+
it("considère « en ligne » quand l'information manque (serveur, navigateur sans API)", () => {
|
|
31
|
+
expect(resolveOnline(undefined)).toBe(true)
|
|
32
|
+
expect(resolveOnline(null)).toBe(true)
|
|
33
|
+
expect(resolveOnline({})).toBe(true)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it("suit navigator.onLine quand il est un booléen", () => {
|
|
37
|
+
expect(resolveOnline({ onLine: true })).toBe(true)
|
|
38
|
+
expect(resolveOnline({ onLine: false })).toBe(false)
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe("onlineAfterEvent", () => {
|
|
43
|
+
it("online → connecté, offline → déconnecté", () => {
|
|
44
|
+
expect(onlineAfterEvent("online", false)).toBe(true)
|
|
45
|
+
expect(onlineAfterEvent("offline", true)).toBe(false)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it("un autre événement ne change rien", () => {
|
|
49
|
+
expect(onlineAfterEvent("focus", false)).toBe(false)
|
|
50
|
+
expect(onlineAfterEvent("focus", true)).toBe(true)
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
describe("createNetwork", () => {
|
|
55
|
+
it("lit l'état initial dans navigator.onLine", () => {
|
|
56
|
+
const nav = { onLine: false }
|
|
57
|
+
const net = createNetwork({ navigator: nav, target: null })
|
|
58
|
+
expect(net.online).toBe(false)
|
|
59
|
+
expect(net.offline).toBe(true)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it("sans cible (SSR), aucune écoute n'est posée et l'état reste stable", () => {
|
|
63
|
+
const net = createNetwork({ navigator: null, target: null })
|
|
64
|
+
expect(net.online).toBe(true)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it("suit les événements online / offline de la cible", () => {
|
|
68
|
+
const target = fakeTarget()
|
|
69
|
+
const net = createNetwork({ navigator: { onLine: true }, target })
|
|
70
|
+
|
|
71
|
+
target.emit("offline")
|
|
72
|
+
expect(net.online).toBe(false)
|
|
73
|
+
expect(net.offline).toBe(true)
|
|
74
|
+
|
|
75
|
+
target.emit("online")
|
|
76
|
+
expect(net.online).toBe(true)
|
|
77
|
+
expect(net.offline).toBe(false)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it("est réactif : un effet est rejoué au changement (template `$q.network.online`)", () => {
|
|
81
|
+
const target = fakeTarget()
|
|
82
|
+
const net = createNetwork({ navigator: { onLine: true }, target })
|
|
83
|
+
const seen: boolean[] = []
|
|
84
|
+
|
|
85
|
+
effect(() => {
|
|
86
|
+
seen.push(net.online)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
target.emit("offline")
|
|
90
|
+
target.emit("online")
|
|
91
|
+
|
|
92
|
+
expect(seen).toEqual([true, false, true])
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it("ne notifie qu'un vrai changement d'état", () => {
|
|
96
|
+
const target = fakeTarget()
|
|
97
|
+
const net = createNetwork({ navigator: { onLine: true }, target })
|
|
98
|
+
let offlineCalls = 0
|
|
99
|
+
let onlineCalls = 0
|
|
100
|
+
net.on("offline", () => offlineCalls++)
|
|
101
|
+
net.on("online", () => onlineCalls++)
|
|
102
|
+
|
|
103
|
+
target.emit("offline")
|
|
104
|
+
target.emit("offline")
|
|
105
|
+
target.emit("online")
|
|
106
|
+
target.emit("online")
|
|
107
|
+
|
|
108
|
+
expect(offlineCalls).toBe(1)
|
|
109
|
+
expect(onlineCalls).toBe(1)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it("on() cible le bon événement et rend un désabonnement", () => {
|
|
113
|
+
const target = fakeTarget()
|
|
114
|
+
const net = createNetwork({ navigator: { onLine: true }, target })
|
|
115
|
+
const seen: string[] = []
|
|
116
|
+
const stopOnline = net.on("online", () => seen.push("online"))
|
|
117
|
+
net.on("offline", () => seen.push("offline"))
|
|
118
|
+
|
|
119
|
+
target.emit("offline")
|
|
120
|
+
target.emit("online")
|
|
121
|
+
stopOnline()
|
|
122
|
+
target.emit("offline")
|
|
123
|
+
target.emit("online")
|
|
124
|
+
|
|
125
|
+
expect(seen).toEqual(["offline", "online", "offline"])
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it("on() ignore un type inconnu (JS non typé)", () => {
|
|
129
|
+
const target = fakeTarget()
|
|
130
|
+
const net = createNetwork({ navigator: { onLine: true }, target })
|
|
131
|
+
const stop = net.on("focus" as never, () => {
|
|
132
|
+
throw new Error("ne doit jamais être appelé")
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
target.emit("offline")
|
|
136
|
+
expect(typeof stop).toBe("function")
|
|
137
|
+
stop()
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it("les abonnements se désabonnent", () => {
|
|
141
|
+
const target = fakeTarget()
|
|
142
|
+
const net = createNetwork({ navigator: { onLine: true }, target })
|
|
143
|
+
let calls = 0
|
|
144
|
+
const off = net.on("offline", () => calls++)
|
|
145
|
+
|
|
146
|
+
target.emit("offline")
|
|
147
|
+
off()
|
|
148
|
+
target.emit("online")
|
|
149
|
+
target.emit("offline")
|
|
150
|
+
|
|
151
|
+
expect(calls).toBe(1)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it("un abonné qui jette ne bloque pas les suivants", () => {
|
|
155
|
+
const target = fakeTarget()
|
|
156
|
+
const net = createNetwork({ navigator: { onLine: true }, target })
|
|
157
|
+
let calls = 0
|
|
158
|
+
net.on("offline", () => {
|
|
159
|
+
throw new Error("boom")
|
|
160
|
+
})
|
|
161
|
+
net.on("offline", () => calls++)
|
|
162
|
+
|
|
163
|
+
target.emit("offline")
|
|
164
|
+
|
|
165
|
+
expect(calls).toBe(1)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it("refresh() relit navigator.onLine (valeur devenue obsolète)", () => {
|
|
169
|
+
const nav = { onLine: true }
|
|
170
|
+
const net = createNetwork({ navigator: nav, target: fakeTarget() })
|
|
171
|
+
|
|
172
|
+
nav.onLine = false
|
|
173
|
+
expect(net.refresh()).toBe(false)
|
|
174
|
+
expect(net.online).toBe(false)
|
|
175
|
+
|
|
176
|
+
nav.onLine = true
|
|
177
|
+
expect(net.online).toBe(false) // pas de sondage automatique
|
|
178
|
+
expect(net.refresh()).toBe(true)
|
|
179
|
+
expect(net.online).toBe(true)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it("destroy() détache les écouteurs et vide les abonnements", () => {
|
|
183
|
+
const target = fakeTarget()
|
|
184
|
+
const net = createNetwork({ navigator: { onLine: true }, target })
|
|
185
|
+
let calls = 0
|
|
186
|
+
net.on("offline", () => calls++)
|
|
187
|
+
|
|
188
|
+
expect(target.count("online")).toBe(1)
|
|
189
|
+
expect(target.count("offline")).toBe(1)
|
|
190
|
+
|
|
191
|
+
net.destroy()
|
|
192
|
+
target.emit("offline")
|
|
193
|
+
|
|
194
|
+
expect(target.count("online")).toBe(0)
|
|
195
|
+
expect(target.count("offline")).toBe(0)
|
|
196
|
+
expect(net.online).toBe(true)
|
|
197
|
+
expect(calls).toBe(0)
|
|
198
|
+
})
|
|
199
|
+
})
|