diff --git a/packages/components-dev/input/autofill.ts b/packages/components-dev/input/autofill.ts new file mode 100644 index 0000000000..2b5587cd5e --- /dev/null +++ b/packages/components-dev/input/autofill.ts @@ -0,0 +1,172 @@ +import { ChangeDetectionStrategy, Component, signal, ViewEncapsulation } from '@angular/core'; +import { AbstractControl, FormGroupDirective, FormsModule, NgForm } from '@angular/forms'; +import { KbqButtonModule } from '@koobiq/components/button'; +import { ErrorStateMatcher } from '@koobiq/components/core'; +import { KbqFormFieldModule } from '@koobiq/components/form-field'; +import { KbqInputModule } from '@koobiq/components/input'; +import { KbqTagsModule } from '@koobiq/components/tags'; +import { KbqTextareaModule } from '@koobiq/components/textarea'; + +/** Forces the error state on, so an autofilled field can be inspected while invalid. */ +class AlwaysErrorStateMatcher implements ErrorStateMatcher { + isErrorState(_control: AbstractControl | null, _form: FormGroupDirective | NgForm | null): boolean { + return true; + } +} + +/** + * Harness for the browser's autofill styling (#DS-4096). + * + * Autofill cannot be triggered synthetically — neither Playwright nor jsdom can put an element into + * `:autofill` — so this is the only way to see the real thing. It needs real `autocomplete` tokens on a + * real `
` with a submit button: the browser only offers an entry back after the form has been + * submitted once, and only over a secure context (`localhost` counts). + * + * What to check, per field: the container background matches the field's state and not the autofill + * tint whenever the field is disabled, invalid, in an overlay or has no borders; the text and caret match + * the state; the focus ring is fully visible with no notch; and nothing moves when the field is focused. + */ +@Component({ + selector: 'dev-autofill', + imports: [ + FormsModule, + KbqFormFieldModule, + KbqInputModule, + KbqTextareaModule, + KbqTagsModule, + KbqButtonModule + ], + template: ` +

Autofill (#DS-4096)

+

+ Fill the form and submit it once, then reload and pick the saved entry. Serve over + localhost + — the browser will not autofill an insecure origin. +

+ + +
+ + Username — default + + + + + Password — default + + + +
+ +
+ + Email — invalid (error must beat the autofill tint) + + + + + Phone — disabled after fill (disabled must beat the tint) + + +
+ +
+ + Organization — noBorders + + + + + Country — inOverlay (must stay on the card background) + + +
+ +
+ + Address — textarea + + + + + City — tag input (no autocomplete="off" here, unlike the e2e host) + + + + +
+ +
+ + + +
+
+ + + @if (lateFormShown()) { +
+ + Late field — first-paint autofill + + +
+ } + + @if (submitted()) { +

Submitted — reload the page and the browser should offer the entry back.

+ } + `, + styles: ` + .dev-autofill { + display: flex; + flex-direction: column; + gap: var(--kbq-size-l); + margin-bottom: var(--kbq-size-xxl); + } + + .dev-autofill__row { + display: flex; + gap: var(--kbq-size-l); + align-items: flex-start; + } + + .dev-autofill__row > * { + flex: 1; + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None +}) +export class DevAutofill { + protected readonly alwaysError = new AlwaysErrorStateMatcher(); + + protected readonly disabled = signal(false); + protected readonly submitted = signal(false); + protected readonly lateFormShown = signal(false); + + protected username = ''; + protected password = ''; + protected email = ''; + protected tel = ''; + protected organization = ''; + protected country = ''; + protected address = ''; + protected lateUsername = ''; +} diff --git a/packages/components-dev/input/module.ts b/packages/components-dev/input/module.ts index ff39a308eb..e1100a149e 100644 --- a/packages/components-dev/input/module.ts +++ b/packages/components-dev/input/module.ts @@ -30,6 +30,7 @@ import { } from 'packages/docs-examples/components/input'; import { startWith } from 'rxjs'; import { DevThemeToggle } from '../theme-toggle'; +import { DevAutofill } from './autofill'; @Component({ selector: 'dev-examples', @@ -68,7 +69,8 @@ export class DevDocsExamples {} DevDocsExamples, KbqNormalizeWhitespace, DevThemeToggle, - KbqToggleComponent + KbqToggleComponent, + DevAutofill ], templateUrl: './template.html', styleUrls: ['./styles.scss'], diff --git a/packages/components-dev/input/template.html b/packages/components-dev/input/template.html index 8a42a7c33f..4859683e01 100644 --- a/packages/components-dev/input/template.html +++ b/packages/components-dev/input/template.html @@ -1,6 +1,12 @@
+
+ +
+ +
+
make all controls disabled diff --git a/packages/components/core/common-behaviors/autofill.ts b/packages/components/core/common-behaviors/autofill.ts new file mode 100644 index 0000000000..54468406da --- /dev/null +++ b/packages/components/core/common-behaviors/autofill.ts @@ -0,0 +1,34 @@ +import { AutofillMonitor } from '@angular/cdk/text-field'; +import { DestroyRef, ElementRef, inject, Signal, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; + +/** + * Tracks whether the current control's value was filled in by the browser, as a signal. + * + * Call it from an injection context on a directive whose host is the element the browser actually + * fills — a text `` or a ` + + ` +}) +class TextareaFormField { + @ViewChild(KbqTextarea, { static: true }) textarea: KbqTextarea; + + value = ''; +} + +@Component({ + selector: 'tag-list-form-field', + imports: [KbqFormFieldModule, KbqInputModule, KbqTagsModule, FormsModule], + template: ` + + + + + + ` +}) +class TagListFormField { + @ViewChild(KbqTagList, { static: true }) tagList: KbqTagList; +} + +describe('autofill', () => { + describe('KbqInput', () => { + it('should report autofilled and add the form field class', () => { + const fixture = createComponent(InputFormField); + const { debugElement, componentInstance } = fixture; + const input = debugElement.query(By.css('input')).nativeElement; + + expect(componentInstance.input.autofilled()).toBe(false); + expect(getFormFieldElement(debugElement).classList).not.toContain('kbq-form-field_autofilled'); + + dispatchAutofill(input, true); + fixture.detectChanges(); + + expect(componentInstance.input.autofilled()).toBe(true); + expect(getFormFieldElement(debugElement).classList).toContain('kbq-form-field_autofilled'); + }); + + it('should stop reporting autofilled once the browser fill is undone', () => { + const fixture = createComponent(InputFormField); + const { debugElement, componentInstance } = fixture; + const input = debugElement.query(By.css('input')).nativeElement; + + dispatchAutofill(input, true); + fixture.detectChanges(); + dispatchAutofill(input, false); + fixture.detectChanges(); + + expect(componentInstance.input.autofilled()).toBe(false); + expect(getFormFieldElement(debugElement).classList).not.toContain('kbq-form-field_autofilled'); + }); + + it('should keep the form field disabled state alongside autofill', () => { + const fixture = createComponent(InputFormField); + const { debugElement, componentInstance } = fixture; + const input = debugElement.query(By.css('input')).nativeElement; + + dispatchAutofill(input, true); + componentInstance.disabled = true; + fixture.detectChanges(); + + const formField = getFormFieldElement(debugElement); + + // Both classes are present: the stylesheet resolves the conflict, not the component. + expect(formField.classList).toContain('kbq-form-field_autofilled'); + expect(formField.classList).toContain('kbq-disabled'); + }); + + it('should stop monitoring on destroy', () => { + const fixture = createComponent(InputFormField); + const input = fixture.debugElement.query(By.css('input')).nativeElement; + + dispatchAutofill(input, true); + fixture.detectChanges(); + + expect(input.classList).toContain('cdk-text-field-autofill-monitored'); + + fixture.destroy(); + + expect(input.classList).not.toContain('cdk-text-field-autofill-monitored'); + expect(input.classList).not.toContain('cdk-text-field-autofilled'); + }); + }); + + describe('KbqInputPassword', () => { + it('should report autofilled and add the form field class', () => { + const fixture = createComponent(PasswordFormField); + const { debugElement, componentInstance } = fixture; + const input = debugElement.query(By.css('input')).nativeElement; + + dispatchAutofill(input, true); + fixture.detectChanges(); + + expect(componentInstance.input.autofilled()).toBe(true); + expect(getFormFieldElement(debugElement).classList).toContain('kbq-form-field_autofilled'); + }); + }); + + describe('KbqTextarea', () => { + it('should report autofilled and add the form field class', () => { + const fixture = createComponent(TextareaFormField); + const { debugElement, componentInstance } = fixture; + const textarea = debugElement.query(By.css('textarea')).nativeElement; + + dispatchAutofill(textarea, true); + fixture.detectChanges(); + + expect(componentInstance.textarea.autofilled()).toBe(true); + expect(getFormFieldElement(debugElement).classList).toContain('kbq-form-field_autofilled'); + }); + }); + + describe('KbqTagList', () => { + it('should forward the autofilled state of the registered tag input', () => { + const fixture = createComponent(TagListFormField); + const { debugElement, componentInstance } = fixture; + const input = debugElement.query(By.css('input')).nativeElement; + + expect(componentInstance.tagList.autofilled()).toBe(false); + + dispatchAutofill(input, true); + fixture.detectChanges(); + + // The tag list is the `KbqFormFieldControl`, but the browser autofills the inner input. + expect(componentInstance.tagList.autofilled()).toBe(true); + expect(getFormFieldElement(debugElement).classList).toContain('kbq-form-field_autofilled'); + }); + }); +}); diff --git a/packages/components/form-field/e2e.playwright-spec.ts b/packages/components/form-field/e2e.playwright-spec.ts index 7bb71396a9..1c471530a7 100644 --- a/packages/components/form-field/e2e.playwright-spec.ts +++ b/packages/components/form-field/e2e.playwright-spec.ts @@ -1,4 +1,5 @@ import { expect, Locator, Page, test } from '@playwright/test'; +import { e2eEnableDarkTheme, e2eForceAutofillAll } from '../../e2e/utils'; test.describe('KbqFormFieldModule', () => { test.describe('E2eFormFieldAddons', () => { @@ -92,4 +93,88 @@ test.describe('KbqFormFieldModule', () => { }); } }); + + test.describe('E2eFormFieldAutofill', () => { + const getComponent = (page: Page) => page.getByTestId('e2eFormFieldAutofill'); + const getScreenshotTarget = (locator: Locator) => locator.getByTestId('e2eFormFieldAutofillTable'); + + /** How many rendered boxes stick out of the screenshot target, and would therefore be cropped. */ + const countOutsideTheTable = (target: Locator): Promise => + target.evaluate((table: HTMLElement) => { + const box = table.getBoundingClientRect(); + + return Array.from(table.querySelectorAll('*')) + .map((el) => el.getBoundingClientRect()) + .filter(({ width, height }) => width > 0 && height > 0) + .filter( + ({ left, top, right, bottom }) => + left < box.left - 0.5 || + top < box.top - 0.5 || + right > box.right + 0.5 || + bottom > box.bottom + 0.5 + ).length; + }); + + /** `rgb(…)` carries no alpha and is opaque; `rgba(…)` puts it fourth. */ + const controlBackgroundAlpha = (page: Page): Promise => + page.evaluate(() => { + const background = getComputedStyle(document.querySelector('.kbq-input')!).backgroundColor; + const parts = background.match(/rgba?\(([^)]+)\)/)?.[1].split(',') ?? []; + + return parts.length === 4 ? Number(parts[3]) : 1; + }); + + // The browser paints its own background on an autofilled control and the design system suppresses + // it with a 600000s `background-color` transition, because a transition is the only thing in the + // cascade that outranks the UA's `!important`. `animations: 'disabled'` — the project default — + // calls `finish()` on every animation with a finite end time, and 600000s is finite: the + // suppression would be fast-forwarded to its end value and every shot below would capture Chrome's + // own blue instead of the design system's tint. Measured, not guessed. Do not remove. + const screenshot = { animations: 'allow' } as const; + + const ROWS = 8; + const CONTROLS_PER_ROW = 5; + + // The selector the stylesheet itself keys on, not one that happens to fit the fixture: the tag + // input's host class is `kbq-tag-input` alone, and it only carries `kbq-input` here because the + // fixture writes `kbqInput` next to `kbqTagInputFor`. Drop that attribute and a fixture-shaped + // selector would silently stop forcing the tag column. + const CONTROLS = '[data-testid="e2eFormFieldAutofill"] :is(.kbq-input, .kbq-tag-input, .kbq-textarea)'; + + test('states', async ({ page }) => { + await page.goto('/E2eFormFieldAutofill'); + const component = getComponent(page); + const target = getScreenshotTarget(component); + + // The route is derived from the class name, so a rename yields a blank page and a perfectly + // stable, perfectly meaningless baseline. And a cell that falls outside the table's box is + // cropped by the locator without failing, so containment is asserted rather than assumed. + await expect(component).toBeVisible(); + await expect(target.locator('tbody > tr')).toHaveCount(ROWS); + await expect(target.locator('tbody > tr > td')).toHaveCount(ROWS * (CONTROLS_PER_ROW + 1)); + expect(await countOutsideTheTable(target)).toBe(0); + + await expect(target).toHaveScreenshot('04-light.png', screenshot); + await e2eEnableDarkTheme(page); + await expect(target).toHaveScreenshot('04-dark.png', screenshot); + }); + + test('autofilled states', async ({ page, context }) => { + await page.goto('/E2eFormFieldAutofill'); + const target = getScreenshotTarget(getComponent(page)); + + // Absolute, not a count of the same selector on the other side of CDP — that would only + // catch zero, and the dangerous number is 39. + expect(await e2eForceAutofillAll(page, context, CONTROLS)).toBe(ROWS * CONTROLS_PER_ROW); + + await expect(target).toHaveScreenshot('05-light.png', screenshot); + await e2eEnableDarkTheme(page); + await expect(target).toHaveScreenshot('05-dark.png', screenshot); + + // Self-check, after the last shot: the suppression is a *running* transition, so if anything + // ever finishes it — `animations: 'disabled'` above all — this reads 1 and both screenshots + // above captured Chrome's opaque autofill blue instead of the design system's tint. + expect(await controlBackgroundAlpha(page)).toBe(0); + }); + }); }); diff --git a/packages/components/form-field/e2e.ts b/packages/components/form-field/e2e.ts index 4c728a3c90..0989dec4b1 100644 --- a/packages/components/form-field/e2e.ts +++ b/packages/components/form-field/e2e.ts @@ -3,6 +3,8 @@ import { FormsModule } from '@angular/forms'; import { KbqButtonModule } from '@koobiq/components/button'; import { KbqIconModule } from '@koobiq/components/icon'; import { KbqInputModule } from '@koobiq/components/input'; +import { KbqTagsModule } from '@koobiq/components/tags'; +import { KbqTextareaModule } from '@koobiq/components/textarea'; @Component({ selector: 'e2e-form-field-group', @@ -139,3 +141,149 @@ export class E2eFormFieldAddons { protected numberValue = 10; protected readonly showStepper = signal(false); } + +/** One row of the autofill matrix: the field state every control in that row is rendered in. */ +type AutofillState = { + name: string; + focused?: boolean; + error?: boolean; + disabled?: boolean; + inOverlay?: boolean; + noBorders?: boolean; + /** + * Fakes `kbq-form-field_autofilled`, the class `AutofillMonitor` adds once it sees the fill. Every + * other row is driven through the real `:autofill` pseudo-class by the spec; this row is the only + * cover the TypeScript-driven arm gets, because a forced pseudo-class writes no value and fires no + * `animationstart`, so the monitor never reacts to it. + */ + monitored?: boolean; +}; + +/** The controls a browser can autofill inside a form field. */ +type AutofillControl = 'input' | 'password' | 'number' | 'textarea' | 'tags'; + +/** + * Every field state crossed with every control that can be autofilled. + * + * The spec screenshots this twice — once as-is, once with `:autofill` forced on every control over CDP — + * and the pair is the whole point. Autofill is the weakest state, so the two shots may differ only where + * the field has no stronger state to show, and the control must never look different from the container + * around it: a translucent tint painted on both would appear here as a darker rectangle inside the field, + * which is exactly the regression this fixture exists to catch (#DS-4096). + */ +@Component({ + selector: 'e2e-form-field-autofill', + imports: [KbqInputModule, KbqTextareaModule, KbqTagsModule], + template: ` + + + @for (state of states; track state.name) { + + + + @for (control of controls; track control) { + + } + + } + +
{{ state.name }} + + + + @switch (control) { + @case ('input') { + + } + @case ('password') { + + } + @case ('number') { + + + } + @case ('textarea') { + + } + @case ('tags') { + + Tag + + + } + } + + @if (control === 'password') { + + } + + @if (control === 'number') { + + } + +
+ `, + styles: ` + :host { + /* Deliberately no fixed height: a fixed one crops rows out of the baseline without failing. */ + td { + vertical-align: top; + padding: 4px; + width: 180px; + } + + td.e2e-row-name { + width: 120px; + padding-right: var(--kbq-size-m); + } + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: 'layout-margin-top-l layout-margin-bottom-l layout-column', + 'data-testid': 'e2eFormFieldAutofill' + } +}) +export class E2eFormFieldAutofill { + protected readonly controls: AutofillControl[] = ['input', 'password', 'number', 'textarea', 'tags']; + + protected readonly states: AutofillState[] = [ + { name: 'default' }, + { name: 'focused', focused: true }, + { name: 'error', error: true }, + { name: 'error + focused', error: true, focused: true }, + { name: 'disabled', disabled: true }, + { name: 'inOverlay', inOverlay: true }, + { name: 'noBorders', noBorders: true }, + { name: 'monitored (class)', monitored: true } + ]; +} diff --git a/packages/components/form-field/form-field-control.ts b/packages/components/form-field/form-field-control.ts index 96cf031ed5..7e60329326 100644 --- a/packages/components/form-field/form-field-control.ts +++ b/packages/components/form-field/form-field-control.ts @@ -1,3 +1,4 @@ +import { Signal } from '@angular/core'; import { NgControl } from '@angular/forms'; import { Observable } from 'rxjs'; @@ -36,6 +37,17 @@ export abstract class KbqFormFieldControl { /** Whether the control is in an error state. */ readonly errorState: boolean; + /** + * Whether the control's value was filled in by the browser. + * + * Implement it only where autofill is reachable: on a control that is itself a text input or a + * textarea, or — like `KbqTagList` — on a wrapper that forwards the state of the input it hosts. + * Leave it out on controls the browser never fills. It is a signal rather than a plain property + * because the form field reads it from a host binding and runs `OnPush`: a signal read there marks + * the form field dirty on its own. + */ + readonly autofilled?: Signal; + /** * An optional name for the control type that can be used to distinguish `kbq-form-field` elements * based on their control type. The form field will add a class, diff --git a/packages/components/form-field/form-field-tokens.scss b/packages/components/form-field/form-field-tokens.scss index ac8a0f4755..8146ba651d 100644 --- a/packages/components/form-field/form-field-tokens.scss +++ b/packages/components/form-field/form-field-tokens.scss @@ -36,7 +36,9 @@ --kbq-form-field-states-error-placeholder: var(--kbq-foreground-error-tertiary); --kbq-form-field-states-error-text: var(--kbq-foreground-error); --kbq-form-field-states-error-focused-focus-outline: var(--kbq-states-line-focus-error); - --kbq-form-field-states-autofill-border-color: var(--kbq-states-line-focus-theme); + /* Autofill only tints the background: it is a hint that the browser filled the field, not a state of + its own, and a themed border would be read as focus. The token is kept so it can be turned on. */ + --kbq-form-field-states-autofill-border-color: var(--kbq-line-contrast-fade); --kbq-form-field-states-autofill-background: var(--kbq-background-theme-less); --kbq-form-field-states-autofill-placeholder: var(--kbq-foreground-contrast-tertiary); --kbq-form-field-states-autofill-text: var(--kbq-foreground-contrast); diff --git a/packages/components/form-field/form-field.en.md b/packages/components/form-field/form-field.en.md index 3f4a5ed44f..ae9339be08 100644 --- a/packages/components/form-field/form-field.en.md +++ b/packages/components/form-field/form-field.en.md @@ -102,6 +102,37 @@ import { kbqFormFieldDefaultOptionsProvider } from '@koobiq/components/form-fiel }) ``` +### Autofill + +When the browser fills a field in, it is tinted with `--kbq-form-field-states-autofill-background`, so an autofilled field looks the same in every browser: the browser's own background is suppressed and its text color is repainted. The tint goes on the field, never on the control itself — the control stays transparent, so the tint is never applied twice. + +The tint is the weakest state: `focused`, an error, `disabled` and `inOverlay` all win over it. `noBorders` does not win over it, because it is not a state — it only makes the border transparent, so an autofilled `noBorders` field keeps the tint. The tint is applied in CSS, in the same style pass the browser fills the value, so it appears together with the text. The state is also tracked by the CDK's `AutofillMonitor`, which adds `kbq-form-field_autofilled` on `` and makes it readable from code — `KbqInput`, `KbqInputPassword`, `KbqTextarea` and `KbqTagInput` (forwarded by `KbqTagList`) expose an `autofilled` signal: + +```ts +@ViewChild(KbqInput) input: KbqInput; + +// ... + +const filledByBrowser = this.input.autofilled(); +``` + +To change the tint or switch it off, override the tokens on the field — no `!important` needed: + +```css +.my-form-field { + /* look exactly like a normally filled field */ + --kbq-form-field-states-autofill-background: var(--kbq-form-field-default-background); + --kbq-form-field-states-autofill-text: var(--kbq-form-field-default-text); +} +``` + +| Token | Applies to | +| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--kbq-form-field-states-autofill-background` | field background | +| `--kbq-form-field-states-autofill-border-color` | field border, the same as the default border unless overridden | +| `--kbq-form-field-states-autofill-text` | value text and caret | +| `--kbq-form-field-states-autofill-placeholder` | placeholder — normally hidden, since an autofilled field has a value. It shows if the value is cleared programmatically (for example by ``) while the browser still marks the field autofilled, so keep it as legible as the default placeholder | + ### Password input field `` is a component that adds a _"Show password"_ button for **filled** `` fields inside the `` component. diff --git a/packages/components/form-field/form-field.ru.md b/packages/components/form-field/form-field.ru.md index b87dddba02..6fdf826840 100644 --- a/packages/components/form-field/form-field.ru.md +++ b/packages/components/form-field/form-field.ru.md @@ -109,6 +109,37 @@ import { kbqFormFieldDefaultOptionsProvider } from '@koobiq/components/form-fiel }) ``` +### Автозаполнение + +Когда браузер заполняет поле, оно подсвечивается цветом `--kbq-form-field-states-autofill-background`, поэтому поле с автозаполнением выглядит одинаково во всех браузерах: собственный фон браузера подавляется, а цвет текста перекрашивается. Подсветка накладывается на поле, но не на сам контрол — контрол остаётся прозрачным, поэтому цвет не накладывается дважды. + +Подсветка — самое слабое состояние: `focused`, ошибка, `disabled` и `inOverlay` перекрывают её. `noBorders` подсветку не перекрывает, потому что это не состояние: он лишь делает рамку прозрачной, поэтому поле с `noBorders` сохраняет подсветку. Подсветка применяется средствами CSS, в том же проходе стилей, в котором браузер подставляет значение, поэтому она появляется вместе с текстом. Состояние также отслеживает `AutofillMonitor` из CDK: он добавляет класс `kbq-form-field_autofilled` на `` и делает состояние доступным из кода — `KbqInput`, `KbqInputPassword`, `KbqTextarea` и `KbqTagInput` (доступен через `KbqTagList`) предоставляют сигнал `autofilled`: + +```ts +@ViewChild(KbqInput) input: KbqInput; + +// ... + +const filledByBrowser = this.input.autofilled(); +``` + +Чтобы изменить или отключить подсветку, переопределите токены на поле — `!important` не нужен: + +```css +.my-form-field { + /* выглядит так же, как обычное заполненное поле */ + --kbq-form-field-states-autofill-background: var(--kbq-form-field-default-background); + --kbq-form-field-states-autofill-text: var(--kbq-form-field-default-text); +} +``` + +| Токен | На что влияет | +| ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--kbq-form-field-states-autofill-background` | фон поля | +| `--kbq-form-field-states-autofill-border-color` | рамка поля, по умолчанию совпадает с обычной | +| `--kbq-form-field-states-autofill-text` | текст значения и каретка | +| `--kbq-form-field-states-autofill-placeholder` | плейсхолдер — обычно скрыт, так как значение уже подставлено. Он появится, если очистить значение программно (например, через ``), пока для браузера поле остаётся полем с автозаполнением, поэтому он должен читаться не хуже обычного | + ### Поле для ввода пароля `` - это компонент, который добавляет кнопку _"Показать пароль"_ для **заполненного** поля diff --git a/packages/components/form-field/form-field.scss b/packages/components/form-field/form-field.scss index 4079343f47..ff4ccb1819 100644 --- a/packages/components/form-field/form-field.scss +++ b/packages/components/form-field/form-field.scss @@ -115,27 +115,14 @@ &.cdk-focused, &.kbq-focused { z-index: 3; - - .kbq-input { - &:-webkit-autofill, - &:-webkit-autofill:hover, - &:-webkit-autofill:focus { - min-height: calc( - var(--kbq-form-field-size-height) - var(--kbq-form-field-size-border-width) * - 2 - var(--kbq-form-field-size-focus-outline-width) * 2 - ); - - --kbq-input-size-padding-vertical: calc( - var(--kbq-size-xs) - var(--kbq-form-field-size-border-width) - var( - --kbq-form-field-size-focus-outline-width - ) - ); - - margin: var(--kbq-form-field-size-border-width) 0; - } - } } + // No autofill geometry here on purpose. An autofilled control keeps its transparent background (see + // the autofill block in `_form-field-theme.scss`), so it cannot cover the focus ring the container + // draws as an inset shadow, and nothing has to be shrunk to make room for it. The + // min-height/padding/margin recalculation this block used to carry (DS-4950) existed only to keep an + // opaque mask off that ring; it shifted the text on focus and covered `.kbq-input` alone. + // The state theme resolves the border and the background through tokens, so overriding the tokens beats // the more specific state selectors without `!important`. // `.kbq-form-field_without-borders` is the pre-v20 name, kept until the `v20-upgrade` migration @@ -146,6 +133,7 @@ --kbq-form-field-states-focused-border-color: transparent; --kbq-form-field-states-error-border-color: transparent; --kbq-form-field-states-disabled-border-color: transparent; + --kbq-form-field-states-autofill-border-color: transparent; --kbq-form-field-states-focused-focus-outline: transparent; --kbq-form-field-states-error-focused-focus-outline: transparent; } @@ -155,6 +143,7 @@ --kbq-form-field-states-focused-background: var(--kbq-background-card); --kbq-form-field-states-error-background: var(--kbq-background-card); --kbq-form-field-states-disabled-background: var(--kbq-background-card); + --kbq-form-field-states-autofill-background: var(--kbq-background-card); } & + .kbq-password-hint { diff --git a/packages/components/form-field/form-field.ts b/packages/components/form-field/form-field.ts index 4a80291ef4..7457a71576 100644 --- a/packages/components/form-field/form-field.ts +++ b/packages/components/form-field/form-field.ts @@ -151,6 +151,7 @@ export const kbqFormFieldDefaultOptionsProvider = (options: KbqFormFieldDefaultO '[class.kbq-form-field_invalid]': 'invalid', '[class.kbq-disabled]': 'disabled', + '[class.kbq-form-field_autofilled]': 'autofilled', '[class.kbq-form-field_no-borders]': 'noBorders()', '[class.kbq-form-field_in-overlay]': 'inOverlay()', '[class.kbq-form-field_horizontal]': 'horizontal()', @@ -365,6 +366,14 @@ export class KbqFormField return !!this.control()?.disabled; } + /** + * Whether the form field control's value was filled in by the browser. Controls that cannot be + * autofilled do not implement `autofilled`, and report `false` here. + */ + get autofilled(): boolean { + return !!this.control()?.autofilled?.(); + } + /** Ids last written to the control's `aria-describedby`, to skip redundant DOM writes. */ private appliedDescribedByIds: string = ''; diff --git a/packages/components/input/e2e.playwright-spec.ts b/packages/components/input/e2e.playwright-spec.ts index 4ca27f6935..a175017375 100644 --- a/packages/components/input/e2e.playwright-spec.ts +++ b/packages/components/input/e2e.playwright-spec.ts @@ -1,5 +1,5 @@ import { expect, Locator, Page, test } from '@playwright/test'; -import { e2eEnableDarkTheme } from '../../e2e/utils'; +import { e2eEnableDarkTheme, e2eForceAutofill } from '../../e2e/utils'; test.describe('KbqInputModule', () => { test.describe('E2eInputStateAndStyle', () => { @@ -19,6 +19,107 @@ test.describe('KbqInputModule', () => { }); }); + /** + * Assertion-level cover for the `:has(:is(…):is(:autofill, :-webkit-autofill))` arm, which paints + * the tint in the same style pass the browser fills the value rather than waiting for change + * detection. The visual side of the same behaviour, across every control and every field state, + * lives in `E2eFormFieldAutofill` (form-field/e2e.playwright-spec.ts). + * + * Colours are compared against the same cell before forcing rather than hardcoded, so the tests + * survive a token change and only fail when the *relationship* between states breaks. + */ + test.describe('autofill', () => { + // Cells are named after their state. Two rows are plain `default`; both selectors below + // resolve in document order, so they consistently address the first of them. + const cellSelector = (state: string) => `[data-testid="e2eInputCell_${state}"]`; + + /** `rgb(…)` has no alpha channel and is fully opaque; `rgba(…)` carries it as the 4th part. */ + const alphaOf = (colour: string): number => { + const parts = colour.match(/rgba?\(([^)]+)\)/)?.[1].split(',') ?? []; + + return parts.length === 4 ? Number(parts[3]) : 1; + }; + + const readStyles = (page: Page, state: string) => + page.evaluate((selector) => { + const cell = document.querySelector(selector)!; + const control = cell.querySelector('.kbq-input')!; + const container = cell.querySelector('.kbq-form-field__container')!; + + return { + containerBackground: getComputedStyle(container).backgroundColor, + controlBackground: getComputedStyle(control).backgroundColor, + controlBoxShadow: getComputedStyle(control).boxShadow, + controlTextFill: getComputedStyle(control).webkitTextFillColor + }; + }, cellSelector(state)); + + const forceAutofill = (page: Page, context: Parameters[1], state: string) => + e2eForceAutofill(page, context, `${cellSelector(state)} .kbq-input`); + + test('tints the field through the real pseudo-class, not only through the class', async ({ + page, + context + }) => { + await page.goto('/E2eInputStateAndStyle'); + + const before = await readStyles(page, 'default'); + + await forceAutofill(page, context, 'default'); + + const after = await readStyles(page, 'default'); + + expect(after.containerBackground).not.toBe(before.containerBackground); + }); + + test('holds the browser background at zero alpha and paints nothing on the control', async ({ + page, + context + }) => { + await page.goto('/E2eInputStateAndStyle'); + await forceAutofill(page, context, 'default'); + + const { controlBackground, controlBoxShadow } = await readStyles(page, 'default'); + + // Chrome applies its own autofill background the moment the pseudo-class matches, and no + // author declaration can outrank it. The huge `transition-duration` parks the used value + // at alpha 0, so it never paints: the colour channels stay Chrome's (232, 240, 254) and + // only the alpha matters. Delete the suppression and this reads 1. + expect(alphaOf(controlBackground)).toBe(0); + + // And nothing of ours paints over it either: the tint tokens are translucent, so painting + // the control in the same colour would stack it on top of the container's and make the + // control visibly darker than the padding around it (#DS-4096). + expect(controlBoxShadow).toBe('none'); + }); + + test('loses to the error state', async ({ page, context }) => { + await page.goto('/E2eInputStateAndStyle'); + + const before = await readStyles(page, 'error'); + + await forceAutofill(page, context, 'error'); + + const after = await readStyles(page, 'error'); + + expect(after.containerBackground).toBe(before.containerBackground); + expect(after.controlTextFill).toBe(before.controlTextFill); + }); + + test('loses to the disabled state', async ({ page, context }) => { + await page.goto('/E2eInputStateAndStyle'); + + const before = await readStyles(page, 'disabled'); + + await forceAutofill(page, context, 'disabled'); + + const after = await readStyles(page, 'disabled'); + + expect(after.containerBackground).toBe(before.containerBackground); + expect(after.controlTextFill).toBe(before.controlTextFill); + }); + }); + test.describe('KbqInputPassword', () => { const getTestTable = (locator: Locator) => locator.getByTestId('e2eInputPasswordTable'); const getInputPasswordTestRow = (locator: Locator) => locator.getByTestId('e2eInputPasswordWithHints'); diff --git a/packages/components/input/e2e.ts b/packages/components/input/e2e.ts index 1b42e725b8..af657db3a8 100644 --- a/packages/components/input/e2e.ts +++ b/packages/components/input/e2e.ts @@ -5,7 +5,7 @@ import { PasswordRules } from '@koobiq/components/form-field'; import { KbqInputModule } from './input.module'; type InputStates = { - state: ('default' | 'focused' | 'disabled' | 'placeholder' | 'invalid' | 'autofill' | 'error' | 'ellipsis')[]; + state: ('default' | 'focused' | 'disabled' | 'placeholder' | 'invalid' | 'error' | 'ellipsis')[]; inputNumberHasSeparator?: boolean; }; @@ -30,7 +30,16 @@ class CustomErrorStateMatcher implements ErrorStateMatcher { @for (state of states; track $index) {
@for (cell of state; track $index) { -
+ +
this.registeredInput()?.autofilled?.() ?? false); + /** * Implemented as part of KbqFormFieldControl. * @docs-private @@ -415,6 +425,12 @@ export class KbqTagList /** The tag input to add more tags */ private tagInput: KbqTagTextControl; + /** + * The same control as `tagInput`, kept in a signal so `autofilled` can track it reactively — + * `registerInput()` runs after the first read. + */ + private readonly registeredInput = signal(undefined); + /** True when the next `tags.changes` emission is triggered by a UI action, not programmatic update. */ private pendingUIChange = false; @@ -548,6 +564,7 @@ export class KbqTagList */ registerInput(inputElement: KbqTagTextControl): void { this.tagInput = inputElement; + this.registeredInput.set(inputElement); // todo need rethink about it (#DS-3740) if (this.ngControl && inputElement.ngControl?.statusChanges) { diff --git a/packages/components/tags/tag-text-control.ts b/packages/components/tags/tag-text-control.ts index 344de8ff6d..b687bbf149 100644 --- a/packages/components/tags/tag-text-control.ts +++ b/packages/components/tags/tag-text-control.ts @@ -1,4 +1,5 @@ /** Interface for a text control that is used to drive interaction with a kbq-tag-list. */ +import { Signal } from '@angular/core'; import { NgControl } from '@angular/forms'; export interface KbqTagTextControl { @@ -10,6 +11,9 @@ export interface KbqTagTextControl { empty: boolean; + /** Whether the control's value was filled in by the browser. */ + autofilled?: Signal; + ngControl?: NgControl; focus(): void; diff --git a/packages/components/textarea/textarea.component.ts b/packages/components/textarea/textarea.component.ts index a471d820a3..e6c9824e92 100644 --- a/packages/components/textarea/textarea.component.ts +++ b/packages/components/textarea/textarea.component.ts @@ -22,7 +22,8 @@ import { CanUpdateErrorState, ErrorStateMatcher, KBQ_PARENT_ANIMATION_COMPONENT, - KBQ_WINDOW + KBQ_WINDOW, + kbqInjectAutofilled } from '@koobiq/components/core'; import { KbqFormFieldControl } from '@koobiq/components/form-field'; import { asapScheduler, observeOn, Subject } from 'rxjs'; @@ -97,6 +98,12 @@ export class KbqTextarea */ focused: boolean = false; + /** + * Implemented as part of KbqFormFieldControl. + * @docs-private + */ + readonly autofilled = kbqInjectAutofilled(); + /** * Implemented as part of KbqFormFieldControl. * @docs-private diff --git a/packages/e2e/routes.ts b/packages/e2e/routes.ts index 9a4b5faadd..8279cb1dcf 100644 --- a/packages/e2e/routes.ts +++ b/packages/e2e/routes.ts @@ -41,7 +41,12 @@ import { E2eFilterBarStates } from '../components/filter-bar/e2e'; import { E2eFlagStyles } from '../components/flag/e2e'; -import { E2eFormFieldAddons, E2eFormFieldGroup, E2eFormFieldset } from '../components/form-field/e2e'; +import { + E2eFormFieldAddons, + E2eFormFieldAutofill, + E2eFormFieldGroup, + E2eFormFieldset +} from '../components/form-field/e2e'; import { E2eIconStateAndStyle, E2eIconSvg } from '../components/icon/e2e'; import { E2eInlineEditActionButtons, @@ -149,6 +154,7 @@ const components = [ E2eFileUploadStateAndStyle, E2eFileUploadDropzone, E2eFormFieldAddons, + E2eFormFieldAutofill, E2eFormFieldGroup, E2eFormFieldset, E2eActionsPanelWithOverlayContainer, diff --git a/packages/e2e/utils/autofill.ts b/packages/e2e/utils/autofill.ts new file mode 100644 index 0000000000..5b8d0e6ff8 --- /dev/null +++ b/packages/e2e/utils/autofill.ts @@ -0,0 +1,68 @@ +import { BrowserContext, Page } from '@playwright/test'; + +/** + * Puts the element matching `selector` into `:autofill` for the duration of the test. + * + * The browser's own autofill cannot be triggered from a test: picking a suggestion happens in browser + * chrome, and the CDP `Autofill` domain that would do it is compiled into Chrome-branded builds only — + * `Schema.getDomains` on the Chromium Playwright bundles does not list it, so `Autofill.enable` fails + * with "wasn't found". `CSS.forcePseudoState` is the way in: it forces the pseudo-class at style + * resolution, `:has()` invalidates on it, and `Element.matches()` reports it. + * + * The forced state is enough to make Chrome apply its own autofill background too, so this covers the + * suppression of the UA styling as well, not only the selectors: read the control's `background-color` + * and it is Chrome's `rgb(232, 240, 254)` held at alpha 0. What it does not reproduce is the fill + * itself — no value is written and no `animationstart` fires, so `AutofillMonitor` stays quiet and + * `kbq-form-field_autofilled` is not added. Fake that class separately when the TypeScript-driven half + * is what is under test. + * + * Only the standard `autofill` spelling is accepted; passing `-webkit-autofill` silently forces nothing. + * Forcing replaces whatever was forced on that node before, so one call has to carry every pseudo-class + * the node needs. + * + * The session is deliberately left attached: the forced state lives with it, and detaching drops the + * pseudo-class again — silently, which turns every assertion after it into a test that passes because + * nothing is being styled. Playwright disposes the session when the context closes. + */ +export const e2eForceAutofill = async (page: Page, context: BrowserContext, selector: string): Promise => { + const session = await context.newCDPSession(page); + + await session.send('DOM.enable'); + await session.send('CSS.enable'); + + const { root } = await session.send('DOM.getDocument'); + const { nodeId } = await session.send('DOM.querySelector', { nodeId: root.nodeId, selector }); + + if (!nodeId) { + throw new Error(`e2eForceAutofill: nothing matches ${selector}`); + } + + await session.send('CSS.forcePseudoState', { nodeId, forcedPseudoClasses: ['autofill'] }); +}; + +/** + * The same, for every element matching `selector` — one session for all of them, since each node has to + * be forced individually and a session per node would be both slow and easy to leak. + * + * Throws when nothing matches: a screenshot of a matrix where the forcing quietly reached nothing looks + * exactly like a correct one, and would pass forever. + */ +export const e2eForceAutofillAll = async (page: Page, context: BrowserContext, selector: string): Promise => { + const session = await context.newCDPSession(page); + + await session.send('DOM.enable'); + await session.send('CSS.enable'); + + const { root } = await session.send('DOM.getDocument'); + const { nodeIds } = await session.send('DOM.querySelectorAll', { nodeId: root.nodeId, selector }); + + if (!nodeIds.length) { + throw new Error(`e2eForceAutofillAll: nothing matches ${selector}`); + } + + for (const nodeId of nodeIds) { + await session.send('CSS.forcePseudoState', { nodeId, forcedPseudoClasses: ['autofill'] }); + } + + return nodeIds.length; +}; diff --git a/packages/e2e/utils/index.ts b/packages/e2e/utils/index.ts index fbb1f0f0dd..59674a56be 100644 --- a/packages/e2e/utils/index.ts +++ b/packages/e2e/utils/index.ts @@ -1,2 +1,3 @@ +export * from './autofill'; export * from './overflow-shadow'; export * from './theme'; diff --git a/tools/cspell-locales/en.json b/tools/cspell-locales/en.json index 07acfc3d56..bea266a67b 100644 --- a/tools/cspell-locales/en.json +++ b/tools/cspell-locales/en.json @@ -6,6 +6,8 @@ "caseSensitive": false, "words": [ "actionbar", + "autofilled", + "autofills", "autoselect", "autotable", "behaviour", @@ -51,6 +53,7 @@ "stackblitz", "stylelintrc", "subfolders", + "textareas", "timepicker", "Topbar", "unclickable", diff --git a/tools/cspell-locales/ru.json b/tools/cspell-locales/ru.json index a110720438..6522f1a9a7 100644 --- a/tools/cspell-locales/ru.json +++ b/tools/cspell-locales/ru.json @@ -10,6 +10,7 @@ "words": [ "автодополнение", "автодополнения", + "автозаполнением", "автоокраски", "алерт", "алерта", diff --git a/tools/public_api_guard/components/core.api.md b/tools/public_api_guard/components/core.api.md index 59ec202267..bd3bc1835a 100644 --- a/tools/public_api_guard/components/core.api.md +++ b/tools/public_api_guard/components/core.api.md @@ -3006,6 +3006,9 @@ export class KbqHover { // @public export function kbqInjectA11yLocaleConfiguration(): Signal; +// @public +export const kbqInjectAutofilled: () => Signal; + // @public export const kbqInjectNativeElement: () => T; diff --git a/tools/public_api_guard/components/form-field.api.md b/tools/public_api_guard/components/form-field.api.md index 6094eaf5eb..f16091711b 100644 --- a/tools/public_api_guard/components/form-field.api.md +++ b/tools/public_api_guard/components/form-field.api.md @@ -114,6 +114,7 @@ export class KbqFieldsetItem { // @public export class KbqFormField extends KbqColorDirective implements AfterContentInit, AfterViewInit, OnDestroy, AfterContentChecked { + get autofilled(): boolean; // @deprecated canCleanerClearByEsc: boolean; get canShowCleaner(): boolean; @@ -174,6 +175,7 @@ export class KbqFormField extends KbqColorDirective implements AfterContentInit, // @public export abstract class KbqFormFieldControl { + readonly autofilled?: Signal; readonly controlType?: string; readonly disabled: boolean; readonly empty: boolean; diff --git a/tools/public_api_guard/components/input.api.md b/tools/public_api_guard/components/input.api.md index a267605958..8e2225e29b 100644 --- a/tools/public_api_guard/components/input.api.md +++ b/tools/public_api_guard/components/input.api.md @@ -67,6 +67,7 @@ export const KBQ_NUMBER_INPUT_VALUE_ACCESSOR: any; // @public (undocumented) export class KbqInput implements KbqFormFieldControl, OnChanges, OnDestroy, DoCheck, OnChanges, CanUpdateErrorState { constructor(); + readonly autofilled: i0.Signal; controlType: string; // (undocumented) defaultErrorStateMatcher: ErrorStateMatcher; @@ -146,6 +147,7 @@ export class KbqInputMono { // @public (undocumented) export class KbqInputPassword implements KbqFormFieldControl, OnChanges, OnDestroy, DoCheck, OnChanges, CanUpdateErrorState { constructor(); + readonly autofilled: i0.Signal; // (undocumented) readonly checkRule: Subject; // (undocumented) diff --git a/tools/public_api_guard/components/tags.api.md b/tools/public_api_guard/components/tags.api.md index d95ac27d11..4db0b38c3e 100644 --- a/tools/public_api_guard/components/tags.api.md +++ b/tools/public_api_guard/components/tags.api.md @@ -34,6 +34,7 @@ import { Observable } from 'rxjs'; import { OnChanges } from '@angular/core'; import { OnDestroy } from '@angular/core'; import { QueryList } from '@angular/core'; +import { Signal } from '@angular/core'; import { Subject } from 'rxjs'; // @public @@ -178,6 +179,7 @@ export class KbqTagInput implements KbqTagTextControl, OnChanges { readonly addOnPaste: _angular_core.InputSignalWithTransform; // (undocumented) autocompleteTrigger?: KbqAutocompleteTrigger | null | undefined; + readonly autofilled: _angular_core.Signal; blur(event: FocusEvent): void; get disabled(): boolean; set disabled(value: boolean); @@ -221,6 +223,7 @@ export interface KbqTagInputEvent { // @public (undocumented) export class KbqTagList implements KbqFormFieldControl, ControlValueAccessor, AfterContentInit, DoCheck, OnDestroy, CanUpdateErrorState, AfterViewInit { constructor(); + readonly autofilled: _angular_core.Signal; blur(): void; get canShowCleaner(): boolean; readonly change: _angular_core.OutputEmitterRef; diff --git a/tools/public_api_guard/components/textarea.api.md b/tools/public_api_guard/components/textarea.api.md index 1aae1a0452..db800aba18 100644 --- a/tools/public_api_guard/components/textarea.api.md +++ b/tools/public_api_guard/components/textarea.api.md @@ -31,6 +31,7 @@ export const KBQ_TEXTAREA_VALUE_ACCESSOR: InjectionToken<{ // @public (undocumented) export class KbqTextarea implements KbqFormFieldControl, OnInit, OnChanges, OnDestroy, DoCheck, CanUpdateErrorState { constructor(); + readonly autofilled: i0.Signal; get canGrow(): boolean; set canGrow(value: boolean); controlType: string;