Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 172 additions & 0 deletions packages/components-dev/input/autofill.ts
Original file line number Diff line number Diff line change
@@ -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 `<form>` with a submit button: the browser only offers an entry back after the form has been
* submitted once, and only over a secure context (`localhost` counts).
*
* What to check, per field: the container background matches the field's state and not the autofill
* tint whenever the field is disabled, invalid, in an overlay or has no borders; the text and caret match
* the state; the focus ring is fully visible with no notch; and nothing moves when the field is focused.
*/
@Component({
selector: 'dev-autofill',
imports: [
FormsModule,
KbqFormFieldModule,
KbqInputModule,
KbqTextareaModule,
KbqTagsModule,
KbqButtonModule
],
template: `
<h3>Autofill (#DS-4096)</h3>
<p>
Fill the form and submit it once, then reload and pick the saved entry. Serve over
<code>localhost</code>
— the browser will not autofill an insecure origin.
</p>

<form class="dev-autofill" (ngSubmit)="submitted.set(true)">
<div class="dev-autofill__row">
<kbq-form-field>
<kbq-label>Username — default</kbq-label>
<input kbqInput name="username" autocomplete="username" [(ngModel)]="username" />
</kbq-form-field>

<kbq-form-field>
<kbq-label>Password — default</kbq-label>
<input kbqInputPassword name="password" autocomplete="current-password" [(ngModel)]="password" />
<kbq-password-toggle />
</kbq-form-field>
</div>

<div class="dev-autofill__row">
<kbq-form-field>
<kbq-label>Email — invalid (error must beat the autofill tint)</kbq-label>
<input
kbqInput
type="email"
name="email"
autocomplete="email"
[errorStateMatcher]="alwaysError"
[(ngModel)]="email"
/>
</kbq-form-field>

<kbq-form-field>
<kbq-label>Phone — disabled after fill (disabled must beat the tint)</kbq-label>
<input kbqInput name="tel" autocomplete="tel" [disabled]="disabled()" [(ngModel)]="tel" />
</kbq-form-field>
</div>

<div class="dev-autofill__row">
<kbq-form-field noBorders>
<kbq-label>Organization — noBorders</kbq-label>
<input kbqInput name="org" autocomplete="organization" [(ngModel)]="organization" />
</kbq-form-field>

<kbq-form-field [inOverlay]="true">
<kbq-label>Country — inOverlay (must stay on the card background)</kbq-label>
<input kbqInput name="country" autocomplete="country-name" [(ngModel)]="country" />
</kbq-form-field>
</div>

<div class="dev-autofill__row">
<kbq-form-field>
<kbq-label>Address — textarea</kbq-label>
<textarea kbqTextarea name="address" autocomplete="street-address" [(ngModel)]="address"></textarea>
</kbq-form-field>

<kbq-form-field>
<kbq-label>City — tag input (no autocomplete="off" here, unlike the e2e host)</kbq-label>
<kbq-tag-list #tagList>
<input kbqInput name="city" autocomplete="address-level2" [kbqTagInputFor]="tagList" />
</kbq-tag-list>
</kbq-form-field>
</div>

<div class="dev-autofill__row">
<button kbq-button type="submit">Submit (teaches the browser the entry)</button>
<button kbq-button type="button" (click)="disabled.set(!disabled())">
Toggle disabled — the only way to reach autofilled + disabled
</button>
<button kbq-button type="button" (click)="lateFormShown.set(!lateFormShown())">
Toggle the late field
</button>
</div>
</form>

<!--
Created after autofill has already run, so the browser applies its background at the field's
very first style computation. The old implementation masked that background with a
5000-second background-color transition, which needs a value change to start and therefore
did nothing here — this is the reproducer for that failure.
-->
@if (lateFormShown()) {
<form class="dev-autofill">
<kbq-form-field>
<kbq-label>Late field — first-paint autofill</kbq-label>
<input kbqInput name="username" autocomplete="username" [(ngModel)]="lateUsername" />
</kbq-form-field>
</form>
}

@if (submitted()) {
<p>Submitted — reload the page and the browser should offer the entry back.</p>
}
`,
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 = '';
}
4 changes: 3 additions & 1 deletion packages/components-dev/input/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -68,7 +69,8 @@ export class DevDocsExamples {}
DevDocsExamples,
KbqNormalizeWhitespace,
DevThemeToggle,
KbqToggleComponent
KbqToggleComponent,
DevAutofill
],
templateUrl: './template.html',
styleUrls: ['./styles.scss'],
Expand Down
6 changes: 6 additions & 0 deletions packages/components-dev/input/template.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
<dev-theme-toggle />
<hr />

<div class="dev-container">
<dev-autofill />
</div>

<hr />

<div>
<kbq-toggle [(ngModel)]="disabled" (ngModelChange)="$event ? control.disable() : control.enable()">
make all controls disabled
Expand Down
34 changes: 34 additions & 0 deletions packages/components/core/common-behaviors/autofill.ts
Original file line number Diff line number Diff line change
@@ -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 `<input>` or a `<textarea>`. 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<boolean> => {
const elementRef = inject<ElementRef<HTMLElement>>(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();
};
1 change: 1 addition & 0 deletions packages/components/core/common-behaviors/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { InjectionToken } from '@angular/core';

export * from './autofill';
export * from './checkable';
export * from './checkbox';
export * from './clipboard';
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
83 changes: 59 additions & 24 deletions packages/components/form-field/_form-field-theme.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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`
Expand Down Expand Up @@ -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);

Expand All @@ -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));
}
}
}
}

Expand Down
Loading
Loading