UNPKG

@edugouvfr/ngx-dsfr-ext

Version:

NgxDsfrExt est une extension au package @edugouvfr/ngx-dsfr (portage Angular des éléments DSFR)

898 lines 136 kB
import { Component, ElementRef, EventEmitter, Input, Output, ViewChild, ViewEncapsulation, } from '@angular/core'; import { DsfrFormSelectModule, DsfrI18nPipe, Language, } from '@edugouvfr/ngx-dsfr'; import { Subscription } from 'rxjs'; import { DateUtils } from '../shared/utils/date/date-utils'; import { MESSAGES_EN } from './i18n/messages.en'; import { MESSAGES_FR } from './i18n/messages.fr'; import * as i0 from "@angular/core"; import * as i1 from "@edugouvfr/ngx-dsfr"; /** * Implémente la saisie d'une date via un input ou via un calendrier affichant les jours et les mois. * Validator est implémenté uniquement par rapport à min et max * @since 0.10 * @author pfontanet */ export class DsfrCalendarComponent { /** @internal */ constructor(i18n, langService, loggerService) { this.i18n = i18n; this.langService = langService; this.loggerService = loggerService; /** * Valeur initial de la date. * La valeur doit prendre la forme dd/mm/yyyy pour la locale fr et mm/dd/yyyy pour la locale en. */ this.value = ''; /** Force le focus sur la date courante à l'initialisation du composant */ this.autofocus = false; /** Id de l'élément correspondant au label de la grille de dates */ this.ariaLabelledBy = ''; /** Emission de la date au format DsfrDateEvent */ this.dateChange = new EventEmitter(); /** Evènement au changement d'affichage du calendrier (changement mois ou année) */ this.calendarChange = new EventEmitter(); this.monthSelectOptions = []; this.yearSelectOptions = []; this.currentMonthIndex = new Date().getMonth(); this.currentYear = new Date().getFullYear(); this.translatedMonthAbbreviations = []; this.translatedMonths = []; this.prevMonthLabel = ''; this.nextMonthLabel = ''; this.monthSelectLabel = ''; this.yearSelectLabel = ''; this.isMonoYear = false; this.isMonoMonth = false; this.rows = new Array(6); this.columns = new Array(7); /* Node */ this.dayCells = []; this.focusDay = new Date(); this.selectedDay = new Date(0, 0, 1); this.lastDate = -1; this._min = ''; this._max = ''; this.subscription = new Subscription(); this.i18n.extendsLabelsBundle(Language.EN, MESSAGES_EN); this.i18n.extendsLabelsBundle(Language.FR, MESSAGES_FR); this.prevCodeLang = langService.lang; const langChange$ = i18n.completeLangChange$.subscribe((code) => this.onLangChange(code)); this.subscription.add(langChange$); } /** * @return Le code de la langue en cours */ get codeLang() { return this.langService.lang; } get min() { return this._min; } get max() { return this._max; } get isEnglishLocale() { return this.langService.lang === 'en'; } /** * Date minimale inclue. Doit être au format IS0-8601 : AAAA-MM-JJ. * Ce paramètre peut être utilisé seul ou avec la propriété max. */ set min(value) { if (value && !this.isValidIso(value)) { this.loggerService.warn(`dsfr-date-picker: La propriété min a un format incorrecte. Attendu: 'YYYY-MM-DD'. Reçu: ${value}`); } else if (value && this.isValidIso(value)) { this._min = value; this.computeYearSelectOptions(); } } /** * Date maximale inclue. Doit être au format IS0-8601 : AAAA-MM-JJ. * Ce paramètre peut être utilisé seul ou avec la propriété min. */ set max(value) { if (value && !this.isValidIso(value)) { this.loggerService.warn(`dsfr-date-picker: La propriété max a un format incorrecte. Attendu: 'YYYY-MM-DD'. Reçu: ${value}`); } else if (value && this.isValidIso(value)) { this._max = value; this.computeYearSelectOptions(); } } /** @internal */ ngOnInit() { this.updateMonthAndYearSelects(); this.computeMonoYearAndMonoMonth(); } /** @internal */ ngOnDestroy() { this.subscription.unsubscribe(); } /** @internal */ ngAfterViewInit() { const tableCells = this.tbody.nativeElement.getElementsByTagName('td'); this.dayCells = Array.from(tableCells); this.computeMonthSelectOptions(); this.computeYearSelectOptions(); this.initializeCalendar(); } /** @internal */ ngOnChanges() { const validDate = this.reformatValue(this.value); if (validDate) { this.setNewValue(validDate, true); this.computeMonoYearAndMonoMonth(); this.computeMonthSelectOptions(); this.computeYearSelectOptions(); this.initializeCalendar(); } } /** * Permet de forcer le focus sur la date active, la dernière date focusée ou la date du jour par défaut. * * Cette méthode est notamment utilisée dans la gestion du calendrier embarqué dans une modale. */ focusDate() { for (let i = 0; i < this.dayCells.length; i++) { const dayNode = this.dayCells[i]; const day = this.getDayFromDataDateAttribute(dayNode); if (this.isSameDay(day, this.focusDay) && this.inMinMaxRange(day)) { dayNode.tabIndex = 0; dayNode.focus(); } if (this.isSameDay(day, new Date())) { dayNode.setAttribute('aria-current', 'date'); } } } /********************** * Event Handlers **********************/ handleCancelButton(event) { let flagToPreventDefault = false; switch (event.type) { case 'keydown': const keyEvent = event; switch (keyEvent.key) { case 'Tab': const reachedBoundary = this.getReachedBoundary(); if (reachedBoundary !== undefined) { this.autoFocusBoundaryDate(reachedBoundary); flagToPreventDefault = true; break; } if (!this.inMinMaxRange(this.focusDay)) { this.nextMonth.nativeElement.focus(); flagToPreventDefault = true; break; } break; default: break; } break; case 'click': flagToPreventDefault = true; break; default: break; } if (flagToPreventDefault) { event.stopPropagation(); event.preventDefault(); } } handleNextMonthButton(event) { let flagToPreventDefault = false; switch (event.type) { case 'keydown': const keyEvent = event; switch (keyEvent.key) { case 'Enter': this.moveToNextMonth(); this.updateTableCellsTabIndex(false); flagToPreventDefault = true; break; case 'Tab': const reachedBoundary = this.getReachedBoundary(); if (reachedBoundary !== undefined) { this.autoFocusBoundaryDate(reachedBoundary); flagToPreventDefault = true; } break; } break; case 'click': this.moveToNextMonth(); this.updateTableCellsTabIndex(false); break; default: break; } if (flagToPreventDefault) { event.stopPropagation(); event.preventDefault(); } } handlePreviousMonthButton(event) { let flagToPreventDefault = false; switch (event.type) { case 'keydown': const keyEvent = event; switch (keyEvent.key) { case 'Enter': this.moveToPreviousMonth(); this.updateTableCellsTabIndex(false); flagToPreventDefault = true; break; } break; case 'click': this.moveToPreviousMonth(); this.updateTableCellsTabIndex(false); flagToPreventDefault = true; break; default: break; } if (flagToPreventDefault) { event.stopPropagation(); event.preventDefault(); } } handleMonthSelectKeydown(event) { let flagToPreventDefault = false; switch (event.key) { case 'Up': case 'ArrowUp': this.moveToPreviousMonth(); this.updateTableCellsTabIndex(false); flagToPreventDefault = true; break; case 'Down': case 'ArrowDown': this.moveToNextMonth(); this.updateTableCellsTabIndex(false); flagToPreventDefault = true; break; case 'Left': case 'ArrowLeft': case 'Right': case 'ArrowRight': flagToPreventDefault = true; } if (flagToPreventDefault) { event.stopPropagation(); event.preventDefault(); } } handleYearSelectKeydown(event) { let flagToPreventDefault = false; switch (event.key) { case 'Up': case 'ArrowUp': this.moveToPreviousYear(); this.updateTableCellsTabIndex(false); flagToPreventDefault = true; break; case 'Down': case 'ArrowDown': this.moveToNextYear(); this.updateTableCellsTabIndex(false); flagToPreventDefault = true; break; case 'Left': case 'ArrowLeft': case 'Right': case 'ArrowRight': flagToPreventDefault = true; } if (flagToPreventDefault) { event.stopPropagation(); event.preventDefault(); } } handleDayClick(event) { if (!this.isDayDisabled(event.currentTarget)) { this.updateCalendarValue(event.currentTarget); } event.stopPropagation(); event.preventDefault(); } handleDayKeyDown(event) { let flagToPreventDefault = false; switch (event.key) { case ' ': case 'Enter': if (this.inMinMaxRange(this.focusDay)) { this.updateCalendarValue(event.currentTarget); flagToPreventDefault = true; } break; case 'Right': case 'ArrowRight': this.moveFocusToNextDay(); flagToPreventDefault = true; break; case 'Left': case 'ArrowLeft': this.moveFocusToPreviousDay(); flagToPreventDefault = true; break; case 'Down': case 'ArrowDown': this.moveFocusToNextWeek(); flagToPreventDefault = true; break; case 'Up': case 'ArrowUp': this.moveFocusToPreviousWeek(); flagToPreventDefault = true; break; case 'PageUp': if (event.shiftKey) { this.moveToPreviousYear(); } else { this.moveToPreviousMonth(); } this.updateTableCellsTabIndex(); flagToPreventDefault = true; break; case 'PageDown': if (event.shiftKey) { this.moveToNextYear(); } else { this.moveToNextMonth(); } this.updateTableCellsTabIndex(); flagToPreventDefault = true; break; case 'Home': this.moveFocusToFirstDayOfWeek(); flagToPreventDefault = true; break; case 'End': this.moveFocusToLastDayOfWeek(); flagToPreventDefault = true; break; } if (flagToPreventDefault) { event.stopPropagation(); event.preventDefault(); } } /** * Gestion de la sélection dans la liste déroulante des mois * * @param selectedMonthIndex Index du mois sélectionné (indexage commençant à 1) */ handleMonthSelect(selectedMonthIndex) { const currentYear = this.focusDay.getFullYear(); const currentDay = this.focusDay.getDate(); const dayToFocus = this.computeDayToFocus(currentYear, selectedMonthIndex, currentDay); const targetDate = new Date(currentYear, selectedMonthIndex, dayToFocus); this.lastDate = targetDate.getDate(); this.focusDay = targetDate; this.updateCalendar(); this.moveFocusToDay(targetDate); } /** * Gestion de la sélection dans la liste déroulante des années * * @param selectedYear Année sélectionnée */ handleYearSelect(selectedYear) { const currentMonth = this.focusDay.getMonth(); const currentDay = this.focusDay.getDate(); const dayToFocus = this.computeDayToFocus(selectedYear, currentMonth, currentDay); const dateToFocus = new Date(selectedYear, currentMonth, dayToFocus); this.lastDate = dateToFocus.getDate(); this.focusDay = dateToFocus; this.updateCalendar(); this.moveFocusToDay(dateToFocus); } /** * Permet de calculer si le jour a focus est bien disponible dans le mois et l'année de destination après sélection * dans la liste déroulante. * * Dans le cas où l'on se trouve le 29 Février 2024 et où l'on change d'année grâce à la liste déroulante à 2023, * le jour a focus devrait être le 28 Février 2023. * * @param currentYear Année cible * @param selectedMonthIndex Mois cible * @param currentDay Jour courant dans le calendrier * @returns Le numéro du jour à focus */ computeDayToFocus(currentYear, selectedMonthIndex, currentDay) { const numberOfDaysInMonth = this.getNumberOfDaysInMonth(currentYear, selectedMonthIndex); const dayToFocus = numberOfDaysInMonth < currentDay ? numberOfDaysInMonth : currentDay; return dayToFocus; } /** * Formatage de la valeur dans la langue courante * @param value Valeur au format ISO ou dans la langue courante */ reformatValue(value) { if (value === undefined || value === null) { return undefined; } const parsedDate = DateUtils.parse(this.codeLang, value); const parsedIsoDate = DateUtils.parseDateIso(value); if (parsedIsoDate === undefined && parsedDate === undefined) { return undefined; } return DateUtils.format(this.codeLang, parsedIsoDate ?? parsedDate); } /** * Gestion du changement de langue dans le composant. * * @param code Code de la locale à prendre en compte */ onLangChange(code) { this.prevMonthLabel = this.i18n.t('calendar.prevMonth'); this.nextMonthLabel = this.i18n.t('calendar.nextMonth'); this.monthSelectLabel = this.i18n.t('calendar.monthSelect'); this.yearSelectLabel = this.i18n.t('calendar.yearSelect'); this.computeMonthSelectOptions(); this.computeYearSelectOptions(); this.updateMonthTranslations(); // Reformatage de l'input const date = DateUtils.parse(this.prevCodeLang, this.value); const newValue = DateUtils.format(code, date); this.setNewValue(newValue); this.prevCodeLang = code; this.initializeCalendar(); } /** * Compare deux dates et retourne true si ces dernières sont les même. * * @param day1 Date 1 à comparer * @param day2 Date 2 à comparer * @returns true si les deux dates sont les même */ isSameDay(day1, day2) { if (day1 === undefined || day2 === undefined) { return false; } else { return (day1.getFullYear() === day2.getFullYear() && day1.getMonth() === day2.getMonth() && day1.getDate() === day2.getDate()); } } /** * Mets à jour l'ensemble du Date Picker */ updateCalendar() { this.updateMonthAndYearSelects(); const firstDayOfMonth = new Date(this.focusDay.getFullYear(), this.focusDay.getMonth(), 1); const currentMonthStartingColumn = this.getCurrentMonthStartingColumn(firstDayOfMonth); const firstDisplayedDayOnCalendar = firstDayOfMonth.setDate(firstDayOfMonth.getDate() - currentMonthStartingColumn); let dateOfCellForGridLoop = new Date(firstDisplayedDayOnCalendar); for (let i = 0; i < this.dayCells.length; i++) { const isDayFromCurrentMonth = dateOfCellForGridLoop.getMonth() !== this.focusDay.getMonth(); this.updateCell(this.dayCells[i], isDayFromCurrentMonth, dateOfCellForGridLoop); dateOfCellForGridLoop.setDate(dateOfCellForGridLoop.getDate() + 1); } this.calendarChange.emit(new Date(this.currentYear, this.currentMonthIndex)); } /** * Détermine la colonne à partir de laquelle le mois commence dans le calendrier. * * Cette colonne est dépendante du jour de la semaine mais également de la locale : le calendrier anglais a comme * première colonne le dimanche, contrairement au calendrier français pour lequel la première colonne est le lundi. * * @param firstDayOfMonth Index du premier jour de la semaine * @returns Index de la colonne où commence le mois */ getCurrentMonthStartingColumn(firstDayOfMonth) { if (this.isEnglishLocale) { return firstDayOfMonth.getDay(); } else if (firstDayOfMonth.getDay() - 1 === -1) { return 6; } else { return firstDayOfMonth.getDay() - 1; } } /** * Mise à jour des valeurs des deux listes déroulantes, mois et année. */ updateMonthAndYearSelects() { this.currentMonthIndex = this.focusDay.getMonth(); this.currentYear = this.focusDay.getFullYear(); } /** * Mise à jour de l'état d'une cellule de la table en fonction de : * - si elle se trouve dans le mois, * - si elle se trouve en dehors des dates limites, * - si elle est sélectionnée. * * @param tdElement Une cellule td du tableau * @param outsideOfCurrentMonth boolean true si le jour fait partie du mois ou non * @param cellDate jour correspondant à la cellule */ updateCell(tdElement, outsideOfCurrentMonth, cellDate) { const numericDayOfTheMonth = cellDate.getDate(); const monthCalendarIndex = cellDate.getMonth() + 1; const yyyy = cellDate.getFullYear().toString(); const mm = monthCalendarIndex <= 9 ? `0${monthCalendarIndex}` : monthCalendarIndex.toString(); const dd = numericDayOfTheMonth <= 9 ? `0${numericDayOfTheMonth}` : numericDayOfTheMonth.toString(); tdElement.tabIndex = -1; tdElement.removeAttribute('aria-selected'); tdElement.classList.remove('disabled', 'out-of-month'); tdElement.removeAttribute('aria-disabled'); tdElement.setAttribute('data-date', `${yyyy}-${mm}-${dd}`); if (!this.inMinMaxRange(cellDate)) { tdElement.classList.add('disabled'); tdElement.setAttribute('aria-disabled', 'true'); } else if (outsideOfCurrentMonth) { tdElement.classList.add('out-of-month'); } // daySpan est un span caché ('.fr-sr-only') permettant la vocalisation du jour de la semaine au focus d'une date. // numberSpan est la partie visible dans la grille représentant le nombre du jour. const [daySpan, numberSpan] = [ tdElement.getElementsByTagName('span')[0], tdElement.getElementsByTagName('span')[1], ]; daySpan.textContent = `${this.i18n.t('calendar.days')[cellDate.getDay()]} `; numberSpan.textContent = cellDate.getDate().toString(); if (this.isSameDay(cellDate, this.selectedDay)) { tdElement.setAttribute('aria-selected', 'true'); } if (this.isSameDay(cellDate, this.focusDay)) { tdElement.tabIndex = 0; } } /** * Initie le changement de focus de la date. * * @param targetDate Date cible attendue */ moveFocusToDay(targetDate) { const focusDayMemory = this.focusDay; this.focusDay = targetDate; if (focusDayMemory.getMonth() !== this.focusDay.getMonth() || focusDayMemory.getFullYear() !== this.focusDay.getFullYear()) { this.updateCalendar(); } this.updateTableCellsTabIndex(); } /** * Met à jour le tabindex de toutes les cellules de la grille. * * Nécessaire pour rendre un élement de la table focusable. * * @param autofocus Permet de forcer le focus sur l'élément défini comme étant à tabindex 0. Valeur par défaut à true. */ updateTableCellsTabIndex(autofocus = true) { for (let i = 0; i < this.dayCells.length; i++) { const dayNode = this.dayCells[i]; const day = this.getDayFromDataDateAttribute(dayNode); dayNode.tabIndex = -1; if (this.isSameDay(day, this.focusDay) && this.inMinMaxRange(day)) { dayNode.tabIndex = 0; if (autofocus) { dayNode.focus(); } } } } /** * Force le focus sur la limite min ou max d'un mois pour éviter que le focus soit indisponible. * * Exemple: L'utilisateur sélectionne le 17 Juillet 2024. Il décide ensuite de changer de mois via la liste * déroulante et cible Octobre. Cependant, la date max donnée au date picker est le 10 Octobre 2024. Etant incapable * de focus cette date au retour du focus dans la grille, la nouvelle date de focus bascule alors à la date max déterminée, * cad. le 10 Octobre 2024. * * @param boundary type du boundary à prendre en compte, entre le min et le max */ autoFocusBoundaryDate(boundary) { const boundaryDate = boundary === 'min' ? DateUtils.parseDateIso(this.min) : DateUtils.parseDateIso(this.max); for (let i = 0; i < this.dayCells.length; i++) { const dayNode = this.dayCells[i]; const day = this.getDayFromDataDateAttribute(dayNode); if (boundaryDate && this.isSameDay(day, boundaryDate)) { dayNode.tabIndex = 0; dayNode.focus(); } } } initializeCalendar() { this.computeInitialDate(); this.lastDate = this.focusDay.getDate(); this.updateCalendar(); if (this.autofocus) { this.focusDate(); } } getNumberOfDaysInMonth(year, monthIndex) { return new Date(year, monthIndex + 1, 0).getDate(); } /** * Calcul la date cible en fonction de la date actuelle et du nombre de mois à ajouter ou enlever * à cette dernière. * * @param currentDate Date actuelle * @param monthsOffset Nombre de mois à ajouter ou enlever à la date actuelle * @returns La date cible */ computeTargetDateWithMonthOffset(currentDate, monthsOffset) { const getDays = (year, month) => new Date(year, month, 0).getDate(); const isPrev = monthsOffset < 0; const numYears = Math.trunc(Math.abs(monthsOffset) / 12); monthsOffset = Math.abs(monthsOffset) % 12; const newYear = isPrev ? currentDate.getFullYear() - numYears : currentDate.getFullYear() + numYears; const newMonth = isPrev ? currentDate.getMonth() - monthsOffset : currentDate.getMonth() + monthsOffset; const newDate = new Date(newYear, newMonth, 1); const daysInMonth = getDays(newDate.getFullYear(), newDate.getMonth() + 1); this.lastDate = this.lastDate ? this.lastDate : currentDate.getDate(); if (this.lastDate > daysInMonth) { newDate.setDate(daysInMonth); } else { newDate.setDate(this.lastDate); } return newDate; } moveToNextYear() { this.focusDay = this.computeTargetDateWithMonthOffset(this.focusDay, 12); this.updateCalendar(); } moveToPreviousYear() { this.focusDay = this.computeTargetDateWithMonthOffset(this.focusDay, -12); this.updateCalendar(); } moveToNextMonth() { this.focusDay = this.computeTargetDateWithMonthOffset(this.focusDay, 1); this.updateCalendar(); } moveToPreviousMonth() { this.focusDay = this.computeTargetDateWithMonthOffset(this.focusDay, -1); this.updateCalendar(); } moveFocusToNextDay() { const d = new Date(this.focusDay); d.setDate(d.getDate() + 1); if (this.inMinMaxRange(d)) { this.lastDate = d.getDate(); this.moveFocusToDay(d); } } moveFocusToNextWeek() { const d = new Date(this.focusDay); d.setDate(d.getDate() + 7); if (this.inMinMaxRange(d)) { this.lastDate = d.getDate(); this.moveFocusToDay(d); } } moveFocusToPreviousDay() { const d = new Date(this.focusDay); d.setDate(d.getDate() - 1); if (this.inMinMaxRange(d)) { this.lastDate = d.getDate(); this.moveFocusToDay(d); } } moveFocusToPreviousWeek() { const d = new Date(this.focusDay); d.setDate(d.getDate() - 7); if (this.inMinMaxRange(d)) { this.lastDate = d.getDate(); this.moveFocusToDay(d); } } moveFocusToFirstDayOfWeek() { const d = new Date(this.focusDay); d.setDate(d.getDate() - d.getDay()); this.lastDate = d.getDate(); this.moveFocusToDay(d); } moveFocusToLastDayOfWeek() { const d = new Date(this.focusDay); d.setDate(d.getDate() + (6 - d.getDay())); this.lastDate = d.getDate(); this.moveFocusToDay(d); } // Day methods isDayDisabled(domNode) { return domNode.classList.contains('disabled'); } getDayFromDataDateAttribute(dayNode) { const dataDate = dayNode.getAttribute('data-date'); const parts = dataDate ? dataDate.split('-') : []; return parts.length >= 3 ? new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2])) : new Date(); } // Textbox methods /** * Mets à jour la valeur du composant et émets un événement de type DsfrDateEvent. * Cette emission peut être omise via le paramètre booléen skipEmission, notamment à l'initialisation du composant. * @param value * @private */ setNewValue(value, skipEmission = false) { this.value = value; const date = DateUtils.parse(this.codeLang, this.value); if (!skipEmission) { this.dateChange.emit({ value: this.value, date: date }); } } /** * Valorise la date de l'input suite à la sélection d'une date dans le calendrier * @param domNode cellule td du calendrier telle que 'data-date' contienne la date ISO, ex : data-date="2024-05-28". * - i18n : le code de formatage fait appel à DateUtils */ updateCalendarValue(domNode) { this.selectedDay = this.getDayFromDataDateAttribute(domNode); this.focusDay = this.getDayFromDataDateAttribute(domNode); this.updateCalendar(); const newValue = DateUtils.format(this.codeLang, this.selectedDay); this.setNewValue(newValue); } /** * Positionne la date du calendrier à partir de * - i18n : la date est obtenue à partir de DateUtils */ computeInitialDate() { const day = DateUtils.parse(this.codeLang, this.value); const isOnBoundary = day ? this.isInBounds(day) : false; if (day && isOnBoundary) { this.focusDay = day; this.selectedDay = new Date(this.focusDay); } else if (day && !isOnBoundary) { this.focusDay = new Date(); this.selectedDay = new Date(day); } else { // If not a valid date (MM/DD/YY) initialize with todays date this.focusDay = new Date(); this.selectedDay = new Date(0, 0, 1); } } /** * @param date date * @returns true si la data 'd' est dans l'intervalle [min, max] du cou */ inMinMaxRange(date) { const minDate = this.min ? DateUtils.parseDateIso(this.min) : undefined; const maxDate = this.max ? DateUtils.parseDateIso(this.max) : undefined; return ((minDate === undefined || minDate < date || this.isSameDay(date, minDate)) && (maxDate === undefined || date < maxDate || this.isSameDay(date, maxDate))); } computeMonthSelectOptions() { const translatedMonths = DateUtils.arr(this.i18n, this.loggerService, 'calendar.months'); this.monthSelectOptions = translatedMonths.map((monthLabel, i) => ({ label: monthLabel, value: i, // Pour information, en JS, les mois sont indexés à partir de 0 contrairement aux jours et années })); } computeYearSelectOptions() { const currentYear = new Date().getFullYear(); const defaultMinOffset = 100; const defaultMaxOffset = 100; const minYear = this.min ? new Date(this.min).getFullYear() : undefined; const maxYear = this.max ? new Date(this.max).getFullYear() : undefined; const lowerBoundYear = minYear ?? currentYear - defaultMinOffset; const higherBoundYear = maxYear ?? currentYear + defaultMaxOffset; const options = []; for (let year = lowerBoundYear; year <= higherBoundYear; year++) { options.push({ label: year.toString(), value: year }); } this.yearSelectOptions = options; } updateMonthTranslations() { this.translatedMonths = DateUtils.arr(this.i18n, this.loggerService, 'calendar.months'); this.translatedMonthAbbreviations = DateUtils.arr(this.i18n, this.loggerService, 'calendar.monthsAbbr'); } isValidIso(boundaryString) { const dateRegex = /^\d{4}-\d{2}-\d{2}$/; return dateRegex.test(boundaryString); } /** * Dans le cas d'un changement de mois ou d'année dans le calendrier, cette fonction détermine le type de butée atteinte * dans le cas ou le nouveau focusDay se trouverait en dehors des dates min ou max, uniquement dans un mois contenant lesdits min ou max. * * Exemple: Le focusDay est le 20 Juin 2024 et la date max le 15 Juillet 2024. Si l'utilisateur navigue vers le mois de Juillet via la * liste déroulante ou la flèche de "Mois suivant", getReachedBoundary retourne 'max', qui est le type de butée atteinte. * * @returns Retourne le type de butée atteinte (min ou max) ou undefined si aucune butée n'est atteinte ou qu'aucun focusDay n'est valide pour * cette date. */ getReachedBoundary() { const minDate = this.min ? DateUtils.parseDateIso(this.min) : undefined; const maxDate = this.max ? DateUtils.parseDateIso(this.max) : undefined; if (!this.min && !this.max) { return undefined; } if (minDate && this.focusDay.getFullYear() === minDate.getFullYear() && this.focusDay.getMonth() === minDate.getMonth() && this.focusDay.getDate() < minDate.getDate()) { return 'min'; } if (maxDate && this.focusDay.getFullYear() === maxDate.getFullYear() && this.focusDay.getMonth() === maxDate.getMonth() && this.focusDay.getDate() > maxDate.getDate()) { return 'max'; } return undefined; } computeMonoYearAndMonoMonth() { const hasMinAndMax = !!this.min && !!this.max; this.isMonoYear = hasMinAndMax && DateUtils.parseDateIso(this.min)?.getFullYear() === DateUtils.parseDateIso(this.max)?.getFullYear(); this.isMonoMonth = hasMinAndMax && this.isMonoYear && DateUtils.parseDateIso(this.min)?.getMonth() === DateUtils.parseDateIso(this.max)?.getMonth(); } /** * Vérifie si la date active du composant se trouve dans les limites données du composant. * * @param date * @returns Un booléen indiquant si la date se trouve bien entre les limites minimum et maximum données. */ isInBounds(date) { const minDate = this.min ? DateUtils.parseDateIso(this.min) : undefined; const maxDate = this.max ? DateUtils.parseDateIso(this.max) : undefined; if (!minDate && !maxDate) { return true; } else if (maxDate && !minDate) { return date.getTime() < maxDate.getTime(); } else if (minDate && !maxDate) { return date.getTime() > minDate.getTime(); } else { return date.getTime() < maxDate.getTime() && date.getTime() > minDate.getTime(); } } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: DsfrCalendarComponent, deps: [{ token: i1.DsfrI18nService }, { token: i1.LangService }, { token: i1.LoggerService }], target: i0.ɵɵFactoryTarget.Component }); } static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.13", type: DsfrCalendarComponent, isStandalone: true, selector: "dsfr-ext-calendar, dsfrx-calendar", inputs: { value: "value", autofocus: "autofocus", ariaLabelledBy: "ariaLabelledBy", min: "min", max: "max" }, outputs: { dateChange: "dateChange", calendarChange: "calendarChange" }, viewQueries: [{ propertyName: "previousMonth", first: true, predicate: ["previousMonth"], descendants: true }, { propertyName: "nextMonth", first: true, predicate: ["nextMonth"], descendants: true }, { propertyName: "monthSelect", first: true, predicate: ["monthSelect"], descendants: true, read: ElementRef }, { propertyName: "yearSelect", first: true, predicate: ["yearSelect"], descendants: true, read: ElementRef }, { propertyName: "tbody", first: true, predicate: ["tbody"], descendants: true }], usesOnChanges: true, ngImport: i0, template: "<header class=\"header\">\n <button\n type=\"button\"\n class=\"prev-month fr-btn fr-btn--tertiary-no-outline fr-icon-arrow-left-line\"\n [title]=\"prevMonthLabel\"\n [attr.aria-label]=\"prevMonthLabel\"\n (click)=\"handlePreviousMonthButton($event)\"\n (keydown)=\"handlePreviousMonthButton($event)\"\n #previousMonth\n data-testid=\"datepicker-prev-month\"></button>\n\n <div class=\"header__select-container\">\n <dsfr-form-select\n placeHolder=\"Mois\"\n [value]=\"currentMonthIndex\"\n [options]=\"monthSelectOptions\"\n [label]=\"monthSelectLabel\"\n [labelSrOnly]=\"true\"\n [disabled]=\"isMonoMonth\"\n (keydown)=\"handleMonthSelectKeydown($event)\"\n (selectChange)=\"handleMonthSelect($event)\"\n #monthSelect\n data-testid=\"datepicker-month-select\"></dsfr-form-select>\n\n <div class=\"select-displayer\" aria-live=\"polite\">\n {{ translatedMonthAbbreviations[currentMonthIndex] }}&nbsp;\n @if (!isMonoMonth) {\n <span aria-hidden=\"true\" class=\"fr-icon-arrow-down-s-line\"></span>\n }\n </div>\n </div>\n\n <div class=\"header__select-container\">\n <dsfr-form-select\n ariaLabel=\"Ann\u00E9e\"\n placeHolder=\"Ann\u00E9e\"\n [value]=\"currentYear\"\n [options]=\"yearSelectOptions\"\n [label]=\"monthSelectLabel\"\n [labelSrOnly]=\"true\"\n [disabled]=\"isMonoYear\"\n (keydown)=\"handleYearSelectKeydown($event)\"\n (selectChange)=\"handleYearSelect($event)\"\n #yearSelect\n data-testid=\"datepicker-year-select\"></dsfr-form-select>\n\n <div class=\"select-displayer\" aria-live=\"polite\">\n {{ currentYear }}&nbsp;\n @if (!isMonoYear) {\n <span aria-hidden=\"true\" class=\"fr-icon-arrow-down-s-line\"></span>\n }\n </div>\n </div>\n\n <button\n type=\"button\"\n class=\"next-month fr-btn fr-btn--tertiary-no-outline fr-icon-arrow-right-line\"\n [title]=\"nextMonthLabel\"\n (click)=\"handleNextMonthButton($event)\"\n [attr.aria-label]=\"nextMonthLabel\"\n (keydown)=\"handleNextMonthButton($event)\"\n #nextMonth\n data-testid=\"datepicker-next-month\"></button>\n</header>\n\n<div class=\"table-wrap\">\n <table class=\"dates\" role=\"grid\" aria-labelledby=\"id-grid-label\">\n <thead>\n <tr>\n @if (isEnglishLocale) {\n <th scope=\"col\" abbr=\"{{ 'calendar.days[0]' | dsfrI18n }}\">{{ 'calendar.daysAbbr[0]' | dsfrI18n }}</th>\n }\n <th scope=\"col\" abbr=\"{{ 'calendar.days[1]' | dsfrI18n }}\">{{ 'calendar.daysAbbr[1]' | dsfrI18n }}</th>\n <th scope=\"col\" abbr=\"{{ 'calendar.days[2]' | dsfrI18n }}\">{{ 'calendar.daysAbbr[2]' | dsfrI18n }}</th>\n <th scope=\"col\" abbr=\"{{ 'calendar.days[3]' | dsfrI18n }}\">{{ 'calendar.daysAbbr[3]' | dsfrI18n }}</th>\n <th scope=\"col\" abbr=\"{{ 'calendar.days[4]' | dsfrI18n }}\">{{ 'calendar.daysAbbr[4]' | dsfrI18n }}</th>\n <th scope=\"col\" abbr=\"{{ 'calendar.days[5]' | dsfrI18n }}\">{{ 'calendar.daysAbbr[5]' | dsfrI18n }}</th>\n <th scope=\"col\" abbr=\"{{ 'calendar.days[6]' | dsfrI18n }}\">{{ 'calendar.daysAbbr[6]' | dsfrI18n }}</th>\n @if (!isEnglishLocale) {\n <th scope=\"col\" abbr=\"{{ 'calendar.days[0]' | dsfrI18n }}\">{{ 'calendar.daysAbbr[0]' | dsfrI18n }}</th>\n }\n </tr>\n </thead>\n\n <tbody #tbody>\n @for (row of rows; track $index) {\n <tr>\n @for (column of columns; track $index) {\n <td tabindex=\"-1\" (click)=\"handleDayClick($event)\" (keydown)=\"handleDayKeyDown($event)\" #td>\n <span class=\"fr-sr-only\"></span>\n <span>-1</span>\n </td>\n }\n </tr>\n }\n </tbody>\n </table>\n</div>\n", styles: ["dsfr-ext-calendar,dsfrx-calendar{display:block;width:320px;clear:both}.header{cursor:default;padding:.375rem;font-weight:700;text-transform:uppercase;display:flex;justify-content:space-around}.header__select-container{position:relative}.header__select-container:focus-within{outline-style:solid;outline-offset:2px;outline-width:2px;outline-color:#0a76f6}.header__select-container .fr-select-group{max-width:6.5rem}.header__select-container .fr-select-group select.fr-select{margin-top:0;opacity:0}.header__select-container .select-displayer{z-index:-1;position:absolute;inset:0;display:flex;align-items:center;justify-content:center}h2{margin:0;padding:0;display:inline-block;font-size:1em;text-transform:none;font-weight:700;border:none}button::-moz-focus-inner{border:0}.dates{width:320px}.dialog-ok-cancel-group{text-align:right;margin-top:1em;margin-bottom:1em;margin-right:1em}.dialog-ok-cancel-group button{margin-left:1em}table.dates{padding-left:1em;padding-right:1em;border:none;border-collapse:separate}table.dates th,table.dates td{text-align:center;color:var(--text-default-grey);background-color:var(--background-default-grey)}table.dates tr{border:1px solid black}table.dates td{padding:3px;margin:0;line-height:inherit;height:40px;width:40px;border:1px solid var(--blue-france-925-125);font-size:15px}table.dates td.disabled{padding:2px;border:none;height:41px;width:41px;color:var(--text-disabled-grey);background-color:var(--background-disabled-grey);font-style:italic}table.dates td.disabled:hover{cursor:not-allowed}table.dates td.out-of-month{background-color:var(--background-alt-grey);color:var(--text-mention-grey);border:none}table.dates td:focus:not(.disabled){padding:1px;outline:2px solid var(--border-active-blue-france)!important;background-color:var(--background-default-grey);color:var(--text-default-grey)}table.dates td:hover{padding:0;background-color:var(--background-action-low-blue-france-hover);color:var(--text-default-grey);cursor:pointer}table.dates td:hover:not(.disabled){padding:2px;border:1px solid var(--border-active-blue-france)}table.dates td[aria-selected]:not(.disabled){background-color:var(--background-action-high-blue-france);color:var(--text-inverted-grey)}\n"], dependencies: [{ kind: "ngmodule", type: DsfrFormSelectModule }, { kind: "component", type: i1.DsfrFormSelectComponent, selector: "dsfr-form-select", inputs: ["placeholder", "required", "ariaLabel", "ariaInvalid", "error", "valid", "message", "messageSeverity", "labelSrOnly", "placeHolder", "options", "compareWith"], outputs: ["selectChange"] }, { kind: "pipe", type: DsfrI18nPipe, name: "dsfrI18n" }], encapsulation: i0.ViewEncapsulation.None }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: DsfrCalendarComponent, decorators: [{ type: Component, args: [{ selector: 'dsfr-ext-calendar, dsfrx-calendar', encapsulation: ViewEncapsulation.None, standalone: true, imports: [DsfrFormSelectModule, DsfrI18nPipe], template: "<header class=\"header\">\n <button\n type=\"button\"\n class=\"prev-month fr-btn fr-btn--tertiary-no-outline fr-icon-arrow-left-line\"\n [title]=\"prevMonthLabel\"\n [attr.aria-label]=\"prevMonthLabel\"\n (click)=\"handlePreviousMonthButton($event)\"\n (keydown)=\"handlePreviousMonthButton($event)\"\n #previousMonth\n data-testid=\"datepicker-prev-month\"></button>\n\n <div class=\"header__select-container\">\n <dsfr-form-select\n placeHolder=\"Mois\"\n [value]=\"currentMonthIndex\"\n [options]=\"monthSelectOptions\"\n [label]=\"monthSelectLabel\"\n [labelSrOnly]=\"true\"\n [disabled]=\"isMonoMonth\"\n (keydown)=\"handleMonthSelectKeydown($event)\"\n (selectChange)=\"handleMonthSelect($event)\"\n #monthSelect\n data-testid=\"datepicker-month-select\"></dsfr-form-select>\n\n <div class=\"select-displayer\" aria-live=\"polite\">\n {{ translatedMonthAbbreviations[currentMonthIndex] }}&nbsp;\n @if (!isMonoMonth) {\n <span aria-hidden=\"true\" class=\"fr-icon-arrow-down-s-line\"></span>\n }\n </div>\n </div>\n\n <div class=\"header__select-container\">\n <dsfr-form-select\n ariaLabel=\"Ann\u00E9e\"\n placeHolder=\"Ann\u00E9e\"\n [value]=\"currentYear\"\n [options]=\"yearSelectOptions\"\n [label]=\"monthSelectLabel\"\n [labelSrOnly]=\"true\"\n [disabled]=\"isMonoYear\"\n (keydown)=\"handleYearSelectKeydown($event)\"\n (selectChange)=\"handleYearSelect($event)\"\n #yearSelect\n data-testid=\"datepicker-year-select\"></dsfr-form-select>\n\n <div class=\"select-displayer\" aria-live=\"polite\">\n {{ currentYear }}&nbsp;\n @if (!isMonoYear) {\n <span aria-hidden=\"true\" class=\"fr-icon-arrow-down-s-line\"></span>\n }\n </div>\n </div>\n\n <button\n type=\"button\"\n class=\"next-month fr-btn fr-btn--tertiary-no-outline fr-icon-arrow-right-line\"\n [title]=\"nextMonthLabel\"\n (click)=\"handleNextMonthButton($event)\"\n [attr.aria-label]=\"nextMonthLabel\"\n (keydown)=\"handleNextMonthButton($event)\"\n #nextMonth\n data-testid=\"datepicker-next-month\"></button>\n</header>\n\n<div class=\"table-wrap\">\n <table class=\"dates\" role=\"grid\" aria-labelledby=\"id-grid-label\">\n <thead>\n <tr>\n @if (isEnglishLocale) {\n <th scope=\"col\" abbr=\"{{ 'calendar.days[0]' | dsfrI18n }}\">{{ 'calendar.daysAbbr[0]' | dsfrI18n }}</th>\n }\n <th scope=\"col\" abbr=\"{{ 'calendar.days[1]' | dsfrI18n }}\">{{ 'calendar.daysAbbr[1]' | dsfrI18n }}</th>\n <th scope=\"col\" abbr=\"{{ 'calendar.days[2]' | dsfrI18n }}\">{{ 'calendar.daysAbbr[2]' | dsfrI18n }}</th>\n <th scope=\"col\" abbr=\"{{ 'calendar.days[3]' | dsfrI18n }}\">{{ 'calendar.daysAbbr[3]' | dsfrI18n }}</th>\n <th scope=\"col\" abbr=\"{{ 'calendar.days[4]' | dsfrI18n }}\">{{ 'calendar.daysAbbr[4]' | dsfrI18n }}</th>\n <th scope=\"col\" abbr=\"{{ 'calendar.days[5]' | dsfrI18n }}\">{{ 'calendar.daysAbbr[5]' | dsfrI18n }}</th>\n <th scope=\"col\" abbr=\"{{ 'calendar.days[6]' | dsfrI18n }}\">{{ 'calendar.daysAbbr[6]' | dsfrI18n }}</th>\n @if (!isEnglishLocale) {\n <th scope=\"col\" abbr=\"{{ 'calendar.days[0]' | dsfrI18n }}\">{{ 'calendar.daysAbbr[0]' | dsfrI18n }}</th>\n }\n </tr>\n </thead>\n\n <tbody #tbody>\n @for (row of rows; track $index) {\n <tr>\n @for (column of columns; track $index) {\n <td tabindex=\"-1\" (click)=\"handleDayClick($event)\" (keydown)=\"handleDayKeyDown($event)\" #td>\n <span class=\"fr-sr-only\"></span>\n <span>-1</span>\n </td>\n }\n </tr>\n }\n </tbody>\n </table>\n</div>\n", styles: ["dsfr-ext-calendar,dsfrx-calendar{display:block;width:320px;clear:both}.header{cursor:default;padding:.375rem;font-weight:700;text-transform:uppercase;display:flex;justify-content:space-around}.header__select-container{position:relative}.header__select-container:focus-within{outline-style:solid;outline-offset:2px;outline-width:2px;outline-color:#0a76f6}.header__select-container .fr-select-group{max-width:6.5rem}.header__select-container .fr-select-group select.fr-select{margin-top:0;opacity:0}.header__select-container .select-displayer{z-index:-1;position:absolute;inset:0;display:flex;align-items:center;justify-content:center}h2{margin:0;padding:0;display:inline-block;font-size:1em;text-transform:none;font-weight:700;border:none}button::-moz-focus-inner{border:0}.dates{width:320px}.dialog-ok-cancel-group{text-align:right;margin-top:1em;margin-bottom:1em;margin-right:1em}.dialog-ok-cancel-group button{margin-left:1em}table.dates{padding-left:1em;padding-right:1em;border:none;border-collapse:separate}table.dates th,table.dates td{text-align:center;color:var(--text-default-grey);background-color:var(--background-default-grey)}table.dates tr{border:1px solid black}table.dates td{padding:3px;margin:0;line-height:inherit;height:40px;width:40px;border:1px solid var(--blue-france-925-125);font-size:15px}table.dates td.disabled{padding:2px;border:none;height:41px;width:41px;color:var(--text-disabled-grey);background-color:var(--background-disabled-grey);font-style:italic}table.dates td.disabled:hover{cursor:not-allowed}table.dates td.out-of-month{background-color:var(--background-alt-grey);color:var(--text-mention-grey);border:none}table.dates td:focus:not(.disabled){padding:1px;outline:2px solid var(--border-active-blue-france)!important;background-color:var(--background-default-grey);color:var(--text-default-grey)}table.dates td:hover{padding:0;background-color:var(--background-action-low-blue-france-hover);color:var(--text-default-grey);cursor:pointer}table.dates td:hover:not(.disabled){padding:2px;border:1px solid var(--border-active-blue-france)}table.dates td[aria-selected]:not(.disabled){background-color:var(--background-action-high-blue-france);color:var(--text-inverted-grey)}\n"] }] }], ctorParameters: () => [{ type: i1.DsfrI18nService }, { type: i1.LangService }, { type: i1.LoggerService }], propDecorators: { value: [{ type: Input }], autofocus: [{ type: Input }], ariaLabelledBy: [{ type: Input, args: [{ required: true }] }], dateChange: [{ type: Output }], calendarChange: [{ type: Output }], previousMonth: [{ type: ViewChild, args: ['previousMonth'] }], nextMonth: [{ type: ViewChild, args: ['nextMonth'] }], monthSelect: [{ type: ViewChild, args: ['monthSelect', { read: ElementRef }] }], yearSelect: [{ type: ViewChild, args: ['yearSelect', { read: ElementRef }] }], tbody: [{ type: ViewChild, args: ['tbody'] }], min: [{ type: Input }], max: [{ type: Input }] } }); //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY2FsZW5kYXIuY29tcG9uZW50LmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vLi4vLi4vLi4vLi4vcHJvamVjdHMvbmd4LWRzZnItZXh0L3NyYy9saWIvY2FsZW5kYXIvY2FsZW5kYXIuY29tcG9uZW50LnRzIiwiLi4vLi4vLi4vLi4vLi4vcHJvamVjdHMvbmd4LWRzZnItZXh0L3NyYy9saWIvY2FsZW5kYXIvY2FsZW5kYXIuY29tcG9uZW50Lmh0bWwiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsT0FBTyxFQUVMLFNBQVMsRUFDVCxVQUFVLEVBQ1YsWUFBWSxFQUNaLEtBQUssRUFJTCxNQUFNLEVBQ04sU0FBUyxFQUNULGlCQUFpQixHQUNsQixNQUFNLGVBQWUsQ0FBQztBQUN2QixPQUFPLEVBQ0wsb0JBQW9CLEVBQ3BCLFlBQVksRUFJWixRQUFRLEdBRVQsTUFBTSxxQkFBcUIsQ0FBQztBQUM3QixPQUFPLEVBQUUsWUFBWSxFQUF