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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -164,9 +164,14 @@ 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

- **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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -350,10 +350,15 @@
@if (inline()) {
<ng-container [ngTemplateOutlet]="calendarTpl" />
} @else {
<!-- Trigger: readonly composed field. Clicking the field or the icon opens the panel. -->
<!-- Trigger: composed field. The icon opens the panel; a click on the field itself only does
so when the field isn't typeable (see `onTriggerClick`). -->
<!-- Convenience wrapper: focus and keyboard are carried by the inner ui-input. -->
<!-- eslint-disable-next-line @angular-eslint/template/interactive-supports-focus -->
<div class="ui-datepicker-trigger" (click)="open()" (keydown)="onTriggerKeydown($event)">
<div
class="ui-datepicker-trigger"
(click)="onTriggerClick()"
(keydown)="onTriggerKeydown($event)"
>
<ui-input
#trigger
[label]="label()"
Expand Down
207 changes: 199 additions & 8 deletions projects/ui-kit/forms/ui-datepicker/src/lib/ui-datepicker.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,13 @@
* 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), and which trigger clicks open the panel
* (FSHSP-180). Not covered: `multiple` (no live mask) or the format-hint derivation.
*
* The panel is rendered through a `cdkConnectedOverlay`, so once opened it lives in the global
* overlay container under `document.body`, not in `fixture.nativeElement` — as in
* `ui-select.spec.ts`, assertions on it query `document` directly.
*/
import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
Expand Down Expand Up @@ -42,6 +47,60 @@ class DatepickerRangeHost {
readonly control = new FormControl<Date[] | null>(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: `<ui-datepicker
label="Date"
valueType="date"
[dateFormat]="dateFormat"
[formControl]="control"
/>`,
})
class DatepickerCustomFormatHost {
readonly control = new FormControl<Date | null>(null);
readonly dateFormat = (d: Date): string =>
new Intl.DateTimeFormat('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
}).format(d);
}

/** Non-typeable trigger (`allowInput=false`): the field itself is the affordance, so it keeps
* opening the panel on click. */
@Component({
imports: [ReactiveFormsModule, UiDatepicker],
template: `<ui-datepicker
label="Date"
valueType="date"
locale="fr-FR"
[allowInput]="false"
[formControl]="control"
/>`,
})
class DatepickerReadonlyTriggerHost {
readonly control = new FormControl<Date | null>(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: `<ui-datepicker
label="Date"
valueType="date"
locale="fr-FR"
[showIcon]="false"
[formControl]="control"
/>`,
})
class DatepickerNoIconHost {
readonly control = new FormControl<Date | null>(null);
}

async function setup(initial: Date | null = null) {
await TestBed.configureTestingModule({ imports: [DatepickerHost] }).compileComponents();
const fixture: ComponentFixture<DatepickerHost> = TestBed.createComponent(DatepickerHost);
Expand All @@ -59,9 +118,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<DatepickerRangeHost> = TestBed.createComponent(
DatepickerRangeHost,
);
const fixture: ComponentFixture<DatepickerRangeHost> =
TestBed.createComponent(DatepickerRangeHost);
const host = fixture.componentInstance;
if (initial) host.control.setValue(initial);
fixture.detectChanges();
Expand All @@ -72,6 +130,48 @@ 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<DatepickerCustomFormatHost> = 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 };
}

/** Same as {@link setup}, for a host whose only difference is static template inputs. */
async function setupHost<T>(host: new () => T) {
await TestBed.configureTestingModule({ imports: [host] }).compileComponents();
const fixture: ComponentFixture<T> = 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<unknown>) {
el.click();
fixture.detectChanges();
await fixture.whenStable();
}
/** Dispatches a `keydown` on `el` and flushes CD. */
async function keydown(el: HTMLElement, key: string, fixture: ComponentFixture<unknown>) {
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,
Expand Down Expand Up @@ -133,10 +233,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 () => {
Expand All @@ -159,6 +260,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));
Expand All @@ -175,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)', () => {
Expand Down
Loading