Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: input field label autocomplete bug fix #289

Merged
merged 7 commits into from
Dec 14, 2022
Merged
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
31 changes: 28 additions & 3 deletions ui/input-shared/src/InputShared.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from "react";
import { theme } from "@washingtonpost/wpds-theme";
import React, { useEffect } from "react";
import { theme, globalCss } from "@washingtonpost/wpds-theme";

export const sharedInputStyles = {
borderRadius: theme.radii["012"],
Expand Down Expand Up @@ -37,6 +37,19 @@ export const sharedInputVariants = {
},
};

export const globalInputAutoFillTriggerAnimations = globalCss({
"@keyframes jsTriggerAutoFillStart": {
from: {
alpha: 1,
},
},
"@keyframes jsTriggerAutoFillCancel": {
from: {
alpha: 1,
},
},
});

export const unstyledInputStyles = {
backgroundColor: "transparent",
border: "none",
Expand All @@ -57,6 +70,18 @@ export const unstyledInputStyles = {
"&:disabled": {
color: "inherit",
},

"&:-webkit-autofill": {
"-webkit-box-shadow": `0 0 0 100px ${theme.colors.secondary} inset`,
"-webkit-text-fill-color": `${theme.colors.primary}`,
// used to trigger JS so that we can do the label shrinking
animation: "jsTriggerAutoFillStart 200ms",
},

"&:not(:-webkit-autofill)": {
// used to trigger JS so that we can stop the label shrinking
animation: "jsTriggerAutoFillCancel 200ms",
},
};

export const useFloating = (
Expand All @@ -75,7 +100,7 @@ export const useFloating = (
const [isFocused, setIsFocused] = React.useState(false);
const prevValue = React.useRef();

React.useEffect(() => {
useEffect(() => {
if (val && !isFloating) {
setIsFloating(true);
setIsTouched(true);
Expand Down
119 changes: 114 additions & 5 deletions ui/input-text/README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,118 @@
# InputText

```jsx
import { InputText } from "@washingtonpost/wpds-ui-kit";
### Handling browser autofill

function Component() {
return <InputText />;
}
It is now standard practice for browsers to save users information and use it to autofill forms for them to provide users with the best experience.

Reasons this can be tricky:

- Browsers override our styles with theirs in these situations. This means they add a blue or yellow background on the inputs that have been autofilled and that could clash with our styles.
- Autofilling a form does _not_ trigger any events on the input. This can cause issues if, for instance, you have a floating label that is triggered solely by JS like we do.

Tackling the first problem isn't too difficult. All we have to do is apply the styles that we want targeting the pseudo element that the browsers add. We use `-webkit-box-shadow` to override the yellow/blue background color the browser adds to the input. We also need to set the color of the font - especially so that you can read the text in dark mode.

```js
"&:-webkit-autofill": {
"-webkit-box-shadow": `0 0 0 100px ${theme.colors.secondary} inset`,
"-webkit-text-fill-color": `${theme.colors.primary}`,
},
```

The second problem is trickier to solve. How can we make our label float when no event is triggered on the autofill?

Approaches we can take:

- Use an interval/timeout to check whether the pseudo class has been applied.
- This is not the best approach because it can make the page slower. Also,We are working on a component level, which means that each input component on the form would have one of these timers. Imagine a page full of inputs with each input having a never-ending interval! The horror!
- Use the `:has` selector.
- This could work, but it's not the best solution. We would have to poll the input to see if the selector has been applied. Additionally, the `:has` selector is still a draft and not fully supported by all browsers.
- Listen for an animation change and use that to trigger label.
- This is the solution we ultimately went with. A more thorough explanation can be found below, but essentially add an animation to the autofill pseudo class selectors and use JS to listen for the change.

#### Our solution

From all the possible approaches, we opted to go with listening to the animation. This solution was adapted from a [solution Klarna UI](https://medium.com/@brunn/detecting-autofilled-fields-in-javascript-aed598d25da7) has used in the past.

To start, we had to add the animation on the autofill pseudo selectors. We created two animations and called them inside the autofill selectors. Note: We need to add these animations to the globalCss because stitches will change the class names and we won't be able to match them inside of our component. We couldn't just add them to globalStyles, however, because not all teams have adopted and/or are using our global styles.

```js
export const globalInputAutoFillTriggerAnimations = globalCss({
"@keyframes jsTriggerAutoFillStart": {
from: {
alpha: 1,
},
},
"@keyframes jsTriggerAutoFillCancel": {
from: {
alpha: 1,
},
},
});
```

```js
export const unstyledInputStyles = {
"&:-webkit-autofill": {
"-webkit-box-shadow": `0 0 0 100px ${theme.colors.secondary} inset`,
"-webkit-text-fill-color": `${theme.colors.primary}`,
// used to trigger JS so that we can do the label shrinking
animation: "jsTriggerAutoFillStart 200ms",
},

"&:not(:-webkit-autofill)": {
// used to trigger JS so that we can stop the label shrinking
animation: "jsTriggerAutoFillCancel 200ms",
},
};
```

In our component we now have to create an event listener to listen to these animations. We need to check the animationName to make sure that it matches either the start or cancel, so that our label floats or stays normal accordingly.

For this component, we also accept a reference from outside and have an interal reference to keep track of our listener. We have to make sure that we don't forget about the external reference.

```js
export const InputText = () => {
// This useEffect checks whether we have an external reference.
// If so, then we take it into account
useEffect(() => {
if (!ref) return;

if (typeof ref === "function") {
ref(internalRef.current);
} else {
ref.current = internalRef.current;
}
}, [ref, internalRef]);

useEffect(() => {
const element = internalRef.current;

const onAnimationStart = (e) => {
// This switch case will not work due to the way stitches does classes
switch (e.animationName) {
case "jsTriggerAutoFillStart":
return setIsAutofilled(true);
case "jsTriggerAutoFillCancel":
return setIsAutofilled(false);
}
};

element?.addEventListener("animationstart", onAnimationStart, false);

// don't forget to clean up your listener
return () => {
element?.removeEventListener("animationstart", onAnimationStart, false);
};
});
};
```

Some extra resources:

- https://medium.com/@brunn/detecting-autofilled-fields-in-javascript-aed598d25da7
- https://stackoverflow.com/questions/11708092/detecting-browser-autofill?page=1&tab=scoredesc#tab-top
- https://github.com/mui/material-ui/issues/22488
- https://github.com/mui/material-ui/issues/14427
- http://webagility.com/posts/the-ultimate-list-of-hacks-for-chromes-forced-yellow-background-on-autocompleted-inputs
- https://stackoverflow.com/questions/22631943/change-font-color-of-autofill-input-field
- https://developer.mozilla.org/en-US/docs/Web/CSS/:has
52 changes: 46 additions & 6 deletions ui/input-text/src/InputText.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import React from "react";
import React, { useEffect, useState } from "react";
import { nanoid } from "nanoid";
import { theme, styled } from "@washingtonpost/wpds-theme";
import { Button } from "@washingtonpost/wpds-button";
import { Icon } from "@washingtonpost/wpds-icon";
import {
useFloating,
unstyledInputStyles,
globalInputAutoFillTriggerAnimations,
} from "@washingtonpost/wpds-input-shared";
import { InputLabel } from "@washingtonpost/wpds-input-label";
import { ErrorMessage } from "@washingtonpost/wpds-error-message";
Expand Down Expand Up @@ -135,16 +136,55 @@ export const InputText = React.forwardRef<HTMLInputElement, InputTextProps>(
},
ref
) => {
const [helperId, setHelperId] = React.useState<string | undefined>();
const [errorId, setErrorId] = React.useState<string | undefined>();
globalInputAutoFillTriggerAnimations();

React.useEffect(() => {
const [helperId, setHelperId] = useState<string | undefined>();
const [errorId, setErrorId] = useState<string | undefined>();
const [isAutofilled, setIsAutofilled] = useState<boolean>(false);
const internalRef = React.useRef<HTMLInputElement>(null);

useEffect(() => {
setHelperId(`wpds-input-helper-${nanoid(6)}`);
setErrorId(`wpds-input-error-${nanoid(6)}`);
}, []);

//takes into account ref that might be passed into the component
useEffect(() => {
if (!ref) return;

if (typeof ref === "function") {
ref(internalRef.current);
} else {
ref.current = internalRef.current;
}
}, [ref, internalRef]);

useEffect(() => {
const element = internalRef.current;

const onAnimationStart = (e) => {
switch (e.animationName) {
case "jsTriggerAutoFillStart":
return setIsAutofilled(true);
case "jsTriggerAutoFillCancel":
return setIsAutofilled(false);
}
};

element?.addEventListener("animationstart", onAnimationStart, false);

return () => {
element?.removeEventListener("animationstart", onAnimationStart, false);
};
});

const [isFloating, handleOnFocus, handleOnBlur, handleOnChange] =
useFloating(value || defaultValue, onFocus, onBlur, onChange);
useFloating(
value || defaultValue || isAutofilled,
onFocus,
onBlur,
onChange
);

function handleButtonIconClick(event) {
onButtonIconClick && onButtonIconClick(event);
Expand Down Expand Up @@ -238,7 +278,7 @@ export const InputText = React.forwardRef<HTMLInputElement, InputTextProps>(
onBlur={handleOnBlur}
onChange={handleOnChange}
onFocus={handleOnFocus}
ref={ref}
ref={internalRef}
required={required}
type={type}
value={value}
Expand Down
63 changes: 51 additions & 12 deletions ui/input-textarea/src/InputTextarea.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
import * as React from "react";
import { nanoid } from "nanoid";
import * as Theme from "@washingtonpost/wpds-theme";
import { theme, css, styled } from "@washingtonpost/wpds-theme";
import type * as WPDS from "@washingtonpost/wpds-theme";
import {
sharedInputStyles,
sharedInputVariants,
useFloating,
globalInputAutoFillTriggerAnimations,
} from "@washingtonpost/wpds-input-shared";
import { InputLabel } from "@washingtonpost/wpds-input-label";
import { ErrorMessage } from "@washingtonpost/wpds-error-message";
import { HelperText } from "@washingtonpost/wpds-helper-text";

import { useEffect, useState } from "react";

const NAME = "InputTextarea";

const InputTextareaCSS = Theme.css({
const InputTextareaCSS = css({
...sharedInputStyles,
display: "block",
minHeight: "$500",
Expand All @@ -33,23 +36,23 @@ const InputTextareaCSS = Theme.css({
},
});

const TextAreaLabel = Theme.styled(InputLabel, {
const TextAreaLabel = styled(InputLabel, {
insetBlockStart: "$050",
insetInlineStart: "$050",
pointerEvents: "none",
position: "absolute",
transition: Theme.theme.transitions.allFast,
transition: theme.transitions.allFast,
variants: {
isFloating: {
true: {
fontSize: Theme.theme.fontSizes["075"],
lineHeight: Theme.theme.lineHeights["100"],
fontSize: theme.fontSizes["075"],
lineHeight: theme.lineHeights["100"],
},
},
},
});

const ControlCSS = Theme.css({
const ControlCSS = css({
display: "flex",
flexDirection: "column",
position: "relative",
Expand Down Expand Up @@ -116,16 +119,52 @@ export const InputTextarea = React.forwardRef<
},
ref
) => {
const [helperId, setHelperId] = React.useState<string | undefined>();
const [errorId, setErrorId] = React.useState<string | undefined>();
globalInputAutoFillTriggerAnimations();

const [helperId, setHelperId] = useState<string | undefined>();
const [errorId, setErrorId] = useState<string | undefined>();
const [isAutofilled, setIsAutofilled] = useState<boolean>(false);

const internalRef = React.useRef<HTMLTextAreaElement>(null);

React.useEffect(() => {
useEffect(() => {
setHelperId(`wpds-input-helper-${nanoid(6)}`);
setErrorId(`wpds-input-error-${nanoid(6)}`);
}, []);

//takes into account ref that might be passed into the component
useEffect(() => {
if (!ref) return;

if (typeof ref === "function") {
ref(internalRef.current);
} else {
ref.current = internalRef.current;
}
}, [ref, internalRef]);

useEffect(() => {
const element = internalRef.current;

const onAnimationStart = (e) => {
console.log(e.animationName);
switch (e.animationName) {
case "jsTriggerAutoFillStart":
return setIsAutofilled(true);
case "jsTriggerAutoFillCancel":
return setIsAutofilled(false);
}
};

element?.addEventListener("animationstart", onAnimationStart, false);

return () => {
element?.removeEventListener("animationstart", onAnimationStart, false);
};
});

const [isFloating, handleFocus, handleBlur, handleChange] = useFloating(
value || defaultValue,
value || defaultValue || isAutofilled,
onFocus,
onBlur,
onChange
Expand All @@ -137,7 +176,7 @@ export const InputTextarea = React.forwardRef<
{...props}
id={id}
name={name}
ref={ref}
ref={internalRef}
className={InputTextareaCSS({
css: css,
canResize: canResize,
Expand Down