Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
110 changes: 108 additions & 2 deletions projects/ui-kit/forms/src/lib/mask-engine.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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');
});
});
Loading
Loading