@dnax/ui 0.0.63 → 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.
@@ -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 « Today » sous le calendrier (tous les modes) */
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(undefined, { day: "2-digit", month: "short", year: "numeric" }).format(d)),
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 || "Sélectionner une date")
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="Effacer"
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="`Fermer ${sheetTitle}`"
314
+ :aria-label="`${labels.close} ${sheetTitle}`"
308
315
  @click="closePopup"
309
316
  >
310
317
  <Icon :icon="icons.x" aria-hidden="true" />
@@ -25,9 +25,12 @@ export const qTabsKey: InjectionKey<QTabContext> = Symbol("q-tabs")
25
25
 
26
26
  <script setup lang="ts">
27
27
  import { computed, getCurrentInstance, nextTick, onBeforeUnmount, onMounted, provide, ref, shallowRef, watch } from "vue"
28
+ import { Icon } from "@iconify/vue"
28
29
  import { cva } from "class-variance-authority"
29
30
  import { cn } from "../lib/utils"
30
31
  import { colorValue } from "../lib/colors"
32
+ import { icons } from "../lib/icons"
33
+ import { tabArrowState, tabArrowStep } from "../lib/tabs"
31
34
 
32
35
  const tabsVariants = cva("q-tabs", {
33
36
  variants: {
@@ -70,6 +73,12 @@ interface Props {
70
73
  switchIndicatorPosition?: boolean | "top" | "bottom"
71
74
  /** Tabs en colonne (verticales) */
72
75
  vertical?: boolean
76
+ /**
77
+ * **Flèches de défilement** quand les tabs débordent (comportement Quasar, défaut `true`) :
78
+ * le contenu devient défilable et deux boutons apparaissent aux extrémités débordantes.
79
+ * `false` → aucun défilement (les tabs débordent, comme avant).
80
+ */
81
+ showArrows?: boolean
73
82
  /** Ne pas s'étendre (usage dans un QToolbar) */
74
83
  shrink?: boolean
75
84
  /** S'étendre sur l'axe croisé du parent flex */
@@ -106,6 +115,7 @@ const props = withDefaults(defineProps<Props>(), {
106
115
  noCaps: false,
107
116
  switchIndicatorPosition: undefined,
108
117
  vertical: false,
118
+ showArrows: true,
109
119
  shrink: false,
110
120
  stretch: false,
111
121
  contentClass: "",
@@ -148,10 +158,39 @@ const tabRegs = shallowRef<QTabRegistration[]>([])
148
158
  const tick = ref(0)
149
159
  const rootEl = ref<HTMLElement | null>(null)
150
160
 
151
- // Recalcule la position de l'indicateur après chaque changement de DOM
161
+ // — Flèches de défilement (`show-arrows`, comportement Quasar) —
162
+ const contentEl = ref<HTMLElement | null>(null)
163
+ const canScrollStart = ref(false)
164
+ const canScrollEnd = ref(false)
165
+ let contentObserver: ResizeObserver | null = null
166
+
167
+ /** Met à jour la visibilité des flèches depuis les mesures de défilement (axe courant). */
168
+ const updateArrows = () => {
169
+ const el = contentEl.value
170
+ if (!el) return
171
+ const state = props.vertical
172
+ ? tabArrowState(el.scrollTop, el.clientHeight, el.scrollHeight)
173
+ : tabArrowState(el.scrollLeft, el.clientWidth, el.scrollWidth)
174
+ canScrollStart.value = state.canStart
175
+ canScrollEnd.value = state.canEnd
176
+ }
177
+
178
+ /** Fait défiler d'un cran (≈ 60 % de la zone visible) — fluide. */
179
+ const scrollArrows = (dir: -1 | 1) => {
180
+ const el = contentEl.value
181
+ if (!el) return
182
+ const delta = tabArrowStep(props.vertical ? el.clientHeight : el.clientWidth) * dir
183
+ el.scrollBy(props.vertical ? { top: delta, behavior: "smooth" } : { left: delta, behavior: "smooth" })
184
+ }
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
+
189
+ // Recalcule la position de l'indicateur (et les flèches) après chaque changement de DOM
152
190
  const measure = async () => {
153
191
  await nextTick()
154
192
  tick.value++
193
+ updateArrows()
155
194
  }
156
195
 
157
196
  // collapse-inactive : le label s'étend/retombe via une transition max-width
@@ -169,10 +208,18 @@ onMounted(() => {
169
208
  measure()
170
209
  if (typeof window !== "undefined") window.addEventListener("resize", measure)
171
210
  rootEl.value?.addEventListener("transitionend", onCollapseEnd)
211
+ // Le conteneur peut changer de largeur sans resize fenêtre (sidebar, flex…) : les
212
+ // flèches doivent suivre le débordement réel.
213
+ if (typeof ResizeObserver !== "undefined" && contentEl.value) {
214
+ contentObserver = new ResizeObserver(updateArrows)
215
+ contentObserver.observe(contentEl.value)
216
+ }
172
217
  })
173
218
  onBeforeUnmount(() => {
174
219
  if (typeof window !== "undefined") window.removeEventListener("resize", measure)
175
220
  rootEl.value?.removeEventListener("transitionend", onCollapseEnd)
221
+ contentObserver?.disconnect()
222
+ contentObserver = null
176
223
  })
177
224
 
178
225
  const context: QTabContext = {
@@ -240,6 +287,11 @@ const tabsClasses = computed(() =>
240
287
  props.animated && "q-tabs--animated",
241
288
  props.animated && `q-tabs--anim-${props.transition}`,
242
289
  props.collapseInactive && "q-tabs--collapse-inactive",
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",
243
295
  ),
244
296
  )
245
297
  </script>
@@ -253,9 +305,34 @@ const tabsClasses = computed(() =>
253
305
  role="tablist"
254
306
  :aria-orientation="vertical ? 'vertical' : 'horizontal'"
255
307
  >
256
- <div class="q-tabs__content" :class="contentClass">
308
+ <button
309
+ v-if="showArrows && scrollable"
310
+ type="button"
311
+ class="q-tabs__arrow q-tabs__arrow--start"
312
+ :disabled="!canScrollStart"
313
+ :aria-label="vertical ? 'Defiler les onglets vers le haut' : 'Defiler les onglets vers la gauche'"
314
+ @click="scrollArrows(-1)"
315
+ >
316
+ <Icon :icon="vertical ? icons.chevronUp : icons.chevronLeft" aria-hidden="true" />
317
+ </button>
318
+ <div
319
+ ref="contentEl"
320
+ class="q-tabs__content"
321
+ :class="contentClass"
322
+ @scroll="updateArrows"
323
+ >
257
324
  <slot />
258
325
  <div class="q-tabs__indicator" :style="indicatorStyle" aria-hidden="true" />
259
326
  </div>
327
+ <button
328
+ v-if="showArrows && scrollable"
329
+ type="button"
330
+ class="q-tabs__arrow q-tabs__arrow--end"
331
+ :disabled="!canScrollEnd"
332
+ :aria-label="vertical ? 'Defiler les onglets vers le bas' : 'Defiler les onglets vers la droite'"
333
+ @click="scrollArrows(1)"
334
+ >
335
+ <Icon :icon="vertical ? icons.chevronDown : icons.chevronRight" aria-hidden="true" />
336
+ </button>
260
337
  </div>
261
338
  </template>
@@ -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
- /** Raccourci « Today » sous la grille */
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 du navigateur (comme `monthLabel`) */
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 « Today » : désactivé quand aujourd'hui est hors bornes / désactivé */
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="Mois précédent"
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="Choisir le mois"
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="Mois suivant"
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="Année précédente"
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="Année suivante"
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
- Today
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
+ })
@@ -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
+ }