Skip to content
Open
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
38 changes: 38 additions & 0 deletions packages/components/core/common-behaviors/autofill.ts
Original file line number Diff line number Diff line change
@@ -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 `<input>` or a `<textarea>`. Every `KbqFormFieldControl` that can be autofilled
* exposes the result as its `autofilled` member.
*
* This is a hook for application code, not the mechanism behind the autofill styling. The styling
* keys on `:autofill` in CSS, which matches in the same style pass the browser fills the field;
* detection here goes through a zero-length keyframe animation and an `animationstart` listener, so
* it lands a frame or two later — too late to paint with, and early enough for anything a component
* wants to do about it.
*
* `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
19 changes: 19 additions & 0 deletions packages/components/core/styles/theming/_theming.scss
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,25 @@
background: var(--kbq-background-bg);
color: var(--kbq-foreground-contrast);
}

// Which palette the browser paints *its own* surfaces with — scrollbars, native controls, the
// autofill highlight, spellcheck. A theme is a class, and a class tells the browser nothing, so
// without this every one of those renders from the light palette however dark the application
// looks. Autofill is where it shows most: refocusing a filled field reopens the browser's own
// popup, and in that state it paints the highlight and the text itself, over author styles that
// normally hide it — a light block with black text in the middle of a dark form.

// Both declarations at the same specificity on purpose. `color-scheme` inherits, so the nearest
// ancestor carrying either class decides, and a light subtree inside a dark application gets the
// light palette. Weighting one over the other — or scoping this to a component — inverts exactly
// that case.
.kbq-light {
color-scheme: light;
}

.kbq-dark {
color-scheme: dark;
}
}

// @deprecated @TODO: unused. Will be removed in next major release
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.
95 changes: 75 additions & 20 deletions packages/components/form-field/_form-field-theme.scss
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,18 @@
.kbq-input,
.kbq-tag-input,
.kbq-textarea {
color: var(--kbq-form-field-#{$state-name}-text);
// Private, and only for the autofill rule near the bottom of this file. The browser forces
// `color` on an autofilled control with a UA `!important` rule, so that rule has to repaint
// through `-webkit-text-fill-color` — and to do that it 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.
// Published as a variable rather than emitting an autofill rule per state: this mixin runs
// five times, once of them nested under eight `kbq-form-field-type-*` classes, so the
// per-state form compiled to 84 selectors for two declarations — and gave a text colour a
// specificity of (0,7,0), which no consumer could override without `!important`.
--kbq-form-field-resolved-text: var(--kbq-form-field-#{$state-name}-text);

color: var(--kbq-form-field-resolved-text);

&::placeholder {
color: var(--kbq-form-field-#{$state-name}-placeholder);
Expand All @@ -42,21 +53,47 @@
.kbq-form-field {
@include _kbq-form-field-state(default);

// `color-scheme` is what stops the browser painting its own autofill highlight from the
// light palette in a dark application. It is declared once for the whole theme in
// `kbq-core-theme()`, not here: scoping it to the form field inverted nested themes, since
// both the base and the dark rule land on the same specificity and the dark one then won on
// source order under any `.kbq-dark` ancestor, however near a `.kbq-light` was.

// The browser paints its own background on an autofilled control with a UA `!important`
// declaration that no author rule can out-rank. It is suppressed rather than painted over:
// a running transition sits *above* important-UA in the cascade, so animating
// `background-color` over an absurd duration parks its used value at the control's own
// transparent background — every control here is already transparent, `.kbq-input` and
// `.kbq-tag-input` through `kbq-reset-input`, `.kbq-textarea` in its own stylesheet — and
// the container's tint shows through untouched.
// https://css-tricks.com/almanac/selectors/a/autofill/

// Painting over it instead would be wrong: the tint token is translucent, so a second coat
// on the control would make its rectangle visibly darker than the container's padding
// around it, and would still not hide the UA colour underneath.
& .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;
& .kbq-tag-input,
& .kbq-textarea {
// `:is()` takes a forgiving selector list, so every browser keeps whichever of the two
// spellings 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, which would also reset `transition-delay`
// and `transition-timing-function`. Nothing transitions on these controls today,
// but the next thing that does should not break here.
// The duration has to outlast the page, not merely be large: a transition that
// *finishes* lands on its end value, which is the browser's own background, and
// there is no recovering it without a reload. At 5000s that happened after 83
// minutes — a dashboard left open over lunch.
transition-property: background-color;
transition-duration: 600000s;

// Repaints what the state cascade resolved above, so an autofilled control that is
// also invalid or disabled still gets that state's text colour. The fallback covers
// a control outside any state block, which cannot happen today because `default`
// always runs.
-webkit-text-fill-color: var(--kbq-form-field-resolved-text, var(--kbq-form-field-default-text));
caret-color: var(--kbq-form-field-resolved-text, var(--kbq-form-field-default-text));
}
}

Expand Down Expand Up @@ -108,11 +145,29 @@
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;
// Autofill says "the browser filled this in" and nothing more, so it contributes a tint and
// leaves every other channel — border, focus ring, text colour — to the state the field is
// actually in.

// The tint goes into `background-image` rather than `background-color`, and that is what
// makes the sentence above true. The state keeps ownership of `background-color`, the tint
// composites over whatever it resolved, and no priority conflict is possible: an invalid
// field stays error-red *and* tinted, a field in an overlay keeps its card background.
// Painting `background-color` instead is what DS-4060 did, and it needed `!important` to
// land — which is exactly why it then out-ranked error, disabled and the overlay.

// One arm, not three. The rule this replaces spelled out `:-webkit-autofill`,
// `:-webkit-autofill:hover` and `:-webkit-autofill:focus`, mirroring the UA's own selector
// list — but the extra two match the same elements as the bare pseudo-class, and nothing in
// the UA styles this container. The control rule above dropped them for the same reason.
&
.kbq-form-field__container:has(
:is(.kbq-input, .kbq-tag-input, .kbq-textarea):is(:autofill, :-webkit-autofill)
) {
background-image: linear-gradient(
var(--kbq-form-field-states-autofill-background),
var(--kbq-form-field-states-autofill-background)
);
}

&.kbq-disabled {
Expand Down
Loading
Loading