diff --git a/packages/components/core/common-behaviors/autofill.ts b/packages/components/core/common-behaviors/autofill.ts new file mode 100644 index 0000000000..998593f387 --- /dev/null +++ b/packages/components/core/common-behaviors/autofill.ts @@ -0,0 +1,38 @@ +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.name, () => { + 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: autofill is orthogonal to the state, not weaker than it. + 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(); + + // `AutofillMonitor` is `providedIn: 'root'`, so without an explicit `stopMonitoring()` + // the element stays registered with an app-lifetime service and keeps these classes. + expect(input.classList).not.toContain('cdk-text-field-autofill-monitored'); + expect(input.classList).not.toContain('cdk-text-field-autofilled'); + }); + }); + + describe(KbqInputPassword.name, () => { + 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.name, () => { + 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.name, () => { + 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..3cd5ca0fdf 100644 --- a/packages/components/form-field/e2e.playwright-spec.ts +++ b/packages/components/form-field/e2e.playwright-spec.ts @@ -1,4 +1,11 @@ import { expect, Locator, Page, test } from '@playwright/test'; +import { + e2eClearForcedAutofill, + e2eEnableDarkTheme, + e2eForceAutofill, + e2eResolveCssValue, + e2eRunningAnimations +} from '../../e2e/utils'; test.describe('KbqFormFieldModule', () => { test.describe('E2eFormFieldAddons', () => { @@ -92,4 +99,712 @@ test.describe('KbqFormFieldModule', () => { }); } }); + + /** + * Browser autofill (#DS-4096). + * + * The appearance of an autofilled field is entirely CSS, and jsdom implements neither + * `:-webkit-autofill` nor CSS animations, so none of it can be reached from a unit test. It had + * also regressed five times (DS-2873, DS-4060, DS-4667, DS-4950, DS-4958) with no automated + * coverage at all. The `autofilled` signal is the one part that unit-tests cleanly, in + * `autofill.spec.ts`. + * + * `:-webkit-autofill` cannot be produced synthetically — choosing a suggestion happens in + * browser chrome, and the CDP `Autofill` domain is Chrome-branded only, absent from the + * Chromium Playwright bundles — so these tests force the pseudo-class over CDP. Chrome applies + * its own autofill background to a forced element too, so the suppression of that background is + * genuinely exercised, and the CDK's detection keyframe keys on the same pseudo-class, so the + * signal is live here as well. + * + * What forcing does *not* reproduce is the browser repainting its own autofill popup over the + * author styles, which is why `color-scheme` matters and why it is asserted here rather than + * assumed. + */ + test.describe('E2eFormFieldAutofill', () => { + const getComponent = (page: Page) => page.getByTestId('e2eFormFieldAutofill'); + const getField = (page: Page, cell: string) => page.getByTestId(cell); + const getContainer = (field: Locator) => field.locator('.kbq-form-field__container'); + const getControl = (field: Locator) => field.locator('.kbq-input, .kbq-tag-input, .kbq-textarea'); + + /** Every state the state matrix renders a plain input in, in the order it renders them. */ + const STATES = [ + 'default', + 'focused', + 'kbqFocused', + 'error', + 'errorFocused', + 'disabled', + 'noBorders', + 'inOverlay' + ] as const; + + /** Every control the control matrix renders, in the order it renders them. */ + const CONTROLS = [ + 'input', + 'password', + 'number', + 'timepicker', + 'tagInput', + 'tagInputBare', + 'textarea', + 'select' + ] as const; + + /** `rgb(…)` carries no alpha and is opaque; `rgba(…)` puts it fourth. */ + const alphaOf = (color: string): number => { + const parts = color.match(/rgba?\(([^)]+)\)/)?.[1].split(',') ?? []; + + return parts.length === 4 ? Number(parts[3]) : 1; + }; + + const resolveToken = (field: Locator, token: string): Promise => + e2eResolveCssValue(field, 'background-color', `var(${token})`); + + /** + * The tint as the browser renders it. + * + * It is painted into `background-image` rather than `background-color` so that the state + * keeps ownership of the latter — which is what makes autofill impossible to put in + * conflict with error, disabled or the overlay. + */ + const tintLayer = async (field: Locator): Promise => { + const tint = await resolveToken(field, '--kbq-form-field-states-autofill-background'); + + return `linear-gradient(${tint}, ${tint})`; + }; + + /** + * Forces autofill on one cell's control and hands back the pieces every test needs. + * + * Scoped to the cell rather than the page: the matrix renders the same control in eight + * states, and forcing all of them would make every assertion below depend on cells it is + * not about. + */ + const autofill = async (page: Page, cell: string) => { + const field = getField(page, cell); + const forced = await e2eForceAutofill(page, `[data-testid="${cell}"] :is(.kbq-input, .kbq-tag-input)`); + + return { field, container: getContainer(field), control: getControl(field), forced }; + }; + + type Rule = { index: number; selector: string; text: string }; + + /** + * Every style rule in document order, flattened across all stylesheets. + * + * `adoptedStyleSheets` as well as `document.styleSheets`: a rule that was never found reads + * exactly like a rule that does not exist, so anything asserting "no rule does X" has to + * look everywhere a rule can live — and has to assert it found the rules at all before the + * negative means anything. + */ + const readRules = (page: Page): Promise => + page.evaluate(() => { + const rules: { index: number; selector: string; text: string }[] = []; + const sheets = [...Array.from(document.styleSheets), ...document.adoptedStyleSheets]; + + for (const sheet of sheets) { + // A cross-origin stylesheet throws on access; none is expected here, but one + // would otherwise take the whole group down with a security error. + let cssRules: CSSRuleList; + + try { + cssRules = sheet.cssRules; + } catch { + continue; + } + + for (const rule of Array.from(cssRules)) { + if (rule instanceof CSSStyleRule) { + rules.push({ index: rules.length, selector: rule.selectorText, text: rule.cssText }); + } + } + } + + return rules; + }); + + test.beforeEach(async ({ page }) => page.goto('/E2eFormFieldAutofill')); + + test('the matrix renders every cell the tests address', async ({ page }) => { + // The route is the class name with Angular's leading underscore stripped, so a rename + // yields a blank page — and a blank page is a perfectly stable, perfectly meaningless + // baseline. Assert the fixture exists before anything trusts it. + await expect(getComponent(page)).toBeVisible(); + + for (const state of STATES) { + await expect(getField(page, `state_${state}`)).toBeVisible(); + } + + for (const control of CONTROLS) { + await expect(getField(page, `control_${control}_default`)).toBeVisible(); + await expect(getField(page, `control_${control}_focused`)).toBeVisible(); + } + }); + + test.describe('container background', () => { + for (const state of STATES) { + test(`is tinted in the ${state} state`, async ({ page }) => { + const { field, container } = await autofill(page, `state_${state}`); + + await expect(container).toHaveCSS('background-image', await tintLayer(field)); + }); + } + + // The tint is a layer, so every state keeps the background it resolved and the tint + // composites over it. That is the whole of the DS-4096 fix: painting into + // `background-image` leaves `background-color` to the state, which makes the two + // impossible to put in conflict — where the old rule needed `!important` to land, and + // then out-ranked error, disabled and the overlay with it. + for (const [state, token] of [ + ['default', '--kbq-form-field-default-background'], + ['focused', '--kbq-form-field-states-focused-background'], + ['error', '--kbq-form-field-states-error-background'], + ['errorFocused', '--kbq-form-field-states-error-background'], + ['disabled', '--kbq-form-field-states-disabled-background'] + ] as const) { + test(`keeps the ${state} background under the tint`, async ({ page }) => { + const { field, container } = await autofill(page, `state_${state}`); + + await expect(container).toHaveCSS('background-color', await resolveToken(field, token)); + }); + } + + test('keeps the card background in an overlay', async ({ page }) => { + const { field, container } = await autofill(page, 'state_inOverlay'); + + // `form-field.scss` remaps the state backgrounds to the card colour for + // `_in-overlay` and never had to know autofill exists. + await expect(container).toHaveCSS( + 'background-color', + await resolveToken(field, '--kbq-background-card') + ); + await expect(container).toHaveCSS('background-image', await tintLayer(field)); + }); + + test('no autofill rule declares !important', async ({ page }) => { + // The `!important` at the heart of DS-4060 is what made autofill out-rank every + // state. Nothing about the layer approach needs it back. + await autofill(page, 'state_error'); + + const autofillRules = (await readRules(page)).filter((rule) => rule.selector.includes('autofill')); + const important = autofillRules + .filter((rule) => rule.text.includes('!important')) + .map((rule) => rule.selector); + + // The negative below is only worth anything once the rules have been found: an + // empty set — a future bundler moving the styles somewhere `readRules` does not + // look — reports "no `!important`" exactly as loudly as a clean stylesheet does. + expect(autofillRules.length).toBeGreaterThan(0); + expect(important).toEqual([]); + }); + + test('leaves the border to the state', async ({ page }) => { + // Autofill contributes no border of its own — the token that would have given it + // one aliased the focus colour, which made a merely filled field read as focused. + const plain = await autofill(page, 'state_default'); + const invalid = await autofill(page, 'state_error'); + + await expect(plain.container).toHaveCSS( + 'border-top-color', + await resolveToken(plain.field, '--kbq-form-field-default-border-color') + ); + await expect(invalid.container).toHaveCSS( + 'border-top-color', + await resolveToken(invalid.field, '--kbq-form-field-states-error-border-color') + ); + }); + + test('the local token wins over the one the npm package publishes', async ({ page }) => { + // `@koobiq/design-tokens` declares the same four names globally on `.kbq-light` + // with different values. Both declarations are (0,1,0); the local one wins only + // because it sits on the form field itself while the package's is inherited from + // `body`. Nothing enforces that, so it is worth an assertion. + const field = getField(page, 'state_default'); + const onField = await resolveToken(field, '--kbq-form-field-states-autofill-background'); + const onBody = await resolveToken(page.locator('body'), '--kbq-form-field-states-autofill-background'); + + expect(onField).not.toBe(onBody); + }); + }); + + test.describe('control fill suppression', () => { + test('the control stays transparent so the container tint shows through', async ({ page }) => { + const { control } = await autofill(page, 'state_default'); + + // Chrome paints its own background on an autofilled control with a UA `!important` + // rule that an author declaration cannot out-rank. What does out-rank it is a + // running transition, which is why the stylesheet parks `background-color` on a + // 600000s one. The used value is Chrome's `rgb(232, 240, 254)` held at alpha 0 — so + // assert the alpha, not the colour: immediately after forcing it still reads + // `rgba(0, 0, 0, 0)` and only settles on the blue a frame later. + expect(alphaOf(await control.evaluate((el) => getComputedStyle(el).backgroundColor))).toBe(0); + }); + + test('the suppression is a running transition, not a finished one', async ({ page }) => { + const { control } = await autofill(page, 'state_default'); + + expect(await e2eRunningAnimations(control)).toContainEqual(['background-color', 600_000_000]); + }); + + for (const [state, token] of [ + ['default', '--kbq-form-field-default-text'], + ['error', '--kbq-form-field-states-error-text'], + ['disabled', '--kbq-form-field-states-disabled-text'] + ] as const) { + test(`repaints the text in the ${state} colour`, async ({ page }) => { + const { field, control } = await autofill(page, `state_${state}`); + const expected = await resolveToken(field, token); + + // The UA forces `color` on an autofilled control, so the state repaints through + // `-webkit-text-fill-color`, which wins over `color` when glyphs are drawn. The + // rule is emitted once per state inside `_kbq-form-field-state()`, so the + // ordinary cascade picks the right one and an autofilled invalid field still + // prints its error colour. + await expect(control).toHaveCSS('-webkit-text-fill-color', expected); + // No baseline can show the caret: `toHaveScreenshot` defaults to `caret: 'hide'` + // and sets `caret-color: transparent !important` inline for the capture. + await expect(control).toHaveCSS('caret-color', expected); + }); + } + + for (const [theme, scheme, uaColor] of [ + ['light', 'light', 'rgb(0, 0, 0)'], + ['dark', 'dark', 'rgb(255, 255, 255)'] + ] as const) { + test(`tells the browser the palette is ${theme}`, async ({ page }) => { + if (theme === 'dark') await e2eEnableDarkTheme(page); + + const { control } = await autofill(page, 'state_default'); + + // The theme is a class, and a class tells the browser nothing. Without + // `color-scheme` Chrome renders every surface it paints itself from the light + // palette, which is how a dark-themed field ends up with a light autofill + // highlight and black text the moment the autofill popup reopens. + // + // Declared for the whole theme in `kbq-core-theme()` and reaching the control by + // inheritance, so this asserts the property where it matters rather than where + // it is written. + // + // `color` is the readout: the UA forces it on an autofilled control, and which + // colour it forces comes from the used `color-scheme`. It is the one thing here + // that reflects the palette Chrome would paint with, since that paint never + // goes through the cascade and nothing else in the CSSOM shows it. + await expect(control).toHaveCSS('color-scheme', scheme); + await expect(control).toHaveCSS('color', uaColor); + }); + } + + test('a light subtree inside a dark application stays light', async ({ page }) => { + await e2eEnableDarkTheme(page); + + const { field, control } = await autofill(page, 'state_default'); + + await expect(control).toHaveCSS('color-scheme', 'dark'); + + // The regression this guards: scoped to the component, the base and the dark rule + // landed on the same specificity, so the dark one won on source order under any + // `.kbq-dark` ancestor however near a `.kbq-light` was. Declared once per theme + // class, inheritance picks the nearest instead. Nested themes are a supported + // scenario — filter-bar and the shadow-DOM toast dev app both do it. + await field.evaluate((el) => el.classList.add('kbq-light')); + + await expect(control).toHaveCSS('color-scheme', 'light'); + }); + + test('paints nothing of its own on the control', async ({ page }) => { + const { control } = await autofill(page, 'state_default'); + + // The control contributes no background and no inset shadow — the tint belongs to + // the container. A second, translucent coat here would make the control's rectangle + // visibly darker than the container's padding around it. + await expect(control).toHaveCSS('box-shadow', 'none'); + expect(alphaOf(await control.evaluate((el) => getComputedStyle(el).backgroundColor))).toBe(0); + }); + }); + + test.describe('focus ring', () => { + test('survives an autofilled control', async ({ page }) => { + const { container, control } = await autofill(page, 'state_focused'); + + // DS-4950 shrank the autofilled control by twice the outline width because its + // background painted over the ring. Nothing paints over it now: the control is + // transparent and the ring is an inset shadow on the container, drawn above the + // tint. The geometry compensation is gone, and this is what replaces it. + expect(await container.evaluate((el) => getComputedStyle(el).boxShadow)).not.toBe('none'); + expect(alphaOf(await control.evaluate((el) => getComputedStyle(el).backgroundColor))).toBe(0); + }); + + test('the control keeps one height focused or not', async ({ page }) => { + const focused = await autofill(page, 'state_focused'); + const plain = await autofill(page, 'state_default'); + + // With the compensation deleted there is nothing left to compensate for, so the + // control no longer resizes on focus and the text no longer has to be nudged back. + for (const { control } of [focused, plain]) { + await expect(control).toHaveCSS('min-height', '30px'); + await expect(control).toHaveCSS('margin-top', '0px'); + await expect(control).toHaveCSS('padding-top', '5px'); + } + + expect((await focused.container.boundingBox())!.height).toBeCloseTo( + (await plain.container.boundingBox())!.height, + 1 + ); + }); + }); + + test.describe('controls the stylesheet reaches', () => { + test('a textarea is treated like every other control', async ({ page }) => { + const field = getField(page, 'control_textarea_default'); + const control = getControl(field); + + await e2eForceAutofill(page, '[data-testid="control_textarea_default"] .kbq-textarea'); + + // `.kbq-textarea` was in `_kbq-form-field-state()`'s colour list and in none of the + // autofill rules, so nothing suppressed the UA background: an autofilled textarea + // painted Chrome's raw opaque blue, which in the dark theme was a near-white block + // with dark text. It is now suppressed the same way as the others. + expect(alphaOf(await control.evaluate((el) => getComputedStyle(el).backgroundColor))).toBe(0); + expect(await e2eRunningAnimations(control)).toContainEqual(['background-color', 600_000_000]); + await expect(getContainer(field)).toHaveCSS('background-image', await tintLayer(field)); + }); + + test('a select has no native control to autofill', async ({ page }) => { + // The trigger is a div, so `:-webkit-autofill` can never match inside it. Asserted + // rather than assumed, because a future select that renders an input would silently + // join the set of things this suite does not cover. + await expect(getField(page, 'control_select_default').locator('input')).toHaveCount(0); + }); + + test('every text control the rules do reach carries .kbq-input', async ({ page }) => { + // The three blocks key on classes, not on directives, so this is what decides + // whether a control is covered. Datepicker is checked here rather than rendered in + // the matrix: a KbqDatepicker throws when a second input binds to it. + for (const control of ['input', 'password', 'number', 'timepicker'] as const) { + await expect(getField(page, `control_${control}_default`).locator('.kbq-input')).toHaveCount(1); + } + + await expect( + getField(page, 'control_tagInput_default').locator('.kbq-input.kbq-tag-input') + ).toHaveCount(1); + await expect(getField(page, 'control_tagInputBare_default').locator('.kbq-input')).toHaveCount(0); + await expect(getField(page, 'control_textarea_default').locator('.kbq-input')).toHaveCount(0); + }); + }); + + test.describe('selector variants', () => { + test('the :hover variant carries no declarations of its own', async ({ page }) => { + // Named properties rather than `cssText`: on a computed style that is always the + // empty string, so comparing it would pass whatever hovering did. + const paint = (locator: Locator) => + locator.evaluate((el) => { + const style = getComputedStyle(el); + + return [ + style.backgroundColor, + style.backgroundImage, + style.boxShadow, + style.webkitTextFillColor + ].join(' | '); + }); + + const { field, container, control } = await autofill(page, 'state_default'); + const before = { control: await paint(control), container: await paint(container) }; + + await control.hover(); + + // Nothing in the autofill rules keys on `:hover`; the state does, and the state is + // what owns every channel except the tint. + expect(await paint(control)).toBe(before.control); + expect(await paint(container)).toBe(before.container); + await expect(container).toHaveCSS('background-image', await tintLayer(field)); + }); + + test('clearing the forced state puts the field back', async ({ page }) => { + const { field, container } = await autofill(page, 'state_default'); + + await expect(container).toHaveCSS('background-image', await tintLayer(field)); + + await e2eClearForcedAutofill(page); + + // Guards the helper itself: a forced state is keyed to the node id it was set on, + // and `DOM.getDocument` re-issues ids, so a clear that addresses a fresh id looks + // like it worked and changes nothing. + await expect(container).toHaveCSS('background-image', 'none'); + await expect(container).toHaveCSS( + 'background-color', + await resolveToken(field, '--kbq-form-field-default-background') + ); + }); + }); + + /** + * The rules themselves, read out of the CSSOM. + * + * These need no pseudo-class to match, so they keep working if a future Chromium drops the + * forcing this suite depends on — and they pin things a rendered assertion cannot see at + * all: which selectors exist, which declarations are `!important`, and where each block + * sits relative to the state blocks it competes with. + */ + test.describe('the rules themselves', () => { + const findRule = (rules: Rule[], match: (selector: string) => boolean): Rule | undefined => + rules.find((rule) => match(rule.selector)); + + test('the control block suppresses the background and repaints the text', async ({ page }) => { + const rules = await readRules(page); + const block = rules.find((rule) => rule.text.includes('transition-property: background-color')); + + expect(block).toBeDefined(); + + for (const control of ['.kbq-input', '.kbq-tag-input', '.kbq-textarea']) { + expect(block!.selector).toContain(control); + } + + // Both spellings, through a forgiving list: a plain comma list would be invalidated + // whole by whichever of the two a browser does not know. + expect(block!.selector).toContain(':autofill'); + expect(block!.selector).toContain(':-webkit-autofill'); + // The tint belongs to the container, and nothing paints the control itself. + expect(block!.text).not.toContain('box-shadow'); + expect(block!.text).not.toContain('background-color:'); + }); + + test('the text repaint is one low-specificity rule, not one per state', async ({ page }) => { + const rules = await readRules(page); + // Scoped to autofill: the error and disabled states also repaint an icon through + // `-webkit-text-fill-color`, and those have nothing to do with this. + const repaints = rules.filter( + (rule) => rule.text.includes('-webkit-text-fill-color') && rule.selector.includes('autofill') + ); + + // Emitting the repaint inside `_kbq-form-field-state()` compiled to 84 selectors — + // the mixin runs five times, once nested under eight `kbq-form-field-type-*` + // classes — and gave a text colour a specificity of (0,7,0), which a consumer could + // only override with `!important`. One rule reading a variable the state publishes + // does the same job at (0,3,0). The bound is what this test is for; the exact count + // is allowed to grow a little, three orders of magnitude is not. + expect(repaints.length).toBeLessThanOrEqual(2); + + /** + * The worst class-level specificity — the `b` of (a,b,c) — across one selector list. + * + * Counting `.class` tokens alone, as this once did, could not see the pseudo-class + * the whole rule turns on: `.kbq-form-field .kbq-input:is(:autofill, + * :-webkit-autofill)` is (0,3,0), not the (0,2,0) a class count reports, and a bound + * blind to pseudo-classes cannot refuse a `:hover` or a second `:is()` being + * appended — which is the shape it exists to refuse. + * + * The arguments of a functional pseudo-class are dropped first: it takes the + * specificity of its most specific argument, a single class or pseudo-class in + * everything this component emits, so the `:is()` counts once and its arguments do + * not count again. That also gets the commas inside it out of the way of the split. + */ + const worstSpecificity = (selectorList: string): number => { + let flattened = selectorList; + + while (/\([^()]*\)/.test(flattened)) { + flattened = flattened.replace(/\([^()]*\)/g, ''); + } + + return Math.max( + ...flattened + .split(',') + .map( + (selector) => (selector.match(/\.[-\w]+|\[[^\]]*\]|(? worstSpecificity(rule.selector)))).toBeLessThanOrEqual(3); + }); + + test('no rule gives an autofilled control its own geometry', async ({ page }) => { + const rules = await readRules(page); + + // DS-4950's compensation is gone with the background it was compensating for. If it + // ever comes back, the control resizes on focus again and tag inputs are excluded + // from it again by `tag-list.scss`. + expect( + rules.filter((rule) => rule.selector.includes('autofill') && /min-height|margin/.test(rule.text)) + ).toEqual([]); + }); + + test('the container block lays the tint over the state background', async ({ page }) => { + const rules = await readRules(page); + // Both halves are load-bearing. `:has(` alone also matches the focused state's text + // rule, nested under `:not(:has(.cdk-keyboard-focused, .kbq-focused))`; the + // container prefix alone also matches the padding rule keyed on + // `__container:has(.kbq-textarea)`. Both come first in document order. + const block = findRule( + rules, + (selector) => selector.includes('.kbq-form-field__container:has(') && selector.includes('autofill') + ); + + expect(block).toBeDefined(); + expect(block!.selector).toContain('.kbq-textarea'); + // The tint is a layer, so `background-color` stays with the state and no + // `!important` is needed to make it land. Both halves matter: painting + // `background-color` here is what DS-4060 did, and it is what out-ranked error, + // disabled and the overlay. + expect(block!.text).toContain('background-image'); + expect(block!.text).not.toContain('background-color:'); + expect(block!.text).not.toContain('!important'); + }); + + test('no modifier has to remap the autofill background', async ({ page }) => { + const rules = await readRules(page); + const remaps = rules.filter( + (rule) => + /_no-borders|_without-borders|_in-overlay/.test(rule.selector) && + rule.text.includes('--kbq-form-field-states-autofill-background') + ); + + // `_in-overlay` remaps the four state backgrounds and never mentions autofill. Under + // the old rule that was the bug; under a layer it is the correct amount of work — + // the tint composites over whatever the modifier left behind. + expect(remaps).toEqual([]); + }); + + test('background is the only autofill token anything reads', async ({ page }) => { + const rules = await readRules(page); + const reads = (token: string) => rules.filter((rule) => rule.text.includes(`var(${token})`)); + + // Reads rather than declarations: `@koobiq/design-tokens` still publishes all four + // names on `.kbq-light`/`.kbq-dark` — it deprecates them upstream, on its own + // schedule — so what the component dropped can only be seen from the consuming side. + // + // `-border-color` aliased the focus colour and made a merely filled field read as + // focused, `-placeholder` could never be seen because an autofilled field has a + // value, and `-text` forced one colour on every state. + expect(reads('--kbq-form-field-states-autofill-border-color')).toEqual([]); + expect(reads('--kbq-form-field-states-autofill-placeholder')).toEqual([]); + expect(reads('--kbq-form-field-states-autofill-text')).toEqual([]); + expect(reads('--kbq-form-field-states-autofill-background').length).toBeGreaterThan(0); + }); + + test('every autofill selector carries both spellings', async ({ page }) => { + const rules = await readRules(page); + const autofillRules = rules.filter( + (rule) => rule.selector.includes('autofill') && rule.selector.includes('kbq-') + ); + + expect(autofillRules.length).toBeGreaterThan(0); + + // The legacy spelling is what browsers implement today and the standard one is where + // they are going; a forgiving `:is()` list keeps whichever a given browser knows, + // where a plain comma list would be invalidated whole by the other. + for (const rule of autofillRules) { + expect(rule.selector).toMatch(/(^|[^-]):autofill\b/); + expect(rule.selector).toContain(':-webkit-autofill'); + } + }); + + test('the TypeScript hook observes, and no rule paints from it', async ({ page }) => { + const rules = await readRules(page); + + // The class is an API for application code and a marker in the DOM. Nothing in the + // stylesheet keys on it, and that is deliberate: the tint has exactly one source, + // so a class left behind by a detached or re-attached control cannot tint a field + // the browser no longer considers autofilled. Two arms that must agree is how the + // earlier attempt at this ticket could drift. + expect(rules.filter((rule) => rule.selector.includes('kbq-form-field_autofilled'))).toEqual([]); + + const { field } = await autofill(page, 'state_default'); + + // Forcing does reach the monitor, which was worth discovering: the CDK keys its + // detection keyframe on `:-webkit-autofill` itself, so the same CDP call that lights + // the CSS also fires `animationstart` and feeds the signal. Both halves are live + // here even though only one of them paints. + await expect(field).toHaveClass(/kbq-form-field_autofilled/); + await expect(getControl(field)).toHaveClass(/cdk-text-field-autofill-monitored/); + await expect(getContainer(field)).toHaveCSS('background-image', await tintLayer(field)); + }); + }); + + /** + * What computed style cannot show: how a 10%-alpha tint composites over each state's own + * background, and the 1px seam the focus geometry leaves behind. + * + * Nested in its own describe so a local run can exclude it with `--grep-invert screenshots` + * — baselines are Linux bytes compared at `threshold: 0` and only reproduce under Docker. + */ + test.describe('screenshots', () => { + /** + * The project default is `animations: 'disabled'`, which calls `finish()` on every + * animation with a finite end time — and the 600000s `background-color` transition that + * hides Chrome's autofill background is finite. Fast-forwarding it makes every control + * paint Chrome's opaque blue instead of the design system's tint, and it does not come + * back: once finished the transition is gone, so a later capture with 'allow' still + * shows the blue. Measured, not guessed. Do not remove. + */ + const screenshot = { animations: 'allow' } as const; + + /** + * Fails loudly if the suppression has already been fast-forwarded. + * + * Takes the cell to probe rather than assuming one: each shot forces only its own + * matrix, so a fixed probe in the other one reads "no transition" and would report the + * suppression as broken when it is merely untouched. + */ + const expectStillSuppressed = async (page: Page, cell: string) => { + const control = getField(page, cell).locator('.kbq-input'); + + expect(await e2eRunningAnimations(control)).toContainEqual(['background-color', 600_000_000]); + }; + + /** + * How many rendered boxes stick out of the screenshot target. + * + * A locator screenshot of something wider than the viewport is cropped without failing, + * and a cropped baseline is stable, reviewable and wrong — it silently stops covering + * whatever fell off the edge. Counting the overflow is the only thing that catches it. + */ + const countOutside = (target: Locator): Promise => + target.evaluate((root: HTMLElement) => { + const box = root.getBoundingClientRect(); + + return Array.from(root.querySelectorAll('*')) + .map((element) => element.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; + }); + + for (const [name, target, probeCell] of [ + ['04', 'e2eStateMatrix', 'state_default'], + ['05', 'e2eControlMatrix', 'control_input_default'] + ] as const) { + test(`${target} under forced autofill`, async ({ page }) => { + const matrix = page.getByTestId(target); + + // Every control in the matrix, so the shot shows the whole cross-section rather + // than one forced cell among unforced ones. + const forced = await e2eForceAutofill( + page, + `[data-testid="${target}"] :is(.kbq-input, .kbq-tag-input, .kbq-textarea)` + ); + + // An absolute count, not a re-query of the same selector: that would only ever + // catch zero, and the number that matters is "one column silently missing". + expect(forced).toBe(target === 'e2eStateMatrix' ? STATES.length : (CONTROLS.length - 1) * 2); + + // Forcing reaches elements that are scrolled out of the shot, so the count above + // says nothing about whether they are in it. + expect(await countOutside(matrix)).toBe(0); + expect((await matrix.boundingBox())!.width).toBeLessThanOrEqual(1200); + + await expectStillSuppressed(page, probeCell); + await expect(matrix).toHaveScreenshot(`${name}-light.png`, screenshot); + + await e2eEnableDarkTheme(page); + await expect(matrix).toHaveScreenshot(`${name}-dark.png`, screenshot); + }); + } + }); + }); }); diff --git a/packages/components/form-field/e2e.ts b/packages/components/form-field/e2e.ts index 4c728a3c90..76f86055cf 100644 --- a/packages/components/form-field/e2e.ts +++ b/packages/components/form-field/e2e.ts @@ -1,8 +1,13 @@ import { ChangeDetectionStrategy, Component, signal, ViewEncapsulation } from '@angular/core'; import { FormsModule } from '@angular/forms'; +import { KbqLuxonDateModule } from '@koobiq/angular-luxon-adapter/adapter'; import { KbqButtonModule } from '@koobiq/components/button'; import { KbqIconModule } from '@koobiq/components/icon'; import { KbqInputModule } from '@koobiq/components/input'; +import { KbqSelectModule } from '@koobiq/components/select'; +import { KbqTagsModule } from '@koobiq/components/tags'; +import { KbqTextareaModule } from '@koobiq/components/textarea'; +import { KbqTimepickerModule } from '@koobiq/components/timepicker'; @Component({ selector: 'e2e-form-field-group', @@ -139,3 +144,250 @@ export class E2eFormFieldAddons { protected numberValue = 10; protected readonly showStepper = signal(false); } + +/** A field state the autofill matrix renders a control in. */ +type AutofillState = { + name: string; + /** `FocusMonitor` writes this in real use; the matrix needs it without stealing focus. */ + focused?: boolean; + /** + * The second branch of `form-field.scss`'s `&.cdk-focused, &.kbq-focused` — where DS-4950's + * autofill geometry lived until this ticket deleted it. Nothing autofill-specific keys on the + * class today, and the row exists so that reinstating any of it moves the control and fails the + * state-matrix baseline. + */ + kbqFocused?: boolean; + error?: boolean; + disabled?: boolean; + noBorders?: boolean; + inOverlay?: boolean; +}; + +/** + * Everything a browser could put a value into, plus `select`, which it cannot. + * + * No `datepicker`: it carries `.kbq-input` exactly as `timepicker` does, so it would add a duplicate + * column, and a `KbqDatepicker` throws when a second input is bound to it — which a matrix rendering + * the same control in more than one state necessarily does. The spec asserts its class membership + * instead. + */ +type AutofillControl = + 'input' | 'password' | 'number' | 'timepicker' | 'tagInput' | 'tagInputBare' | 'textarea' | 'select'; + +/** + * Autofill styling, crossed two ways (#DS-4096). + * + * `stateMatrix` holds one plain input in every field state, and is what shows whether autofill + * out-ranks the state the field is already in — it does, which is the bug the ticket is about. + * `controlMatrix` holds every control the stylesheet could reach, focused and not, and is what + * shows which of them the three autofill rule blocks actually cover: `.kbq-input` and + * `.kbq-tag-input` get the design system's treatment, `.kbq-textarea` gets nothing and paints + * Chrome's own opaque blue instead. + * + * Two grids rather than one 9x9: the interesting states are all a plain input can show, and the + * interesting controls all differ in the default and focused states alone, so the product would be + * mostly duplicate cells in a baseline that is expensive to review. + */ +@Component({ + selector: 'e2e-form-field-autofill', + imports: [ + FormsModule, + KbqInputModule, + KbqTextareaModule, + KbqTagsModule, + KbqSelectModule, + KbqTimepickerModule, + KbqLuxonDateModule + ], + template: ` +
+ @for (state of stateMatrix; track state.name) { +
+
{{ state.name }}
+ +
+ + + + +
+
+ } +
+ + +
+ @for (control of controls; track control) { +
+
{{ control }}
+ + @for (state of controlMatrix; track state.name) { +
+ + + @switch (control) { + @case ('input') { + + } + @case ('password') { + + } + @case ('number') { + + + + } + @case ('timepicker') { + + } + @case ('tagInput') { + + + Tag + + + } + @case ('tagInputBare') { + + + Tag + + + } + @case ('textarea') { + + } + @case ('select') { + + One + + } + } + +
+ } +
+ } +
+ `, + styles: ` + :host { + /* No fixed height anywhere: a fixed one crops cells out of the baseline without failing. */ + .e2e__grid { + display: inline-flex; + flex-direction: column; + gap: 4px; + /* The host is a flex column, so without this the grid stretches to the full + viewport width and three quarters of every baseline is empty page. */ + align-self: flex-start; + } + + .e2e__row { + display: flex; + gap: 4px; + align-items: flex-start; + } + + .e2e__label { + display: flex; + width: 110px; + flex: none; + } + + .e2e__cell { + display: flex; + width: 180px; + flex: none; + } + } + `, + 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', + 'timepicker', + 'tagInput', + 'tagInputBare', + 'textarea', + 'select' + ]; + + protected readonly stateMatrix: AutofillState[] = [ + { name: 'default' }, + { name: 'focused', focused: true }, + { name: 'kbqFocused', kbqFocused: true }, + { name: 'error', error: true }, + { name: 'errorFocused', error: true, focused: true }, + { name: 'disabled', disabled: true }, + { name: 'noBorders', noBorders: true }, + { name: 'inOverlay', inOverlay: true } + ]; + + protected readonly controlMatrix: AutofillState[] = [{ name: 'default' }, { name: 'focused', focused: true }]; + + // Built here rather than concatenated in the template: a template literal is the lint-approved + // way to join these, and a backtick inside an inline template would close the template literal + // the whole decorator lives in. + protected stateTestId({ name }: AutofillState): string { + return `state_${name}`; + } + + protected controlTestId(control: AutofillControl, { name }: AutofillState): string { + return `control_${control}_${name}`; + } +} diff --git a/packages/components/form-field/form-field-control.ts b/packages/components/form-field/form-field-control.ts index 96cf031ed5..3fe986e7e7 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,18 @@ 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. + * + * 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..0aca6197d0 100644 --- a/packages/components/form-field/form-field-tokens.scss +++ b/packages/components/form-field/form-field-tokens.scss @@ -36,9 +36,23 @@ --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, so the border, the focus ring and the text colour stay with the state that + owns them. Background is the only one of these four the component reads; the other three are + declared and no longer read, and overriding them has no effect (#DS-4096). They are kept + rather than deleted because `@koobiq/design-tokens` publishes all four names globally + anyway — removing them here would not take the names out of the CSS a consumer loads — and + upstream already marks the set `deprecated`, so it retires them on its own schedule. + This file is nominally generated by tools/tokens/build-each-component.js, but has been + hand-maintained for a long time and no npm script runs the generator. Re-running it would + revert the background below to `theme-fade` (see DS-2873) and drop the size tokens above. */ --kbq-form-field-states-autofill-background: var(--kbq-background-theme-less); + /* No longer read: it aliased the focus colour, so a merely filled field read as focused. */ + --kbq-form-field-states-autofill-border-color: var(--kbq-states-line-focus-theme); + /* No longer read, and never observable: an autofilled field has a value, so no placeholder. */ --kbq-form-field-states-autofill-placeholder: var(--kbq-foreground-contrast-tertiary); + /* No longer read: it forced one colour on every state, which is what lost the error and + disabled text colours. The state owns the text now. */ --kbq-form-field-states-autofill-text: var(--kbq-foreground-contrast); --kbq-form-field-states-disabled-border-color: var(--kbq-states-line-disabled); --kbq-form-field-states-disabled-background: var(--kbq-states-background-disabled); diff --git a/packages/components/form-field/form-field.en.md b/packages/components/form-field/form-field.en.md index 3f4a5ed44f..ccac285b40 100644 --- a/packages/components/form-field/form-field.en.md +++ b/packages/components/form-field/form-field.en.md @@ -102,6 +102,35 @@ import { kbqFormFieldDefaultOptionsProvider } from '@koobiq/components/form-fiel }) ``` +### Autofill + +When the browser fills a field in, `` tints it 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, so it is never applied twice. + +The tint is a layer rather than a state. It composites over whatever background the field's state resolved, which means nothing is lost and nothing has to be arbitrated: an invalid field stays red **and** shows the tint, a field inside an overlay keeps its card background, a disabled field keeps the gray that says it cannot be edited. Border, focus ring and text color all stay with the state — an autofilled invalid field prints its error color, not a flat autofill color. + +`kbq-form-field` also declares `color-scheme` for the active theme. Without it the browser paints every surface it owns from the light palette however dark the application looks, and an autofilled field in a dark theme ends up a light block with black text the moment the browser reopens its autofill popup. Note that this popup is drawn by the browser over author styles; `color-scheme` is the only thing that influences it. + +To change the tint or switch it off, override the token on the field — no `!important` needed: + +```css +.my-form-field { + /* look exactly like a normally filled field */ + --kbq-form-field-states-autofill-background: transparent; +} +``` + +The state is also tracked in TypeScript. `KbqInput`, `KbqInputPassword`, `KbqTextarea` and `KbqTagInput` (forwarded by `KbqTagList`) each wrap the CDK's `AutofillMonitor` around their own element and expose the result as an `autofilled` signal, which `` reflects as the `kbq-form-field_autofilled` class: + +```ts +@ViewChild(KbqInput) input: KbqInput; + +// ... + +const filledByBrowser = this.input.autofilled(); +``` + +The signal is a hook for application code, not the mechanism behind the styling: it arrives a frame or two after the browser paints, where the CSS matches in the same style pass. + ### 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..74ff541e6e 100644 --- a/packages/components/form-field/form-field.ru.md +++ b/packages/components/form-field/form-field.ru.md @@ -109,6 +109,47 @@ import { kbqFormFieldDefaultOptionsProvider } from '@koobiq/components/form-fiel }) ``` +### Автозаполнение + +Когда браузер заполняет поле, `` подсвечивает его цветом `--kbq-form-field-states-autofill-background`, +поэтому автозаполненное поле выглядит одинаково во всех браузерах: собственный фон браузера подавляется, а цвет текста +перерисовывается. Подсветка ложится на поле, а не на сам контрол, поэтому она никогда не накладывается дважды. + +Подсветка — это слой, а не состояние. Она накладывается поверх того фона, который разрешило состояние поля: ничего не +теряется и ничего не нужно разрешать в пользу одного из двух. Поле с ошибкой остаётся красным **и** подсвеченным, поле +в оверлее сохраняет фон карточки, отключённое поле сохраняет серый цвет, который и говорит, что редактировать его +нельзя. Рамка, кольцо фокуса и цвет текста остаются за состоянием — автозаполненное поле с ошибкой печатает текст +цветом ошибки, а не плоским цветом автозаполнения. + +`kbq-form-field` также объявляет `color-scheme` для активной темы. Без этого браузер рисует все свои собственные +поверхности светлой палитрой, каким бы тёмным ни было приложение, и автозаполненное поле в тёмной теме превращается в +светлый блок с чёрным текстом, как только браузер снова откроет выпадающий список автозаполнения. Этот список браузер +рисует поверх авторских стилей, и `color-scheme` — единственное, что на него влияет. + +Чтобы изменить подсветку или отключить её, переопределите токен на поле — `!important` не нужен: + +```css +.my-form-field { + /* выглядеть точно как обычное заполненное поле */ + --kbq-form-field-states-autofill-background: transparent; +} +``` + +Состояние также отслеживается в TypeScript. `KbqInput`, `KbqInputPassword`, `KbqTextarea` и `KbqTagInput` (проброшенный +через `KbqTagList`) оборачивают `AutofillMonitor` из CDK вокруг собственного элемента и отдают результат сигналом +`autofilled`, а `` отражает его классом `kbq-form-field_autofilled`: + +```ts +@ViewChild(KbqInput) input: KbqInput; + +// ... + +const filledByBrowser = this.input.autofilled(); +``` + +Сигнал — это хук для кода приложения, а не механизм стилизации: он приходит на кадр-два позже отрисовки, тогда как CSS +срабатывает в том же проходе стилей. + ### Поле для ввода пароля `` - это компонент, который добавляет кнопку _"Показать пароль"_ для **заполненного** поля diff --git a/packages/components/form-field/form-field.scss b/packages/components/form-field/form-field.scss index 4079343f47..b76bf65f28 100644 --- a/packages/components/form-field/form-field.scss +++ b/packages/components/form-field/form-field.scss @@ -115,27 +115,15 @@ &.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. DS-4950 shrank the autofilled control by twice the + // focus-outline width and handed the pixels back as margin, because the control's own autofill + // background was painting over the 1px ring the container draws as an inset shadow. It has + // nothing left to paint over: the control is transparent — the browser's background is + // suppressed, not replaced — and the tint lives on the container, under a shadow that is drawn + // above it. Reinstating any of it would only move the text on focus. + // 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 diff --git a/packages/components/form-field/form-field.ts b/packages/components/form-field/form-field.ts index 4a80291ef4..260c9c9fbe 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,20 @@ 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. + * + * Reflected as `kbq-form-field_autofilled` for application code to key on. The autofill styling + * does not use it: CSS matches `:autofill` directly, in the same style pass the browser fills + * the field, where this arrives a frame or two later. Keeping the paint on one source also means + * a class left behind by a detached or re-attached control cannot tint a field the browser no + * longer considers autofilled. + */ + 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.ts b/packages/components/input/e2e.ts index 1b42e725b8..38ebe9f208 100644 --- a/packages/components/input/e2e.ts +++ b/packages/components/input/e2e.ts @@ -5,7 +5,9 @@ import { PasswordRules } from '@koobiq/components/form-field'; import { KbqInputModule } from './input.module'; type InputStates = { - state: ('default' | 'focused' | 'disabled' | 'placeholder' | 'invalid' | 'autofill' | 'error' | 'ellipsis')[]; + // No 'autofill': no row ever emitted it and no spec asserted it, so it advertised coverage that + // did not exist. Autofill is covered for real by E2eFormFieldAutofill (#DS-4096). + state: ('default' | 'focused' | 'disabled' | 'placeholder' | 'invalid' | 'error' | 'ellipsis')[]; inputNumberHasSeparator?: boolean; }; diff --git a/packages/components/input/input-password.ts b/packages/components/input/input-password.ts index f1ccf62da3..14b026afe8 100644 --- a/packages/components/input/input-password.ts +++ b/packages/components/input/input-password.ts @@ -1,7 +1,7 @@ import { coerceBooleanProperty } from '@angular/cdk/coercion'; import { Directive, DoCheck, ElementRef, Input, OnChanges, OnDestroy, inject } from '@angular/core'; import { FormGroupDirective, NgControl, NgForm, UntypedFormControl } from '@angular/forms'; -import { CanUpdateErrorState, ErrorStateMatcher } from '@koobiq/components/core'; +import { CanUpdateErrorState, ErrorStateMatcher, kbqInjectAutofilled } from '@koobiq/components/core'; import { KbqFormFieldControl } from '@koobiq/components/form-field'; import { Subject } from 'rxjs'; import { KBQ_INPUT_VALUE_ACCESSOR } from './input-value-accessor'; @@ -56,6 +56,12 @@ export class KbqInputPassword */ focused: boolean = false; + /** + * Implemented as part of KbqFormFieldControl. + * @docs-private + */ + readonly autofilled = kbqInjectAutofilled(); + /** * Implemented as part of KbqFormFieldControl. * @docs-private diff --git a/packages/components/input/input.ts b/packages/components/input/input.ts index 38c6e206f1..993f931daa 100644 --- a/packages/components/input/input.ts +++ b/packages/components/input/input.ts @@ -2,7 +2,7 @@ import { coerceBooleanProperty } from '@angular/cdk/coercion'; import { getSupportedInputTypes } from '@angular/cdk/platform'; import { Directive, DoCheck, ElementRef, Input, OnChanges, OnDestroy, inject } from '@angular/core'; import { FormGroupDirective, NgControl, NgForm, UntypedFormControl } from '@angular/forms'; -import { CanUpdateErrorState, ErrorStateMatcher } from '@koobiq/components/core'; +import { CanUpdateErrorState, ErrorStateMatcher, kbqInjectAutofilled } from '@koobiq/components/core'; import { KbqFormFieldControl } from '@koobiq/components/form-field'; import { Subject } from 'rxjs'; import { getKbqInputUnsupportedTypeError } from './input-errors'; @@ -70,6 +70,12 @@ export class KbqInput */ focused: boolean = false; + /** + * Implemented as part of KbqFormFieldControl. + * @docs-private + */ + readonly autofilled = kbqInjectAutofilled(); + /** * Implemented as part of KbqFormFieldControl. * @docs-private diff --git a/packages/components/tags/tag-input.ts b/packages/components/tags/tag-input.ts index e5f1e67a01..0514d945d4 100644 --- a/packages/components/tags/tag-input.ts +++ b/packages/components/tags/tag-input.ts @@ -14,7 +14,16 @@ import { } from '@angular/core'; import { NgControl } from '@angular/forms'; import { KbqAutocompleteTrigger } from '@koobiq/components/autocomplete'; -import { COMMA, ENTER, hasModifierKey, KbqFieldSizingContent, SEMICOLON, SPACE, TAB } from '@koobiq/components/core'; +import { + COMMA, + ENTER, + hasModifierKey, + KbqFieldSizingContent, + kbqInjectAutofilled, + SEMICOLON, + SPACE, + TAB +} from '@koobiq/components/core'; import { KbqTrim } from '@koobiq/components/form-field'; import { KbqTagList } from './tag-list.component'; import { KbqTagTextControl } from './tag-text-control'; @@ -133,6 +142,9 @@ export class KbqTagInput implements KbqTagTextControl, OnChanges { */ focused: boolean = false; + /** Whether the control's value was filled in by the browser. */ + readonly autofilled = kbqInjectAutofilled(); + /** * The list of key codes that will trigger a tagEnd event. * diff --git a/packages/components/tags/tag-list.component.ts b/packages/components/tags/tag-list.component.ts index 8b4c24945d..13216a24c1 100644 --- a/packages/components/tags/tag-list.component.ts +++ b/packages/components/tags/tag-list.component.ts @@ -10,6 +10,7 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, + computed, contentChild, ContentChildren, DestroyRef, @@ -22,6 +23,7 @@ import { OnDestroy, output, QueryList, + signal, ViewEncapsulation } from '@angular/core'; import { outputToObservable, takeUntilDestroyed } from '@angular/core/rxjs-interop'; @@ -264,6 +266,14 @@ export class KbqTagList return (this.tagInput && this.tagInput.focused) || this.hasFocusedTag(); } + /** + * Implemented as part of KbqFormFieldControl. Forwarded from the registered `kbqTagInput`, which + * is the element the browser actually autofills — the tag list itself is not an input. + * + * @docs-private + */ + readonly autofilled = computed(() => 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 09d8602ce7..93490c4fcf 100644 --- a/packages/e2e/routes.ts +++ b/packages/e2e/routes.ts @@ -51,7 +51,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, @@ -160,6 +165,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..4a7443c454 --- /dev/null +++ b/packages/e2e/utils/autofill.ts @@ -0,0 +1,184 @@ +import { CDPSession, Locator, Page } from '@playwright/test'; + +/** + * The pseudo-class name `CSS.forcePseudoState` understands. + * + * Only the standard spelling works. Passing `-webkit-autofill` forces nothing at all — measured on + * the Chromium that `@playwright/test` 1.62.1 ships (151.0.7922.34), where forcing the legacy name + * left every probe unset while the standard one lit both spellings. Blink keeps `:autofill` and + * `:-webkit-autofill` as separate `CSSSelector::PseudoType` values but resolves both against the + * same forced bit, so forcing the standard name is enough for a stylesheet written in the legacy + * one — which is what `kbq-form-field` uses throughout. + * + * `forcePseudoState` does not validate: an unknown name resolves successfully and silently forces + * nothing, which is why every helper below verifies the result instead of trusting the call. + */ +const FORCED_AUTOFILL = 'autofill'; + +/** Set by the probe stylesheet on whatever the browser reports as autofilled. */ +const PROBE_PROPERTY = '--kbq-e2e-autofilled'; + +/** + * Deliberately not one of the declarations the form field makes. Reading back the component's own + * `box-shadow` or the container's tint to decide whether forcing worked would make the check pass + * for the same reason the assertion under it passes, and both would go green on a stylesheet that + * had stopped matching anything. + */ +const PROBE_STYLESHEET = `:autofill, :-webkit-autofill { ${PROBE_PROPERTY}: 1; }`; + +type Forcing = { + cdp: CDPSession; + /** + * `DOM.getDocument` re-issues node ids and orphans the ones handed out before it, while the + * pseudo-states forced on those ids stay in effect. Calling it once per page and keeping the + * root is what makes `e2eClearForcedAutofill` able to undo what `e2eForceAutofill` did; calling + * it per request produces ids that force correctly and cannot be cleared afterwards. + */ + rootNodeId: number; + forcedNodeIds: Set; +}; + +/** + * One session per page, kept for the page's lifetime. + * + * Forced states live with the CSS agent, so detaching the session — or letting it be garbage + * collected — drops every one of them, silently, turning each later assertion into a test that + * passes because nothing is being styled. Playwright disposes the session when the page closes. + */ +const forcings = new WeakMap>(); + +const openForcing = async (page: Page): Promise => { + const cdp = await page.context().newCDPSession(page); + + await cdp.send('DOM.enable'); + await cdp.send('CSS.enable'); + await page.addStyleTag({ content: PROBE_STYLESHEET }); + + const { root } = await cdp.send('DOM.getDocument', { depth: -1 }); + + return { cdp, rootNodeId: root.nodeId, forcedNodeIds: new Set() }; +}; + +const getForcing = (page: Page): Promise => { + if (!forcings.has(page)) { + forcings.set(page, openForcing(page)); + } + + return forcings.get(page)!; +}; + +/** How many elements the browser currently reports as autofilled, according to the probe alone. */ +const countProbed = (page: Page, selector: string): Promise => + page.evaluate( + ([sel, property]) => + Array.from(document.querySelectorAll(sel)).filter( + (element) => getComputedStyle(element).getPropertyValue(property).trim() !== '' + ).length, + [selector, PROBE_PROPERTY] as const + ); + +/** + * Puts every element matching `selector` into `:-webkit-autofill` for the rest of the test, and + * returns how many were affected. + * + * Real autofill cannot be triggered from a test: choosing 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 lists 35 domains and `Autofill` is not + * among them, so `Autofill.enable` fails with "wasn't found". Forcing the pseudo-class is the whole + * of what is available, and it is enough: Chrome applies its own autofill background to a forced + * element too, so the design system's suppression of that background is exercised for real. + * + * Throws when nothing matched. A run where the forcing quietly reached nothing renders exactly like + * a correct one, so it would produce a perfectly stable baseline and pass forever. + */ +export const e2eForceAutofill = async (page: Page, selector: string): Promise => { + const { cdp, rootNodeId, forcedNodeIds } = await getForcing(page); + const { nodeIds } = await cdp.send('DOM.querySelectorAll', { nodeId: rootNodeId, selector }); + + if (!nodeIds.length) { + throw new Error(`e2eForceAutofill: nothing matches ${selector}`); + } + + for (const nodeId of nodeIds) { + // One node at a time: `forcePseudoState` takes a single node, and the forced list replaces + // whatever that node carried before rather than adding to it. + await cdp.send('CSS.forcePseudoState', { nodeId, forcedPseudoClasses: [FORCED_AUTOFILL] }); + forcedNodeIds.add(nodeId); + } + + const probed = await countProbed(page, selector); + + if (probed !== nodeIds.length) { + throw new Error( + `e2eForceAutofill: forced ${nodeIds.length} node(s) matching ${selector}, but ${probed} report ` + + `as autofilled. Forcing an unknown pseudo-class name succeeds and does nothing, so this is ` + + `what a rename of "${FORCED_AUTOFILL}" in a newer Chromium looks like.` + ); + } + + return probed; +}; + +/** + * Undoes every forcing made on this page, so one test can compare an autofilled control against the + * same control before the fill. + * + * Clearing goes through the node ids the forcing used — see the note on `Forcing.rootNodeId`. + */ +export const e2eClearForcedAutofill = async (page: Page): Promise => { + const { cdp, forcedNodeIds } = await getForcing(page); + + for (const nodeId of forcedNodeIds) { + await cdp.send('CSS.forcePseudoState', { nodeId, forcedPseudoClasses: [] }); + } + + forcedNodeIds.clear(); +}; + +/** + * What `property: value` computes to in `locator`'s own cascade — the way to compare against a + * design token without hardcoding a colour. + * + * Reading the custom property directly does not work: `getPropertyValue('--x')` returns the token's + * substituted text (`oklch(52.7% 0.2480 258.1 / 0.10)`) while the computed `background-color` it + * feeds is renormalized (`oklch(0.527 0.248 258.1 / 0.1)`), so the two never compare equal as + * strings. Letting the browser resolve the same declaration on a throwaway child sidesteps that, + * and survives a token being re-valued or moved between theme files. + */ +export const e2eResolveCssValue = (locator: Locator, property: string, value: string): Promise => + locator.evaluate( + (element, [prop, val]) => { + const probe = document.createElement('div'); + + probe.style.setProperty(prop, val); + element.append(probe); + + const resolved = getComputedStyle(probe).getPropertyValue(prop); + + probe.remove(); + + return resolved; + }, + [property, value] as const + ); + +/** + * The animations currently running on `locator`, as `[property, duration]` pairs. + * + * The form field hides Chrome's autofill background by parking a `background-color` transition at + * an absurd duration, because a running transition is the one thing in the cascade that outranks + * the UA's `!important`. `animations: 'disabled'` — the project default for screenshots — calls + * `finish()` on every animation with a finite end time, and 5000s is finite: the suppression is + * fast-forwarded to its end value and the control paints Chrome's opaque blue instead. That is not + * recoverable within the page. Once finished the transition is gone, and a later capture with + * `animations: 'allow'` still sees the blue, so this has to be checked *before* a screenshot rather + * than after one. + */ +export const e2eRunningAnimations = (locator: Locator): Promise<[string, number | string][]> => + locator.evaluate( + (element) => + element.getAnimations().map((animation) => [ + (animation as CSSTransition).transitionProperty ?? animation.constructor.name, + animation.effect?.getComputedTiming().duration ?? 'unknown' + ]) as [string, number | string][] + ); 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..91a97ecf49 100644 --- a/tools/cspell-locales/en.json +++ b/tools/cspell-locales/en.json @@ -6,6 +6,7 @@ "caseSensitive": false, "words": [ "actionbar", + "autofilled", "autoselect", "autotable", "behaviour", diff --git a/tools/cspell-locales/ru.json b/tools/cspell-locales/ru.json index e6b17b1257..f4ca2c5408 100644 --- a/tools/cspell-locales/ru.json +++ b/tools/cspell-locales/ru.json @@ -10,6 +10,8 @@ "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 9aece38f2b..cc7b5db42d 100644 --- a/tools/public_api_guard/components/tags.api.md +++ b/tools/public_api_guard/components/tags.api.md @@ -35,6 +35,7 @@ import { OnChanges } from '@angular/core'; import { OnDestroy } from '@angular/core'; import { Provider } from '@angular/core'; import { QueryList } from '@angular/core'; +import { Signal } from '@angular/core'; import { Subject } from 'rxjs'; // @public @@ -177,6 +178,7 @@ export class KbqTagInput implements KbqTagTextControl, OnChanges { set addOnBlur(value: boolean); readonly addOnPaste: i0.InputSignalWithTransform; autocompleteTrigger?: KbqAutocompleteTrigger | null | undefined; + readonly autofilled: i0.Signal; blur(event: FocusEvent): void; get disabled(): boolean; set disabled(value: boolean); @@ -215,6 +217,7 @@ export interface KbqTagInputEvent { // @public (undocumented) export class KbqTagList implements KbqFormFieldControl, ControlValueAccessor, AfterContentInit, DoCheck, OnDestroy, CanUpdateErrorState, AfterViewInit { constructor(); + readonly autofilled: i0.Signal; blur(): void; get canShowCleaner(): boolean; readonly change: i0.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;