diff --git a/CHANGELOG.md b/CHANGELOG.md index dedef88e..f6f96499 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/projects/ui-kit/README.fr.md b/projects/ui-kit/README.fr.md index 1344bec3..010a4a25 100644 --- a/projects/ui-kit/README.fr.md +++ b/projects/ui-kit/README.fr.md @@ -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 @@ -370,6 +371,13 @@ export class MyField extends BaseFormField { } ``` +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é diff --git a/projects/ui-kit/README.md b/projects/ui-kit/README.md index 83f08686..fc35c498 100644 --- a/projects/ui-kit/README.md +++ b/projects/ui-kit/README.md @@ -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 @@ -367,6 +368,13 @@ export class MyField extends BaseFormField { } ``` +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 diff --git a/projects/ui-kit/forms/src/lib/browser-fill.spec.ts b/projects/ui-kit/forms/src/lib/browser-fill.spec.ts new file mode 100644 index 00000000..b68eb1eb --- /dev/null +++ b/projects/ui-kit/forms/src/lib/browser-fill.spec.ts @@ -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: ``, +}) +class FillHost { + readonly fills: Event[] = []; +} + +async function setup() { + await TestBed.configureTestingModule({ imports: [FillHost] }).compileComponents(); + const fixture: ComponentFixture = 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); + }); +}); diff --git a/projects/ui-kit/forms/src/lib/browser-fill.ts b/projects/ui-kit/forms/src/lib/browser-fill.ts new file mode 100644 index 00000000..38ad2d9c --- /dev/null +++ b/projects/ui-kit/forms/src/lib/browser-fill.ts @@ -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 + * ``/` diff --git a/projects/ui-kit/forms/ui-textarea/src/lib/ui-textarea.scss b/projects/ui-kit/forms/ui-textarea/src/lib/ui-textarea.scss index ec3c0c74..a3a51af7 100644 --- a/projects/ui-kit/forms/ui-textarea/src/lib/ui-textarea.scss +++ b/projects/ui-kit/forms/ui-textarea/src/lib/ui-textarea.scss @@ -16,6 +16,8 @@ $font-size-small: var(--ui-textarea-font-size-small, var(--size-typography-text- $count-font-size: var(--ui-textarea-count-font-size, var(--size-typography-text-md)); /// Taille de police du compteur de caractères. $count-font-size-small: var(--ui-textarea-count-font-size-small, var(--size-typography-text-sm)); /// Taille de police du compteur de caractères, variante `small`. +@include utils.browser-fill; + .ui-textarea { &-native { @include utils.field-native-input($pad-x); diff --git a/projects/ui-kit/forms/ui-textarea/src/lib/ui-textarea.ts b/projects/ui-kit/forms/ui-textarea/src/lib/ui-textarea.ts index a69cdcb0..0dc2d444 100644 --- a/projects/ui-kit/forms/ui-textarea/src/lib/ui-textarea.ts +++ b/projects/ui-kit/forms/ui-textarea/src/lib/ui-textarea.ts @@ -10,7 +10,7 @@ import { viewChild, } from '@angular/core'; import { NG_VALUE_ACCESSOR } from '@angular/forms'; -import { BaseFormField } from '@4sh/ui-kit/forms'; +import { BaseFormField, UiBrowserFill } from '@4sh/ui-kit/forms'; import { UiField } from '@4sh/ui-kit/forms/ui-field'; /** Which axes the user can manually resize (ignored when `autoResize` is on). */ @@ -28,7 +28,7 @@ export type TextareaResize = 'none' | 'vertical' | 'horizontal' | 'both'; */ @Component({ selector: 'ui-textarea', - imports: [UiField], + imports: [UiField, UiBrowserFill], templateUrl: './ui-textarea.html', styleUrl: './ui-textarea.scss', providers: [ @@ -112,6 +112,12 @@ export class UiTextarea extends BaseFormField { if (this.autoResize()) this.resizeToContent(); } + /** @ignore Value written by the browser without an `input` event. */ + protected onBrowserFill(): void { + const el = this.textareaEl()?.nativeElement; + if (el && el.value !== this.displayValue()) this.onInput(); + } + /** @ignore */ protected onBlur(event: FocusEvent): void { this.emitTouch(); diff --git a/projects/ui-kit/forms/ui-textarea/ui-textarea.mdx b/projects/ui-kit/forms/ui-textarea/ui-textarea.mdx index 118cb810..451b9dfb 100644 --- a/projects/ui-kit/forms/ui-textarea/ui-textarea.mdx +++ b/projects/ui-kit/forms/ui-textarea/ui-textarea.mdx @@ -63,6 +63,10 @@ aussi le marqueur `*` sur le label. Pas besoin de forcer `level="error"`. +Une adresse préremplie par le navigateur met à jour le modèle sans clic dans le champ, même +quand le navigateur n'émet aucun `input` : voir la section « Saisie automatique du +navigateur » de [`ui-input`](?path=/docs/components-ui-forms-ui-input--docs). + ## Redimensionnement Par défaut, la poignée native permet d'agrandir **verticalement** le champ. `resize` diff --git a/projects/ui-kit/styles/utils/_mixins.scss b/projects/ui-kit/styles/utils/_mixins.scss index ef023094..509c9f74 100644 --- a/projects/ui-kit/styles/utils/_mixins.scss +++ b/projects/ui-kit/styles/utils/_mixins.scss @@ -114,6 +114,17 @@ $_moving-properties: ( padding-block-start: calc(#{$extra} + var(--_field-inset-block-start, 0px)); } +/// Starts the animation `UiBrowserFill` listens to while the browser autofills +/// a field marked `uiBrowserFill`. Include once, at the root of the stylesheet +/// of the component whose template holds that field. +@mixin browser-fill { + @keyframes ui-browser-fill { + from { opacity: 1; } + } + + .ui-browser-fill:-webkit-autofill { animation: ui-browser-fill 0s; } +} + /// Bare native /