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 `
+
+
+ @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 ``. Every `KbqFormFieldControl` that can be autofilled
+ * exposes the result as its `autofilled` member, which the form field reads to toggle
+ * `kbq-form-field_autofilled`.
+ *
+ * The CDK detects autofill by running a zero-length keyframe animation on `:-webkit-autofill` and
+ * listening for `animationstart`; `monitor()` returns EMPTY off the browser platform, so this needs no
+ * `Platform` guard.
+ */
+export const kbqInjectAutofilled = (): Signal => {
+ const elementRef = inject>(ElementRef);
+ const autofillMonitor = inject(AutofillMonitor);
+ const destroyRef = inject(DestroyRef);
+ const autofilled = signal(false);
+
+ autofillMonitor
+ .monitor(elementRef)
+ .pipe(takeUntilDestroyed(destroyRef))
+ .subscribe(({ isAutofilled }) => autofilled.set(isAutofilled));
+
+ // `stopMonitoring()` is the load-bearing teardown, not a duplicate of `takeUntilDestroyed()` above:
+ // `AutofillMonitor` is `providedIn: 'root'`, so dropping only the subscription would leave the
+ // element registered with the app-lifetime service and its marker classes on the DOM node.
+ destroyRef.onDestroy(() => autofillMonitor.stopMonitoring(elementRef));
+
+ return autofilled.asReadonly();
+};
diff --git a/packages/components/core/common-behaviors/index.ts b/packages/components/core/common-behaviors/index.ts
index 46e7052216..5f1173a499 100644
--- a/packages/components/core/common-behaviors/index.ts
+++ b/packages/components/core/common-behaviors/index.ts
@@ -1,5 +1,6 @@
import { InjectionToken } from '@angular/core';
+export * from './autofill';
export * from './checkable';
export * from './checkbox';
export * from './clipboard';
diff --git a/packages/components/form-field/__screenshots__/04-dark.png b/packages/components/form-field/__screenshots__/04-dark.png
new file mode 100644
index 0000000000..1962650246
Binary files /dev/null and b/packages/components/form-field/__screenshots__/04-dark.png differ
diff --git a/packages/components/form-field/__screenshots__/04-light.png b/packages/components/form-field/__screenshots__/04-light.png
new file mode 100644
index 0000000000..aa15b4ae66
Binary files /dev/null and b/packages/components/form-field/__screenshots__/04-light.png differ
diff --git a/packages/components/form-field/__screenshots__/05-dark.png b/packages/components/form-field/__screenshots__/05-dark.png
new file mode 100644
index 0000000000..2c68b3f6dc
Binary files /dev/null and b/packages/components/form-field/__screenshots__/05-dark.png differ
diff --git a/packages/components/form-field/__screenshots__/05-light.png b/packages/components/form-field/__screenshots__/05-light.png
new file mode 100644
index 0000000000..20f5a9a34a
Binary files /dev/null and b/packages/components/form-field/__screenshots__/05-light.png differ
diff --git a/packages/components/form-field/_form-field-theme.scss b/packages/components/form-field/_form-field-theme.scss
index e3c4638f84..4114d09b52 100644
--- a/packages/components/form-field/_form-field-theme.scss
+++ b/packages/components/form-field/_form-field-theme.scss
@@ -22,7 +22,13 @@
.kbq-input,
.kbq-tag-input,
.kbq-textarea {
- color: var(--kbq-form-field-#{$state-name}-text);
+ // Published for the autofill block at the bottom of this file. The browser forces `color` on an
+ // autofilled control with a UA `!important` rule, so that block repaints through
+ // `-webkit-text-fill-color` and has to know what *this* state wanted. Declared in the same rule
+ // as the `color` that reads it, so one cascade decides both and they cannot drift apart.
+ --kbq-form-field-current-text: var(--kbq-form-field-#{$state-name}-text);
+
+ color: var(--kbq-form-field-current-text);
&::placeholder {
color: var(--kbq-form-field-#{$state-name}-placeholder);
@@ -42,22 +48,24 @@
.kbq-form-field {
@include _kbq-form-field-state(default);
- & .kbq-input,
- & .kbq-tag-input {
- //https://css-tricks.com/almanac/selectors/a/autofill/
- &:-webkit-autofill,
- &:-webkit-autofill:hover,
- &:-webkit-autofill:focus {
- // set as transparent to not override container background-color;
- --kbq-form-field-states-autofill-background: var(--kbq-background-transparent);
- -webkit-box-shadow: inset 0 0 0 40rem var(--kbq-form-field-states-autofill-background);
- -webkit-text-fill-color: var(--kbq-form-field-states-autofill-text);
- caret-color: var(--kbq-form-field-states-autofill-text);
-
- /* hide browser default autofill background, no matter what background color set */
- transition: background-color 5000s ease-in-out;
- background-color: var(--kbq-background-transparent) !important;
- }
+ // Autofill is the weakest state: it only says "the browser filled this in", and any real state
+ // the field is in has to win over it. `:where()` contributes zero specificity, so everything
+ // this emits is (0,2,0) — the same as `default` above, which it beats on source order alone,
+ // and below `states-error` / `states-disabled` (0,3,0) and `states-focused` (0,5,0), which beat
+ // it on specificity alone. No `!important` anywhere, and no other state has to know autofill
+ // exists. Keep this block directly after `default`.
+ // Two arms on purpose. `:has()` is what users see: it matches in the same style pass the browser
+ // fills the field, so the tint appears with the value. `kbq-form-field_autofilled` arrives a
+ // frame or two later — `AutofillMonitor` waits for `animationstart`, then a change-detection
+ // pass — which is too late to paint but is what carries the state into TypeScript and is the
+ // only handle a test has, since `:autofill` cannot be triggered synthetically. Both arms set the
+ // same declarations, so the late one is a no-op repaint.
+ // `:where()` is forgiving, so a browser without `:has()` silently keeps the class arm.
+ &:where(
+ .kbq-form-field_autofilled,
+ :has(:is(.kbq-input, .kbq-tag-input, .kbq-textarea):is(:autofill, :-webkit-autofill))
+ ) {
+ @include _kbq-form-field-state(states-autofill);
}
// Invalid by control `ErrorStateMatcher`
@@ -108,13 +116,6 @@
color: var(--kbq-form-field-label-color);
}
- // todo quick fix for bug DS-4060. Technical debt DS-4096
- & .kbq-form-field__container:has(:is(.kbq-input, .kbq-tag-input):-webkit-autofill),
- & .kbq-form-field__container:has(:is(.kbq-input, .kbq-tag-input):-webkit-autofill:hover),
- & .kbq-form-field__container:has(:is(.kbq-input, .kbq-tag-input):-webkit-autofill:focus) {
- background-color: var(--kbq-form-field-states-autofill-background) !important;
- }
-
&.kbq-disabled {
@include _kbq-form-field-state(states-disabled);
@@ -134,6 +135,40 @@
.kbq-form-field__hint {
@include kbq-form-field-hint-theme();
}
+
+ // The browser paints its own background and forces `color` on an autofilled control, both with
+ // UA `!important` declarations that an author declaration cannot outrank — important-author sits
+ // *below* important-UA in the cascade. See
+ // https://css-tricks.com/almanac/selectors/a/autofill/
+ // The background is suppressed rather than painted over. A transition wins where an author
+ // declaration cannot, because transitions sit *above* important-UA, so animating the property
+ // over an absurd duration parks its used value at the control's own transparent background and
+ // the container's tint shows through untouched.
+ // Painting over it — the usual `inset 0 0 0 40rem` trick — is wrong here: the state tokens are
+ // translucent (`--kbq-background-theme-less` is 10% opaque in the light theme), so an inset
+ // shadow in the same colour would land the tint a second time on top of the container's, making
+ // the control's rectangle visibly darker than the container's padding around it, and would still
+ // not hide the UA colour underneath. Keep the control transparent.
+ // The text does have to be repainted, and `-webkit-text-fill-color` can do it: it wins over
+ // `color` when glyphs are painted and the UA sets no such property. It reads back what the state
+ // cascade resolved above, so an autofilled control that is also disabled or invalid still gets
+ // that state's text colour.
+ & .kbq-input,
+ & .kbq-tag-input,
+ & .kbq-textarea {
+ // `:is()` takes a forgiving selector list, so every browser keeps whichever of the two it
+ // knows. A plain comma list would not: one unknown pseudo-class invalidates the whole list.
+ &:is(:autofill, :-webkit-autofill) {
+ // Longhands rather than the shorthand: the shorthand would also reset `transition-delay`
+ // and `transition-timing-function` on the control. Nothing transitions on these controls
+ // today, but the next thing that does should not break here.
+ transition-property: background-color;
+ transition-duration: 600000s;
+
+ -webkit-text-fill-color: var(--kbq-form-field-current-text, var(--kbq-form-field-default-text));
+ caret-color: var(--kbq-form-field-current-text, var(--kbq-form-field-default-text));
+ }
+ }
}
}
diff --git a/packages/components/form-field/autofill.spec.ts b/packages/components/form-field/autofill.spec.ts
new file mode 100644
index 0000000000..da482db59e
--- /dev/null
+++ b/packages/components/form-field/autofill.spec.ts
@@ -0,0 +1,210 @@
+import { Component, DebugElement, Type, ViewChild } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { FormsModule } from '@angular/forms';
+import { By } from '@angular/platform-browser';
+import { KbqInput, KbqInputModule, KbqInputPassword } from '@koobiq/components/input';
+import { KbqTagList, KbqTagsModule } from '@koobiq/components/tags';
+import { KbqTextarea, KbqTextareaModule } from '@koobiq/components/textarea';
+import { KbqFormField } from './form-field';
+import { KbqFormFieldModule } from './form-field.module';
+
+/**
+ * The CDK detects autofill by running a zero-length keyframe animation on `:-webkit-autofill` and
+ * listening for `animationstart`. jsdom implements neither the pseudo-class nor CSS animations, so the
+ * event is dispatched by hand — the same technique the CDK's own tests use. `AnimationEvent` is not
+ * constructible everywhere, hence the plain `Event` with `animationName` defined on it.
+ */
+const dispatchAutofill = (element: HTMLElement, isAutofilled: boolean): void => {
+ const event = new Event('animationstart');
+
+ Object.defineProperty(event, 'animationName', {
+ get: () => (isAutofilled ? 'cdk-text-field-autofill-start' : 'cdk-text-field-autofill-end')
+ });
+
+ element.dispatchEvent(event);
+};
+
+const createComponent = (component: Type): ComponentFixture => {
+ TestBed.configureTestingModule({ imports: [component] }).compileComponents();
+
+ const fixture = TestBed.createComponent(component);
+
+ fixture.detectChanges();
+
+ return fixture;
+};
+
+const getFormFieldElement = (debugElement: DebugElement): HTMLElement => {
+ return debugElement.query(By.directive(KbqFormField)).nativeElement;
+};
+
+// No `ngModel` here: with an `NgControl` attached, `KbqInput.disabled` reads the control instead of the
+// binding, and the `disabled` case below would never turn on.
+@Component({
+ selector: 'input-form-field',
+ imports: [KbqFormFieldModule, KbqInputModule],
+ template: `
+
+
+
+ `
+})
+class InputFormField {
+ @ViewChild(KbqInput, { static: true }) input: KbqInput;
+
+ disabled = false;
+}
+
+// `KbqInputPassword` is a directive of its own, not a subclass of `KbqInput` — and a password field is
+// the most common autofill target there is, so it gets its own case.
+@Component({
+ selector: 'password-form-field',
+ imports: [KbqFormFieldModule, KbqInputModule],
+ template: `
+
+
+
+ `
+})
+class PasswordFormField {
+ @ViewChild(KbqInputPassword, { static: true }) input: KbqInputPassword;
+}
+
+@Component({
+ selector: 'textarea-form-field',
+ imports: [KbqFormFieldModule, KbqTextareaModule, FormsModule],
+ template: `
+
+
+
+ `
+})
+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: `
+
+ `,
+ 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;