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
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,41 @@ Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le pr

## [Unreleased]

### Added

- **`ui-autocomplete` expose `nativeInputElement()` et `close()`** (FSHSP-258). `close()`
ferme le panneau sans déplacer le focus, `close(true)` le rend au champ.
- **Directive `UiBrowserFill` et mixin `utils.browser-fill`** (FSHSP-258), pour un champ
maison bâti sur `@4sh/ui-kit/forms`. La directive signale les événements par lesquels le
navigateur peut écrire une valeur sans `input` (animation `:-webkit-autofill`, `change`), à
comparer avec la valeur affichée ; le mixin, inclus à la racine de la feuille du composant,
déclare cette animation. `hasFocus(element)` distingue une frappe d'un `input` reçu par un
champ sans focus. Une case personnalisée de `ui-input-otp` (template `#input`) s'y branche
par `events.browserFill`.

### Fixed

- **Une valeur préremplie par le navigateur met à jour le modèle sans clic dans le champ**
(FSHSP-258). `ui-input`, `ui-textarea`, `ui-input-number`, `ui-input-mask`,
`ui-input-otp`, `ui-autocomplete` et la saisie de `ui-datepicker` n'écoutaient que
l'`input` de leur champ natif ; selon le navigateur, la saisie automatique n'en émet aucun
ou seulement un `change`. Ils suivent aussi l'animation `:-webkit-autofill` et le `change`
dès que la valeur native diffère de celle affichée, et `valueChange` comme le `onChange` du
CVA partent une seule fois. Une valeur écrite sans focus est traitée comme telle :
`ui-input-number` la formate aussitôt, `ui-datepicker` la valide sans attendre la sortie du
champ (et revient à la valeur précédente si ce n'est pas une date), `ui-input-otp` la
répartit sans prendre le focus, `ui-input-mask` ne place pas de curseur, `ui-autocomplete`
lance la recherche sans ouvrir son panneau et, avec `forceSelection`, retient la suggestion
qui porte ce libellé dès qu'elle arrive. Dans les tests d'une application, un `input` envoyé sans focus à ces deux
derniers composants vaut désormais remplissage : focaliser le champ pour simuler une
frappe. Hors de portée : une valeur que Chrome préremplit au chargement de la page reste
illisible par JavaScript jusqu'à une interaction.
- **Les événements que Chrome envoie avec sa saisie automatique n'ouvrent plus de panneau**
(FSHSP-258). Chaque champ rempli reçoit un `focus` qui ne lui donne pas le focus, puis un
`keydown` sans touche. `ui-datepicker` en `showOnFocus` ouvrait son calendrier,
`ui-autocomplete` en `completeOnFocus` lançait sa recherche, `ui-input-otp` prenait le focus
et levait une erreur : tous trois ignorent désormais ces événements.

## [0.15.0] - 2026-10-23

### Added
Expand Down
8 changes: 8 additions & 0 deletions projects/ui-kit/README.fr.md
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,7 @@ construisez **votre propre champ** en réutilisant les conventions du kit.
| `maskEngine` | Moteur de masque de saisie partagé (`ui-input-mask`, `ui-datepicker`). |
| `date-utils` | Helpers `Date` ↔ chaîne ISO, sérialisés depuis les composantes LOCALES et jamais via `toISOString()` (`ui-datepicker`, `ui-input-date`). |
| `option-resolver`, `format-label`, `warn-missing-accessible-name` | Utilitaires de résolution d'options, de formatage de libellé et garde-fou d'accessibilité. |
| `UiBrowserFill`, `hasFocus()` | Suivi de la saisie automatique du navigateur dans un champ maison (voir ci-dessous). |

### Créer son propre champ

Expand All @@ -370,6 +371,13 @@ export class MyField extends BaseFormField<string> {
}
```

Pour suivre la **saisie automatique du navigateur**, qui n'émet pas toujours d'`input` :
posez la directive `uiBrowserFill` sur l'élément natif, incluez `@include utils.browser-fill;`
à la racine de la feuille du composant, et sur `(browserFill)` synchronisez le modèle quand la
valeur native diffère de celle affichée. `hasFocus(element)` distingue une frappe d'une valeur
écrite sans focus. Détail et exemple : section « Saisie automatique du navigateur » de la page
`ui-input` dans Storybook.

---

## Accessibilité
Expand Down
8 changes: 8 additions & 0 deletions projects/ui-kit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,7 @@ build **your own field** while reusing the kit's conventions.
| `maskEngine` | Shared input mask engine (`ui-input-mask`, `ui-datepicker`). |
| `date-utils` | Pure `Date` ↔ ISO-string helpers, serialized from LOCAL components and never through `toISOString()` (`ui-datepicker`, `ui-input-date`). |
| `option-resolver`, `format-label`, `warn-missing-accessible-name` | Option resolution, label formatting and accessibility guardrail helpers. |
| `UiBrowserFill`, `hasFocus()` | Tracks the browser's autofill in a custom field (see below). |

### Building your own field

Expand All @@ -367,6 +368,13 @@ export class MyField extends BaseFormField<string> {
}
```

To follow the **browser's autofill**, which does not always fire `input`: put the
`uiBrowserFill` directive on the native element, add `@include utils.browser-fill;` at the
root of the component stylesheet, and on `(browserFill)` sync the model when the native value
differs from the one rendered. `hasFocus(element)` tells a keystroke apart from a value written
without focus. Details and example: the "Saisie automatique du navigateur" section of the
`ui-input` page in Storybook.

---

## Accessibility
Expand Down
73 changes: 73 additions & 0 deletions projects/ui-kit/forms/src/lib/browser-fill.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* Spec for `UiBrowserFill`: which native events it reports as a possible
* browser fill. Deduplication against what the host renders is the host's
* job, covered by each field's own browser-fill spec.
*/
import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { describe, expect, it } from 'vitest';
import { hasFocus, UiBrowserFill } from './browser-fill';

@Component({
imports: [UiBrowserFill],
template: `<input uiBrowserFill (browserFill)="fills.push($event)" />`,
})
class FillHost {
readonly fills: Event[] = [];
}

async function setup() {
await TestBed.configureTestingModule({ imports: [FillHost] }).compileComponents();
const fixture: ComponentFixture<FillHost> = TestBed.createComponent(FillHost);
fixture.detectChanges();
const input = fixture.nativeElement.querySelector('input') as HTMLInputElement;
return { fills: fixture.componentInstance.fills, input };
}

function animationStart(el: HTMLElement, animationName: string): Event {
const event = new Event('animationstart', { bubbles: true });
Object.defineProperty(event, 'animationName', { value: animationName });
el.dispatchEvent(event);
return event;
}

describe('UiBrowserFill', () => {
it('marks its host for the browser-fill stylesheet rule', async () => {
const { input } = await setup();
expect(input.classList).toContain('ui-browser-fill');
});

it('reports the autofill animation, under its encapsulated name too', async () => {
const { fills, input } = await setup();
const event = animationStart(input, '_ngcontent-ng-c1_ui-browser-fill');
expect(fills).toEqual([event]);
});

it('ignores any other animation', async () => {
const { fills, input } = await setup();
animationStart(input, 'ui-ripple-wave');
expect(fills).toEqual([]);
});

it('reports a change', async () => {
const { fills, input } = await setup();
const event = new Event('change', { bubbles: true });
input.dispatchEvent(event);
expect(fills).toEqual([event]);
});

it('leaves input to the host', async () => {
const { fills, input } = await setup();
input.dispatchEvent(new Event('input', { bubbles: true }));
expect(fills).toEqual([]);
});
});

describe('hasFocus', () => {
it('tells the focused element apart', async () => {
const { input } = await setup();
expect(hasFocus(input)).toBe(false);
input.focus();
expect(hasFocus(input)).toBe(true);
});
});
40 changes: 40 additions & 0 deletions projects/ui-kit/forms/src/lib/browser-fill.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { Directive, output } from '@angular/core';

/** Keyframes run by the `browser-fill` SCSS mixin while the browser autofills the field. */
const AUTOFILL_ANIMATION = 'ui-browser-fill';

/**
* Reports the events through which the browser may write a value into a native
* `<input>`/`<textarea>` without an `input` event:
*
* - its autofill: the `:-webkit-autofill` animation declared by the
* `utils.browser-fill` mixin, which the host stylesheet must include;
* - a `change`.
*
* Either may follow an edit the host already handled through `input`: the
* host syncs only when the native value differs from the one it renders, so
* nothing is emitted twice. An `input` sent by the browser to a field without
* focus already reaches the host's own `(input)` handler: `hasFocus()` tells
* it apart from a keystroke.
*/
@Directive({
selector: '[uiBrowserFill]',
host: {
class: 'ui-browser-fill',
'(change)': 'browserFill.emit($event)',
'(animationstart)': 'onAnimationStart($event)',
},
})
export class UiBrowserFill {
/** Emitted with the change/animation event that may reveal a fill. */
readonly browserFill = output<Event>();

protected onAnimationStart(event: AnimationEvent): void {
if (event.animationName.endsWith(AUTOFILL_ANIMATION)) this.browserFill.emit(event);
}
}

/** Whether `element` holds the focus (works inside a shadow root). */
export function hasFocus(element: Element): boolean {
return (element.getRootNode() as Document | ShadowRoot).activeElement === element;
}
1 change: 1 addition & 0 deletions projects/ui-kit/forms/src/public-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@ export * from './lib/date-utils';
export * from './lib/option-resolver';
export * from './lib/format-label';
export * from './lib/warn-missing-accessible-name';
export * from './lib/browser-fill';
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
/**
* TestBed spec for a value the browser writes into `ui-autocomplete` without a
* keystroke: the model follows, the query runs, but no panel opens under a
* field the user is not in. With `forceSelection`, the suggestion carrying
* that label is picked once it arrives.
*/
import { Component, signal, viewChild } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { describe, expect, it } from 'vitest';
import { AutocompleteCompleteEvent, UiAutocomplete } from './ui-autocomplete';

if (!Element.prototype.scrollIntoView) {
// eslint-disable-next-line @typescript-eslint/no-empty-function -- jsdom stub, intentionally a no-op
Element.prototype.scrollIntoView = () => {};
}

const CITIES = ['Paris', 'Lyon', 'Marseille'];

@Component({
imports: [ReactiveFormsModule, UiAutocomplete],
template: `
<ui-autocomplete
label="Ville"
[delay]="0"
[completeOnFocus]="completeOnFocus()"
[forceSelection]="forceSelection()"
[suggestions]="suggestions()"
[formControl]="control"
(completeMethod)="onComplete($event)"
(valueChange)="emitted.push($event)"
(optionSelect)="selected.push($event)"
/>
`,
})
class FillHost {
readonly autocomplete = viewChild.required(UiAutocomplete);
readonly control = new FormControl<string | null>(null);
readonly forceSelection = signal(false);
readonly completeOnFocus = signal(false);
readonly suggestions = signal<string[]>([]);
/** Answers each query at once, unless the test answers it later. */
answerAtOnce = true;
readonly queries: string[] = [];
readonly emitted: unknown[] = [];
readonly selected: unknown[] = [];

onComplete(event: AutocompleteCompleteEvent): void {
this.queries.push(event.query);
if (this.answerAtOnce) this.answer(event.query);
}

answer(query: string): void {
const needle = query.trim().toLowerCase();
this.suggestions.set(CITIES.filter((c) => c.toLowerCase().includes(needle)));
}
}

async function setup(forceSelection = false) {
await TestBed.configureTestingModule({ imports: [FillHost] }).compileComponents();
const fixture: ComponentFixture<FillHost> = TestBed.createComponent(FillHost);
fixture.componentInstance.forceSelection.set(forceSelection);
fixture.detectChanges();
await fixture.whenStable();
const input = fixture.nativeElement.querySelector('.ui-autocomplete-input') as HTMLInputElement;
const settle = async () => {
fixture.detectChanges();
await fixture.whenStable();
};
const fire = async (type: string, animationName?: string) => {
const event = new Event(type, { bubbles: true });
if (animationName) Object.defineProperty(event, 'animationName', { value: animationName });
input.dispatchEvent(event);
await settle();
};
return { host: fixture.componentInstance, input, settle, fire };
}

const panel = () => document.querySelector('.ui-autocomplete-panel');
const tick = () => new Promise((resolve) => setTimeout(resolve));

describe('UiAutocomplete — browser fill', () => {
it('takes a value written without focus, queries at once, opens nothing', async () => {
const { host, input, fire } = await setup();
input.value = 'Paris';
await fire('input');
await tick();
expect(host.control.value).toBe('Paris');
expect(host.emitted).toEqual(['Paris']);
expect(host.queries).toEqual(['Paris']);
expect(panel()).toBeNull();
expect(document.activeElement).not.toBe(input);
});

it('takes a value revealed by a change alone, once', async () => {
const { host, input, fire } = await setup();
input.value = 'Lyon';
await fire('change');
await fire('animationstart', 'ui-browser-fill');
await tick();
expect(host.control.value).toBe('Lyon');
expect(host.emitted).toEqual(['Lyon']);
expect(host.queries).toEqual(['Lyon']);
expect(panel()).toBeNull();
});

it('still opens the panel for a typed query', async () => {
const { host, input, settle, fire } = await setup();
input.focus();
input.value = 'Par';
await fire('input');
await tick();
await settle();
expect(host.queries).toEqual(['Par']);
expect(panel()).not.toBeNull();
});

describe('with forceSelection', () => {
it('picks the suggestion carrying the filled label', async () => {
const { host, input, fire } = await setup(true);
input.value = 'paris';
await fire('input');
expect(host.control.value).toBe('Paris');
expect(host.selected).toEqual(['Paris']);
expect(input.value).toBe('Paris');
expect(panel()).toBeNull();
});

it('waits for the suggestions answering the query', async () => {
const { host, input, settle, fire } = await setup(true);
host.answerAtOnce = false;
input.value = 'Marseille';
await fire('change');
expect(host.control.value).toBeNull();
host.answer('Marseille');
await settle();
expect(host.control.value).toBe('Marseille');
expect(host.selected).toEqual(['Marseille']);
});

it('keeps the model empty when no suggestion carries the label', async () => {
const { host, input, fire } = await setup(true);
input.value = 'Pa';
await fire('input');
expect(host.control.value).toBeNull();
expect(host.selected).toEqual([]);
});

it('drops the pending fill once the user types', async () => {
const { host, input, settle, fire } = await setup(true);
host.answerAtOnce = false;
input.value = 'Lyon';
await fire('input');
input.focus();
input.value = 'Lyo';
await fire('input');
host.answer('Lyon');
await settle();
expect(host.control.value).toBeNull();
expect(host.selected).toEqual([]);
});
});

it('ignores the focus event Chrome sends to a field its autofill fills', async () => {
const { host, input, settle } = await setup();
host.completeOnFocus.set(true);
await settle();
input.dispatchEvent(new FocusEvent('focus'));
await settle();
expect(host.queries).toEqual([]);
expect(panel()).toBeNull();
input.focus();
await settle();
expect(host.queries).toEqual(['']);
});

it('exposes its native input and closes without taking the focus', async () => {
const { host, input, settle, fire } = await setup();
expect(host.autocomplete().nativeInputElement()).toBe(input);
input.focus();
input.value = 'Par';
await fire('input');
await tick();
await settle();
expect(panel()).not.toBeNull();
input.blur();
host.autocomplete().close();
await settle();
expect(panel()).toBeNull();
expect(document.activeElement).not.toBe(input);
});
});
Loading
Loading