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

## [Unreleased]

### Added

- **`BROWSER_FILL_CLASS`, `announceBrowserFill` et `hasFocus`** dans `@4sh/ui-kit-react/forms`
(FSHSP-259), pour un champ maison : la classe et le gestionnaire `onAnimationStart` font
suivre au champ une valeur que le navigateur préremplit sans aucun événement. Une case sur
mesure de `ui-input-otp` reçoit `onAnimationStart` dans le contexte de `renderCell`.

### Fixed

- **Une valeur préremplie par le navigateur est prise en compte sans clic dans le champ**
(FSHSP-259). Quand le navigateur n'émettait aucun événement, la valeur s'affichait sans que
React la connaisse, et le rendu suivant l'écrasait, dans tous les champs. `styles.css` lance
désormais une animation sur `:-webkit-autofill`, qui fait recevoir au champ l'`input`
manquant : `ui-input`, `ui-textarea`, `ui-input-number`, `ui-input-mask`, `ui-input-otp`,
`ui-autocomplete` et `ui-datepicker` appellent `onValueChange` une seule fois, en mode
contrôlé comme non contrôlé. 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`
appelle `onComplete` sans ouvrir son panneau et, avec `forceSelection`, retient la
suggestion qui porte ce libellé dès qu'elle arrive, même après le flou que Chrome envoie au
champ rempli. Le `focus` que Chrome envoie à chaque
champ qu'il remplit n'ouvre plus le calendrier en `showOnFocus`, ne lance plus la recherche
en `completeOnFocus` et ne focalise plus une case de code. Dans les tests d'une application,
un `input` envoyé sans focus à `ui-datepicker` ou `ui-autocomplete` 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.

## [0.3.0] - 2026-10-23

### Added
Expand Down
32 changes: 32 additions & 0 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -686,6 +686,21 @@ Ne pas les repayer. Chacun est documenté sur place, dans le fichier concerné.
frappe directe d'un déclencheur `<button>` n'en voit rien, et `shift+Equal` y produit une
touche vide au lieu de « + ». Pour vérifier un raccourci, envoyer un vrai `KeyboardEvent`, ou
passer par `userEvent` dans un test.
- **La saisie automatique de Chrome se rejoue pour de vrai, pas les autres.** `Autofill.trigger`
du protocole DevTools remplit un formulaire de carte (`cc-name`, `cc-number`, `cc-exp`,
`cc-csc`) dans le Chrome système (`channel: 'chrome'`), pas dans le Chromium de Playwright :
page servie en HTTP, 1,5 s pour l'analyse du formulaire, champ déclencheur focalisé et dans
le même `<form>` que les champs à remplir. Chrome envoie alors à chaque champ rempli
`focus`, `keydown` (un `Event` nu), `input`, `change` et `blur`, sans jamais lui donner le
focus : un `onFocus` qui ouvre un panneau ou appelle `select()` réagit à ce faux focus.
Safari et Firefox ne se rejouent pas ; on simule en écrivant par le setter du prototype
(React ne voit pas l'écriture), puis en émettant l'événement voulu.
- **Deux événements du même remplissage, un seul rendu entre eux, au mieux.** Une
`animationstart` est rendue avec une priorité ordinaire : un `change` qui la suit peut
arriver avant le rendu, et son gestionnaire lit encore la valeur d'avant. Comparer la valeur
au rendu ne protège donc pas d'une double notification. `announceBrowserFill` émet plutôt
l'`input` manquant : React suit lui-même la dernière valeur vue et ne rapporte jamais deux
fois la même. FSHSP-259.

---

Expand Down Expand Up @@ -2381,3 +2396,20 @@ removed » alors qu'il ne fait que retirer le `./` du chemin. Le chemin s'écrit
sans. Après publication, npm a d'abord servi un paquet provisoire `0.0.0-stage` et un 404
sur la `0.1.0` pendant quelques minutes (`docs/PUBLISHING.md`). Vérifié ensuite depuis le
registre : installation dans un dossier vierge, `bin` lié, serveur MCP qui répond.

### 2026-10-26 : la saisie automatique du navigateur

FSHSP-259, pendant de FSHSP-258 côté Angular. Les défauts ont d'abord été reproduits sur le
kit tel quel : vrai autofill Chrome par le protocole DevTools, et simulations sur Chromium,
Firefox et WebKit. Le `onChange` de React couvrait déjà `input` et `change`. Restaient une
valeur écrite sans événement, écrasée au rendu suivant dans **tous** les champs, contrôlés ou
non ; le panneau de `ui-autocomplete` ouvert sous un champ sans focus ; le focus volé par
`ui-input-otp` ; une date remplie jamais validée ; et le faux `focus` de Chrome, qui ouvrait le
calendrier en `showOnFocus`.

Le correctif tient dans `core/forms/browser-fill.ts` et une règle de `styles.css` : une
animation sur `:-webkit-autofill` fait recevoir au champ l'`input` manquant. Chaque champ
distingue ensuite une écriture sans focus (`hasFocus`) d'une frappe. Frappe et collage
mesurés identiques avant et après, sur les trois moteurs. Reste un double appel préexistant,
commun aux deux stacks : `ui-input-number` republie sa valeur à la sortie du champ, ce que
Chrome déclenche aussi avec son faux `blur`.
31 changes: 31 additions & 0 deletions packages/ui-kit-react/src/core/forms/browser-fill.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { expect, test, vi } from 'vitest';
import { render } from 'vitest-browser-react';

import { announceBrowserFill, hasFocus } from './browser-fill';

test("announceBrowserFill émet l'input manquant, pour la seule animation d'autofill", async () => {
const onInput = vi.fn();
const screen = await render(
<input aria-label="Champ" onInput={onInput} onAnimationStart={announceBrowserFill} />,
);
const input = screen.container.querySelector('input')!;

input.dispatchEvent(
new AnimationEvent('animationstart', { animationName: 'ui-ripple-wave', bubbles: true }),
);
expect(onInput).not.toHaveBeenCalled();

input.dispatchEvent(
new AnimationEvent('animationstart', { animationName: 'ui-browser-fill', bubbles: true }),
);
expect(onInput).toHaveBeenCalledOnce();
});

test('hasFocus distingue le champ qui a le focus', async () => {
const screen = await render(<input aria-label="Champ" />);
const input = screen.container.querySelector('input')!;

expect(hasFocus(input)).toBe(false);
input.focus();
expect(hasFocus(input)).toBe(true);
});
26 changes: 26 additions & 0 deletions packages/ui-kit-react/src/core/forms/browser-fill.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { AnimationEvent } from 'react';

/** Marque un champ natif pour la règle `:-webkit-autofill` de la feuille globale. */
export const BROWSER_FILL_CLASS = 'ui-browser-fill';

const BROWSER_FILL_ANIMATION = 'ui-browser-fill';

/** L'élément a-t-il le focus ? (fonctionne aussi dans une racine fantôme) */
export function hasFocus(element: Element): boolean {
return (element.getRootNode() as Document | ShadowRoot).activeElement === element;
}

/**
* `onAnimationStart` d'un champ marqué `BROWSER_FILL_CLASS`. L'animation que la feuille
* globale lance sur `:-webkit-autofill` est le seul signe d'une valeur préremplie sans aucun
* événement, que le rendu suivant écraserait. Le champ reçoit alors l'`input` que le
* navigateur n'a pas émis : son `onChange` le traite comme une autre écriture, et React ne
* rapporte jamais deux fois la même valeur.
*/
export function announceBrowserFill(
event: AnimationEvent<HTMLInputElement | HTMLTextAreaElement>,
): void {
if (event.animationName === BROWSER_FILL_ANIMATION) {
event.currentTarget.dispatchEvent(new Event('input', { bubbles: true }));
}
}
1 change: 1 addition & 0 deletions packages/ui-kit-react/src/core/forms/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

export { useControllableState, type UiControllableStateOptions } from './use-controllable-state';
export { useUiField, joinIds, type UiFieldWiring } from './use-ui-field';
export { BROWSER_FILL_CLASS, announceBrowserFill, hasFocus } from './browser-fill';
export type { FieldSize, FieldLevel, FieldFloatLabel, UiFieldSharedProps } from './field-types';
export {
MASK_TOKENS,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,16 @@ libre.

<Canvas of={AutocompleteStories.ForceSelection} />

Une valeur que le navigateur écrit dans le champ sans que l'utilisateur y soit (saisie
automatique) appelle `onComplete` aussitôt, **sans ouvrir le panneau** sous un champ où
personne n'est. Avec `forceSelection`, elle n'attend pas de sortie du champ : la suggestion qui
porte ce libellé est retenue dès que `suggestions` la contient, même quand une recherche
asynchrone la renvoie après le flou que Chrome envoie au champ rempli. Tant qu'elle attend, la
sortie du champ ne la juge pas : sans correspondance, le texte reste affiché et la valeur vide.
`completeOnFocus` ignore le `focus` que Chrome envoie au champ qu'il remplit. Comportement
commun aux champs : section « Saisie automatique du navigateur » de
[`ui-input`](?path=/docs/components-ui-forms-ui-input--docs).

## Sélection multiple

La valeur devient un tableau, rendu en puces retirables. `unique` refuse deux fois la même
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -530,3 +530,90 @@ test('le panneau attend sa position avant d’être peint', async () => {
.forEach((animation) => animation.finish());
expect(getComputedStyle(panneau(screen)).opacity).toBe('1');
});

// --- Saisie automatique du navigateur -------------------------------------

/** Écrit comme le navigateur (setter natif, que React ne voit pas), puis émet `evenements`. */
function remplir(el: HTMLInputElement, texte: string, ...evenements: string[]) {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(el, texte);
for (const type of evenements) {
el.dispatchEvent(
type === 'animationstart'
? new AnimationEvent(type, { animationName: 'ui-browser-fill', bubbles: true })
: new Event(type, { bubbles: true }),
);
}
}

test('une valeur écrite sans focus lance la recherche sans ouvrir le panneau', async () => {
const onComplete = vi.fn();
const onValueChange = vi.fn();
const screen = await render(<Demo onComplete={onComplete} onValueChange={onValueChange} />);

remplir(champ(screen), 'Lyon', 'input');

await expect.poll(() => onComplete.mock.calls).toEqual([['Lyon']]);
await new Promise((r) => setTimeout(r, 50));
expect(panneau(screen).matches(':popover-open')).toBe(false);
expect(onValueChange.mock.calls).toEqual([['Lyon']]);
expect(document.activeElement).not.toBe(champ(screen));
});

test('forceSelection retient une seule fois la suggestion qui porte le libellé prérempli', async () => {
const onValueChange = vi.fn();
const screen = await render(<Demo forceSelection onValueChange={onValueChange} />);

remplir(champ(screen), 'lyon', 'animationstart');

await expect.poll(() => onValueChange.mock.calls).toEqual([['Lyon']]);
await expect.element(champ(screen)).toHaveValue('Lyon');
// Le flou que Chrome envoie ensuite ne la republie pas.
champ(screen).dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
await new Promise((r) => setTimeout(r, 50));
expect(onValueChange).toHaveBeenCalledOnce();
});

test('completeOnFocus ignore le focus que Chrome envoie avec son autofill', async () => {
const onComplete = vi.fn();
const screen = await render(<Demo completeOnFocus onComplete={onComplete} />);

champ(screen).dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
await new Promise((r) => setTimeout(r, 50));
expect(onComplete).not.toHaveBeenCalled();

champ(screen).focus();
await expect.poll(() => onComplete.mock.calls).toEqual([['']]);
});

test('forceSelection attend les suggestions asynchrones malgré le faux flou de Chrome', async () => {
const onValueChange = vi.fn();
function Lent() {
const [suggestions, setSuggestions] = useState<readonly unknown[]>([]);
return (
<UiAutocomplete
label="Ville"
delay={0}
forceSelection
suggestions={suggestions}
onValueChange={onValueChange}
onComplete={(query) =>
setTimeout(
() =>
setSuggestions(
VILLES.filter((v) => normalizeText(v).startsWith(normalizeText(query))),
),
100,
)
}
/>
);
}
const screen = await render(<Lent />);
const input = champ(screen);

remplir(input, 'lyon', 'input', 'change');
input.dispatchEvent(new FocusEvent('focusout', { bubbles: true }));

await expect.poll(() => onValueChange.mock.calls).toEqual([['Lyon']]);
await expect.element(input).toHaveValue('Lyon');
});
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,12 @@ import { UiChip } from '../../informative/ui-chip';
import { UiSpinner } from '../../informative/ui-spinner';
import { useRovingTabIndex } from '../../core/focus';
import {
BROWSER_FILL_CLASS,
createOptionResolver,
formatLabel,
hasFocus,
normalizeText,
announceBrowserFill,
useControllableState,
useUiField,
type OptionEntry,
Expand Down Expand Up @@ -229,6 +232,8 @@ export function UiAutocomplete({
const searchTimer = useRef(0);
/** Une requête a déjà été traitée : évite que `completeOnFocus` en refasse une. */
const queryDirty = useRef(false);
/** `forceSelection` : texte écrit par le navigateur, en attente de la suggestion qui le porte. */
const pendingFill = useRef<string | null>(null);

const uid = useId();
const [open, setOpen] = useState(false);
Expand Down Expand Up @@ -279,6 +284,13 @@ export function UiAutocomplete({
[group, groups, suggestions, resolver],
);

/** Suggestion active dont le libellé vaut `text`, à la casse et aux accents près. */
const entryLabelled = useCallback(
(text: string) =>
flatEntries.find((e) => !e.disabled && normalizeText(e.label) === normalizeText(text.trim())),
[flatEntries],
);

const rows = useMemo<AcRow[]>(() => {
const out: AcRow[] = [];
let index = 0;
Expand Down Expand Up @@ -571,9 +583,11 @@ export function UiAutocomplete({
};

// --- Saisie --------------------------------------------------------------
const onInput = (event: ChangeEvent<HTMLInputElement>) => {
const query = event.target.value;
const onQuery = (query: string, element: HTMLInputElement) => {
// Écrit par le navigateur sans focus : la recherche part aussitôt, panneau fermé.
const typed = hasFocus(element);
setInputText(query);
pendingFill.current = !typed && forceSelection && query ? query : null;

// Texte libre pendant la frappe, sauf avec `forceSelection` (une vraie suggestion
// est attendue) et `multiple` (la requête ne touche jamais la sélection).
Expand All @@ -592,20 +606,28 @@ export function UiAutocomplete({
}

setFocusedIndex(-1);
if (!typed) {
onComplete?.(query);
return;
}
searchTimer.current = window.setTimeout(() => runSearch(query), delay);
};

const onInput = (event: ChangeEvent<HTMLInputElement>) =>
onQuery(event.target.value, event.target);

/**
* `forceSelection` : à la sortie du champ, un texte sans suggestion correspondante
* est effacé, pour que le modèle ne contienne jamais de texte libre.
*/
const validateForceSelection = useCallback(() => {
pendingFill.current = null;
const text = inputText.trim();
if (!text) return;
const match = flatEntries.find(
(e) => !e.disabled && normalizeText(e.label) === normalizeText(text),
);
const match = entryLabelled(text);
if (match) {
// Déjà retenue (remplissage du navigateur) : la reprendre la publierait deux fois.
if (!multiple && resolver.equals(model, match.value)) return;
selectEntry(match, false);
} else if (multiple) {
setInputText('');
Expand All @@ -614,13 +636,24 @@ export function UiAutocomplete({
setInputText('');
onClear?.();
}
}, [inputText, flatEntries, selectEntry, multiple, commit, onClear]);
}, [inputText, entryLabelled, resolver, model, selectEntry, multiple, commit, onClear]);

// `forceSelection` applique à un remplissage du navigateur la règle de la sortie du champ,
// dès qu'une suggestion porte son libellé : aucune sortie du champ ne viendra l'appliquer.
useEffect(() => {
const text = pendingFill.current;
const match = text === null ? undefined : entryLabelled(text);
if (!match) return;
pendingFill.current = null;
selectEntry(match, false);
}, [inputText, entryLabelled, selectEntry]);

const onInputBlur = (event: FocusEvent<HTMLInputElement>) => {
const next = event.relatedTarget as Node | null;
const inside =
(next && anchorRef.current?.contains(next)) || (next && panelRef.current?.contains(next));
if (!inside && forceSelection) validateForceSelection();
// Un remplissage du navigateur attend sa suggestion : le faux flou de Chrome ne le juge pas.
if (!inside && forceSelection && pendingFill.current === null) validateForceSelection();
};

const onInputKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
Expand Down Expand Up @@ -708,6 +741,7 @@ export function UiAutocomplete({

const clear = (event: MouseEvent) => {
event.stopPropagation();
pendingFill.current = null;
commit(multiple ? [] : null);
setInputText('');
onClear?.();
Expand Down Expand Up @@ -926,7 +960,11 @@ export function UiAutocomplete({
}}
id={field.inputId}
type="text"
className={cx('ui-autocomplete-input', size === 'small' && '_small')}
className={cx(
'ui-autocomplete-input',
size === 'small' && '_small',
BROWSER_FILL_CLASS,
)}
role="combobox"
aria-haspopup="listbox"
aria-autocomplete="list"
Expand All @@ -944,9 +982,12 @@ export function UiAutocomplete({
disabled={disabled}
readOnly={readOnly}
onChange={onInput}
onAnimationStart={announceBrowserFill}
onKeyDown={onInputKeyDown}
onFocus={() => {
onFocus={(event) => {
if (disabled) return;
// Chrome envoie ce focus au champ qu'il remplit, sans le lui donner.
if (!hasFocus(event.currentTarget)) return;
if (!queryDirty.current && completeOnFocus) runSearch(inputText);
}}
onBlur={onInputBlur}
Expand Down
Loading
Loading