From ab64f629093cb4b0a16bd14ca352828263784686 Mon Sep 17 00:00:00 2001 From: LBU Date: Thu, 27 Aug 2026 11:19:52 +0200 Subject: [PATCH 1/3] FSHSP-179 fix(ui-datepicker): stop swapping day/month and shifting on in-place edits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Day and month swapped as soon as the entry ended: `dateFieldOrder` only ever looked at the resolved locale, never at `dateFormat`. A `fr-FR` formatter under an unconfigured `LOCALE_ID` (so `en-US`) displayed "08/07/2026" but read it back month-first — the auto-derived placeholder already advertised the formatter's own order. The order is now probed from that formatter's output for an illustrative date, and drives the parser, the live mask and the placeholder alike; the locale takes over when the format isn't numeric enough to probe. Correcting an already-typed segment shifted everything after it: replacing the month "07" with a single digit re-sliced the whole digit stream, turning "08/07/2026" into "08/12/026". The mask can only re-derive the field from a flat stream when typing forward (or deleting from the tail), so it now steps aside for the rest of the entry as soon as a keystroke lands anywhere but the tail — plain text, parsed on blur/Enter, exactly what already applied once a value existed. It re-arms on the next commit, or as soon as the field reads empty. --- CHANGELOG.md | 6 +- .../src/lib/ui-datepicker.spec.ts | 108 +++++++++++++-- .../ui-datepicker/src/lib/ui-datepicker.ts | 125 +++++++++++++++--- .../forms/ui-datepicker/ui-datepicker.mdx | 13 ++ 4 files changed, 224 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ada07d9..b7e3068 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,7 +82,7 @@ Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le pr - **`ui-editor` : outils `alignLeft`/`alignCenter`/`alignRight`/`alignJustify`** (FSHSP-160). Quatre nouveaux `EditorButtonTool`, passthrough `execCommand('justifyLeft'|'justifyCenter'| - 'justifyRight'|'justifyFull')` — contrairement à `indent`/`outdent`, ce sont des bascules +'justifyRight'|'justifyFull')` — contrairement à `indent`/`outdent`, ce sont des bascules (`EditorToggleTool`) : le navigateur garantit lui-même leur exclusion mutuelle (`queryCommandState`), donc elles suivent le même modèle `EditorState`/`aria-pressed` que gras/italique/souligné plutôt que celui d'un menu. **Dans `DEFAULT_EDITOR_TOOLS`** (à la @@ -167,6 +167,10 @@ Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le pr ### Fixed +- **La saisie clavier de `ui-datepicker` inversait le jour et le mois en fin de saisie** (FSHSP-179). Avec un `dateFormat` custom et sans `parseDate`, l'ordre des champs restait celui de la locale résolue : un formatteur `fr-FR` sous un `LOCALE_ID` non configuré (donc `en-US`) affichait « 08/07/2026 » mais relisait ce même texte mois-en-premier — taper le 8 juillet commitait le 7 août, réaffiché « 07/08/2026 », le placeholder auto-dérivé annonçant pourtant l'ordre du formatteur. L'ordre jour/mois/année est maintenant **sondé sur la sortie du `dateFormat` lui-même** (son rendu d'une date d'illustration), et pilote à la fois le parser, le masque de frappe et le placeholder ; la locale reprend la main quand ce format n'est pas numérique (« 22 novembre 2023 »), cas qui demande de toute façon son `parseDate`. + +- **Corriger un segment déjà tapé décalait tout ce qui suit** (FSHSP-179). Remplacer le mois « 07 » par un seul chiffre re-découpait le flux de chiffres complet : « 08/07/2026 » devenait « 08/12/026 », le « 2 » de l'année promu dans le mois et l'année réduite à trois chiffres. Le masque ne sait re-dériver le champ qu'en frappe séquentielle vers l'avant (ou en suppression depuis la fin) ; dès qu'une frappe n'a pas lieu en fin de texte, il **cède la place pour le reste de la saisie** — texte libre, parsé au blur/Entrée, exactement ce qui s'appliquait déjà quand une valeur existe. Il se réarme au commit suivant, ou dès que le champ se lit vide. + - **Le panneau de `ui-datepicker` débordait de l'écran sur un viewport étroit**. Trois causes cumulées, corrigées ensemble : le panneau était plafonné à `100vw - units-lg` mais la colonne de mois gardait une largeur fixe de 360px sans pouvoir rétrécir, donc la dernière colonne de jours et le chevron « suivant » passaient sous la bordure ; l'overlay CDK n'avait ni marge de viewport ni repoussage (`push`), donc un champ ancré n'importe où ailleurs qu'à gauche sortait le panneau hors de l'écran ; et en `numberOfMonths > 1` la rangée de mois faisait deux fois 360px, dont le second mois entièrement invisible. La colonne de mois et les grilles mois / année rétrécissent maintenant avec le panneau, la pastille d'un jour suit la largeur de sa colonne quand celle-ci passe sous `size/components/default` (la bande d'une plage la suit aussi), les mois s'empilent verticalement sous 600px avec un filet horizontal en séparateur, et l'overlay garde 12px de gouttière avec les bords du viewport. Le panneau défile désormais au-delà de `100dvh - units-3xl` plutôt que de déborder en hauteur (paysage sur téléphone), et un panneau `inline` ne dépasse plus la largeur de son conteneur. - **Les `@media` générées depuis `responsive.json` sortaient dans l'ordre du JSON, pas par largeur croissante** — donc le desktop héritait des valeurs tablette. Les modes sont lus dans l'ordre où Figma les exporte (`modeDesktop`, `modeTablet`, `modeMobile`), et le bloc `@media (min-width: 1440px)` était émis **avant** `@media (min-width: 1024px)`. Sur un écran de 1440px les deux règles s'appliquent : à spécificité égale, c'est la dernière écrite qui gagne, et tout le desktop repartait sur l'échelle **tablette** (`--spacing-default: var(--units-md)` au lieu de `var(--units-default)`, `--grid-columns: 8` au lieu de `12`, toute la série `--size-components-*`, `--screen-width`/`--screen-height`). `scripts/tokens.build.mjs` trie maintenant les groupes `@media` par `min-width` croissante, puis par `max-width` décroissante — les deux conventions (mobile-first, desktop-first) placent ainsi le bloc le plus étroit en dernier, quel que soit l'ordre des modes dans le JSON source. Le tri comprend `min-width`/`max-width`, la syntaxe de plage (`width >= 900px`, `900px <= width`) et les unités `px`/`rem`/`em` ; une media query sans contrainte de largeur garde son ordre d'apparition. 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 56478ec..1d2da5e 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 @@ -4,8 +4,9 @@ * caret, dispatch, flush CD — mirroring a real keystroke. * * Covers: `hasValue()`-gated mask on/off (`single` and `range`), the `enforceBounds`/`dataEnd` - * deletion fixes, re-arming the mask on a manual clear. Not covered: `multiple` (no live mask) - * or the format-hint/placeholder derivation. + * deletion fixes, re-arming the mask on a manual clear, the in-place-edit suspension and the + * custom-`dateFormat` field order (FSHSP-179). Not covered: `multiple` (no live mask) or the + * format-hint derivation. */ import { Component } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; @@ -42,6 +43,28 @@ class DatepickerRangeHost { readonly control = new FormControl(null); } +/** `dateFormat` in `fr-FR` (day-first) with NO `locale` and NO `parseDate` — so `LOCALE_ID` + * stays whatever the environment resolves (`en-US` here, month-first). The exact shape of the + * demo stories that surfaced FSHSP-179. */ +@Component({ + imports: [ReactiveFormsModule, UiDatepicker], + template: ``, +}) +class DatepickerCustomFormatHost { + readonly control = new FormControl(null); + readonly dateFormat = (d: Date): string => + new Intl.DateTimeFormat('fr-FR', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }).format(d); +} + async function setup(initial: Date | null = null) { await TestBed.configureTestingModule({ imports: [DatepickerHost] }).compileComponents(); const fixture: ComponentFixture = TestBed.createComponent(DatepickerHost); @@ -59,9 +82,8 @@ async function setup(initial: Date | null = null) { * 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 fixture: ComponentFixture = + TestBed.createComponent(DatepickerRangeHost); const host = fixture.componentInstance; if (initial) host.control.setValue(initial); fixture.detectChanges(); @@ -72,6 +94,22 @@ async function setupRange(initial: Date[] | null = null) { return { fixture, host, input }; } +/** Same as {@link setup}, for the custom-`dateFormat` host (own component, same reason). */ +async function setupCustomFormat() { + await TestBed.configureTestingModule({ + imports: [DatepickerCustomFormatHost], + }).compileComponents(); + const fixture: ComponentFixture = TestBed.createComponent( + DatepickerCustomFormatHost, + ); + fixture.detectChanges(); + await fixture.whenStable(); + const input = fixture.nativeElement.querySelector( + '.ui-datepicker-trigger input.ui-input-native', + ) as HTMLInputElement; + return { fixture, host: fixture.componentInstance, input }; +} + /** Mirrors a single native keystroke: sets the raw value + caret, dispatches `input`, flushes CD. */ async function typeInto( input: HTMLInputElement, @@ -133,10 +171,11 @@ describe('UiDatepicker — keyboard entry masking (FSHSP-118)', () => { // 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. + // the user typed. Then re-slicing the stream positionally still shifted them ("80/72/026", + // FSHSP-118). The edit isn't at the tail at all, so no re-derivation can be right: the + // mask steps aside and the text is left exactly as edited (FSHSP-179). await typeInto(input, '8/07/2026', 0, fixture); - expect(input.value).toBe('80/72/026'); - expect(input.value).not.toBe('07/02/6'); + expect(input.value).toBe('8/07/2026'); }); it('does not get stuck backspacing through a completed segment', async () => { @@ -159,6 +198,59 @@ describe('UiDatepicker — keyboard entry masking (FSHSP-118)', () => { }); }); + // FSHSP-179: two field-reported bugs, one per describe block below. + describe('editing a segment in place suspends the mask instead of shifting the rest', () => { + it('leaves the text as typed when a 2-digit month is replaced by one digit', async () => { + const { input, fixture } = await setup(); + await typeSequentially(input, '08072026', fixture); + expect(input.value).toBe('08/07/2026'); + + // Select the month "07" and type "1" (the browser has already replaced the selection). + // The mask used to re-slice the whole digit stream positionally: "08/12/026" — the "2" of + // the year promoted into the month, the year down to 3 digits. Nothing the mask can derive + // from a flat stream is right here, so it steps aside for the rest of the entry. + await typeInto(input, '08/1/2026', 4, fixture); + expect(input.value).toBe('08/1/2026'); + + // Typing the month's second digit no longer re-formats either (the "2026" stays intact), + // and the completed date parses on blur. + await typeInto(input, '08/12/2026', 5, fixture); + expect(input.value).toBe('08/12/2026'); + }); + + it('re-arms the mask once the suspended field reads empty', async () => { + const { input, fixture } = await setup(); + await typeSequentially(input, '08072026', fixture); + await typeInto(input, '08/1/2026', 4, fixture); // suspends the mask (in-place edit) + expect(input.value).toBe('08/1/2026'); + + await typeInto(input, '', 0, fixture); + await typeSequentially(input, '20082020', fixture); + expect(input.value).toBe('20/08/2020'); + }); + }); + + describe('a custom dateFormat drives the parsed field order (FSHSP-179)', () => { + it("reads the typed date back in the order that formatter writes, not the locale's", async () => { + const { input, fixture, host } = await setupCustomFormat(); + // The placeholder advertises the formatter's own day-first output… + expect(input.placeholder).toBe('22/11/2023'); + + await typeSequentially(input, '08072026', fixture); + expect(input.value).toBe('08/07/2026'); + input.dispatchEvent(new Event('blur')); + fixture.detectChanges(); + await fixture.whenStable(); + + // …and the parser now agrees with it. Under the resolved `LOCALE_ID` (`en-US`, month-first) + // this committed 7 August and redisplayed it day-first as "07/08/2026": day and month + // visibly swapped the moment the entry ended. + expect(input.value).toBe('08/07/2026'); + expect(host.control.value?.getMonth()).toBe(6); // July + expect(host.control.value?.getDate()).toBe(8); + }); + }); + 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)); 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 759608e..ce64f90 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 @@ -132,6 +132,26 @@ const RANGE_DISPLAY_SEPARATOR = ' – '; /** Typed/displayed `multiple` separator (`"jj/mm/aaaa, jj/mm/aaaa, ..."`) — one separator for both. */ const MULTIPLE_SEPARATOR = ', '; +/** @ignore One field of a numeric date, in the order the resolved format writes them. */ +type DateField = 'day' | 'month' | 'year'; + +/** Illustrative date the auto placeholder and the field-order probe are both derived from + * (22 Nov 2023): day, month and year are pairwise distinct, so each one is identifiable in a + * formatter's own output. A fresh instance per call — a consumer's `dateFormat` receives it, + * and a shared one could be mutated in place. */ +function probeDate(): Date { + return new Date(2023, 10, 22); +} +/** First index at which any of `tokens` occurs in `text` (`-1` if none) — probes a formatter's + * output for one {@link probeDate} component, most specific token first. */ +function firstIndexOf(text: string, tokens: readonly string[]): number { + for (const token of tokens) { + const i = text.indexOf(token); + if (i !== -1) return i; + } + return -1; +} + /** Splits a flat cell list into rows of `size` (month/year pickers need `role="row"` wrappers). */ function chunk(items: readonly T[], size: number): T[][] { const rows: T[][] = []; @@ -212,8 +232,15 @@ 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) — also - * used, unchanged, as the time formatter in `timeOnly` mode. */ + /** + * Custom display formatter for a single date (overrides the default `Intl` format) — also + * used, unchanged, as the time formatter in `timeOnly` mode. + * + * With `allowInput` and no `parseDate`, the typed text is read back in the day/month/year + * order **this formatter writes** (probed from its own output, whatever locale it uses + * internally), not the resolved locale's — so entry and display always agree. A non-numeric + * format (e.g. `"22 novembre 2023"`) can't be probed: pair it with a matching `parseDate`. + */ dateFormat = input<(date: Date) => string>(); /** @@ -225,9 +252,10 @@ export class UiDatepicker extends BaseFormField { * 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). + * empty field (see `typingSlots`), free-form text editing otherwise — no live reformatting, + * parsed on blur/Enter. "Otherwise" covers both a value that already exists and an edit made + * inside the text being typed rather than at its tail: editing a segment in place, e.g. just + * the month, never shifts what follows (FSHSP-118, FSHSP-179). * * `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, @@ -368,6 +396,10 @@ export class UiDatepicker extends BaseFormField { protected readonly minutes = signal(0); /** @ignore Raw text while the user edits the trigger (`allowInput`); `null` when not editing. */ protected readonly typedValue = signal(null); + /** @ignore Live mask given up on for the rest of the current entry, because the user edited + * inside the text instead of appending at its tail (see `onTriggerInput`/`typingSlots`). + * Lifted by any commit, and as soon as the field reads empty. */ + private readonly maskSuspended = signal(false); /** @ignore Date-based source of truth for all calendar/selection logic; `modelValue` * (inherited) only ever carries the public value (`Date` or ISO string per `valueType`), * written in lockstep. */ @@ -409,7 +441,7 @@ export class UiDatepicker extends BaseFormField { * 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)); + if (custom) return custom(probeDate()); const fr = this.resolvedLocale().toLowerCase().startsWith('fr'); const token = { day: fr ? 'jj' : 'dd', month: 'mm', year: fr ? 'aaaa' : 'yyyy' }; const view = this.view(); @@ -419,7 +451,7 @@ export class UiDatepicker extends BaseFormField { ? { month: '2-digit', year: 'numeric' } : { day: '2-digit', month: '2-digit', year: 'numeric' }; return new Intl.DateTimeFormat(this.resolvedLocale(), opts) - .formatToParts(new Date(2023, 10, 22)) + .formatToParts(probeDate()) .map((p) => p.type === 'day' ? token.day @@ -463,20 +495,49 @@ export class UiDatepicker extends BaseFormField { /** @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')[]>(() => { + /** + * @ignore Field order a custom `dateFormat` actually writes, probed from its own output for + * {@link probeDate}. `null` when there is no custom formatter, or when its output isn't + * numeric enough to tell (e.g. `"22 novembre 2023"`) — the locale order stands then. + * + * The default parser and the live mask have to read the text back in the order the field + * displays it, and a `dateFormat` is free to use a locale of its own: an `fr-FR` formatter + * under an `en-US` `LOCALE_ID` displayed `"08/07/2026"` but parsed it month-first, so + * finishing the entry silently swapped day and month (FSHSP-179). + */ + private readonly customFormatFieldOrder = computed(() => { + const custom = this.dateFormat(); + if (!custom) return null; + const out = custom(probeDate()); + // 4-digit year probed before the 2-digit one: `"23"` also occurs inside `"2023"` ("20|23"), + // at an offset that isn't where the year starts. + const probed: [DateField, number][] = [ + ['day', firstIndexOf(out, ['22'])], + ['month', firstIndexOf(out, ['11'])], + ['year', firstIndexOf(out, ['2023', '23'])], + ]; + if (probed.some(([, i]) => i === -1)) return null; + if (new Set(probed.map(([, i]) => i)).size !== probed.length) return null; + return probed.sort((a, b) => a[1] - b[1]).map(([field]) => field); + }); + /** @ignore Order of day/month/year driving numeric parsing, the live mask and the auto + * placeholder: what a custom `dateFormat` writes when that can be probed + * ({@link customFormatFieldOrder}), the resolved locale's own order otherwise. */ + private readonly dateFieldOrder = computed(() => { + const fromFormat = this.customFormatFieldOrder(); + if (fromFormat) return fromFormat; const parts = new Intl.DateTimeFormat(this.resolvedLocale(), { day: '2-digit', month: '2-digit', year: 'numeric', - }).formatToParts(new Date(2023, 10, 22)); + }).formatToParts(probeDate()); const order = parts .map((p) => p.type) - .filter((t): t is 'day' | 'month' | 'year' => t === 'day' || t === 'month' || t === 'year'); + .filter((t): t is DateField => t === 'day' || t === 'month' || t === 'year'); return order.length === 3 ? order : ['day', 'month', 'year']; }); /** @ignore `dateFieldOrder`, minus `day` in month view — the fields actually typed. */ - private readonly activeFields = computed<('day' | 'month' | 'year')[]>(() => + private readonly activeFields = computed(() => this.dateFieldOrder().filter((f) => (this.view() === 'month' ? f !== 'day' : true)), ); /** @@ -491,6 +552,9 @@ export class UiDatepicker extends BaseFormField { * after any edit — see `onTriggerInput`). So it's off once a value exists — plain text instead, * parsed on blur/Enter — and re-arms the moment the field reads empty (`onTriggerInput` commits * the clear right there, not just on blur, so `hasValue()` actually flips before the next key). + * + * `maskSuspended()`: the same limitation, hit before any value exists — an edit made *inside* + * the text being typed rather than at its tail (see `onTriggerInput`). */ private readonly typingSlots = computed(() => { const mode = this.selectionMode(); @@ -499,11 +563,12 @@ export class UiDatepicker extends BaseFormField { this.view() === 'year' || this.parseDate() || this.hasValue() || + this.maskSuspended() || (mode !== 'single' && mode !== 'range') ) return null; const widths = { day: '99', month: '99', year: '9999' } as const; - const bounds: Record<'day' | 'month' | 'year', MaskBounds | null> = { + const bounds: Record = { day: { min: 1, max: 31 }, month: { min: 1, max: 12 }, year: null, @@ -946,12 +1011,16 @@ 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(); + // The mask is off because a value already exists, or an in-place edit suspended it (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 and the suspension lifts, so + // `typingSlots()` re-arms the mask on the very next keystroke, for whatever fresh date + // comes next (FSHSP-118). + if (!value.trim()) { + this.maskSuspended.set(false); + if (this.hasValue()) this.clear(); + } if (this.panelOpen()) this.previewTyped(); return; } @@ -960,6 +1029,20 @@ export class UiDatepicker extends BaseFormField { // 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 newData = extractMaskData(value); + // Editing INSIDE the text (data still sits after the caret) rather than appending at its + // tail: the mask can only re-derive the whole field from one flat digit stream, so re-running + // it here re-slices every following segment by however much this edit grew or shrank — + // replacing the month "07" with a single "1" turned "08/07/2026" into "08/12/026", the year + // silently losing a digit (FSHSP-179). Give the mask up for the rest of the entry instead — + // exactly what `allowInput` already promises once a value exists: plain text, parsed on + // blur/Enter. For the rest of the entry, not just this keystroke: the digits still to come + // belong to the segment being fixed, and re-arming under the caret would shift them again. + if (dataBeforeCaret < newData.length) { + this.maskSuspended.set(true); + this.typedValue.set(value); + if (this.panelOpen()) this.previewTyped(); + return; + } // 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 @@ -1039,6 +1122,9 @@ export class UiDatepicker extends BaseFormField { if (this.triggerReadonly()) return; const raw = this.typedValue(); if (raw === null) return; // untouched + // Parsed, cleared or reverted, this commit ends the current entry: re-arm the live mask for + // the next one (`commit` does it too, for the paths that go through it). + this.maskSuspended.set(false); const text = raw.trim(); if (!text) { this.typedValue.set(null); @@ -1663,6 +1749,7 @@ export class UiDatepicker extends BaseFormField { * `Date`/`Date[]`/`null` exactly as before — the ISO contract lives entirely in this method. */ private commit(value: DatepickerDateValue): void { this.typedValue.set(null); // any committed value re-formats the trigger + this.maskSuspended.set(false); // …and re-arms the live mask (see `typingSlots`) this.internalValue.set(value ?? null); const external = this.toExternalValue(value ?? null); this.modelValue.set(external ?? undefined); diff --git a/projects/ui-kit/forms/ui-datepicker/ui-datepicker.mdx b/projects/ui-kit/forms/ui-datepicker/ui-datepicker.mdx index 78f8a8e..3661ebf 100644 --- a/projects/ui-kit/forms/ui-datepicker/ui-datepicker.mdx +++ b/projects/ui-kit/forms/ui-datepicker/ui-datepicker.mdx @@ -191,6 +191,12 @@ format non numérique rendrait l'auto-slash faux). > 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. +> +> **Même mise en retrait dès qu'une frappe n'a pas lieu en fin de texte** (FSHSP-179), avant même +> qu'une valeur existe : corriger le mois d'une date en cours de frappe re-découpait le flux de +> chiffres et décalait tout ce qui suit (« 08/07/2026 » → « 08/12/026 », l'année perdant un +> chiffre). Le masque cède la place pour le reste de la saisie — texte libre, parsé 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 @@ -198,6 +204,13 @@ format non numérique rendrait l'auto-slash faux). > (ou configurez `LOCALE_ID`) pour saisir en JJ/MM/AAAA. Le **placeholder est dérivé > automatiquement** de cet ordre quand il n'est pas fourni, pour ne jamais afficher un format > trompeur. +> +> **Un `dateFormat` custom (sans `parseDate`) l'emporte sur la locale** (FSHSP-179) : l'ordre est +> sondé sur la sortie du formatteur lui-même, pour que la saisie soit relue dans l'ordre où le +> champ l'affiche. Sinon un formatteur `fr-FR` sous un `LOCALE_ID` `en-US` affichait +> « 08/07/2026 » tout en le parsant mois-en-premier : jour et mois s'inversaient à la fin de la +> saisie. Un format non numérique (« 22 novembre 2023 ») n'est pas sondable — fournissez alors le +> `parseDate` correspondant. 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 From 91c7fa8fe31765da1fd794dec1c3f737a90d3183 Mon Sep 17 00:00:00 2001 From: LBU Date: Thu, 27 Aug 2026 11:19:52 +0200 Subject: [PATCH 2/3] FSHSP-179 docs(datepicker): pin the demo stories to fr-FR --- .../ui-datepicker/ui-datepicker.stories.ts | 51 ++++++++++++------- 1 file changed, 32 insertions(+), 19 deletions(-) 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 efc62c6..b0a2dfd 100644 --- a/projects/ui-kit/forms/ui-datepicker/ui-datepicker.stories.ts +++ b/projects/ui-kit/forms/ui-datepicker/ui-datepicker.stories.ts @@ -220,6 +220,11 @@ const meta: Meta = { args: { label: 'Date', placeholder: 'jj/mm/aaaa', + // Les démos sont en français (libellés, formats, textes d'aide) : sans `locale`, la locale + // résolue retombe sur le `LOCALE_ID` d'Angular — `en-US` ici, faute de configuration — et le + // panneau affichait des noms de mois anglais sous des dates écrites en jj/mm/aaaa. + // `CustomFormat` est la seule story à repasser volontairement en `en-US`. + locale: 'fr-FR', size: 'default', level: 'default', hourFormat: '24', @@ -285,9 +290,9 @@ const sample = new Date(2026, 6, 8); // 8 July 2026 // 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. +// résolue (`fr-FR`, voir `meta.args`) et afficherait "8 juil. 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". @@ -463,7 +468,6 @@ export const EditableInput: Story = { label: 'Date', allowInput: true, showClear: true, - locale: 'fr-FR', placeholder: '', // vide → placeholder auto dérivé de la locale (jj/mm/aaaa) helperText: 'Tapez "08072026" au clavier : les "/" apparaissent seuls (jj/mm/aaaa).', }, @@ -478,7 +482,6 @@ export const AutoFormattedInputMonthPicker: Story = { view: 'month', allowInput: true, showClear: true, - locale: 'fr-FR', placeholder: '', helperText: 'Tapez "072026" : auto-formaté en "07/2026" (mm/aaaa).', }, @@ -493,7 +496,6 @@ export const EditableInputWithTime: Story = { allowInput: true, showTime: true, showClear: true, - locale: 'fr-FR', placeholder: '', helperText: 'Tapez "080720261430" pour "08/07/2026 14:30" (jj/mm/aaaa hh:mm).', }, @@ -508,7 +510,6 @@ export const EditableInputWithTime12h: Story = { showTime: true, hourFormat: '12', showClear: true, - locale: 'fr-FR', placeholder: '', helperText: 'Tapez "080720260200PM" pour "08/07/2026 02:00 PM" (jj/mm/aaaa hh:mm aa).', }, @@ -595,13 +596,16 @@ export const Range: Story = { // 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 }, + 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).', }, @@ -622,7 +626,6 @@ export const MultipleTypedInput: Story = { 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, ...).', }, @@ -643,13 +646,21 @@ export const YearPicker: Story = { // Plusieurs mois côte à côte (numberOfMonths). export const TwoMonths: Story = { render: () => ({ - props: { model: new Date(2026, 6, 8), inline: true, numberOfMonths: 2, label: 'Deux mois', valueType: 'date', dateFormat: demoDateFormat }, + props: { + model: new Date(2026, 6, 8), + inline: true, + numberOfMonths: 2, + label: 'Deux mois', + valueType: 'date', + dateFormat: demoDateFormat, + }, template: ``, }), parameters: { layout: 'centered' }, @@ -659,7 +670,7 @@ export const TwoMonths: Story = { export const CustomButtonBar: Story = { render: () => ({ props: { model: null }, - template: `
+ template: `
@@ -684,6 +695,7 @@ export const SmartPosition: Story = { template: `
`, @@ -702,6 +714,7 @@ export const SmartPosition: Story = { [formField]="birth" valueType="date" label="Date de naissance" + locale="fr-FR" placeholder="jj/mm/aaaa" /> !d.otherMonth && [3, 8, 12, 19, 24].includes(d.day), }, - template: `
+ template: `
{{ d.day }} @@ -777,12 +790,12 @@ export const IconTemplate: Story = { render: () => ({ props: { a: null, b: sample, dateFormat: demoDateFormat }, template: `
-
+
-
+
@@ -804,7 +817,7 @@ export const ScopedIconFamily: Story = { render: () => ({ props: { model: new Date(2026, 6, 8), dateFormat: demoDateTimeFormat }, template: `
`, + [(ngModel)]="model" valueType="date" label="Rendez-vous" showTime locale="fr-FR" [dateFormat]="dateFormat" />
`, }), }; @@ -820,9 +833,9 @@ export const FloatLabel: Story = { props: { a: null, b: null, c: sample, dateFormat: demoDateFormat }, template: `
- - - + + +
`, }), From 2e0f3a6ac18d5a1cfe45983f424a5e4938bed4ee Mon Sep 17 00:00:00 2001 From: LBU Date: Thu, 27 Aug 2026 11:41:15 +0200 Subject: [PATCH 3/3] FSHSP-180 fix(ui-datepicker): stop the field click from opening the panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `allowInput` is true by default, so the field is a text field first — but the whole trigger wrapper carried `(click)="open()"`, label and format hint included. Combined with the overlay's permanent backdrop, that made the field unusable with a mouse: the first click placed the caret, the next one was swallowed by the backdrop and lost focus without moving the caret, so correcting a single segment was impossible; a click on the label opened a panel `Escape` could no longer close (focus was nowhere inside the wrapper); and opening live-committed the typed text through `previewTyped`, disarming the auto-"/" mask mid-entry. The icon, `↓` and typing are unchanged. The click keeps opening the panel exactly where it's the affordance: a non-typeable trigger (`allowInput="false"`, `readonly`, `timeOnly`) and `showIcon="false"`, which leaves no icon to click. --- CHANGELOG.md | 1 + .../ui-datepicker/src/lib/ui-datepicker.html | 9 +- .../src/lib/ui-datepicker.spec.ts | 103 +++++++++++++++++- .../ui-datepicker/src/lib/ui-datepicker.ts | 35 +++++- .../forms/ui-datepicker/ui-datepicker.mdx | 13 ++- 5 files changed, 153 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7e3068..886e97d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -164,6 +164,7 @@ Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le pr - **La règle `nested-interactive` est levée sur `ui-input-tags` et `ui-autocomplete` (mode `multiple`)** (FSHSP-106) : le bouton de suppression de chaque tag reste imbriqué dans son `role="option"`. Correctif accepté en l'état, décision prise avec l'équipe ; ticket de restructuration à suivre. - **Deux paires de tokens à un cheveu du seuil AA sont montées d'un cran** (FSHSP-106) : `actions.low.content.default` `slate-500` → `slate-600` (4,44 → 5,45), et `navigation.highOutlined.content.active` `primary-500` → `primary-600` (4,20 → 6,00). Dans les deux cas, l'état voisin qui partageait la même valeur (`hover`) est monté d'un cran aussi, pour ne pas le fusionner avec l'état qu'on vient de corriger : `actions.low.content.hover` passe à `slate-700` (sinon identique à `default`), `navigation.highOutlined.content.hover` à `primary-600` (même défaut que `active`, non mesuré par axe car en `:hover`, mais réel). Vérifié à l'exécution sur `ui-link`, `ui-segment-control` et `ui-tabs` : zéro violation, écart visuel imperceptible. Source Figma (`src/design-tokens/semantics.json`) — la variable doit être mise à jour côté Figma pour ne pas être écrasée au prochain export. - **La rampe de surfaces `warning` est décalée d'un cran** (FSHSP-106) : `default` passe de `orange-500` à `orange-700` (2,80 → **5,18:1** sous le texte blanc) et `hover` de `orange-700` à `orange-800` (7,31:1). Aucune couleur créée — contrairement au vert, l'orange n'avait pas de place pour un palier intermédiaire : la teinte conforme la plus claire a la même luminosité que le `700` déjà utilisé par le survol. `focused` (`orange-800`) et `pressed` (`orange-900`) sont inchangés. Conséquence visible : le `warning` par défaut est un orange nettement plus sombre. Mode clair uniquement. +- **Le clic dans le champ de `ui-datepicker` n'ouvre plus le calendrier quand le champ est saisissable** (FSHSP-180). `allowInput` étant vrai par défaut, le champ est d'abord un champ texte — or c'est le wrapper entier du déclencheur qui ouvrait le panneau, label et texte d'aide compris. Combiné au backdrop permanent de l'overlay, le champ en devenait inutilisable à la souris : le premier clic posait le curseur, le suivant était avalé par le backdrop et perdait le focus sans déplacer le curseur, donc corriger un seul segment à la souris était impossible ; un clic sur le label ouvrait un panneau que `Échap` ne fermait plus (le focus n'était nulle part) ; et l'ouverture commitait le texte tapé en direct, désarmant le masque auto-"/" en pleine frappe. L'icône calendrier, `↓` et le clavier ouvrent le panneau comme avant. Le clic garde son rôle d'ouverture là où il est la seule affordance : champ non saisissable (`allowInput="false"`, `readonly`, `timeOnly`) et `showIcon="false"`. ### Fixed 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 9b016a6..41c3a5b 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 @@ -350,10 +350,15 @@ @if (inline()) { } @else { - + -
+
`, +}) +class DatepickerReadonlyTriggerHost { + readonly control = new FormControl(null); +} + +/** Typeable but icon-less (`showIcon=false`): nothing else could open the panel with a mouse, + * so the click keeps doing it. */ +@Component({ + imports: [ReactiveFormsModule, UiDatepicker], + template: ``, +}) +class DatepickerNoIconHost { + readonly control = new FormControl(null); +} + async function setup(initial: Date | null = null) { await TestBed.configureTestingModule({ imports: [DatepickerHost] }).compileComponents(); const fixture: ComponentFixture = TestBed.createComponent(DatepickerHost); @@ -110,6 +146,32 @@ async function setupCustomFormat() { return { fixture, host: fixture.componentInstance, input }; } +/** Same as {@link setup}, for a host whose only difference is static template inputs. */ +async function setupHost(host: new () => T) { + await TestBed.configureTestingModule({ imports: [host] }).compileComponents(); + const fixture: ComponentFixture = TestBed.createComponent(host); + fixture.detectChanges(); + await fixture.whenStable(); + const trigger = fixture.nativeElement.querySelector('.ui-datepicker-trigger') as HTMLElement; + return { fixture, trigger }; +} + +function panel(): HTMLElement | null { + return document.querySelector('.ui-datepicker-panel'); +} +/** Clicks `el` and flushes CD. */ +async function click(el: HTMLElement, fixture: ComponentFixture) { + el.click(); + fixture.detectChanges(); + await fixture.whenStable(); +} +/** Dispatches a `keydown` on `el` and flushes CD. */ +async function keydown(el: HTMLElement, key: string, fixture: ComponentFixture) { + el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })); + fixture.detectChanges(); + await fixture.whenStable(); +} + /** Mirrors a single native keystroke: sets the raw value + caret, dispatches `input`, flushes CD. */ async function typeInto( input: HTMLInputElement, @@ -267,6 +329,43 @@ describe('UiDatepicker — keyboard entry masking (FSHSP-118)', () => { }); }); + describe('which trigger clicks open the panel (FSHSP-180)', () => { + it('does not open on a click in a typeable field — the icon and the keyboard do', async () => { + const { fixture, input } = await setup(); + const trigger = fixture.nativeElement.querySelector('.ui-datepicker-trigger') as HTMLElement; + // Opening on this click made the field unusable with a mouse: the overlay's permanent + // backdrop then swallowed every further click, so the caret couldn't be moved — and the + // panel's live preview disarmed the auto-"/" mask mid-entry. + await click(trigger, fixture); + expect(panel()).toBeNull(); + + // The calendar toggle still opens it… + const icon = fixture.nativeElement.querySelector( + '.ui-datepicker-trigger .ui-input-action', + ) as HTMLButtonElement; + await click(icon, fixture); + expect(panel()).not.toBeNull(); + await keydown(input, 'Escape', fixture); + expect(panel()).toBeNull(); + + // …and so does the keyboard. + await keydown(input, 'ArrowDown', fixture); + expect(panel()).not.toBeNull(); + }); + + it('still opens on click when the field is not typeable', async () => { + const { fixture, trigger } = await setupHost(DatepickerReadonlyTriggerHost); + await click(trigger, fixture); + expect(panel()).not.toBeNull(); + }); + + it('still opens on click when no calendar icon is shown', async () => { + const { fixture, trigger } = await setupHost(DatepickerNoIconHost); + await click(trigger, fixture); + expect(panel()).not.toBeNull(); + }); + }); + // 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)', () => { 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 ce64f90..158e828 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 @@ -263,6 +263,11 @@ export class UiDatepicker extends BaseFormField { * (any count, duplicates collapsed) — see `parseTypedMulti`. The grid keeps working exactly as * before either way; typing is a complement, not a replacement. * + * A typeable field is a text field first: **a click in it no longer opens the panel** + * (FSHSP-180) — the calendar icon, `↓` and the keyboard do. `allowInput="false"` (or + * `readonly`/`timeOnly`) restores the click-to-open, the field being the affordance there; so + * does `showIcon="false"`, which leaves no icon to click. See `onTriggerClick`. + * * Has no effect in `timeOnly` mode: the trigger stays read-only there (no parser/formatter for * a bare time string). */ @@ -944,9 +949,9 @@ export class UiDatepicker extends BaseFormField { // Keep focus in the input whenever it's typeable; only rove into the grid when it isn't // (`triggerReadonly`) or when opened via the icon specifically (`viaIcon` — "I want the // grid"). Used to key off `selectionMode() !== 'single'` instead, which broke once `range` - // got its own live mask: the field's own click ALSO calls `open()` (see the template), so - // roving on every non-`single` mode stole focus back out of the field the moment you clicked - // it to type (FSHSP-118 follow-up). + // got its own live mask: back then the field's own click ALSO called `open()` (now gated, + // see `onTriggerClick`), so roving on every non-`single` mode stole focus back out of the + // field the moment you clicked it to type (FSHSP-118 follow-up). if (this.showCalendar() && (this.triggerReadonly() || viaIcon)) { if (this.currentView() === 'date') this.queueDayFocus(); else if (this.currentView() === 'month') this.queueMonthFocus(); @@ -976,6 +981,30 @@ export class UiDatepicker extends BaseFormField { else this.toggle(true); } + /** + * @ignore Whether a click on the trigger opens the panel. It does when the field isn't + * typeable (`triggerReadonly`: the field then *is* the button), and when `showIcon` is off + * (no calendar toggle left — without this, nothing would open the panel with a mouse). + * + * On a typeable field it does NOT (FSHSP-180). The panel is an overlay with a permanent + * backdrop, so an open panel swallows every further click on the field: the first click + * placed the caret, the next one lost focus without moving it — correcting a single segment + * with the mouse was impossible. Opening also live-commits the typed text through + * `previewTyped`, which disarms the auto-"/" mask mid-entry (see `typingSlots`). The icon, + * `↓` and typing are unaffected. + */ + private readonly openOnTriggerClick = computed(() => this.triggerReadonly() || !this.showIcon()); + + /** @ignore Click anywhere on the trigger (label, field or format hint — they share the wrapper). */ + protected onTriggerClick(): void { + if (!this.openOnTriggerClick()) return; + // The click may have landed on the label or the format hint, leaving focus outside the + // wrapper — `Escape`/`↓` are bound there, so they'd be dead while the panel is open. A click + // on the field itself is already focused by then, so this never moves the caret. + if (!this.triggerReadonly()) this.triggerInput()?.focus(); + this.open(); + } + /** @ignore */ protected onTriggerKeydown(event: KeyboardEvent): void { // Typeable trigger: let printable keys through, only intercept nav/commit. diff --git a/projects/ui-kit/forms/ui-datepicker/ui-datepicker.mdx b/projects/ui-kit/forms/ui-datepicker/ui-datepicker.mdx index 3661ebf..cbdc09a 100644 --- a/projects/ui-kit/forms/ui-datepicker/ui-datepicker.mdx +++ b/projects/ui-kit/forms/ui-datepicker/ui-datepicker.mdx @@ -161,6 +161,17 @@ lecteur d'écran. Le texte est parsé au **blur** et sur **Entrée** ; une saisi 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. +> **Un champ saisissable est d'abord un champ texte : le clic dedans n'ouvre plus le panneau** +> (FSHSP-180). L'icône calendrier, `↓` et le clavier l'ouvrent. Le panneau est un overlay avec un +> backdrop permanent : tant qu'il est ouvert, il avale les clics suivants sur le champ — le +> premier clic posait le curseur, le suivant perdait le focus sans le déplacer, donc corriger un +> seul segment à la souris était impossible. L'ouverture commite aussi le texte tapé en direct +> (`previewTyped`), ce qui désarmait le masque auto-"/" en cours de frappe. +> +> Le clic continue d'ouvrir le panneau quand le champ **n'est pas saisissable** (`allowInput` à +> `false`, `readonly`, `timeOnly` — le champ y tient le rôle du bouton) et quand +> `showIcon="false"`, où il ne resterait sinon aucune affordance souris. + `allowInput` est **sans effet** en `timeOnly` (pas de parseur/formatteur dédié à une heure seule) : le champ y reste toujours en lecture seule. @@ -359,7 +370,7 @@ via l'interop CVA native, ainsi qu'aux formulaires template-driven (`[(ngModel)] Déclencheur - Champ ui-input lié à un label natif. Ouverture au clic, ou clavier Entrée/Espace/ ; role="combobox" + aria-haspopup="dialog" + aria-expanded — le rôle est nécessaire pour qu'aria-expanded soit pris en compte (interdit sur le textbox implicite), donc pour que l'existence du calendrier et son état soient annoncés. + Champ ui-input lié à un label natif. Ouverture par l'icône calendrier, ou clavier Entrée/Espace/ — le clic dans le champ n'ouvre que si celui-ci n'est pas saisissable, ou s'il n'a pas d'icône (voir Saisie manuelle) ; role="combobox" + aria-haspopup="dialog" + aria-expanded — le rôle est nécessaire pour qu'aria-expanded soit pris en compte (interdit sur le textbox implicite), donc pour que l'existence du calendrier et son état soient annoncés. Navigation grille