From 1c0b97d59b101bc41d0c837ddfb64407e1c7e797 Mon Sep 17 00:00:00 2001 From: LBU Date: Mon, 24 Aug 2026 10:37:16 +0200 Subject: [PATCH 01/13] FSHSP-162 chore(ui-datepicker): uniformize story date formats on jj/mm/aaaa MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The actual regression: the readonly trigger's default display (no allowInput) formats via Intl dateStyle:'medium' on the resolved locale, which falls back to en-US in Storybook (no LOCALE_ID configured) — so most demos silently rendered "Jul 8, 2026" instead of "08/07/2026", even though the placeholder/docs advertise jj/mm/aaaa. This also affected stories that start empty but format a date once one is picked interactively (Default, Required, Error, ButtonBar, DisabledWeekends, DisabledDates, IconTemplate, SmartPosition). Give every demo an explicit numeric dateFormat (with a time-aware variant for the showTime stories) so they render 08/07/2026 (08/07/2026 14:30 with time) regardless of the ambient locale — verified both on the stories' initial value and by picking a date interactively. Drop the AutoFormattedInputEnUs demo (mm/dd/yyyy) and keep CustomFormat as the single deliberate exception ("Jul 8, 2026", en-US) showing that dateFormat/parseDate can support any display format. Left untouched: AutoFormattedInputMonthPicker (adding a default dateFormat there would disable its own numeric mm/aaaa masking, gated on dateFormat being absent) and TimeOnly (the timeOnly branch ignores dateFormat and hourFormat entirely and always renders a locale AM/PM time — a component behavior issue, out of scope here). --- .../forms/ui-datepicker/ui-datepicker.mdx | 6 +- .../ui-datepicker/ui-datepicker.stories.ts | 143 +++++++++++------- 2 files changed, 95 insertions(+), 54 deletions(-) diff --git a/projects/ui-kit/forms/ui-datepicker/ui-datepicker.mdx b/projects/ui-kit/forms/ui-datepicker/ui-datepicker.mdx index a47fcd5..1503da0 100644 --- a/projects/ui-kit/forms/ui-datepicker/ui-datepicker.mdx +++ b/projects/ui-kit/forms/ui-datepicker/ui-datepicker.mdx @@ -173,10 +173,12 @@ format non numérique rendrait l'auto-slash faux). 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. +ne sont jamais round-trippés à travers la CVA, donc pas concernés par le contrat ISO. 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. - 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..bc720bc 100644 --- a/projects/ui-kit/forms/ui-datepicker/ui-datepicker.stories.ts +++ b/projects/ui-kit/forms/ui-datepicker/ui-datepicker.stories.ts @@ -276,20 +276,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 +320,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 +330,7 @@ export const WithTime: Story = { showTime: true, label: 'Rendez-vous', helperText: 'Heures et minutes saisissables au clavier.', + dateFormat: demoDateTimeFormat, }, }; @@ -320,12 +341,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 +362,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 +382,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 +399,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 +423,23 @@ 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 } }; +export const Clearable: Story = { + render: story(sample), + args: { label: 'Date', showClear: true, 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 +458,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 +503,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 +545,8 @@ 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', + helperText: 'Affichage « Jul 8, 2026 », parseDate symétrique.', }, }; @@ -582,10 +620,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: `
`, }), @@ -674,8 +713,8 @@ export const IconTemplate: Story = { moduleMetadata({ imports: [UiDatepicker, UiIcon, FormsModule] }), ], render: () => ({ - props: { model: null }, - template: `
+ props: { model: null, dateFormat: demoDateFormat }, + template: `
@@ -694,9 +733,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 +748,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: `
- - - + + +
`, }), From 1d61587f45af8b648537694f23b7329b8e60cea6 Mon Sep 17 00:00:00 2001 From: LBU Date: Mon, 24 Aug 2026 11:03:40 +0200 Subject: [PATCH 02/13] FSHSP-163 fix(ui-datepicker): respect hourFormat and dateFormat in timeOnly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit displayValue's timeOnly branch formatted directly via Intl 'timeStyle: short' on the resolved locale, bypassing hourFormat and dateFormat entirely: hourFormat="24" (the default) had no effect — the clock followed whatever AM/PM-vs-24h convention the locale defaulted to (en-US shows AM/PM) — and a custom dateFormat had no way to reach this mode at all. Add formatTime(), symmetric with formatDate()/formatMonth(): honors dateFormat when provided, otherwise forces hour12 from hourFormat instead of leaving it to the locale. --- CHANGELOG.md | 4 ++++ .../ui-datepicker/src/lib/ui-datepicker.ts | 20 +++++++++++++++---- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb0ee54..e4e5a31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,10 @@ Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le pr - **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. +### 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`). + ## [0.6.1] - 2026-08-22 ### Fixed 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..25b34f8 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 @@ -190,7 +190,8 @@ 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>(); /** @@ -552,6 +553,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,9 +594,7 @@ 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(', '); From a73525f417a30a2c5ead600068adaa399f40709a Mon Sep 17 00:00:00 2001 From: LBU Date: Mon, 24 Aug 2026 11:37:54 +0200 Subject: [PATCH 03/13] FSHSP-118 feat(ui-input): accept an external ariaDescribedBy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chained onto the native input's aria-describedby alongside the helper/ error message id (never replacing it) — a plain [attr.aria-describedby] override on a composite host (e.g. ui-datepicker's own format hint) would otherwise clobber whichever of the two lands last. --- .../ui-kit/forms/ui-input/src/lib/ui-input.html | 2 +- .../ui-kit/forms/ui-input/src/lib/ui-input.ts | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) 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 { From d39d27293e01907429e31c89a252205eaefb4c6b Mon Sep 17 00:00:00 2001 From: LBU Date: Mon, 24 Aug 2026 11:38:07 +0200 Subject: [PATCH 04/13] FSHSP-118 fix(mask-engine): stop deletions stealing digits across segments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit autoFormatSegments() re-derives the masked text from the flat digit stream on every keystroke. The bounds check (acceptsMaskChar) 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) — applied instead to the digits left over after a deletion, that same skip can discard a still-valid residual digit and reassign every following segment by one position (day steals month's slice, month steals year's...). Add an enforceBounds option (default true, unchanged for typing/ pasting) and disable it in ui-datepicker's onTriggerInput whenever the new data is shorter than before (a shrink = a deletion). Segments can show a transient out-of-range value until the final blur/Enter parse (finalizeParsed already validates and reverts), but digits are never reassigned to the wrong segment anymore. True in-place segment editing (rewriting just the day while leaving month/year untouched, wherever the caret is) remains a bigger, separate effort — this only fixes the cross-segment corruption. --- .../ui-kit/forms/src/lib/mask-engine.spec.ts | 31 +++++++++++++++++++ projects/ui-kit/forms/src/lib/mask-engine.ts | 26 ++++++++++++---- 2 files changed, 51 insertions(+), 6 deletions(-) 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..748c57c 100644 --- a/projects/ui-kit/forms/src/lib/mask-engine.spec.ts +++ b/projects/ui-kit/forms/src/lib/mask-engine.spec.ts @@ -98,6 +98,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 +167,28 @@ describe('autoFormatSegments', () => { expect(result.text).toBe(''); expect(result.tokenIndices).toEqual([0]); }); + + // 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 + }); }); diff --git a/projects/ui-kit/forms/src/lib/mask-engine.ts b/projects/ui-kit/forms/src/lib/mask-engine.ts index ec13474..074d7b4 100644 --- a/projects/ui-kit/forms/src/lib/mask-engine.ts +++ b/projects/ui-kit/forms/src/lib/mask-engine.ts @@ -82,14 +82,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,6 +164,7 @@ export function caretForMask(tokenIndices: number[], n: number, length: number): export function autoFormatSegments( slots: MaskSlot[], data: string, + { enforceBounds = true }: { enforceBounds?: boolean } = {}, ): { text: string; tokenIndices: number[] } { let di = 0; let text = ''; @@ -166,7 +180,7 @@ export function autoFormatSegments( } 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]; if (slot.bound) segment += data[di]; From 8a7996b84ee6c5dc4ca45a1515fec59f488621cb Mon Sep 17 00:00:00 2001 From: LBU Date: Mon, 24 Aug 2026 11:38:25 +0200 Subject: [PATCH 05/13] FSHSP-118 feat(ui-datepicker): make keyboard entry the default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A calendar grid alone forces a screen-reader user through ~30 cells to pick a date; typing is far faster. allowInput now defaults to true (single mode, unchanged for multiple/range/timeOnly). showClear also defaults to true, but only takes effect when showIcon is false: the calendar/clock toggle otherwise always wins the trigger's single icon slot, so there's always a click target to reopen the panel and change the date directly, without ever losing that affordance to the clear cross. Clearing then goes through the keyboard instead (select the text, delete it) — consistent with allowInput being on by default. showClearButton is gated accordingly. Also adds an aria-describedby format hint (e.g. 'Format attendu : jj/mm/aaaa'), chained onto the trigger's existing helper/error message rather than replacing it (ui-input's new ariaDescribedBy input) — the placeholder alone is an unreliable, disappearing-on-input signal across screen readers. New formatHintLabel input to override/disable it. Updates the affected stories (meta defaults, Clearable now toggles showIcon to actually demonstrate the cross, IconTemplate shows both icon states side by side) and the MDX doc. --- CHANGELOG.md | 5 ++ .../ui-datepicker/src/lib/ui-datepicker.html | 4 + .../ui-datepicker/src/lib/ui-datepicker.scss | 13 ++++ .../ui-datepicker/src/lib/ui-datepicker.ts | 75 ++++++++++++++++--- .../forms/ui-datepicker/ui-datepicker.mdx | 50 +++++++++---- .../ui-datepicker/ui-datepicker.stories.ts | 50 +++++++++---- 6 files changed, 154 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4e5a31..fe89984 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,15 +25,20 @@ 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`. ### 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. ## [0.6.1] - 2026-08-22 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 }} + }
{ 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 @@ -195,13 +201,15 @@ export class UiDatepicker extends BaseFormField { 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 (single selection only). **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. Has no effect in `multiple`/`range` + * (no parser defined yet for either) or `timeOnly` mode: the trigger stays read-only there. */ - 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. @@ -228,6 +236,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. */ @@ -387,6 +405,18 @@ export class UiDatepicker extends BaseFormField { ) .join(''); }); + /** + * @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')[]>(() => { @@ -445,11 +475,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; @@ -822,7 +864,16 @@ 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`). + const enforceBounds = newData.length >= extractMaskData(this.typedValue() ?? '').length; + const { text, tokenIndices } = autoFormatSegments(slots, newData, { enforceBounds }); this.typedValue.set(text); if (el) { el.value = text; diff --git a/projects/ui-kit/forms/ui-datepicker/ui-datepicker.mdx b/projects/ui-kit/forms/ui-datepicker/ui-datepicker.mdx index 1503da0..bd04185 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`, mode `single`), 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 (mode `single` uniquement), 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** dans deux cas : en mode `multiple`/`range` (aucun parseur défini +pour deux dates ou une liste — chantier séparé, non résolu) et 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. 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. @@ -171,6 +181,12 @@ 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. Les @@ -194,9 +210,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. @@ -315,9 +332,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`, mode `single`, hors `timeOnly`/`multiple`/`range` — 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 bc720bc..56cabaf 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, @@ -435,10 +442,14 @@ export const Disabled: Story = { args: { disabled: true, dateFormat: demoDateFormat }, }; -// Effaçable : une croix apparaît dans le champ dès qu'une valeur est présente. +// `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', showClear: true, dateFormat: demoDateFormat }, + args: { label: 'Date', showIcon: false, dateFormat: demoDateFormat }, }; // Saisie manuelle : tapez la date au clavier (parsée au blur / Entrée). Les "/" s'insèrent @@ -704,8 +715,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: [ @@ -713,12 +726,19 @@ export const IconTemplate: Story = { moduleMetadata({ imports: [UiDatepicker, UiIcon, FormsModule] }), ], render: () => ({ - props: { model: null, dateFormat: demoDateFormat }, - template: `
- - - -
`, + props: { a: null, b: sample, dateFormat: demoDateFormat }, + template: `
+
+ + + +
+
+ + + +
+
`, }), }; From aaccbfec11052e4898b07d79492d34a840efd810 Mon Sep 17 00:00:00 2001 From: LBU Date: Mon, 24 Aug 2026 12:03:35 +0200 Subject: [PATCH 06/13] FSHSP-118 fix(ui-datepicker): deleting past a completed segment got stuck MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported via screen recording: typing/committing a full date (e.g. 20/08/2020), then Backspacing from the end, correctly shrinks down to "20/08/" (day+month complete) and then stops responding — every further Backspace looks like a no-op. Root cause: autoFormatSegments() eagerly appends a segment's trailing '/' as soon as it's complete, and the caret was placed at text.length, i.e. right after that separator. A Backspace there deletes the separator, not a digit — and the very next render silently re-inserts it (still no data past it), so the field appears frozen. autoFormatSegments() now also returns dataEnd, the position right after the last actual data character (never past a dangling separator). onTriggerInput uses it as the caret target specifically for a deletion whose caret sat at/past all remaining data (editing at the tail — the common case); a genuine mid-string deletion still falls back to the existing tokenIndices-based placement, unchanged. Verified live (simulated native input events, not just unit tests): 20/08/2020 -> 20/08/202 -> ... -> 20/08/ -> 20/0 -> 20/ -> 2 -> '', with no stall at any step. --- CHANGELOG.md | 1 + .../ui-kit/forms/src/lib/mask-engine.spec.ts | 10 ++++++++++ projects/ui-kit/forms/src/lib/mask-engine.ts | 12 +++++++++-- .../ui-datepicker/src/lib/ui-datepicker.ts | 20 ++++++++++++++++--- 4 files changed, 38 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe89984..b06aa03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le pr - **`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. ## [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 748c57c..73097dc 100644 --- a/projects/ui-kit/forms/src/lib/mask-engine.spec.ts +++ b/projects/ui-kit/forms/src/lib/mask-engine.spec.ts @@ -168,6 +168,16 @@ describe('autoFormatSegments', () => { 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() { diff --git a/projects/ui-kit/forms/src/lib/mask-engine.ts b/projects/ui-kit/forms/src/lib/mask-engine.ts index 074d7b4..02d2110 100644 --- a/projects/ui-kit/forms/src/lib/mask-engine.ts +++ b/projects/ui-kit/forms/src/lib/mask-engine.ts @@ -165,12 +165,19 @@ export function autoFormatSegments( slots: MaskSlot[], data: string, { enforceBounds = true }: { enforceBounds?: boolean } = {}, -): { text: string; tokenIndices: number[] } { +): { 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) { @@ -183,9 +190,10 @@ export function autoFormatSegments( 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.ts b/projects/ui-kit/forms/ui-datepicker/src/lib/ui-datepicker.ts index da23022..e13bcca 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 @@ -872,12 +872,26 @@ export class UiDatepicker extends BaseFormField { // 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`). - const enforceBounds = newData.length >= extractMaskData(this.typedValue() ?? '').length; - const { text, tokenIndices } = autoFormatSegments(slots, newData, { enforceBounds }); + // 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(); From 6d38a59a3be593e719d802aea6313bf6c22beba5 Mon Sep 17 00:00:00 2001 From: LBU Date: Mon, 24 Aug 2026 12:14:38 +0200 Subject: [PATCH 07/13] FSHSP-118 fix(ui-datepicker): stop live-formatting once a value exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review: the previous two fixes (bounds-skip disabled on deletion, caret parked before a dangling separator) still didn't cover the reported case — clicking into an already-valid date to fix one segment (e.g. just the month) still shifted everything after it, and repeated Backspace from the end could still stall. Root cause is structural: re-deriving the ENTIRE text from a flat digit stream on every keystroke only ever behaves well for *constructing* a date from nothing (sequential forward typing, or backspacing from the end) — it has no notion of "this segment was already valid, leave it alone". typingSlots() now also returns null once hasValue() is true, routing onTriggerInput to the existing plain-passthrough branch: no live auto-slash, no re-derivation, so no way to shift or corrupt a segment — just ordinary text editing, parsed on blur/Enter as before (already tolerant of arbitrary separators via defaultParse). The mask re-arms on its own once the field is cleared and hasValue() goes back to false, so the very next fresh date still gets the auto-"/" guidance. Verified live against the exact reported scenario (native input events against a running instance, hasValue()/typingSlots() inspected via ng.getComponent): editing 20/08/2020 in place now behaves as plain text (delete/retype any character anywhere, e.g. just the month's '8' -> "20/0/2020" with day and year untouched, then retype -> commits correctly on blur), and clearing the field flips typingSlots() back to non-null for the next entry. --- CHANGELOG.md | 1 + .../ui-datepicker/src/lib/ui-datepicker.ts | 23 +++++++++++++------ .../forms/ui-datepicker/ui-datepicker.mdx | 10 ++++++++ 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b06aa03..3024b05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le pr - **`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. ## [0.6.1] - 2026-08-22 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 e13bcca..0681bb4 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 @@ -438,15 +438,24 @@ 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. * `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), or `hasValue()` (FSHSP-118). + * + * That last one: re-deriving the mask from a flat digit stream on every keystroke only ever + * behaves well for *constructing* a date 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-slash, but no corruption either — and defers to `commitTyped`'s parser (already + * tolerant of arbitrary separators, see `defaultParse`) on blur/Enter. The mask re-arms on its + * own once the field is cleared and `hasValue()` goes back to `false`. */ private readonly typingSlots = computed(() => { - if (this.triggerReadonly() || this.view() === 'year' || this.parseDate()) return null; + if (this.triggerReadonly() || this.view() === 'year' || this.parseDate() || this.hasValue()) + 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 }, diff --git a/projects/ui-kit/forms/ui-datepicker/ui-datepicker.mdx b/projects/ui-kit/forms/ui-datepicker/ui-datepicker.mdx index bd04185..0e3599b 100644 --- a/projects/ui-kit/forms/ui-datepicker/ui-datepicker.mdx +++ b/projects/ui-kit/forms/ui-datepicker/ui-datepicker.mdx @@ -174,6 +174,16 @@ 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 une fois le champ vidé (`null`). + > **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"` From 3000e79879c72d826e391d018cec9863c43ee268 Mon Sep 17 00:00:00 2001 From: LBU Date: Mon, 24 Aug 2026 12:26:37 +0200 Subject: [PATCH 08/13] FSHSP-118 fix(ui-datepicker): re-arm the mask without waiting for blur MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hasValue() only flips to false on a real commit (blur/Enter, or the clear cross) — clearing the field by hand and typing straight back in, with no blur in between, left it masked off for the whole next entry too, since nothing had actually told the model the value was gone. onTriggerInput's passthrough branch (mask off) now calls clear() the instant the raw text reads empty, instead of waiting for commitTyped on blur. hasValue() genuinely flips to false right there, so typingSlots() re-arms on the very next keystroke and stays armed for the whole fresh entry - not just the one instant the field happens to be empty (an earlier attempt at this, checking typedValue() === '' in typingSlots itself, only held for that single keystroke: hasValue() was still stale-true, so the mask flipped off again the moment the first new character landed). Verified live: clearing 08/07/2026 by hand (no blur) flips hasValue() to false and typingSlots() to non-null immediately; typing '01011999' right after auto-slashes correctly the whole way to 01/01/1999, with no blur anywhere in the sequence. --- CHANGELOG.md | 2 +- .../forms/ui-datepicker/src/lib/ui-datepicker.ts | 12 +++++++++++- .../ui-kit/forms/ui-datepicker/ui-datepicker.mdx | 4 +++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3024b05..c99dc39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,7 +40,7 @@ Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le pr - **`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. +- **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. ## [0.6.1] - 2026-08-22 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 0681bb4..df6cff2 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 @@ -451,7 +451,11 @@ export class UiDatepicker extends BaseFormField { * Disabling the mask there routes typing to the plain passthrough branch below instead — no * live auto-slash, but no corruption either — and defers to `commitTyped`'s parser (already * tolerant of arbitrary separators, see `defaultParse`) on blur/Enter. The mask re-arms on its - * own once the field is cleared and `hasValue()` goes back to `false`. + * 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() || this.hasValue()) @@ -866,6 +870,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; } diff --git a/projects/ui-kit/forms/ui-datepicker/ui-datepicker.mdx b/projects/ui-kit/forms/ui-datepicker/ui-datepicker.mdx index 0e3599b..2c93234 100644 --- a/projects/ui-kit/forms/ui-datepicker/ui-datepicker.mdx +++ b/projects/ui-kit/forms/ui-datepicker/ui-datepicker.mdx @@ -182,7 +182,9 @@ format non numérique rendrait l'auto-slash faux). > 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 une fois le champ vidé (`null`). +> 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 From 20259b547ed0ce99b98d2e8a17fab2e164d1a01b Mon Sep 17 00:00:00 2001 From: LBU Date: Mon, 24 Aug 2026 14:19:58 +0200 Subject: [PATCH 09/13] FSHSP-118 feat(ui-datepicker): make keyboard entry the default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends allowInput to range and multiple, as a complement to the grid (clicking a day keeps working exactly as before, both paths feed the same model). Deliberately no live auto-"/" mask for either — that engine only ever models one date, and single mode already showed how fiddly it gets; range/multiple stay plain-text, parsed on blur/Enter: - range: both dates in the same field, joined by " - " (e.g. "08/07/2026 - 18/07/2026"), reordered chronologically if typed backwards (mirrors the grid's own reordering in selectDay). - multiple: a ", "-joined list, any count, duplicates collapsed (mirrors the grid's click-to-toggle). Implementation: triggerReadonly() no longer hardcodes single mode; typingSlots() explicitly opts back out for range/multiple (never runs the live mask for them, regardless of hasValue()). New parseTypedValue()/parseTypedMulti() split typed text on the mode's separator and parse each part with the existing single-date parseTyped() (so a custom parseDate applies per part, symmetric with dateFormat already applying per date via formatDate()). An unparseable or disabled part fails the whole thing - never a partial commit. resolvedPlaceholder() composes the single-date token into "jj/mm/aaaa - jj/mm/aaaa" / "jj/mm/aaaa, ..." when not overridden. Not covered: showTime combined with range/multiple typed entry (typed dates always land at startOfDay; the grid still handles time for these modes) - a further chantier of its own. Verified live against a running instance (real typed input + blur, inspected via ng.getComponent): range reorders end-before-start input and highlights correctly in the grid; multiple collapses a typed duplicate and highlights all three dates; an incomplete range reverts on blur; grid clicks and typed entry compose (typing a range then clicking a third day promotes it to multiple's existing 3-date array). --- CHANGELOG.md | 1 + .../ui-datepicker/src/lib/ui-datepicker.ts | 166 +++++++++++++----- .../forms/ui-datepicker/ui-datepicker.mdx | 40 ++++- .../ui-datepicker/ui-datepicker.stories.ts | 39 ++++ 4 files changed, 197 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c99dc39..481d466 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le pr - 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é. Contrairement au mode `single`, ni l'un ni l'autre n'a de masque auto-"/" en direct : texte libre, parsé au blur/Entrée uniquement, 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 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 df6cff2..9acdc9c 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,12 @@ export interface DatepickerMonthPanel { let nextPanelUid = 0; +/** Joins the two dates of a typed/displayed `range` value (FSHSP-118: `"jj/mm/aaaa - jj/mm/aaaa"`). + * Used both to render `displayValue` and to split typed text back apart in `parseTypedMulti`. */ +const RANGE_SEPARATOR = ' - '; +/** Joins the dates of a typed/displayed `multiple` value (`"jj/mm/aaaa, jj/mm/aaaa, ..."`). */ +const MULTIPLE_SEPARATOR = ', '; + /** * ui-datepicker — headless date / month / year (and optional time) picker. * @@ -201,13 +207,26 @@ export class UiDatepicker extends BaseFormField { dateFormat = input<(date: Date) => string>(); /** - * Allow typing the date directly in the trigger (single selection only). **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. Has no effect in `multiple`/`range` - * (no parser defined yet for either) or `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(true, { transform: booleanAttribute }); /** @@ -366,24 +385,19 @@ 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` — but never through the live auto-"/" mask, which only ever models a + * single date (see `typingSlots`'s own `selectionMode` check). */ 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`. */ + private readonly singleDatePlaceholder = computed(() => { const fr = this.resolvedLocale().toLowerCase().startsWith('fr'); const token = { day: fr ? 'jj' : 'dd', month: 'mm', year: fr ? 'aaaa' : 'yyyy' }; const view = this.view(); @@ -405,6 +419,23 @@ 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) 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. @@ -439,7 +470,10 @@ export class UiDatepicker extends BaseFormField { * widths when `showTime`) 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), a custom `parseDate` (a non-numeric format would - * make the auto-slash wrong), or `hasValue()` (FSHSP-118). + * make the auto-slash wrong), `hasValue()` (FSHSP-118), or `selectionMode() !== 'single'` + * (FSHSP-118: `range`/`multiple` ARE typeable — see `triggerReadonly` — but only ever through + * plain text parsed on blur/Enter via `parseTypedMulti`; this mask only ever models one date's + * worth of digits, never two dates plus a separator). * * That last one: re-deriving the mask from a flat digit stream on every keystroke only ever * behaves well for *constructing* a date from nothing — sequential forward typing, or @@ -458,7 +492,13 @@ export class UiDatepicker extends BaseFormField { * character typed, since nothing ever committed it to `false` for real. */ private readonly typingSlots = computed(() => { - if (this.triggerReadonly() || this.view() === 'year' || this.parseDate() || this.hasValue()) + if ( + this.triggerReadonly() || + this.view() === 'year' || + this.parseDate() || + this.hasValue() || + this.selectionMode() !== 'single' + ) return null; const widths = { day: '99', month: '99', year: '9999' } as const; const bounds: Record<'day' | 'month' | 'year', MaskBounds | null> = { @@ -652,8 +692,8 @@ export class UiDatepicker extends BaseFormField { 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_SEPARATOR); return this.formatDate(dates[0]); }); @@ -917,15 +957,15 @@ export class UiDatepicker extends BaseFormField { } /** - * @ignore Reflect a fully-typed date in the open panel (navigate + highlight) - * without reformatting the field, so the caret stays put while typing. + * @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); + 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); @@ -933,8 +973,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. */ @@ -945,7 +985,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(); @@ -956,13 +997,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 } @@ -978,6 +1022,46 @@ 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` 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.trim() : MULTIPLE_SEPARATOR.trim(); + const parts = text + .split(sep) + .map((p) => p.trim()) + .filter((p) => p.length > 0); + 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 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 2c93234..ff331ea 100644 --- a/projects/ui-kit/forms/ui-datepicker/ui-datepicker.mdx +++ b/projects/ui-kit/forms/ui-datepicker/ui-datepicker.mdx @@ -8,7 +8,7 @@ import { ConfigTable } from '../../../../storybook/blocks/config-table'; 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), saisissable au clavier -par défaut (`allowInput`, mode `single`), ouvre un calendrier stylé (tokens `form.*` / +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 @@ -149,19 +149,19 @@ des trois variantes, du déclenchement et des conséquences de mise en page sur ## Saisie manuelle (`allowInput`) **`allowInput` est vrai par défaut** (FSHSP-118) : la frappe au clavier dans le champ est -autorisée d'emblée (mode `single` uniquement), 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 +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** dans deux cas : en mode `multiple`/`range` (aucun parseur défini -pour deux dates ou une liste — chantier séparé, non résolu) et en `timeOnly` (pas de -parseur/formatteur dédié à une heure seule) — le champ y reste toujours en lecture seule. +`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. +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. @@ -212,6 +212,30 @@ exemples ci-dessous restent tous au format classique **jj/mm/aaaa**, à l'except +### `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. Aucun masque auto-"/" ici (contrairement à `single`) : texte libre, +parsé au **blur**/**Entrée** uniquement — 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"`. 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é. 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` : @@ -345,7 +369,7 @@ via l'interop CVA native, ainsi qu'aux formulaires template-driven (`[(ngModel)] > **Périmètre / choix** : le champ déclencheur est **saisissable au clavier par défaut** -> (`allowInput`, mode `single`, hors `timeOnly`/`multiple`/`range` — voir +> (`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 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 56cabaf..edcb0c7 100644 --- a/projects/ui-kit/forms/ui-datepicker/ui-datepicker.stories.ts +++ b/projects/ui-kit/forms/ui-datepicker/ui-datepicker.stories.ts @@ -588,6 +588,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)), From 2bedb97271576eb16ffff2b2a800c452c4d663f6 Mon Sep 17 00:00:00 2001 From: LBU Date: Mon, 24 Aug 2026 14:29:35 +0200 Subject: [PATCH 10/13] FSHSP-118 fix(ui-datepicker): auto-derived placeholder ignored dateFormat singleDatePlaceholder always built a locale-numeric token ("jj/mm/aaaa") regardless of a custom dateFormat, so a consumer using e.g. "Jul 8, 2026" style formatting still saw a placeholder describing a format the field neither displays nor accepts (and that a matching custom parseDate would reject outright). With dateFormat set, the placeholder (and the aria-describedby hint derived from it) now comes from that same formatter applied to an illustrative date, instead of the locale-numeric token. CustomFormat story updated to demonstrate it (placeholder: '' to let it auto-derive) instead of masking the bug behind meta.args' default placeholder. --- CHANGELOG.md | 1 + .../forms/ui-datepicker/src/lib/ui-datepicker.ts | 15 +++++++++++---- .../ui-kit/forms/ui-datepicker/ui-datepicker.mdx | 11 +++++++++-- .../forms/ui-datepicker/ui-datepicker.stories.ts | 1 + 4 files changed, 22 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 481d466..3c3a60f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,7 @@ Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le pr - **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. ## [0.6.1] - 2026-08-22 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 9acdc9c..f9f4a25 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 @@ -396,8 +396,13 @@ export class UiDatepicker extends BaseFormField { () => this.readonly() || !this.allowInput() || this.timeOnly(), ); /** @ignore Single-date placeholder token (e.g. `jj/mm/aaaa`) — the building block - * `resolvedPlaceholder` composes for `range`/`multiple`. */ + * `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(); @@ -420,9 +425,11 @@ 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) so the field never advertises the wrong format — composed into + * @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`). */ diff --git a/projects/ui-kit/forms/ui-datepicker/ui-datepicker.mdx b/projects/ui-kit/forms/ui-datepicker/ui-datepicker.mdx index ff331ea..bce21e0 100644 --- a/projects/ui-kit/forms/ui-datepicker/ui-datepicker.mdx +++ b/projects/ui-kit/forms/ui-datepicker/ui-datepicker.mdx @@ -201,8 +201,15 @@ 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. Les -exemples ci-dessous restent tous au format classique **jj/mm/aaaa**, à l'exception du dernier +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. 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 edcb0c7..5889dcc 100644 --- a/projects/ui-kit/forms/ui-datepicker/ui-datepicker.stories.ts +++ b/projects/ui-kit/forms/ui-datepicker/ui-datepicker.stories.ts @@ -557,6 +557,7 @@ export const CustomFormat: Story = { allowInput: true, showClear: true, 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.', }, }; From 51a5c3e18a8f6095454c7a340932c991b065a916 Mon Sep 17 00:00:00 2001 From: LBU Date: Mon, 24 Aug 2026 14:37:44 +0200 Subject: [PATCH 11/13] FSHSP-118 test(ui-datepicker): cover the keyboard-entry masking fixes TestBed spec (follows ui-select.spec.ts/ui-autocomplete.spec.ts: host component + native input events dispatched directly on the trigger's , mirroring a real keystroke) for the three behaviors chased down across this ticket: - hasValue()-gated mask on/off (single mode): auto-formats while constructing a date from empty, plain text once a value exists. - enforceBounds/dataEnd deletion fixes: mid-string delete no longer scrambles segments, backspacing through a completed segment no longer stalls (regression test pinned to the exact sequence that used to freeze at "20/08/"). - mask re-arms the instant the field reads empty, no blur needed. Sanity-checked these actually catch a regression, not just pass by construction: temporarily disabled the hasValue() gate in typingSlots() and confirmed the corresponding test failed with the expected diff (masked '01/01/1999' instead of plain '01011999'), then restored the fix and reran to confirm green again. --- .../src/lib/ui-datepicker.spec.ts | 150 ++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 projects/ui-kit/forms/ui-datepicker/src/lib/ui-datepicker.spec.ts diff --git a/projects/ui-kit/forms/ui-datepicker/src/lib/ui-datepicker.spec.ts b/projects/ui-kit/forms/ui-datepicker/src/lib/ui-datepicker.spec.ts new file mode 100644 index 0000000..078a64d --- /dev/null +++ b/projects/ui-kit/forms/ui-datepicker/src/lib/ui-datepicker.spec.ts @@ -0,0 +1,150 @@ +/** + * TestBed spec for `ui-datepicker`'s keyboard-entry masking (FSHSP-118). Follows the pattern + * from `ui-select.spec.ts`/`ui-autocomplete.spec.ts`: a minimal host component + + * `TestBed.configureTestingModule`, native `input` events dispatched directly on the trigger's + * `` — 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. Not covered here: `range`/`multiple` typed parsing (no live mask at + * all for those — 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); +} + +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 }; +} + +/** 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'); + }); + }); +}); From 0960a115def8f3cf8d86293f1a4df2e9ba50f7a7 Mon Sep 17 00:00:00 2001 From: LBU Date: Mon, 24 Aug 2026 15:24:16 +0200 Subject: [PATCH 12/13] FSHSP-118 fix(ui-datepicker): apply branch code-review fixes - showTime mask: unranged year segment never triggered its own trailing literal (mask-engine.ts atSegmentEnd), so the first hour digit typed after the year glued straight onto it; previewTyped now also waits for a complete time before committing a live preview. - range/multiple typed parsing: splitting on a literal separator broke when a custom dateFormat's own text contained it (ISO dash, comma format); splitTypedSegments now only accepts a boundary once the text up to it parses as a complete date. - range display separator: displayValue reused the typing separator (plain hyphen) instead of the pre-existing en dash, silently changing the look for every non-typing range consumer. - grid focus on open: moving triggerReadonly's hardcoded non-single read-only out dropped the implicit rove-into-grid on icon-click open for range/multiple; restored explicitly. All four found and verified during the branch review requested for update-date-picker-examples-and-behaviour. --- CHANGELOG.md | 4 + .../ui-kit/forms/src/lib/mask-engine.spec.ts | 43 ++++++- projects/ui-kit/forms/src/lib/mask-engine.ts | 11 +- .../ui-datepicker/src/lib/ui-datepicker.ts | 113 +++++++++++++++--- 4 files changed, 151 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c3a60f..8849147 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,10 @@ Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le pr - **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 73097dc..36f01cb 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); }); }); @@ -201,4 +213,31 @@ describe('autoFormatSegments', () => { 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'); + }); }); diff --git a/projects/ui-kit/forms/src/lib/mask-engine.ts b/projects/ui-kit/forms/src/lib/mask-engine.ts index 02d2110..117c22b 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 })); }); 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 f9f4a25..9298e98 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,10 +123,24 @@ export interface DatepickerMonthPanel { let nextPanelUid = 0; -/** Joins the two dates of a typed/displayed `range` value (FSHSP-118: `"jj/mm/aaaa - jj/mm/aaaa"`). - * Used both to render `displayValue` and to split typed text back apart in `parseTypedMulti`. */ +/** + * 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 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 dates of a typed/displayed `multiple` value (`"jj/mm/aaaa, jj/mm/aaaa, ..."`). */ +/** 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 = ', '; /** @@ -700,7 +714,7 @@ export class UiDatepicker extends BaseFormField { } const mode = this.selectionMode(); 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_SEPARATOR); + if (mode === 'range') return dates.map((d) => this.formatDate(d)).join(RANGE_DISPLAY_SEPARATOR); return this.formatDate(dates[0]); }); @@ -853,8 +867,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(); @@ -963,6 +983,26 @@ export class UiDatepicker extends BaseFormField { if (this.panelOpen()) this.previewTyped(); } + /** + * @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. @@ -970,6 +1010,7 @@ export class UiDatepicker extends BaseFormField { private previewTyped(): void { const raw = this.typedValue(); if (raw === null || !raw.trim()) return; + 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; @@ -1044,22 +1085,20 @@ export class UiDatepicker extends BaseFormField { /** * @ignore `range`/`multiple` typed entry (FSHSP-118): splits on `RANGE_SEPARATOR`/ - * `MULTIPLE_SEPARATOR` 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 + * `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.trim() : MULTIPLE_SEPARATOR.trim(); - const parts = text - .split(sep) - .map((p) => p.trim()) - .filter((p) => p.length > 0); + 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)); @@ -1069,6 +1108,48 @@ export class UiDatepicker extends BaseFormField { 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 From d76845aac6e090acb8cfbddc7f551c6a7111a2e5 Mon Sep 17 00:00:00 2001 From: LBU Date: Mon, 24 Aug 2026 15:48:14 +0200 Subject: [PATCH 13/13] FSHSP-118 feat(ui-datepicker): extend the live mask to range typing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit range now gets the same auto-"/" mask as single: both dates plus their " - " separator build up as you type, gated on hasValue() the same way single already is (mask off once a complete range exists, edit as plain text, re-arms on clear). multiple stays plain-text-on-blur only — an unbounded date count doesn't fit a fixed mask template, a bigger chantier of its own. mask-engine.ts: autoFormatSegments only auto-inserted the FIRST literal character right after a completed segment, which is enough for every single-char separator ("/", ":", " ") but silently drops the rest of a multi-char one like range's " - " (three literal slots in a row). Now appends every consecutive literal in one go. --- CHANGELOG.md | 2 +- .../ui-kit/forms/src/lib/mask-engine.spec.ts | 26 ++++++++ projects/ui-kit/forms/src/lib/mask-engine.ts | 8 ++- .../src/lib/ui-datepicker.spec.ts | 55 +++++++++++++++- .../ui-datepicker/src/lib/ui-datepicker.ts | 62 +++++++++++-------- .../forms/ui-datepicker/ui-datepicker.mdx | 16 ++--- 6 files changed, 132 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8849147..8eedf95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,7 @@ Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le pr - 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é. Contrairement au mode `single`, ni l'un ni l'autre n'a de masque auto-"/" en direct : texte libre, parsé au blur/Entrée uniquement, 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). +- **`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 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 36f01cb..cde7882 100644 --- a/projects/ui-kit/forms/src/lib/mask-engine.spec.ts +++ b/projects/ui-kit/forms/src/lib/mask-engine.spec.ts @@ -240,4 +240,30 @@ describe('autoFormatSegments', () => { 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 117c22b..0eb732d 100644 --- a/projects/ui-kit/forms/src/lib/mask-engine.ts +++ b/projects/ui-kit/forms/src/lib/mask-engine.ts @@ -188,8 +188,14 @@ export function autoFormatSegments( 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); diff --git a/projects/ui-kit/forms/ui-datepicker/src/lib/ui-datepicker.spec.ts b/projects/ui-kit/forms/ui-datepicker/src/lib/ui-datepicker.spec.ts index 078a64d..eb79f70 100644 --- a/projects/ui-kit/forms/ui-datepicker/src/lib/ui-datepicker.spec.ts +++ b/projects/ui-kit/forms/ui-datepicker/src/lib/ui-datepicker.spec.ts @@ -8,8 +8,9 @@ * * 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. Not covered here: `range`/`multiple` typed parsing (no live mask at - * all for those — see the component doc) or the format-hint/placeholder derivation. + * 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'; @@ -32,6 +33,20 @@ 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); @@ -45,6 +60,23 @@ async function setup(initial: Date | null = null) { 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, @@ -147,4 +179,23 @@ describe('UiDatepicker — keyboard entry masking (FSHSP-118)', () => { 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 9298e98..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 @@ -125,8 +125,9 @@ 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 and - * to split typed text back apart in `parseTypedMulti`. + * 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 @@ -403,8 +404,9 @@ export class UiDatepicker extends BaseFormField { * 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` — but never through the live auto-"/" mask, which only ever models a - * single date (see `typingSlots`'s own `selectionMode` check). + * `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.timeOnly(), @@ -488,37 +490,39 @@ 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), a custom `parseDate` (a non-numeric format would - * make the auto-slash wrong), `hasValue()` (FSHSP-118), or `selectionMode() !== 'single'` - * (FSHSP-118: `range`/`multiple` ARE typeable — see `triggerReadonly` — but only ever through - * plain text parsed on blur/Enter via `parseTypedMulti`; this mask only ever models one date's - * worth of digits, never two dates plus a separator). + * 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 last one: re-deriving the mask from a flat digit stream on every keystroke only ever - * behaves well for *constructing* a date 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-slash, but no corruption either — and defers to `commitTyped`'s parser (already - * tolerant of arbitrary separators, see `defaultParse`) 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. + * 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(() => { + const mode = this.selectionMode(); if ( this.triggerReadonly() || this.view() === 'year' || this.parseDate() || this.hasValue() || - this.selectionMode() !== 'single' + (mode !== 'single' && mode !== 'range') ) return null; const widths = { day: '99', month: '99', year: '9999' } as const; @@ -531,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. */ diff --git a/projects/ui-kit/forms/ui-datepicker/ui-datepicker.mdx b/projects/ui-kit/forms/ui-datepicker/ui-datepicker.mdx index bce21e0..7137489 100644 --- a/projects/ui-kit/forms/ui-datepicker/ui-datepicker.mdx +++ b/projects/ui-kit/forms/ui-datepicker/ui-datepicker.mdx @@ -223,16 +223,18 @@ Les exemples ci-dessous restent tous au format classique **jj/mm/aaaa**, à l'ex `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. Aucun masque auto-"/" ici (contrairement à `single`) : texte libre, -parsé au **blur**/**Entrée** uniquement — une entrée incomplète ou invalide revient à la dernière -valeur affichée, comme en mode `single`. +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"`. 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. + `"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é. Une date en double est supprimée (même règle que le clic qui - bascule une case déjà sélectionnée). + 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