diff --git a/CHANGELOG.md b/CHANGELOG.md
index cb0ee54..8eedf95 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -25,11 +25,28 @@ Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le pr
- Le `placeholder` natif est neutralisé tant que `floatLabel` et `label` sont tous les deux renseignés : les deux textes occupent la même place et ne seraient lisibles ni l'un ni l'autre. `floatLabel` sans `label` ne fait rien.
- Nouveau mixin partagé `utils.field-float-inset($extra)`, dans la surface SCSS publiée : c'est par lui qu'un contrôle réserve la bande du libellé `in`. Un champ écrit hors du kit sur `ui-field` l'inclut sur son contrôle (les mixins `utils.field-native-input` le font déjà).
- Neuf nouveaux réglages `--ui-field-float-label-*` (taille, interligne, échelle au repos, décalages, entaille de la variante `on`, retrait derrière une icône gauche), plus les trois valeurs dérivées qui en découlent : voir la table « Theming » de la doc.
+- **`ui-datepicker` annonce le format de date attendu aux lecteurs d'écran** (FSHSP-118). Le `placeholder` seul (« jj/mm/aaaa ») est un support inégal selon les lecteurs d'écran, et il disparaît dès la première frappe. Un hint dédié, dérivé du même `resolvedPlaceholder`, est maintenant chaîné sur l'`aria-describedby` du déclencheur — à côté du message d'aide/erreur, jamais à sa place. Nouvel input `formatHintLabel` pour le personnaliser (ou `''` pour le désactiver) ; sans effet quand le champ n'est pas saisissable au clavier.
+ - `ui-input` (donc tout champ construit dessus) accepte désormais un `ariaDescribedBy` externe, chaîné de la même façon sur son `aria-describedby` natif plutôt que de l'écraser — c'est le mécanisme qui rend le point ci-dessus possible sans dupliquer la logique dans `ui-datepicker`.
+- **`ui-datepicker` : `allowInput` couvre maintenant `range` et `multiple`** (FSHSP-118), en complément de la grille (le clic continue de fonctionner à l'identique). `range` se tape dans le même champ, les deux dates séparées par `" - "` (ex. `"08/07/2026 - 18/07/2026"`) ; `multiple` accepte une liste séparée par `", "`, nombre de dates non borné. `range` bénéficie du même masque auto-"/" en direct qu'en mode `single` (les deux dates, puis leur séparateur, se construisent au fil de la frappe) ; `multiple`, dont le nombre de dates n'est pas borné, reste en texte libre, parsé au blur/Entrée uniquement — dans les deux cas avec les mêmes garanties qu'en `single` : une entrée incomplète ou invalide revient à la dernière valeur affichée, une plage tapée dans le désordre est réordonnée chronologiquement (comme un second clic dans la grille), une date dupliquée en `multiple` est supprimée (comme un clic sur une case déjà sélectionnée). Un `parseDate` custom s'applique par date individuelle, symétrique de `dateFormat`. Non couvert : la combinaison avec `showTime` (les dates tapées en `range`/`multiple` sont toujours calées à minuit — seule la grille gère l'heure sur ces modes pour l'instant).
### Changed
- **La boîte de `ui-field` est désormais enveloppée dans un `.ui-field-control`** (FSHSP-157). C'est le contexte de positionnement du libellé flottant, et il est rendu dans les deux modes plutôt que conditionnellement, pour que le DOM d'un champ ne dépende pas de l'option. Aucun impact visuel ni sur les sélecteurs publics ; un consommateur qui aurait écrit du CSS sur l'enchaînement direct `.ui-field > .ui-field-box` doit passer par le descendant.
- **`ui-label` tronque son texte quand il est contraint** au lieu de déborder (`text-overflow: ellipsis` sur `.ui-label-text`, `max-width: 100%` sur la racine). Sans contrainte de largeur, le comportement est inchangé : le texte passe à la ligne comme avant.
+- **`ui-datepicker` est saisissable au clavier par défaut** (`allowInput` passe de `false` à `true`, FSHSP-118). Le champ ne proposait la sélection qu'au calendrier dans la grande majorité des configurations ; pour un non-voyant, taper une date est bien plus rapide que naviguer une grille de ~30 cases au lecteur d'écran. Repasser `allowInput` à `false` restaure l'ancien comportement (grille seule). Sans effet en `multiple`/`range` (aucun parseur défini pour deux dates ou une liste — chantier séparé) ni en `timeOnly` (pas de parseur pour une heure seule) : ces modes restent lecture seule comme avant.
+- **`showClear` passe de `false` à `true` par défaut, et sa priorité change face à l'icône calendrier** (FSHSP-118). Avant, la croix remplaçait systématiquement l'icône calendrier/horloge dès qu'une valeur était présente (si `showClear`) — au prix de perdre le seul déclencheur focusable capable de rouvrir le panneau. Désormais la croix ne prend le pas que si `showIcon` est à `false` : avec l'icône affichée (le défaut), elle reste cliquable pour changer la date directement, et l'effacement passe par le clavier (`allowInput`, sélectionner + supprimer le texte). Un consommateur qui utilisait déjà `showClear` avec `showIcon` à `true` verra donc la croix disparaître au profit de l'icône calendrier ; passer `showIcon` à `false` restaure son ancien comportement.
+
+### Fixed
+
+- **`ui-datepicker` en mode `timeOnly` ignorait `hourFormat` et `dateFormat`** (FSHSP-163). L'affichage formatait directement via `Intl` en `timeStyle: 'short'` sur la locale résolue, sans jamais consulter ces deux inputs : `hourFormat="24"` (le défaut) n'avait aucun effet — l'heure basculait en AM/PM dès que la locale résolue en avait un par défaut (ex. `en-US`) — et un `dateFormat` custom n'avait aucune prise sur ce mode. `hourFormat` est maintenant respecté (`hour12` forcé en conséquence, jamais laissé au défaut de la locale), et `dateFormat`, quand fourni, s'applique aussi en `timeOnly` (symétrique de son usage en `date`/`month`).
+- **La saisie clavier de `ui-datepicker` (`allowInput`) pouvait mélanger les segments jour/mois/année après une suppression** (FSHSP-118). Le masque re-dérive l'affichage à chaque frappe depuis le flux brut des chiffres tapés ; une vérification de bornes (1-31, 1-12) — pensée pour rejeter un chiffre de tête invalide en cours de frappe — s'appliquait aussi après une suppression, où elle pouvait sauter un chiffre encore valide et décaler tout ce qui suit d'un cran vers le mauvais segment (le jour hérite d'un chiffre du mois, etc.) ; l'année, seule sans borne, n'était jamais concernée — d'où l'observation qu'elle seule se supprimait « proprement ». Cette vérification est maintenant désactivée quand la frappe raccourcit le texte (suppression), et rétablie dès qu'elle le rallonge. Une segmentation en place pleinement fiable quel que soit le point d'édition (clic au milieu du champ, par ex.) reste un chantier plus large, non couvert ici.
+- **Effacer une date au clavier (`allowInput`) se bloquait dès que jour et mois étaient complets** (FSHSP-118). Le "/" auto-inséré entre deux segments place le curseur juste après lui, et un Retour arrière à cette position supprimait ce séparateur cosmétique plutôt qu'un chiffre — séparateur aussitôt réinséré au rendu suivant, donnant l'impression que la touche ne fait plus rien (ex. `20/08/2020` s'effaçait normalement jusqu'à `20/08/`, puis restait bloqué indéfiniment). Une suppression en fin de champ positionne maintenant le curseur juste avant ce séparateur, pas après, pour que le Retour arrière suivant retire le dernier chiffre du segment.
+- **Corriger un segment d'une date déjà saisie (ex. juste le mois) décalait tout ce qui suit** (FSHSP-118), les deux points précédents n'y suffisant pas : re-dériver l'intégralité du texte à chaque frappe (le principe même de l'auto-"/") n'a de sens que pour *construire* une date depuis un champ vide, jamais pour en corriger une déjà valide en place. Le masque se désactive désormais dès qu'une valeur existe (saisie complétée ou déjà présente au chargement) : la frappe redevient alors un champ texte ordinaire — aucun reformatage en direct, aucun risque de mélange de segments — et seul le parsing au blur/Entrée s'applique, déjà tolérant à un séparateur quelconque. Il se réactive de lui-même une fois le champ vidé, pour guider à nouveau la construction de la prochaine date — y compris en vidant le champ à la main puis en retapant aussitôt, sans passer par le blur ou par la croix : le vidage est désormais commité dès que le texte lu est vide, pas seulement au blur/Entrée.
+- **Le placeholder auto-dérivé de `ui-datepicker` restait numérique (« jj/mm/aaaa ») avec un `dateFormat` custom**, alors que le champ n'affiche ni n'accepte ce format-là dans ce cas — un placeholder qui décrit une saisie que le parser va rejeter. Il reprend maintenant la sortie du `dateFormat` fourni pour une date d'illustration (ex. « Nov 22, 2023 »), cohérent avec ce que le champ affiche et attend réellement. Le hint `aria-describedby` (« Format attendu : … »), dérivé du même placeholder, en profite aussi.
+- **`ui-datepicker` : taper l'heure juste après l'année (`showTime`) pouvait corrompre l'année affichée** (FSHSP-118). Le segment année, volontairement sans borne (`1-12`/`1-31` s'appliquent au jour/mois, pas à elle), ne déclenchait jamais l'insertion de son propre séparateur (l'espace avant l'heure) une fois ses 4 chiffres tapés — un suivi de position réservé aux segments bornés. L'heure tapée ensuite s'accolait donc directement à l'année (ex. `08/07/2026` + `10` tapé → `08/07/202610`, relu comme une année à 6 chiffres). Le suivi de position du moteur de masque partagé (`mask-engine.ts`, utilisé aussi par `ui-input-mask`) couvre maintenant tout segment, borné ou non ; la prévisualisation en direct attend en plus que l'heure soit complète avant de la commiter.
+- **`ui-datepicker` : la saisie tapée en `range`/`multiple` pouvait couper une date en plein milieu si son `dateFormat` custom contenait le caractère du séparateur** (FSHSP-118). Le découpage retenait la première occurrence littérale de `" - "` (`range`) ou `", "` (`multiple`) dans le texte tapé, sans vérifier qu'elle délimitait bien deux dates plutôt que d'appartenir à l'une d'elles (ex. un `dateFormat` ISO contenant un tiret). Chaque occurrence candidate doit désormais faire parser valablement le texte qui la précède comme une date avant d'être retenue comme frontière.
+- **`ui-datepicker` : le séparateur affiché d'une plage (`range`) était le même que celui attendu en saisie**, rendant les deux indiscernables à l'écran. L'affichage utilise maintenant un tiret cadratin dédié (« 08/07/2026 – 18/07/2026 »), découplé du séparateur de saisie/parsing (`" - "`, inchangé).
+- **`ui-datepicker` : ouvrir le panneau via l'icône calendrier en `range`/`multiple` ne redonnait plus le focus à la grille** (FSHSP-118). Sortir la lecture-seule du déclencheur de `triggerReadonly` (pour permettre la saisie tapée sur ces modes) avait supprimé avec elle le renvoi systématique du focus vers la grille à l'ouverture, propre à ces deux modes. Restauré explicitement, indépendamment de `triggerReadonly`.
## [0.6.1] - 2026-08-22
diff --git a/projects/ui-kit/forms/src/lib/mask-engine.spec.ts b/projects/ui-kit/forms/src/lib/mask-engine.spec.ts
index 8935b6f..cde7882 100644
--- a/projects/ui-kit/forms/src/lib/mask-engine.spec.ts
+++ b/projects/ui-kit/forms/src/lib/mask-engine.spec.ts
@@ -53,9 +53,21 @@ describe('buildMaskSlots', () => {
expect(slots[9].bound).toEqual({ min: 1900, max: 2100, pos: 3, len: 4 });
});
- it('leaves a segment unbounded when no matching range was provided', () => {
+ it('still tracks pos/len for a segment with no matching range (±Infinity, no-op validation)', () => {
+ // FSHSP-118 code-review fix: `.bound` used to stay entirely undefined for an unranged
+ // segment, which also meant `atSegmentEnd` (driven by `bound.pos`/`bound.len`) could never
+ // fire for it — an unranged 4-digit segment (e.g. ui-datepicker's year) never triggered its
+ // own trailing literal once fully typed. `pos`/`len` are now always attached; only the
+ // min/max become a no-op sentinel.
const slots = buildMaskSlots('99', []);
- expect(slots[0].bound).toBeUndefined();
+ expect(slots[0].bound).toEqual({ min: -Infinity, max: Infinity, pos: 0, len: 2 });
+ expect(slots[1].bound).toEqual({ min: -Infinity, max: Infinity, pos: 1, len: 2 });
+ });
+
+ it('an unranged segment still accepts any digit regardless of value (no-op bounds check)', () => {
+ const slots = buildMaskSlots('99', []);
+ expect(acceptsMaskChar(slots[0], '', '9')).toBe(true);
+ expect(acceptsMaskChar(slots[1], '9', '9')).toBe(true);
});
});
@@ -98,6 +110,13 @@ describe('acceptsMaskChar', () => {
expect(acceptsMaskChar(slots[3], '', '1')).toBe(true);
expect(acceptsMaskChar(slots[3], '', '3')).toBe(false);
});
+
+ it('enforceBounds=false accepts a digit the bounds check would otherwise reject', () => {
+ // "3" fails the month leading-digit check above; with the bounds check off (used to
+ // re-derive the mask after a deletion — see FSHSP-118), only the token class still applies.
+ expect(acceptsMaskChar(slots[3], '', '3', false)).toBe(true);
+ expect(acceptsMaskChar(slots[3], '', 'a', false)).toBe(false); // still not a digit
+ });
});
describe('applyMaskTemplate', () => {
@@ -160,4 +179,91 @@ describe('autoFormatSegments', () => {
expect(result.text).toBe('');
expect(result.tokenIndices).toEqual([0]);
});
+
+ // FSHSP-118: `dataEnd` stops right before an eagerly-inserted trailing separator that has no
+ // data typed past it yet — never at `text.length`, which includes it. That's what lets the
+ // caller park the caret BEFORE the separator instead of after it (see ui-datepicker).
+ it('dataEnd stops right after the last data character, before any dangling separator', () => {
+ expect(autoFormatSegments(dateSlots(), '1501').dataEnd).toBe(5); // "15/01/" — before the "/"
+ expect(autoFormatSegments(dateSlots(), '15').dataEnd).toBe(2); // "15/" — before the "/"
+ expect(autoFormatSegments(dateSlots(), '').dataEnd).toBe(0); // "" — nothing typed at all
+ expect(autoFormatSegments(dateSlots(), '15012024').dataEnd).toBe(10); // fully filled, no dangling separator
+ });
+
+ // FSHSP-118: reproduces `ui-datepicker`'s actual mask (day/month bounded, year deliberately
+ // left UNbounded — see its `typingSlots`), not the bounded-year `dateSlots()` above.
+ function dayMonthYearSlots() {
+ return buildMaskSlots(DATE_MASK, [{ min: 1, max: 31 }, { min: 1, max: 12 }, null]);
+ }
+
+ // Deleting the day's leading digit of "08/07/2026" (raw value "8/07/2026" once the browser
+ // removes it) leaves the residual digit stream "8072026". Re-deriving the mask with bounds
+ // enforced (the default — meant to reject an invalid *new* leading digit while typing forward)
+ // instead SKIPS "8" (no valid 1-31 day starts with it) and reassigns the digits meant for
+ // month/year across the segment boundaries, producing a value with no relation to what was on
+ // screen. `enforceBounds: false` keeps each segment to its own positional slice of the stream
+ // instead — segments can show a transient out-of-range value (caught by the final blur/Enter
+ // parse, see `finalizeParsed`), but digits are never stolen from one segment by another.
+ it('without enforceBounds, a deletion can steal digits across segment boundaries', () => {
+ const result = autoFormatSegments(dayMonthYearSlots(), '8072026');
+ expect(result.text).toBe('07/02/6'); // day/month/year no longer match ANY sensible edit
+ });
+
+ it('enforceBounds: false keeps the same deletion positional instead', () => {
+ const result = autoFormatSegments(dayMonthYearSlots(), '8072026', { enforceBounds: false });
+ expect(result.text).toBe('80/72/026'); // each segment keeps its own slice of the stream
+ });
+
+ // FSHSP-118 code-review fix: an unranged segment (year) used to never trigger its own
+ // trailing literal, because `atSegmentEnd` (mask-engine.ts) required a real bound to have been
+ // attached at all — so the space before a showTime segment never auto-inserted once a bare
+ // 4-digit year was typed, and the next digit typed (the hour) landed glued straight onto the
+ // year with no separator (e.g. ui-datepicker's "08/07/2026" + "10" typed next used to become
+ // "08/07/202610", which a later parse misreads as a single corrupted year).
+ function dateTimeSlots() {
+ return buildMaskSlots('99/99/9999 99:99', [
+ { min: 1, max: 31 },
+ { min: 1, max: 12 },
+ null, // year: deliberately unranged, same as ui-datepicker's typingSlots
+ { min: 0, max: 23 },
+ { min: 0, max: 59 },
+ ]);
+ }
+
+ it('auto-inserts the trailing literal after an unranged (year) segment too', () => {
+ // Day, month, and all 4 year digits typed — nothing of the time yet.
+ const result = autoFormatSegments(dateTimeSlots(), '08072026');
+ expect(result.text).toBe('08/07/2026 '); // space auto-inserted, ready for the hour digits
+ });
+
+ it('keeps date and time cleanly separated once time digits follow', () => {
+ const result = autoFormatSegments(dateTimeSlots(), '080720261030');
+ expect(result.text).toBe('08/07/2026 10:30');
+ });
+
+ // FSHSP-118 follow-up: `ui-datepicker`'s `range` mode reuses the single-date mask twice,
+ // joined by its three-character typing separator (" - "). Auto-inserting only the FIRST
+ // literal right after a completed segment (the original behavior) would leave the "-" and
+ // trailing space forever stranded — the fix appends every consecutive literal in one go.
+ function rangeSlots() {
+ return buildMaskSlots('99/99/9999 - 99/99/9999', [
+ { min: 1, max: 31 },
+ { min: 1, max: 12 },
+ null,
+ { min: 1, max: 31 },
+ { min: 1, max: 12 },
+ null,
+ ]);
+ }
+
+ it('auto-inserts every character of a multi-char separator at once', () => {
+ const result = autoFormatSegments(rangeSlots(), '08072026');
+ expect(result.text).toBe('08/07/2026 - '); // all three separator chars, not just the space
+ expect(result.dataEnd).toBe(10); // still right after the last DATA char, before the separator
+ });
+
+ it('keeps typing straight through into the second date', () => {
+ const result = autoFormatSegments(rangeSlots(), '0807202618072026');
+ expect(result.text).toBe('08/07/2026 - 18/07/2026');
+ });
});
diff --git a/projects/ui-kit/forms/src/lib/mask-engine.ts b/projects/ui-kit/forms/src/lib/mask-engine.ts
index ec13474..0eb732d 100644
--- a/projects/ui-kit/forms/src/lib/mask-engine.ts
+++ b/projects/ui-kit/forms/src/lib/mask-engine.ts
@@ -67,9 +67,16 @@ export function buildMaskSlots(
}
}
+ // Every digit segment gets a `.bound` (position tracking is needed regardless of whether the
+ // segment has a real min/max), even one with no entry in `bounds` — an unranged segment (e.g.
+ // ui-datepicker's year, deliberately left unbounded so its 2-digit shortcut keeps working)
+ // still uses ±Infinity as a no-op range: `acceptsMaskChar`'s scale check always passes, but
+ // `pos`/`len` become available for `atSegmentEnd` to detect the segment's last slot. Without
+ // this, an unranged segment never triggers its OWN following literal (e.g. the space before a
+ // showTime segment never auto-inserts once a bare 4-digit year is typed — code-review fix,
+ // FSHSP-118) because that check used to require a real range to have been attached at all.
segments.forEach((seg, i) => {
- const range = bounds[i];
- if (!range) return;
+ const range = bounds[i] ?? { min: -Infinity, max: Infinity };
seg.forEach((slot, pos) => (slot.bound = { ...range, pos, len: seg.length }));
});
@@ -82,14 +89,27 @@ export function extractMaskData(raw: string): string {
}
/**
- * Can `ch` fill this slot? It must match the token class and — when the segment is bounded —
- * the digits typed so far plus `ch` must still admit at least one in-range completion of the
- * remaining positions (`2` then `4` is refused on `0-23`, `2` then `3` is accepted).
+ * Can `ch` fill this slot? It must match the token class and — when the segment is bounded
+ * AND `enforceBounds` — the digits typed so far plus `ch` must still admit at least one in-range
+ * completion of the remaining positions (`2` then `4` is refused on `0-23`, `2` then `3` is
+ * accepted).
+ *
+ * `enforceBounds = false` skips that second check (still requires the token class to match):
+ * meant for re-deriving the mask after characters were REMOVED, not typed. The bounds check
+ * exists to reject an invalid *new* leading digit while typing forward (e.g. `8` can never start
+ * a valid `1-31` day, so it's skipped rather than accepted) — applied instead to the digits left
+ * over after a deletion, that same skip can discard a still-valid residual digit and misalign
+ * every segment after it. See `autoFormatSegments`.
*/
-export function acceptsMaskChar(slot: MaskSlot, segment: string, ch: string): boolean {
+export function acceptsMaskChar(
+ slot: MaskSlot,
+ segment: string,
+ ch: string,
+ enforceBounds = true,
+): boolean {
if (!slot.rgx?.test(ch)) return false;
const bound = slot.bound;
- if (!bound) return true;
+ if (!enforceBounds || !bound) return true;
const scale = 10 ** (bound.len - bound.pos - 1);
const low = Number(segment + ch) * scale;
return low <= bound.max && low + scale - 1 >= bound.min;
@@ -151,27 +171,42 @@ export function caretForMask(tokenIndices: number[], n: number, length: number):
export function autoFormatSegments(
slots: MaskSlot[],
data: string,
-): { text: string; tokenIndices: number[] } {
+ { enforceBounds = true }: { enforceBounds?: boolean } = {},
+): { text: string; tokenIndices: number[]; dataEnd: number } {
let di = 0;
let text = '';
let segment = '';
let atSegmentEnd = false;
const tokenIndices: number[] = [];
+ // Position right after the last DATA character appended — unlike `text.length`, never lands
+ // after a separator inserted eagerly (see the "auto-insert" doc above) with nothing typed past
+ // it yet. Landing the caret there instead (see call sites) means a Backspace right after a
+ // just-completed segment removes that segment's last digit, not the decorative separator —
+ // which would otherwise be silently re-inserted next render, making Backspace look like it did
+ // nothing (FSHSP-118).
+ let dataEnd = 0;
for (const slot of slots) {
if (slot.char !== null) {
+ // Keep appending EVERY consecutive literal right after a just-completed segment, not only
+ // the first — `atSegmentEnd` is deliberately left untouched here; the next digit slot below
+ // always overwrites it before it's read again (or the loop ends, so a stale value here is
+ // never read at all). A single-character separator ("/", ":") never told the two paths
+ // apart; a multi-character one (`ui-datepicker`'s range " - ", three literal slots in a
+ // row) needs all of them auto-inserted in one go, exactly like a single one (code-review
+ // follow-up, FSHSP-118: `range` gets a live mask too now).
if (atSegmentEnd) text += slot.char;
- atSegmentEnd = false;
continue;
}
tokenIndices.push(text.length);
if (!slot.bound || slot.bound.pos === 0) segment = '';
- while (di < data.length && !acceptsMaskChar(slot, segment, data[di])) di++;
+ while (di < data.length && !acceptsMaskChar(slot, segment, data[di], enforceBounds)) di++;
if (di >= data.length) break; // no more data: stop, no filler
text += data[di];
+ dataEnd = text.length;
if (slot.bound) segment += data[di];
atSegmentEnd = !!slot.bound && slot.bound.pos === slot.bound.len - 1;
di++;
}
- return { text, tokenIndices };
+ return { text, tokenIndices, dataEnd };
}
diff --git a/projects/ui-kit/forms/ui-datepicker/src/lib/ui-datepicker.html b/projects/ui-kit/forms/ui-datepicker/src/lib/ui-datepicker.html
index 4d7f5e6..6c5f085 100644
--- a/projects/ui-kit/forms/ui-datepicker/src/lib/ui-datepicker.html
+++ b/projects/ui-kit/forms/ui-datepicker/src/lib/ui-datepicker.html
@@ -362,6 +362,7 @@
[inputId]="resolvedId()"
[ariaLabel]="ariaLabel()"
[ariaLabelledBy]="ariaLabelledBy()"
+ [ariaDescribedBy]="resolvedFormatHint() ? formatHintId() : undefined"
[tabindex]="tabindex()"
[value]="displayValue()"
[iconRight]="triggerIcon()"
@@ -375,6 +376,9 @@
(inputFocus)="inputFocus.emit($event)"
(inputBlur)="onTriggerBlur($event)"
/>
+ @if (resolvedFormatHint(); as hint) {
+ {{ hint }}
+ }
` — set the raw value + caret, dispatch `input`, flush CD — mirroring exactly what a
+ * real keystroke does (`ui-datepicker` reads `nativeInputElement().value`/`.selectionStart`
+ * itself in `onTriggerInput`, not anything carried on the event).
+ *
+ * Scope: the three behaviors chased down (and initially mis-fixed) across FSHSP-118 —
+ * `hasValue()`-gated mask on/off, the `enforceBounds`/`dataEnd` deletion fixes, and re-arming the
+ * mask on a manual clear — plus `range`'s own live mask (added later, same gating). Not covered
+ * here: `multiple` typed parsing (no live mask — unbounded date count, see the component doc) or
+ * the format-hint/placeholder derivation.
+ */
+import { Component } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { FormControl, ReactiveFormsModule } from '@angular/forms';
+import { describe, expect, it } from 'vitest';
+import { UiDatepicker } from './ui-datepicker';
+
+@Component({
+ imports: [ReactiveFormsModule, UiDatepicker],
+ // `locale="fr-FR"` pins the field order to day/month/year for deterministic assertions,
+ // independent of whatever `LOCALE_ID` the test environment resolves by default.
+ template: ``,
+})
+class DatepickerHost {
+ readonly control = new FormControl(null);
+}
+
+@Component({
+ imports: [ReactiveFormsModule, UiDatepicker],
+ template: ``,
+})
+class DatepickerRangeHost {
+ readonly control = new FormControl(null);
+}
+
+async function setup(initial: Date | null = null) {
+ await TestBed.configureTestingModule({ imports: [DatepickerHost] }).compileComponents();
+ const fixture: ComponentFixture = TestBed.createComponent(DatepickerHost);
+ const host = fixture.componentInstance;
+ if (initial) host.control.setValue(initial);
+ fixture.detectChanges();
+ await fixture.whenStable();
+ const input = fixture.nativeElement.querySelector(
+ '.ui-datepicker-trigger input.ui-input-native',
+ ) as HTMLInputElement;
+ return { fixture, host, input };
+}
+
+/** Same as {@link setup}, for the `range`-mode host (own component: `selectionMode` is a
+ * static template attribute, not reactively settable on the single-mode host). */
+async function setupRange(initial: Date[] | null = null) {
+ await TestBed.configureTestingModule({ imports: [DatepickerRangeHost] }).compileComponents();
+ const fixture: ComponentFixture = TestBed.createComponent(
+ DatepickerRangeHost,
+ );
+ const host = fixture.componentInstance;
+ if (initial) host.control.setValue(initial);
+ fixture.detectChanges();
+ await fixture.whenStable();
+ const input = fixture.nativeElement.querySelector(
+ '.ui-datepicker-trigger input.ui-input-native',
+ ) as HTMLInputElement;
+ return { fixture, host, input };
+}
+
+/** Mirrors a single native keystroke: sets the raw value + caret, dispatches `input`, flushes CD. */
+async function typeInto(
+ input: HTMLInputElement,
+ value: string,
+ caret: number,
+ fixture: ComponentFixture,
+) {
+ input.value = value;
+ input.setSelectionRange(caret, caret);
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+ fixture.detectChanges();
+ await fixture.whenStable();
+}
+
+/**
+ * Types each character of `text` one at a time at the end of the field — the mask reacts
+ * per-keystroke, not to a value set in one go, so a real user's sequential typing has to be
+ * simulated as such rather than dispatching the final string directly. Using a plain digit
+ * prefix at every step (rather than replaying the mask's own auto-inserted separators) is
+ * equivalent: `extractMaskData` strips punctuation before the code under test ever sees it, and
+ * the caret is always placed at the logical end of whatever's provided either way.
+ */
+async function typeSequentially(
+ input: HTMLInputElement,
+ text: string,
+ fixture: ComponentFixture,
+) {
+ for (let i = 1; i <= text.length; i++) {
+ await typeInto(input, text.slice(0, i), i, fixture);
+ }
+}
+
+describe('UiDatepicker — keyboard entry masking (FSHSP-118)', () => {
+ describe('mask on/off gated by hasValue (single mode)', () => {
+ it('auto-formats while constructing a date from an empty field', async () => {
+ const { input, fixture } = await setup();
+ await typeSequentially(input, '08072026', fixture);
+ expect(input.value).toBe('08/07/2026');
+ });
+
+ it('does not auto-format once a value already exists — plain text editing instead', async () => {
+ const { input, fixture } = await setup(new Date(2026, 6, 8));
+ expect(input.value).toBe('08/07/2026');
+ // A single raw digit string, no slashes: if the mask were still on, it would reformat
+ // this into "01/01/1999". Off, it's echoed back verbatim — plain text editing.
+ await typeInto(input, '01011999', 8, fixture);
+ expect(input.value).toBe('01011999');
+ });
+ });
+
+ // Both fixes only ever engage in the masked branch, i.e. while hasValue() is still false —
+ // constructing a date from an empty field, never committed/blurred yet.
+ describe('enforceBounds / dataEnd deletion fixes', () => {
+ it('does not scramble digits across segments when deleting mid-string', async () => {
+ const { input, fixture } = await setup();
+ await typeSequentially(input, '08072026', fixture);
+ expect(input.value).toBe('08/07/2026');
+ // Forward-delete the leading '0' of the day (caret at position 0, browser already
+ // removed it): the residual stream "8072026" used to have its bounds check reject the
+ // leading '8' (no valid 1-31 day starts with it) and reassign month/year's digits to
+ // the wrong segment, producing "07/02/6" — day/month/year no longer matching anything
+ // the user typed. Fixed: each segment keeps its own positional slice instead.
+ await typeInto(input, '8/07/2026', 0, fixture);
+ expect(input.value).toBe('80/72/026');
+ expect(input.value).not.toBe('07/02/6');
+ });
+
+ it('does not get stuck backspacing through a completed segment', async () => {
+ const { input, fixture } = await setup();
+ await typeSequentially(input, '20082020', fixture);
+ expect(input.value).toBe('20/08/2020');
+
+ // Regression: this exact sequence used to stall at "20/08/" — the auto-inserted "/"
+ // between month and year parked the caret just after itself, so the next Backspace
+ // deleted that decorative separator instead of a digit, and it was silently
+ // re-inserted next render (the field looked frozen one keystroke short of empty).
+ const expected = ['20/08/202', '20/08/20', '20/08/2', '20/08/', '20/0', '20/', '2', ''];
+ let caret = input.value.length;
+ for (const expectedValue of expected) {
+ const next = input.value.slice(0, caret - 1) + input.value.slice(caret);
+ await typeInto(input, next, caret - 1, fixture);
+ expect(input.value).toBe(expectedValue);
+ caret = input.selectionStart ?? input.value.length;
+ }
+ });
+ });
+
+ describe('mask re-arms once the field reads empty (no blur needed)', () => {
+ it('re-enables the auto-"/" mask immediately after a manual clear, before any blur', async () => {
+ const { input, fixture } = await setup(new Date(2026, 6, 8));
+ expect(input.value).toBe('08/07/2026');
+
+ // Clear by hand — no blur/Enter dispatched anywhere in this test, and no clear cross
+ // either: the field just reads empty. Without this fix, hasValue() (and so the mask)
+ // would stay off until a real commit happened, e.g. on blur.
+ await typeInto(input, '', 0, fixture);
+ expect(input.value).toBe('');
+
+ await typeSequentially(input, '08072026', fixture);
+ expect(input.value).toBe('08/07/2026');
+ });
+ });
+
+ // FSHSP-118 follow-up: `range` reuses the same live mask (own describe block, own host — it
+ // never applied before this, see `typingSlots`).
+ describe('range mode also gets the live auto-"/" mask (mask-engine follow-up)', () => {
+ it('auto-formats both dates, joined by " - ", while constructing a fresh range', async () => {
+ const { input, fixture } = await setupRange();
+ await typeSequentially(input, '0807202618072026', fixture);
+ expect(input.value).toBe('08/07/2026 - 18/07/2026');
+ });
+
+ it('does not auto-format once a complete range already exists — plain text instead', async () => {
+ const { input, fixture } = await setupRange([new Date(2026, 6, 8), new Date(2026, 6, 18)]);
+ expect(input.value).toBe('08/07/2026 – 18/07/2026'); // en dash: displayValue, not the mask
+ // Same probe as the single-mode equivalent above: a raw digit string, if the mask were
+ // still on, would get reformatted instead of echoed back verbatim.
+ await typeInto(input, '0101199901012000', 16, fixture);
+ expect(input.value).toBe('0101199901012000');
+ });
+ });
+});
diff --git a/projects/ui-kit/forms/ui-datepicker/src/lib/ui-datepicker.ts b/projects/ui-kit/forms/ui-datepicker/src/lib/ui-datepicker.ts
index 92dd8fc..a720a93 100644
--- a/projects/ui-kit/forms/ui-datepicker/src/lib/ui-datepicker.ts
+++ b/projects/ui-kit/forms/ui-datepicker/src/lib/ui-datepicker.ts
@@ -123,6 +123,27 @@ export interface DatepickerMonthPanel {
let nextPanelUid = 0;
+/**
+ * Joins the two dates of a TYPED `range` value (FSHSP-118: `"jj/mm/aaaa - jj/mm/aaaa"`) — a
+ * plain hyphen, practically typable on a standard keyboard. Used to compose the placeholder, as
+ * the literal separator of the live auto-"/" mask (`typingSlots`), and to split typed text back
+ * apart in `parseTypedMulti`.
+ *
+ * Deliberately NOT used for `displayValue` (see `RANGE_DISPLAY_SEPARATOR`): a committed range
+ * had always rendered with an en dash, for every `range` consumer, typing or not: reusing this
+ * hyphen there too was a code-review-caught regression — it silently changed that display text
+ * for every existing grid-only range consumer that never opted into typed entry at all.
+ */
+const RANGE_SEPARATOR = ' - ';
+/** Joins the two dates of a DISPLAYED (committed) `range` value — unchanged from before typed
+ * entry existed. Typing a plain hyphen (`RANGE_SEPARATOR`) still round-trips to this on commit;
+ * parsing and display are deliberately decoupled so the pre-existing look survives untouched. */
+const RANGE_DISPLAY_SEPARATOR = ' – ';
+/** Joins the dates of a typed/displayed `multiple` value (`"jj/mm/aaaa, jj/mm/aaaa, ..."`) — one
+ * separator for both: unlike `range`, `multiple`'s display never had an en-dash-style
+ * convention to preserve. */
+const MULTIPLE_SEPARATOR = ', ';
+
/**
* ui-datepicker — headless date / month / year (and optional time) picker.
*
@@ -159,8 +180,14 @@ export class UiDatepicker extends BaseFormField {
showIcon = input(true, { transform: booleanAttribute });
/** Accessible name of the toggle button (a11y). */
iconAriaLabel = input('Ouvrir le calendrier');
- /** Show a clear (×) button in the trigger when a value is set. */
- showClear = input(false, { transform: booleanAttribute });
+ /**
+ * Show a clear (×) button in the trigger when a value is set. **Default `true`** (FSHSP-118):
+ * only takes effect when `showIcon` is `false` — the calendar/clock toggle otherwise always
+ * wins the trigger's single icon slot, so the panel stays reachable by click even once a value
+ * is set. With `showIcon` at its own default (`true`), clearing goes through the keyboard
+ * (`allowInput`, itself `true` by default) instead: select the text and delete it.
+ */
+ showClear = input(true, { transform: booleanAttribute });
/**
* Shape of the emitted value: `'date'` (a plain `Date` — matches a DTO round-tripped through
@@ -190,17 +217,33 @@ export class UiDatepicker extends BaseFormField {
firstDayOfWeek = input(1, { transform: numberAttribute });
/** BCP-47 locale for names and default formatting. Defaults to `LOCALE_ID`. */
locale = input();
- /** Custom display formatter for a single date (overrides the default `Intl` format). */
+ /** Custom display formatter for a single date (overrides the default `Intl` format) — also
+ * used, unchanged, as the time formatter in `timeOnly` mode. */
dateFormat = input<(date: Date) => string>();
/**
- * Allow typing the date directly in the trigger (single selection only).
- * The typed text is parsed on blur / `Enter`; an unparsable value reverts to
- * the previously displayed one. When enabled (and no custom `dateFormat`), the
- * value is displayed in a numeric locale format so it round-trips with typing.
- * Has no effect in `timeOnly` mode: the trigger stays read-only there.
+ * Allow typing the date directly in the trigger. **Default `true`** (FSHSP-118): a calendar
+ * grid alone forces a screen-reader user through ~30 cells to pick a date, when typing it is
+ * far faster — set `false` to force the grid-only path instead. The typed text is parsed on
+ * blur / `Enter`; an unparsable value reverts to the previously displayed one. When enabled
+ * (and no custom `dateFormat`), the value is displayed in a numeric locale format so it
+ * round-trips with typing.
+ *
+ * `single` gets the full experience: a live auto-"/" mask while constructing a date from an
+ * empty field (see `typingSlots`), free-form text editing once a value exists (no live
+ * reformatting, parsed on blur/Enter — editing a segment in place, e.g. just the month, no
+ * longer shifts what follows, FSHSP-118).
+ *
+ * `range`/`multiple` are typeable too (FSHSP-118), but always as plain text — no live mask,
+ * only parsed on blur/Enter: `"jj/mm/aaaa - jj/mm/aaaa"` for `range` (exactly two dates,
+ * reordered chronologically if typed backwards), `"jj/mm/aaaa, jj/mm/aaaa, ..."` for `multiple`
+ * (any count, duplicates collapsed) — see `parseTypedMulti`. The grid keeps working exactly as
+ * before either way; typing is a complement, not a replacement.
+ *
+ * Has no effect in `timeOnly` mode: the trigger stays read-only there (no parser/formatter for
+ * a bare time string).
*/
- allowInput = input(false, { transform: booleanAttribute });
+ allowInput = input(true, { transform: booleanAttribute });
/**
* Custom parser for the typed text (symmetric with `dateFormat`). Return `null`
* to reject the input. When omitted, a locale-aware numeric parser is used.
@@ -227,6 +270,16 @@ export class UiDatepicker extends BaseFormField {
/** Label of the default "Clear" button. */
clearLabel = input('Effacer');
+ /**
+ * Accessible hint announcing the expected typed format, chained onto the trigger's
+ * `aria-describedby` (alongside the helper/error message, never replacing it) whenever it's
+ * typeable (FSHSP-118): the `placeholder` alone is unreliable across screen readers, and it
+ * disappears the moment the user starts typing. Defaults to a sentence built from the resolved
+ * placeholder (e.g. "Format attendu : jj/mm/aaaa"); pass an explicit string to override it, or
+ * `''` to omit it.
+ */
+ formatHintLabel = input();
+
/** Accessible name of the calendar panel (fallback when no `label`/`ariaLabel`). */
panelAriaLabel = input('Calendrier');
/** Accessible label of the previous-month/year navigation arrow. */
@@ -347,24 +400,25 @@ export class UiDatepicker extends BaseFormField {
(this.disabledDates() ?? []).map(normalizeDateInput).filter((d): d is Date => d !== null),
);
/**
- * @ignore The trigger is not typeable: manual input off, not single, read-only, or
- * `timeOnly` — free-form typing in time-only mode isn't implemented (no dedicated
- * parser/formatter for a bare time string), so it's disabled outright here rather than
- * silently mis-parsing.
+ * @ignore The trigger is not typeable: manual input off, read-only, or `timeOnly` — free-form
+ * typing in time-only mode isn't implemented (no dedicated parser/formatter for a bare time
+ * string), so it's disabled outright here rather than silently mis-parsing. `range`/`multiple`
+ * ARE typeable (FSHSP-118: "jj/mm/aaaa - jj/mm/aaaa", "jj/mm/aaaa, jj/mm/aaaa, ...") — see
+ * `parseTypedMulti`. `range` gets the same live auto-"/" mask as `single` (see `typingSlots`);
+ * `multiple`'s unbounded date count doesn't fit a fixed mask template, so it stays plain text
+ * parsed on blur/Enter only.
*/
protected readonly triggerReadonly = computed(
- () =>
- this.readonly() || !this.allowInput() || this.selectionMode() !== 'single' || this.timeOnly(),
+ () => this.readonly() || !this.allowInput() || this.timeOnly(),
);
- /**
- * @ignore Placeholder shown in the typeable trigger. Falls back to a hint derived
- * from the resolved locale's field order (e.g. `jj/mm/aaaa` in French, `mm/dd/yyyy`
- * in en-US) so the field never advertises the wrong format.
- */
- protected readonly resolvedPlaceholder = computed(() => {
- const explicit = this.placeholder();
- // Keep the consumer's placeholder, and don't auto-hint on a read-only trigger.
- if (explicit || this.triggerReadonly()) return explicit;
+ /** @ignore Single-date placeholder token (e.g. `jj/mm/aaaa`) — the building block
+ * `resolvedPlaceholder` composes for `range`/`multiple`. With a custom `dateFormat`, the
+ * locale's numeric token would be flatly wrong (it describes a format nothing actually
+ * produces or accepts) — showing that custom formatter's own output for an illustrative date
+ * instead at least matches what the field really expects. */
+ private readonly singleDatePlaceholder = computed(() => {
+ const custom = this.dateFormat();
+ if (custom) return custom(new Date(2023, 10, 22));
const fr = this.resolvedLocale().toLowerCase().startsWith('fr');
const token = { day: fr ? 'jj' : 'dd', month: 'mm', year: fr ? 'aaaa' : 'yyyy' };
const view = this.view();
@@ -386,6 +440,37 @@ export class UiDatepicker extends BaseFormField {
)
.join('');
});
+ /**
+ * @ignore Placeholder shown in the typeable trigger. Falls back to a hint derived from the
+ * resolved locale's field order (e.g. `jj/mm/aaaa` in French, `mm/dd/yyyy` in en-US) — or, with
+ * a custom `dateFormat`, from that formatter's own output for an illustrative date (see
+ * `singleDatePlaceholder`), since the locale-numeric token would describe a format nothing
+ * actually produces or accepts — so the field never advertises the wrong format. Composed into
+ * `"jj/mm/aaaa - jj/mm/aaaa"` (`range`) or `"jj/mm/aaaa, ..."` (`multiple`), matching
+ * `RANGE_SEPARATOR`/`MULTIPLE_SEPARATOR` (see `parseTypedMulti`).
+ */
+ protected readonly resolvedPlaceholder = computed(() => {
+ const explicit = this.placeholder();
+ // Keep the consumer's placeholder, and don't auto-hint on a read-only trigger.
+ if (explicit || this.triggerReadonly()) return explicit;
+ const single = this.singleDatePlaceholder();
+ const mode = this.selectionMode();
+ if (mode === 'range') return `${single}${RANGE_SEPARATOR}${single}`;
+ if (mode === 'multiple') return `${single}${MULTIPLE_SEPARATOR}...`;
+ return single;
+ });
+ /**
+ * @ignore Format hint text, or `null` when there's nothing to announce: the trigger isn't
+ * typeable, or `formatHintLabel` was explicitly set to `''` to opt out.
+ */
+ protected readonly resolvedFormatHint = computed(() => {
+ if (this.triggerReadonly()) return null;
+ const explicit = this.formatHintLabel();
+ if (explicit === '') return null;
+ return explicit || `Format attendu : ${this.resolvedPlaceholder()}`;
+ });
+ /** @ignore Stable id for the hint element `resolvedFormatHint` renders into. */
+ protected readonly formatHintId = computed(() => `${this.resolvedId()}-format-hint`);
/** @ignore Order of day/month/year for the resolved locale (drives numeric parsing). */
private readonly dateFieldOrder = computed<('day' | 'month' | 'year')[]>(() => {
@@ -405,17 +490,41 @@ export class UiDatepicker extends BaseFormField {
);
/**
* @ignore Dynamic mask (day/month/year widths in locale order, plus hour/minute — and AM/PM —
- * widths when `showTime`) driving the auto-"/" (resp. ":") formatting of the typeable trigger.
+ * widths when `showTime`; `range` repeats the same widths a second time, joined by
+ * `RANGE_SEPARATOR`) driving the auto-"/" (resp. ":", " - ") formatting of the typeable trigger.
* `null` disables it: `triggerReadonly` (covers `timeOnly` — see there), `view === 'year'`
- * (free-form numeric field, out of scope), or a custom `parseDate` (a non-numeric format would
- * make the auto-slash wrong). The `year` segment is deliberately left unbounded so the existing
- * 2-digit shortcut (`normalizeYear`) keeps working — strict validation still happens at
- * `finalizeParsed`. `defaultParse` already expects these trailing hour/minute digits (see its
- * `nums.slice(fields.length)`) — this only keeps the auto-formatting mask in sync with it, so
- * typed hour/minute digits aren't truncated by `autoFormatSegments` before they reach it.
+ * (free-form numeric field, out of scope), a custom `parseDate` (a non-numeric format would
+ * make the auto-slash wrong), `hasValue()` (FSHSP-118), or `multiple` (unbounded number of
+ * dates — a fixed mask template can't model it; typed through plain text on blur/Enter only,
+ * via `parseTypedMulti`, same as `range` before this mask covered it too).
+ *
+ * That `hasValue()` gate: re-deriving the mask from a flat digit stream on every keystroke only
+ * ever behaves well for *constructing* a date (or, in `range`, a pair of dates) from nothing —
+ * sequential forward typing, or backspacing from the end. It has no notion of "this segment was
+ * already valid, only touch it" (day/month are bounds-checked against arbitrary residual digits
+ * after any edit; year, the one unbounded segment, is the sole exception, which is why editing
+ * it works and looks like an inconsistency until you know why). Once a value already exists,
+ * editing it in place is common (fixing a typo, changing the year to file the same form again)
+ * and hits exactly that gap. Disabling the mask there routes typing to the plain passthrough
+ * branch below instead — no live auto-formatting, but no corruption either — and defers to
+ * `commitTyped`'s parser (already tolerant of arbitrary separators, see `defaultParse`/
+ * `parseTypedMulti`) on blur/Enter. The mask re-arms on its own once the field is cleared and
+ * `hasValue()` goes back to `false` — see `onTriggerInput`, which commits the clear the instant
+ * the raw text reads empty (not just on blur/Enter): a transient "text is empty" check here
+ * instead would only hold for the one keystroke that empties the field — `hasValue()` alone,
+ * unrefreshed, flips back on with the very next character typed, since nothing ever committed it
+ * to `false` for real.
*/
private readonly typingSlots = computed(() => {
- if (this.triggerReadonly() || this.view() === 'year' || this.parseDate()) return null;
+ const mode = this.selectionMode();
+ if (
+ this.triggerReadonly() ||
+ this.view() === 'year' ||
+ this.parseDate() ||
+ this.hasValue() ||
+ (mode !== 'single' && mode !== 'range')
+ )
+ return null;
const widths = { day: '99', month: '99', year: '9999' } as const;
const bounds: Record<'day' | 'month' | 'year', MaskBounds | null> = {
day: { min: 1, max: 31 },
@@ -426,13 +535,19 @@ export class UiDatepicker extends BaseFormField {
let mask = fields.map((f) => widths[f]).join('/');
const segmentBounds: (MaskBounds | null)[] = fields.map((f) => bounds[f]);
- if (this.showTime() && this.view() === 'date') {
+ if (mode === 'single' && this.showTime() && this.view() === 'date') {
mask += this.hourFormat() === '12' ? ' 99:99 aa' : ' 99:99';
segmentBounds.push(this.hourFormat() === '12' ? { min: 1, max: 12 } : { min: 0, max: 23 }, {
min: 0,
max: 59,
});
}
+ if (mode === 'range') {
+ // Second date, same widths/bounds, joined by the literal typing separator — no `showTime`
+ // support here (typed `range` is always `startOfDay`, see `parseTypedMulti`).
+ mask += RANGE_SEPARATOR + fields.map((f) => widths[f]).join('/');
+ segmentBounds.push(...fields.map((f) => bounds[f]));
+ }
return buildMaskSlots(mask, segmentBounds);
});
/** @ignore The panel is visible. */
@@ -444,11 +559,23 @@ export class UiDatepicker extends BaseFormField {
/** @ignore A value is currently set. */
protected readonly hasValue = computed(() => this.selectedDates().length > 0);
- /** @ignore The trigger's right action clears the value (instead of toggling the panel). */
+ /**
+ * @ignore The trigger's right action clears the value (instead of toggling the panel). Gated
+ * on `!showIcon()` (FSHSP-118): the calendar/clock toggle always wins the trigger's single icon
+ * slot when it's shown, so there's always a click target to reopen the panel and pick a
+ * different date directly — the cross only replaces it in configs that hid it (`showIcon`
+ * false), where it's the sole remaining affordance to empty the field without a keyboard.
+ */
protected readonly showClearButton = computed(
- () => this.showClear() && this.hasValue() && !this.isDisabled() && !this.readonly(),
+ () =>
+ this.showClear() &&
+ this.hasValue() &&
+ !this.isDisabled() &&
+ !this.readonly() &&
+ !this.showIcon(),
);
- /** @ignore Right-side icon: clear (×) when clearable + set, else the calendar/clock toggle. */
+ /** @ignore Right-side icon: clear (×) when clearable + set + no calendar toggle to show
+ * (see `showClearButton`), else the calendar/clock toggle. */
protected readonly triggerIcon = computed(() => {
if (this.showClearButton()) return 'xmark';
if (!this.showIcon()) return undefined;
@@ -552,6 +679,19 @@ export class UiDatepicker extends BaseFormField {
return new Intl.DateTimeFormat(this.resolvedLocale(), options).format(date);
}
+ /** @ignore `timeOnly` display — respects `hourFormat` (never the locale's own AM/PM-vs-24h
+ * default) and `dateFormat` when provided (symmetric with `formatDate`/`formatMonth`, used
+ * here as a time formatter). */
+ private formatTime(date: Date): string {
+ const custom = this.dateFormat();
+ if (custom) return custom(date);
+ return new Intl.DateTimeFormat(this.resolvedLocale(), {
+ hour: '2-digit',
+ minute: '2-digit',
+ hour12: this.hourFormat() === '12',
+ }).format(date);
+ }
+
/** @ignore Month-view display (numeric + round-trippable when the trigger is typeable). */
private formatMonth(date: Date): string {
if (this.allowInput() && !this.dateFormat()) {
@@ -580,13 +720,11 @@ export class UiDatepicker extends BaseFormField {
if (base === 'year') return String(dates[0].getFullYear());
if (this.timeOnly()) {
- return new Intl.DateTimeFormat(this.resolvedLocale(), { timeStyle: 'short' }).format(
- dates[0],
- );
+ return this.formatTime(dates[0]);
}
const mode = this.selectionMode();
- if (mode === 'multiple') return dates.map((d) => this.formatDate(d)).join(', ');
- if (mode === 'range') return dates.map((d) => this.formatDate(d)).join(' – ');
+ if (mode === 'multiple') return dates.map((d) => this.formatDate(d)).join(MULTIPLE_SEPARATOR);
+ if (mode === 'range') return dates.map((d) => this.formatDate(d)).join(RANGE_DISPLAY_SEPARATOR);
return this.formatDate(dates[0]);
});
@@ -739,8 +877,14 @@ export class UiDatepicker extends BaseFormField {
this.overlayOrigin.set(this.resolveOverlayOrigin());
this.panelOpen.set(true);
this.opened.emit();
- // Keep focus in the input when it's typeable; otherwise rove into the (active) grid.
- if (this.showCalendar() && this.triggerReadonly()) {
+ // Keep focus in the input when it's typeable in single mode (so typing can continue
+ // uninterrupted); otherwise rove into the (active) grid. `range`/`multiple` always rove,
+ // regardless of `triggerReadonly()`: typing there is a plain-text complement (no live mask,
+ // see `typingSlots`), never the primary interaction — the grid is, exactly as before
+ // `allowInput` covered these modes, and opening via the calendar icon signals "I want the
+ // grid" (code review finding: this used to be implicit in `triggerReadonly` hardcoding
+ // non-single modes read-only; restored explicitly now that it no longer does).
+ if (this.showCalendar() && (this.triggerReadonly() || this.selectionMode() !== 'single')) {
if (this.currentView() === 'date') this.queueDayFocus();
else if (this.currentView() === 'month') this.queueMonthFocus();
else this.queueYearFocus();
@@ -803,6 +947,12 @@ export class UiDatepicker extends BaseFormField {
const slots = this.typingSlots();
if (!slots) {
this.typedValue.set(value);
+ // The mask is off because a value already exists (see `typingSlots`) — but the field was
+ // just emptied by hand, with no blur/Enter to go through `commitTyped`'s own clear. Commit
+ // it right here instead of waiting for a commit that may never come: `hasValue()` flips to
+ // `false` for real, so `typingSlots()` re-arms the mask on the very next keystroke, for
+ // whatever fresh date comes next (FSHSP-118).
+ if (!value.trim() && this.hasValue()) this.clear();
if (this.panelOpen()) this.previewTyped();
return;
}
@@ -810,26 +960,70 @@ export class UiDatepicker extends BaseFormField {
const caret = el?.selectionStart ?? value.length;
// Number of data characters located BEFORE the caret (stable anchor, same trick as ui-input-mask).
const dataBeforeCaret = extractMaskData(value.slice(0, caret)).length;
- const { text, tokenIndices } = autoFormatSegments(slots, extractMaskData(value));
+ const newData = extractMaskData(value);
+ // A deletion (Backspace/Delete/selection-clear) leaves only ALREADY-valid digits behind —
+ // re-running the bounds check against them (meant to reject a just-typed invalid leading
+ // digit, e.g. "8" can't start a 1-31 day) can instead skip a still-valid residual one and
+ // misalign every segment after it (FSHSP-118: only the year, which carries no bound, always
+ // survived editing untouched). Comparing data lengths (not `event.inputType`, unavailable
+ // here) distinguishes typing/pasting (grows or holds, still bounds-checked) from deleting
+ // (shrinks, bounds-checked only up to the final blur/Enter parse — see `finalizeParsed`).
+ // Baseline is `displayValue()` (what's actually shown before this edit — typed text OR a
+ // calendar-committed date, `typedValue` alone would miss the latter), read before any
+ // signal write below still reflects the state.
+ const enforceBounds = newData.length >= extractMaskData(this.displayValue()).length;
+ const { text, tokenIndices, dataEnd } = autoFormatSegments(slots, newData, { enforceBounds });
this.typedValue.set(text);
if (el) {
el.value = text;
- const pos = caretForMask(tokenIndices, dataBeforeCaret, text.length);
+ // A deletion (!enforceBounds) whose caret sat at/past all remaining data (editing at the
+ // tail, by far the common case — see `enforceBounds` above) always lands on `dataEnd`,
+ // never on `caretForMask`'s result: that function answers "where's the next slot to type
+ // INTO", which for a just-emptied segment is the position right after its
+ // eagerly-auto-inserted trailing separator — landing there makes the very next Backspace
+ // delete that decorative separator (silently re-inserted next render) instead of the
+ // segment's last digit, so deleting looks stuck one keystroke short forever (FSHSP-118).
+ // A mid-string deletion (more data still sits after the caret) falls back to the normal
+ // computation unchanged — still an approximation (see the class doc), just not this trap.
+ const atTail = dataBeforeCaret >= newData.length;
+ const pos =
+ !enforceBounds && atTail ? dataEnd : caretForMask(tokenIndices, dataBeforeCaret, dataEnd);
el.setSelectionRange(pos, pos);
}
if (this.panelOpen()) this.previewTyped();
}
/**
- * @ignore Reflect a fully-typed date in the open panel (navigate + highlight)
- * without reformatting the field, so the caret stays put while typing.
+ * @ignore Whether `text` already carries a time portion too, when one is required
+ * (`showTime`, single-date `view === 'date'`) — code-review fix, FSHSP-118. `defaultParse`'s
+ * own `requireComplete` only checks day/month/year are present, never the trailing hour/minute
+ * groups, so `previewTyped` (below) used to treat the date-only prefix as "complete" the
+ * moment day/month/year were typed. That flipped `hasValue()` true — and so, per `typingSlots`,
+ * turned the live mask off — before a single time digit had been typed: the very next keystroke
+ * (the first hour digit) then landed with no mask active to insert the "HH:MM" separators,
+ * concatenating straight onto the year (e.g. `"08/07/20261030"`) and corrupting the eventual
+ * parse. Gating the PREVIEW specifically (not `commitTyped`'s own completeness check — a
+ * date-only value typed then immediately blurred is still a legitimate final commit, time
+ * defaulting to the steppers, exactly as before) on the time portion also being present keeps
+ * `hasValue()` — and the mask — off until the whole thing, date and time, is actually done.
+ */
+ private hasCompleteTimeIfNeeded(text: string): boolean {
+ if (!this.showTime() || this.view() !== 'date') return true;
+ const groups = text.match(/\d+/g) ?? [];
+ return groups.length >= this.activeFields().length + 2;
+ }
+
+ /**
+ * @ignore Reflect a fully-typed date (or `range`/`multiple` set) in the open panel
+ * (navigate + highlight) without reformatting the field, so the caret stays put while typing.
*/
private previewTyped(): void {
const raw = this.typedValue();
if (raw === null || !raw.trim()) return;
- const parsed = this.parseTyped(raw.trim(), true);
- if (!parsed || this.isParsedDisabled(parsed)) return;
- const picked = this.showTime() ? parsed : startOfDay(parsed);
+ if (this.selectionMode() === 'single' && !this.hasCompleteTimeIfNeeded(raw.trim())) return;
+ const picked = this.parseTypedValue(raw.trim(), true);
+ if (!picked) return;
+ const first = Array.isArray(picked) ? picked[0] : picked;
// Update the model (drives the selected-day highlight + live value) but keep
// `typedValue` so `displayValue` still returns the raw text (no caret jump).
this.internalValue.set(picked);
@@ -837,8 +1031,8 @@ export class UiDatepicker extends BaseFormField {
this.modelValue.set(external ?? undefined);
this.emitChange(external);
this.valueChange.emit(external);
- this.viewDate.set(firstOfMonth(picked));
- this.focusedDate.set(startOfDay(picked));
+ this.viewDate.set(firstOfMonth(first));
+ this.focusedDate.set(startOfDay(first));
}
/** @ignore Parse the typed text on blur, then forward the blur. */
@@ -849,7 +1043,8 @@ export class UiDatepicker extends BaseFormField {
this.inputBlur.emit(event);
}
- /** @ignore Parse and apply the typed text; revert to the previous value if invalid. */
+ /** @ignore Parse and apply the typed text (single date, or `range`/`multiple` set — see
+ * `parseTypedValue`); revert to the previous value if invalid. */
protected commitTyped(): void {
if (this.triggerReadonly()) return;
const raw = this.typedValue();
@@ -860,13 +1055,16 @@ export class UiDatepicker extends BaseFormField {
if (this.hasValue()) this.clear();
return;
}
- const parsed = this.parseTyped(text, true);
- if (parsed && !this.isParsedDisabled(parsed)) {
- const picked = this.showTime() ? parsed : startOfDay(parsed);
- this.commit(picked); // single-only → clears typedValue and reformats
- this.dateSelect.emit(this.serializeValue(picked));
- this.viewDate.set(firstOfMonth(picked));
- this.focusedDate.set(startOfDay(picked));
+ const picked = this.parseTypedValue(text, true);
+ if (picked) {
+ this.commit(picked); // clears typedValue and reformats
+ const first = Array.isArray(picked) ? picked[0] : picked;
+ // `dateSelect` reports "which single day was just picked" — no such thing for a typed
+ // range/list committed all at once, so it's single-mode only (matches its `Date | string`
+ // output type, which couldn't carry an array anyway).
+ if (!Array.isArray(picked)) this.dateSelect.emit(this.serializeValue(picked));
+ this.viewDate.set(firstOfMonth(first));
+ this.focusedDate.set(startOfDay(first));
} else {
this.typedValue.set(null); // revert: displayValue reformats the current value
}
@@ -882,6 +1080,86 @@ export class UiDatepicker extends BaseFormField {
return custom ? custom(text) : this.defaultParse(text, requireComplete);
}
+ /**
+ * @ignore Parses typed text into whatever shape the current `selectionMode` commits: a single
+ * `Date` for `single`, a `Date[]` for `range`/`multiple` (see `parseTypedMulti`). `null` means
+ * "not a complete, enabled value for this mode" — the caller reverts.
+ */
+ private parseTypedValue(text: string, requireComplete: boolean): Date | Date[] | null {
+ if (this.selectionMode() === 'single') {
+ const parsed = this.parseTyped(text, requireComplete);
+ return parsed && !this.isParsedDisabled(parsed) ? parsed : null;
+ }
+ return this.parseTypedMulti(text, requireComplete);
+ }
+
+ /**
+ * @ignore `range`/`multiple` typed entry (FSHSP-118): splits on `RANGE_SEPARATOR`/
+ * `MULTIPLE_SEPARATOR` (via `splitTypedSegments` — see there for why a plain `String.split`
+ * isn't safe) and parses each part with the very same single-date logic as `single` mode —
+ * `parseTyped`, so a custom `parseDate` applies per part too, symmetric with how a custom
+ * `dateFormat` already applies per date via `formatDate`. `range` requires exactly two parts,
+ * reordered chronologically (mirrors the grid's own reordering in `selectDay`); `multiple`
+ * accepts any number, duplicates collapsed (mirrors the grid's click-to-toggle). Any
+ * unparseable or disabled part fails the whole thing — never a partial commit. Always
+ * `startOfDay` (no `showTime` support here: a "jj/mm/aaaa hh:mm - jj/mm/aaaa hh:mm" format is a
+ * further chantier of its own, out of scope for now).
+ */
+ private parseTypedMulti(text: string, requireComplete: boolean): Date[] | null {
+ const mode = this.selectionMode();
+ const sep = mode === 'range' ? RANGE_SEPARATOR : MULTIPLE_SEPARATOR;
+ const parts = this.splitTypedSegments(text, sep);
+ if (mode === 'range' && parts.length !== 2) return null;
+ if (mode === 'multiple' && parts.length < 1) return null;
+ const parsed = parts.map((p) => this.parseTyped(p, requireComplete));
+ if (parsed.some((d) => d === null || this.isParsedDisabled(d))) return null;
+ const dates = (parsed as Date[]).map(startOfDay);
+ if (mode === 'range') return dates.sort((a, b) => a.getTime() - b.getTime());
+ return dates.filter((d, i) => dates.findIndex((o) => isSameDay(o, d)) === i);
+ }
+
+ /**
+ * @ignore Splits typed `range`/`multiple` text into per-date segments (code-review fix,
+ * FSHSP-118). A plain `text.split(sep)` breaks the moment a single date's own formatted text
+ * contains the separator character — a custom `dateFormat` producing `"Jul 8, 2026"` already
+ * contains the `", "` `multiple` splits on; an ISO/dash `dateFormat` like `"2026-07-08"`
+ * already contains the `-` `range` splits on.
+ *
+ * Instead of splitting blindly, this scans for each occurrence of `sep` and only commits to a
+ * boundary once the text *up to* it already parses as a complete date via `parseTyped` — the
+ * same single-date parser used everywhere else, so a custom `parseDate` decides completeness
+ * exactly as it does for `single` mode. An in-progress prefix (`"Jul 8"`, still missing its
+ * year) fails that check and is skipped in favor of the next `sep` occurrence, so a date's own
+ * internal separator is never mistaken for the boundary between two dates. Falls back to
+ * treating the remainder as one final segment once no more `sep` occurrences parse or exist —
+ * this is also what naturally reports "still incomplete" (e.g. only the first date typed so
+ * far in `range`) up to the part-count check in `parseTypedMulti`.
+ */
+ private splitTypedSegments(text: string, sep: string): string[] {
+ const segments: string[] = [];
+ let rest = text.trim();
+ while (rest.length) {
+ let boundary = -1;
+ let searchFrom = 0;
+ for (;;) {
+ const idx = rest.indexOf(sep, searchFrom);
+ if (idx === -1) break;
+ if (this.parseTyped(rest.slice(0, idx), true)) {
+ boundary = idx;
+ break;
+ }
+ searchFrom = idx + 1;
+ }
+ if (boundary === -1) {
+ segments.push(rest.trim());
+ break;
+ }
+ segments.push(rest.slice(0, boundary).trim());
+ rest = rest.slice(boundary + sep.length).trim();
+ }
+ return segments.filter((s) => s.length > 0);
+ }
+
/**
* @ignore Locale-aware numeric parser (day/month/year order from `dateFieldOrder`).
* With `requireComplete`, returns `null` unless every component is present
diff --git a/projects/ui-kit/forms/ui-datepicker/ui-datepicker.mdx b/projects/ui-kit/forms/ui-datepicker/ui-datepicker.mdx
index a47fcd5..7137489 100644
--- a/projects/ui-kit/forms/ui-datepicker/ui-datepicker.mdx
+++ b/projects/ui-kit/forms/ui-datepicker/ui-datepicker.mdx
@@ -7,8 +7,9 @@ import { ConfigTable } from '../../../../storybook/blocks/config-table';
# ui-datepicker
Sélecteur de **date / mois / année** (et d'**heure** optionnelle) headless. Un champ
-déclencheur [`ui-input`](?path=/docs/components-ui-forms-ui-input--docs) en lecture seule ouvre
-un calendrier stylé (tokens `form.*` / `actions.*`) dans un overlay Angular CDK — ou rendu
+déclencheur [`ui-input`](?path=/docs/components-ui-forms-ui-input--docs), saisissable au clavier
+par défaut (`allowInput`, tous modes de sélection), ouvre un calendrier stylé (tokens `form.*` /
+`actions.*`) dans un overlay Angular CDK — ou rendu
**inline**. Sélection `single` / `multiple` / `range`, vues à tiroir (jour → mois → année),
modes `MonthPicker` / `YearPicker`, plusieurs mois côte à côte, navigation mensuelle, focus
roving et support clavier complet (motif WAI-ARIA date picker).
@@ -118,8 +119,12 @@ dégrade silencieusement en « pas de contrainte » plutôt que de lever une err
-`showClear` ajoute une croix dans le champ (dès qu'une valeur est présente) pour réinitialiser
-la sélection. En mode `timeOnly`, l'icône du déclencheur devient une horloge.
+`showClear` (vrai par défaut) ajoute une croix dans le champ pour réinitialiser la sélection —
+mais seulement quand `showIcon` est à `false` : sinon le calendrier garde l'icône, pour rester
+cliquable et rouvrir le panneau même une fois une date choisie (le point de vue retenu, FSHSP-118 :
+le calendrier reste le raccourci pour **changer** la date, la croix le seul recours pour
+l'**effacer** dans une configuration qui n'a pas d'icône calendrier — sinon la frappe au clavier,
+`allowInput`, s'en charge). En mode `timeOnly`, l'icône du déclencheur devient une horloge.
@@ -143,15 +148,20 @@ des trois variantes, du déclenchement et des conséquences de mise en page sur
## Saisie manuelle (`allowInput`)
-Par défaut le champ est en lecture seule : la date se choisit uniquement dans le panneau.
-Activez `allowInput` pour autoriser la **frappe au clavier** dans le champ (mode `single`
-uniquement). Le texte est parsé au **blur** et sur **Entrée** ; une saisie invalide revient
-à la dernière valeur affichée. `↓` ouvre le panneau et entre dans la grille.
+**`allowInput` est vrai par défaut** (FSHSP-118) : la frappe au clavier dans le champ est
+autorisée d'emblée, quel que soit `selectionMode`, pas seulement la sélection dans le panneau —
+pour un non-voyant, taper une date est bien plus rapide que naviguer une grille de ~30 cases au
+lecteur d'écran. Le texte est parsé au **blur** et sur **Entrée** ; une saisie invalide revient à
+la dernière valeur affichée. `↓` ouvre le panneau et entre dans la grille. Mettez `allowInput` à
+`false` pour revenir à un champ lecture seule, calendrier uniquement.
+
+`allowInput` est **sans effet** en `timeOnly` (pas de parseur/formatteur dédié à une heure
+seule) : le champ y reste toujours en lecture seule.
Quand `showTime` est actif (hors `timeOnly`), le masque couvre aussi les segments heure/minute
(et AM/PM en `hourFormat="12"`) : `allowInput` reste utilisable pour taper la date **et** l'heure
-d'une traite. `allowInput` est en revanche **sans effet en `timeOnly`** (pas de parseur/formatteur
-dédié à une heure seule) : le champ y reste toujours en lecture seule.
+d'une traite — en mode `single` uniquement (voir plus bas pour `range`/`multiple`, qui n'en
+profitent pas encore).
Quand `allowInput` est actif (et sans `dateFormat` custom), la valeur s'affiche au **format
numérique** de la locale (ex. `08/07/2026`) pour un aller-retour fiable avec le parser.
@@ -164,6 +174,18 @@ comportement partage son moteur avec [`ui-input-mask`](?path=/docs/components-ui
(`mask-engine.ts`) et se désactive automatiquement dès qu'un `parseDate` custom est fourni (un
format non numérique rendrait l'auto-slash faux).
+> **L'auto-"/" ne s'applique que pour construire une date depuis un champ vide** (FSHSP-118) : dès
+> qu'une valeur existe (saisie complétée, ou déjà présente au chargement), il se désactive et la
+> frappe redevient un champ texte ordinaire, sans reformatage en direct — seul le parsing au
+> blur/Entrée s'applique. Cette bascule est nécessaire : le masque re-dérive l'intégralité du texte
+> à chaque frappe depuis le flux de chiffres, ce qui n'a de sens que pour une construction
+> séquentielle (taper vers l'avant, ou supprimer depuis la fin) — corriger un segment déjà valide
+> en le retapant sur place (ex. cliquer dans le mois pour le seul corriger) redevient alors une
+> édition de texte tout à fait normale plutôt que de rejouer ce re-dérivage. Le masque se
+> réactive de lui-même dès que le champ se lit vide (`null`) — y compris en le vidant à la main
+> puis en retapant aussitôt, sans passer par un blur ou par la croix : le vidage est commité dès
+> que le texte lu est vide, pas seulement au blur/Entrée.
+
> **L'ordre des champs suit la locale résolue** (`locale` ou, à défaut, `LOCALE_ID`). En
> `fr-FR` c'est **jj/mm/aaaa** ; en `en-US` c'est **mm/dd/yyyy** — l'auto-formatage suit le même
> ordre. Si `LOCALE_ID` n'est pas configuré, Angular tombe sur `en-US` : passez `locale="fr-FR"`
@@ -171,17 +193,58 @@ format non numérique rendrait l'auto-slash faux).
> automatiquement** de cet ordre quand il n'est pas fourni, pour ne jamais afficher un format
> trompeur.
+Le format attendu est aussi annoncé aux lecteurs d'écran via `aria-describedby` (FSHSP-118) :
+le `placeholder` seul est un support inégal selon les lecteurs d'écran, et il disparaît dès la
+première frappe. Un hint dédié (« Format attendu : jj/mm/aaaa »), dérivé du même `resolvedPlaceholder`,
+est chaîné à côté du message d'aide/erreur — jamais à sa place (`formatHintLabel` pour le
+personnaliser, `''` pour le désactiver).
+
Fournissez `parseDate` `(value: string) => Date | null` pour un parsing sur mesure (symétrique
de `dateFormat`). Ces deux hooks travaillent en `Date` (affichage/saisie libre uniquement) — ils
ne sont jamais round-trippés à travers la CVA, donc pas concernés par le contrat ISO.
+Avec un `dateFormat` custom, le placeholder auto-dérivé (et le hint qui en découle) bascule sur
+**la sortie de ce formatteur pour une date d'illustration** plutôt que sur le jeton numérique
+`jj/mm/aaaa` — ce dernier décrirait un format que le champ n'affiche ni n'accepte réellement.
+Voir `CustomFormat` : placeholder « Nov 22, 2023 » (date d'illustration passée dans le même
+`dateFormat`), pas « jj/mm/aaaa ».
+
+Les exemples ci-dessous restent tous au format classique **jj/mm/aaaa**, à l'exception du dernier
+(`CustomFormat`) qui illustre — via `dateFormat`/`parseDate` — qu'un format totalement différent
+(ici « Jul 8, 2026 ») reste possible.
+
-
+### `range` et `multiple`
+
+`allowInput` couvre aussi `range` et `multiple` (FSHSP-118), toujours en **complément** de la
+grille — cliquer un jour continue de fonctionner exactement comme avant, la saisie clavier
+alimente le même modèle. Une entrée incomplète ou invalide revient à la dernière valeur affichée,
+comme en mode `single`.
+
+- **`range`** : les deux dates dans le même champ, séparées par `" - "` — ex.
+ `"08/07/2026 - 18/07/2026"`. Bénéficie du même masque auto-"/" en direct qu'en `single` : les
+ deux dates, puis leur séparateur, se construisent au fil de la frappe. Tapées dans le désordre
+ (fin avant début), elles sont réordonnées chronologiquement au commit, comme le ferait un
+ second clic dans la grille.
+- **`multiple`** : une liste séparée par `", "` — ex. `"08/07/2026, 15/07/2026, 23/07/2026"`,
+ nombre de dates non borné. Pas de masque en direct ici — un nombre de dates non borné ne rentre
+ pas dans un masque à gabarit fixe : texte libre, parsé au **blur**/**Entrée** uniquement. Une
+ date en double est supprimée (même règle que le clic qui bascule une case déjà sélectionnée).
+
+Dans les deux cas, un `parseDate` custom s'applique **par date individuelle** (chaque partie
+séparée par `" - "`/`", "` lui est passée l'une après l'autre) — symétrique de la façon dont
+`dateFormat` s'applique déjà par date à l'affichage. `showTime` combiné à `range`/`multiple` n'est
+pas couvert par la saisie clavier (les dates tapées sont toujours calées à minuit) — seule la
+grille gère ce cas-là pour l'instant.
+
+
+
+
## Templates
Trois points d'extension via `` projetés, résolus par `contentChild` :
@@ -192,9 +255,10 @@ Trois points d'extension via `` projetés, résolus par `contentChi
- **`#buttonbar`** — barre de boutons personnalisée. Contexte : `todayCallback` et `clearCallback`
(fonctions à câbler sur `(click)`).
- **`#icon`** — icône du **déclencheur** (forwardée au `ui-input` sous-jacent). Contexte :
- `$implicit` = le nom résolu par le composant (`calendar`, `clock` en `timeOnly`, ou `xmark`
- quand `showClear` a une valeur à effacer), `size` = la taille calée sur le champ, `disabled` =
- l'état du champ. Sans effet quand `showIcon="false"` : il n'y a alors aucune zone d'icône.
+ `$implicit` = le nom résolu par le composant (`calendar`, `clock` en `timeOnly`, ou `xmark` —
+ seulement quand `showIcon` est à `false` et qu'une valeur est présente, voir la doc de
+ `showClear`), `size` = la taille calée sur le champ, `disabled` = l'état du champ. `showIcon`
+ ne coupe donc pas systématiquement la zone d'icône : la croix peut encore y apparaître.
@@ -313,9 +377,10 @@ via l'interop CVA native, ainsi qu'aux formulaires template-driven (`[(ngModel)]
-> **Périmètre / choix** : le champ déclencheur est en **lecture seule par défaut** (choix par le
-> calendrier uniquement) ; `allowInput` (mode `single`, hors `timeOnly`) l'ouvre à la **saisie
-> clavier avec masque** — voir [Saisie manuelle](#saisie-manuelle-allowinput). Le tiroir de vues
+> **Périmètre / choix** : le champ déclencheur est **saisissable au clavier par défaut**
+> (`allowInput`, tous modes de sélection sauf `timeOnly` — voir
+> [Saisie manuelle](#saisie-manuelle-allowinput)) ; mettre `allowInput` à `false` revient à un
+> champ lecture seule, choix par le calendrier uniquement. Le tiroir de vues
> (jour → mois → année) et le focus roving clavier sont actifs en **mono-mois** — désormais aussi
> dans les grilles `MonthPicker`/`YearPicker` ; en multi-mois les entêtes sont fixes avec flèches
> aux extrémités. Les états interactifs (hover/focus/pressed) sont des **pseudo-classes CSS**
diff --git a/projects/ui-kit/forms/ui-datepicker/ui-datepicker.stories.ts b/projects/ui-kit/forms/ui-datepicker/ui-datepicker.stories.ts
index cfe1bfe..5889dcc 100644
--- a/projects/ui-kit/forms/ui-datepicker/ui-datepicker.stories.ts
+++ b/projects/ui-kit/forms/ui-datepicker/ui-datepicker.stories.ts
@@ -162,8 +162,9 @@ const meta: Meta = {
},
showClear: {
control: 'boolean',
- description: 'Affiche une croix pour effacer la valeur quand elle est renseignée.',
- table: { type: { summary: 'boolean' }, defaultValue: { summary: 'false' } },
+ description:
+ "Affiche une croix pour effacer la valeur quand elle est renseignée — seulement quand `showIcon` est à `false` (sinon le calendrier garde l'icône, voir `Clearable`).",
+ table: { type: { summary: 'boolean' }, defaultValue: { summary: 'true' } },
},
autoFlip: {
control: 'boolean',
@@ -178,7 +179,13 @@ const meta: Meta = {
control: 'boolean',
description:
'Autorise la saisie clavier de la date dans le champ (mode single, hors timeOnly). Parsée au blur / Entrée.',
- table: { type: { summary: 'boolean' }, defaultValue: { summary: 'false' } },
+ table: { type: { summary: 'boolean' }, defaultValue: { summary: 'true' } },
+ },
+ formatHintLabel: {
+ control: 'text',
+ description:
+ 'Hint accessible (aria-describedby) annonçant le format attendu quand le champ est saisissable. Vide (`""`) pour le désactiver. `undefined` (défaut) dérive « Format attendu : jj/mm/aaaa ».',
+ table: { type: { summary: 'string' } },
},
panelStyleClass: {
control: 'text',
@@ -234,10 +241,10 @@ const meta: Meta = {
todayLabel: "Aujourd'hui",
clearLabel: 'Effacer',
inline: false,
- showClear: false,
+ showClear: true,
autoFlip: true,
closeOnSelect: true,
- allowInput: false,
+ allowInput: true,
required: false,
disabled: false,
readonly: false,
@@ -276,20 +283,39 @@ const story =
const sample = new Date(2026, 6, 8); // 8 July 2026
-export const Default: Story = { render: story() };
+// Formatteur numérique partagé par la plupart des démos ci-dessous : "08/07/2026". Sans lui,
+// l'affichage par défaut (hors `allowInput`) suit `Intl` en `dateStyle: 'medium'` sur la locale
+// résolue — qui retombe sur l'anglais quand `LOCALE_ID` n'est pas configuré (le cas ici) et
+// afficherait alors "Jul 8, 2026". `CustomFormat`, plus bas, reste le seul exemple qui s'en
+// écarte volontairement pour montrer qu'un tout autre format est possible.
+const demoDateFormat = (d: Date): string =>
+ new Intl.DateTimeFormat('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' }).format(d);
+// Variante avec heure, pour les démos `showTime` : "08/07/2026 14:30".
+const demoDateTimeFormat = (d: Date): string =>
+ `${demoDateFormat(d)} ${new Intl.DateTimeFormat('fr-FR', { hour: '2-digit', minute: '2-digit' }).format(d)}`;
+
+export const Default: Story = { render: story(), args: { dateFormat: demoDateFormat } };
export const WithValue: Story = {
render: story(sample),
- args: { helperText: 'Sélectionnez une date.' },
+ args: { helperText: 'Sélectionnez une date.', dateFormat: demoDateFormat },
+};
+export const Small: Story = {
+ render: story(sample),
+ args: { size: 'small', dateFormat: demoDateFormat },
};
-export const Small: Story = { render: story(sample), args: { size: 'small' } };
export const Required: Story = {
render: story(),
- args: { required: true, helperText: 'Champ obligatoire.' },
+ args: { required: true, helperText: 'Champ obligatoire.', dateFormat: demoDateFormat },
};
export const Error: Story = {
render: story(),
- args: { level: 'error', invalid: true, errorText: 'Date invalide.' },
+ args: {
+ level: 'error',
+ invalid: true,
+ errorText: 'Date invalide.',
+ dateFormat: demoDateFormat,
+ },
};
// Contrat en mode `'iso'` : la valeur (entrée ET sortie) est une string "yyyy-MM-dd" — pour un
@@ -301,6 +327,7 @@ export const IsoValueType: Story = {
valueType: 'iso',
label: 'Date (mode ISO)',
helperText: 'Valeur : string "yyyy-MM-dd" (au lieu de Date).',
+ dateFormat: demoDateFormat,
},
};
@@ -310,6 +337,7 @@ export const WithTime: Story = {
showTime: true,
label: 'Rendez-vous',
helperText: 'Heures et minutes saisissables au clavier.',
+ dateFormat: demoDateTimeFormat,
},
};
@@ -320,12 +348,18 @@ export const StepperOnlyTime: Story = {
editableTime: false,
label: 'Rendez-vous',
helperText: 'Réglage aux chevrons / flèches uniquement.',
+ dateFormat: demoDateTimeFormat,
},
};
export const Time12h: Story = {
render: story(new Date(2026, 6, 8, 14, 30)),
- args: { showTime: true, hourFormat: '12', label: 'Rendez-vous' },
+ args: {
+ showTime: true,
+ hourFormat: '12',
+ label: 'Rendez-vous',
+ dateFormat: demoDateTimeFormat,
+ },
};
export const TimeOnly: Story = {
@@ -335,7 +369,11 @@ export const TimeOnly: Story = {
export const ButtonBar: Story = {
render: story(),
- args: { showButtonBar: true, helperText: "« Aujourd'hui » et « Effacer »." },
+ args: {
+ showButtonBar: true,
+ helperText: "« Aujourd'hui » et « Effacer ».",
+ dateFormat: demoDateFormat,
+ },
};
// Plage restreinte : ±10 jours autour du 8 juillet 2026. `minDate`/`maxDate` en `Date`
@@ -351,7 +389,11 @@ export const MinMax: Story = {
},
template: TEMPLATE,
}),
- args: { label: 'Date (plage limitée)', helperText: 'Du 28 juin au 18 juillet 2026.' },
+ args: {
+ label: 'Date (plage limitée)',
+ helperText: 'Du 28 juin au 18 juillet 2026.',
+ dateFormat: demoDateFormat,
+ },
};
// Même contrainte que `MinMax`, bornes passées en ISO plutôt qu'en `Date` — pratique
@@ -364,13 +406,18 @@ export const MinMaxIso: Story = {
args: {
label: 'Date (bornes ISO)',
helperText: 'minDate/maxDate passées en string "yyyy-MM-dd".',
+ dateFormat: demoDateFormat,
},
};
// Week-ends (dimanche = 0, samedi = 6) désactivés.
export const DisabledWeekends: Story = {
render: (args) => ({ props: { ...args, model: null, disabledDays: [0, 6] }, template: TEMPLATE }),
- args: { label: 'Jour ouvré', helperText: 'Week-ends indisponibles.' },
+ args: {
+ label: 'Jour ouvré',
+ helperText: 'Week-ends indisponibles.',
+ dateFormat: demoDateFormat,
+ },
};
// Dates ponctuelles désactivées, mixant `Date` et ISO pour montrer que les deux formes coexistent.
@@ -383,13 +430,27 @@ export const DisabledDates: Story = {
},
template: TEMPLATE,
}),
- args: { label: 'Jours indisponibles', helperText: '8, 15 et 22 juillet 2026 désactivés.' },
+ args: {
+ label: 'Jours indisponibles',
+ helperText: '8, 15 et 22 juillet 2026 désactivés.',
+ dateFormat: demoDateFormat,
+ },
};
-export const Disabled: Story = { render: story(sample), args: { disabled: true } };
+export const Disabled: Story = {
+ render: story(sample),
+ args: { disabled: true, dateFormat: demoDateFormat },
+};
-// Effaçable : une croix apparaît dans le champ dès qu'une valeur est présente.
-export const Clearable: Story = { render: story(sample), args: { label: 'Date', showClear: true } };
+// `showClear` est vrai par défaut, mais ne prend effet que si `showIcon` est à `false` : sinon
+// le calendrier garde l'icône, pour rester cliquable et rouvrir le panneau même une fois une
+// valeur choisie (voir la doc de `showClear`). Ici `showIcon` est explicitement coupé pour
+// montrer la croix — la valeur reste sinon effaçable au clavier (`allowInput`, sélectionner +
+// supprimer le texte).
+export const Clearable: Story = {
+ render: story(sample),
+ args: { label: 'Date', showIcon: false, dateFormat: demoDateFormat },
+};
// Saisie manuelle : tapez la date au clavier (parsée au blur / Entrée). Les "/" s'insèrent
// automatiquement au fil de la frappe (dès qu'un segment jour/mois est complet, comme une date
@@ -408,20 +469,6 @@ export const EditableInput: Story = {
},
};
-// Même saisie assistée, en locale `en-US` : l'ordre mois/jour/année piloté par `dateFieldOrder`
-// s'applique aussi à l'auto-formatage (mm/dd/yyyy plutôt que jj/mm/aaaa).
-export const AutoFormattedInputEnUs: Story = {
- render: story(sample),
- args: {
- label: 'Date',
- allowInput: true,
- showClear: true,
- locale: 'en-US',
- placeholder: '',
- helperText: 'Type "08072026": the "/" appear on their own (mm/dd/yyyy).',
- },
-};
-
// Saisie assistée en MonthPicker (view="month") : le masque n'a que 2 segments (mois/année,
// le jour étant hors sujet dans cette vue) — exerce le chemin "99/9999" de typingSlots.
export const AutoFormattedInputMonthPicker: Story = {
@@ -467,38 +514,40 @@ export const EditableInputWithTime12h: Story = {
},
};
-// Formatteur/parseur custom (symétriques) : affichage "8 juil. 2026", saisie au même format.
+// Formatteur/parseur custom (symétriques) : seul exemple qui s'écarte du format classique
+// jj/mm/aaaa utilisé partout ailleurs, pour montrer que `dateFormat`/`parseDate` permettent
+// d'accueillir n'importe quel format d'affichage (ici « Jul 8, 2026 », à l'anglo-saxonne).
export const CustomFormat: Story = {
render: (args) => ({
props: {
...args,
model: sample,
dateFormat: (d: Date) =>
- new Intl.DateTimeFormat('fr-FR', {
+ new Intl.DateTimeFormat('en-US', {
day: 'numeric',
month: 'short',
year: 'numeric',
}).format(d),
parseDate: (text: string): Date | null => {
- const m = /^(\d{1,2})\s+([a-zéûî.]+)\.?\s+(\d{4})$/i.exec(text.trim());
+ const m = /^([a-z]+)\s+(\d{1,2}),\s*(\d{4})$/i.exec(text.trim());
if (!m) return null;
const months = [
- 'janv',
- 'févr',
- 'mars',
- 'avr',
- 'mai',
- 'juin',
- 'juil',
- 'août',
- 'sept',
+ 'jan',
+ 'feb',
+ 'mar',
+ 'apr',
+ 'may',
+ 'jun',
+ 'jul',
+ 'aug',
+ 'sep',
'oct',
'nov',
- 'déc',
+ 'dec',
];
- const idx = months.findIndex((mo) => m[2].toLowerCase().startsWith(mo));
+ const idx = months.findIndex((mo) => m[1].toLowerCase().startsWith(mo));
if (idx < 0) return null;
- return new Date(Number(m[3]), idx, Number(m[1]));
+ return new Date(Number(m[3]), idx, Number(m[2]));
},
},
template: TEMPLATE,
@@ -507,8 +556,9 @@ export const CustomFormat: Story = {
label: 'Format personnalisé',
allowInput: true,
showClear: true,
- locale: 'fr-FR',
- helperText: 'Affichage « 8 juil. 2026 », parseDate symétrique.',
+ locale: 'en-US',
+ placeholder: '', // vide → placeholder auto dérivé du dateFormat custom lui-même (« Nov 22, 2023 »)
+ helperText: 'Affichage « Jul 8, 2026 », parseDate symétrique.',
},
};
@@ -539,6 +589,45 @@ export const Range: Story = {
args: { inline: true, selectionMode: 'range', label: 'Période' },
};
+// Saisie clavier en mode range (FSHSP-118) : les deux dates dans le même champ, séparées par
+// " - ". Le clic dans la grille continue de fonctionner exactement comme avant (voir `Range`) —
+// la saisie clavier ne fait que s'y ajouter. Pas de masque auto-"/" ici (voir doc `allowInput`) :
+// texte libre, parsé au blur/Entrée uniquement.
+export const RangeTypedInput: Story = {
+ render: (args) => ({
+ props: { ...args, model: [new Date(2026, 6, 8), new Date(2026, 6, 18)], dateFormat: demoDateFormat },
+ template: TEMPLATE,
+ }),
+ args: {
+ selectionMode: 'range',
+ label: 'Période',
+ locale: 'fr-FR', // ordre jj/mm/aaaa — sans ça, la locale résolue retombe sur en-US (mm/dd/yyyy)
+ placeholder: '', // vide → placeholder auto dérivé pour range ("jj/mm/aaaa - jj/mm/aaaa")
+ helperText: 'Tapez "08/07/2026 - 18/07/2026" (jj/mm/aaaa - jj/mm/aaaa).',
+ },
+};
+
+// Saisie clavier en mode multiple (FSHSP-118) : une liste de dates séparées par ", " dans le
+// même champ. Même principe que `RangeTypedInput` — texte libre, parsé au blur/Entrée, la grille
+// reste utilisable en parallèle (clic = bascule).
+export const MultipleTypedInput: Story = {
+ render: (args) => ({
+ props: {
+ ...args,
+ model: [new Date(2026, 6, 8), new Date(2026, 6, 15), new Date(2026, 6, 23)],
+ dateFormat: demoDateFormat,
+ },
+ template: TEMPLATE,
+ }),
+ args: {
+ selectionMode: 'multiple',
+ label: 'Dates',
+ locale: 'fr-FR', // ordre jj/mm/aaaa — sans ça, la locale résolue retombe sur en-US (mm/dd/yyyy)
+ placeholder: '', // vide → placeholder auto dérivé pour multiple ("jj/mm/aaaa, ...")
+ helperText: 'Tapez "08/07/2026, 15/07/2026, 23/07/2026" (jj/mm/aaaa, ...).',
+ },
+};
+
// MonthPicker : le clic sur un mois sélectionne le mois (valeur = 1er du mois).
export const MonthPicker: Story = {
render: story(new Date(2027, 1, 1)),
@@ -582,10 +671,11 @@ export const CustomButtonBar: Story = {
// Basculer le contrôle `autoFlip` (false) pour verrouiller l'ouverture vers le bas.
export const SmartPosition: Story = {
render: (args) => ({
- props: { ...args, model: null },
+ props: { ...args, model: null, dateFormat: demoDateFormat },
template: ``,
}),
@@ -665,8 +755,10 @@ const demoFamily = { classes: (name: string) => `fa-solid fa-${DEMO_GLYPHS[name]
/**
* `#icon` remplace le markup de l'icône du **déclencheur** — et rien d'autre : les dix chevrons du
* panneau restent sur la famille par défaut. Contexte reçu : `$implicit` = le nom que le composant
- * a résolu (`calendar`, `clock` en `timeOnly`, ou `xmark` quand `showClear` a une valeur à effacer),
- * `size` = la taille calée sur le champ, `disabled` = l'état du champ.
+ * a résolu (`calendar`, `clock` en `timeOnly`, ou `xmark` — cette dernière seulement si `showIcon`
+ * est à `false`, voir la doc de `showClear`), `size` = la taille calée sur le champ, `disabled` =
+ * l'état du champ. Deux instances ci-dessous pour voir les deux : la première résout `calendar`
+ * (config par défaut), la seconde `xmark` (`showIcon` coupé + valeur déjà présente).
*/
export const IconTemplate: Story = {
decorators: [
@@ -674,12 +766,19 @@ export const IconTemplate: Story = {
moduleMetadata({ imports: [UiDatepicker, UiIcon, FormsModule] }),
],
render: () => ({
- props: { model: null },
- template: `
-
-
-
-
`,
+ props: { a: null, b: sample, dateFormat: demoDateFormat },
+ template: ``,
}),
};
@@ -694,9 +793,9 @@ export const ScopedIconFamily: Story = {
moduleMetadata({ imports: [UiDatepicker, UiIconFamilyScope, FormsModule] }),
],
render: () => ({
- props: { model: new Date(2026, 6, 8) },
+ props: { model: new Date(2026, 6, 8), dateFormat: demoDateTimeFormat },
template: `
`,
+ [(ngModel)]="model" valueType="date" label="Rendez-vous" showTime [dateFormat]="dateFormat" />`,
}),
};
@@ -709,12 +808,12 @@ export const ScopedIconFamily: Story = {
*/
export const FloatLabel: Story = {
render: () => ({
- props: { a: null, b: null, c: sample },
+ props: { a: null, b: null, c: sample, dateFormat: demoDateFormat },
template: `
-
-
-
+
+
+
`,
}),
diff --git a/projects/ui-kit/forms/ui-input/src/lib/ui-input.html b/projects/ui-kit/forms/ui-input/src/lib/ui-input.html
index 638b170..d2dd7fe 100644
--- a/projects/ui-kit/forms/ui-input/src/lib/ui-input.html
+++ b/projects/ui-kit/forms/ui-input/src/lib/ui-input.html
@@ -46,7 +46,7 @@
[attr.aria-controls]="ariaControls() || null"
[attr.aria-label]="label() ? null : ariaLabel() || null"
[attr.aria-labelledby]="ariaLabelledBy() || null"
- [attr.aria-describedby]="displayMessage() ? messageId() : null"
+ [attr.aria-describedby]="resolvedAriaDescribedBy()"
[attr.aria-invalid]="effectiveLevel() === 'error' ? 'true' : null"
(input)="onInput()"
(focus)="inputFocus.emit($event)"
diff --git a/projects/ui-kit/forms/ui-input/src/lib/ui-input.ts b/projects/ui-kit/forms/ui-input/src/lib/ui-input.ts
index 73f0136..5c635dc 100644
--- a/projects/ui-kit/forms/ui-input/src/lib/ui-input.ts
+++ b/projects/ui-kit/forms/ui-input/src/lib/ui-input.ts
@@ -68,6 +68,13 @@ export class UiInput extends BaseFormField {
ariaControls = input();
/** Native placeholder. */
placeholder = input();
+ /**
+ * id of an external element describing this control further (e.g. a composite host's own
+ * format hint), chained onto `aria-describedby` alongside the helper/error message rather than
+ * replacing it — a plain `[attr.aria-describedby]` override on the host would clobber whichever
+ * of the two lands last.
+ */
+ ariaDescribedBy = input();
/** Suffix unit (e.g. "%", "@domain"). Shown when provided. */
unit = input();
/** Left FontAwesome icon name (decorative). */
@@ -142,6 +149,14 @@ export class UiInput extends BaseFormField {
protected readonly hasRightAction = computed(
() => (!!this.iconRight() || !!this.resolvedIconRightTemplate()) && !!this.iconRightAriaLabel(),
);
+ /** @ignore Full `aria-describedby`: the helper/error message id (only when a message is
+ * actually rendered) plus the externally supplied `ariaDescribedBy`, space-joined — `null`
+ * when neither applies (native attribute is then omitted, not left dangling). */
+ protected readonly resolvedAriaDescribedBy = computed(() => {
+ const ids = [this.displayMessage() ? this.messageId() : null, this.ariaDescribedBy() || null];
+ const joined = ids.filter((id): id is string => !!id).join(' ');
+ return joined || null;
+ });
/** Focuses the input. */
focus(options?: FocusOptions): void {