Skip to content

Commit 764b7d9

Browse files
authored
Update Input password component layout to avoid password manager button overlap with visibility button (#12398)
* Update Input password component visibilty button * Update layout to prevent password manager buttons from overlapping with visiblity button * Apply consistent layout to both password and non-password inputs * Add Input component unit test * Update InputProps to interface * Ensure input component can be assigned type * Add aria label to visiblity button in password input * Fix type issues with testutils render
1 parent 407c06c commit 764b7d9

File tree

4 files changed

+123
-56
lines changed

4 files changed

+123
-56
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { render } from "utils/testutils";
2+
3+
import { Input } from "./Input";
4+
5+
describe("<Input />", () => {
6+
test("renders text input", async () => {
7+
const value = "[email protected]";
8+
const { getByTestId, queryByTestId } = await render(<Input type="text" defaultValue={value} />);
9+
10+
expect(getByTestId("input")).toHaveAttribute("type", "text");
11+
expect(getByTestId("input")).toHaveValue(value);
12+
expect(queryByTestId("toggle-password-visibility-button")).toBeFalsy();
13+
});
14+
15+
test("renders another type of input", async () => {
16+
const type = "number";
17+
const value = 888;
18+
const { getByTestId, queryByTestId } = await render(<Input type={type} defaultValue={value} />);
19+
20+
expect(getByTestId("input")).toHaveAttribute("type", type);
21+
expect(getByTestId("input")).toHaveValue(value);
22+
expect(queryByTestId("toggle-password-visibility-button")).toBeFalsy();
23+
});
24+
25+
test("renders password input with visibilty button", async () => {
26+
const value = "eight888";
27+
const { getByTestId, getByRole } = await render(<Input type="password" defaultValue={value} />);
28+
29+
expect(getByTestId("input")).toHaveAttribute("type", "password");
30+
expect(getByTestId("input")).toHaveValue(value);
31+
expect(getByRole("img", { hidden: true })).toHaveAttribute("data-icon", "eye");
32+
});
33+
34+
test("renders visible password when visibility button is clicked", async () => {
35+
const value = "eight888";
36+
const { getByTestId, getByRole } = await render(<Input type="password" defaultValue={value} />);
37+
38+
getByTestId("toggle-password-visibility-button")?.click();
39+
40+
expect(getByTestId("input")).toHaveAttribute("type", "text");
41+
expect(getByTestId("input")).toHaveValue(value);
42+
expect(getByRole("img", { hidden: true })).toHaveAttribute("data-icon", "eye-slash");
43+
});
44+
});

airbyte-webapp/src/components/base/Input/Input.tsx

+69-41
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { faEye, faEyeSlash } from "@fortawesome/free-regular-svg-icons";
22
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
3-
import React, { useState } from "react";
3+
import React from "react";
4+
import { useIntl } from "react-intl";
5+
import { useToggle } from "react-use";
46
import styled from "styled-components";
57
import { Theme } from "theme";
68

@@ -18,72 +20,98 @@ const getBackgroundColor = (props: IStyleProps) => {
1820
return props.theme.greyColor0;
1921
};
2022

21-
export type InputProps = {
23+
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
2224
error?: boolean;
2325
light?: boolean;
24-
} & React.InputHTMLAttributes<HTMLInputElement>;
26+
}
2527

26-
const InputComponent = styled.input<InputProps>`
27-
outline: none;
28+
const InputContainer = styled.div<InputProps>`
2829
width: 100%;
29-
padding: 7px 18px 7px 8px;
30-
border-radius: 4px;
31-
font-size: 14px;
32-
line-height: 20px;
33-
font-weight: normal;
34-
border: 1px solid ${(props) => (props.error ? props.theme.dangerColor : props.theme.greyColor0)};
30+
position: relative;
3531
background: ${(props) => getBackgroundColor(props)};
36-
color: ${({ theme }) => theme.textColor};
37-
caret-color: ${({ theme }) => theme.primaryColor};
38-
39-
&::placeholder {
40-
color: ${({ theme }) => theme.greyColor40};
41-
}
32+
border: 1px solid ${(props) => (props.error ? props.theme.dangerColor : props.theme.greyColor0)};
33+
border-radius: 4px;
4234
4335
&:hover {
4436
background: ${({ theme, light }) => (light ? theme.whiteColor : theme.greyColor20)};
4537
border-color: ${(props) => (props.error ? props.theme.dangerColor : props.theme.greyColor20)};
4638
}
4739
48-
&:focus {
40+
&.input-container--focused {
4941
background: ${({ theme, light }) => (light ? theme.whiteColor : theme.primaryColor12)};
5042
border-color: ${({ theme }) => theme.primaryColor};
5143
}
44+
`;
45+
46+
const InputComponent = styled.input<InputProps & { isPassword?: boolean }>`
47+
outline: none;
48+
width: ${({ isPassword }) => (isPassword ? "calc(100% - 22px)" : "100%")};
49+
padding: 7px 8px 7px 8px;
50+
font-size: 14px;
51+
line-height: 20px;
52+
font-weight: normal;
53+
border: none;
54+
background: none;
55+
color: ${({ theme }) => theme.textColor};
56+
caret-color: ${({ theme }) => theme.primaryColor};
57+
58+
&::placeholder {
59+
color: ${({ theme }) => theme.greyColor40};
60+
}
5261
5362
&:disabled {
5463
pointer-events: none;
5564
color: ${({ theme }) => theme.greyColor55};
5665
}
5766
`;
5867

59-
const Container = styled.div`
60-
width: 100%;
61-
position: relative;
62-
`;
63-
6468
const VisibilityButton = styled(Button)`
6569
position: absolute;
66-
right: 2px;
67-
top: 7px;
70+
right: 0px;
71+
top: 0;
72+
display: flex;
73+
height: 100%;
74+
width: 30px;
75+
align-items: center;
76+
justify-content: center;
77+
border: none;
6878
`;
6979

7080
const Input: React.FC<InputProps> = (props) => {
71-
const [isContentVisible, setIsContentVisible] = useState(false);
72-
73-
if (props.type === "password") {
74-
return (
75-
<Container>
76-
<InputComponent {...props} type={isContentVisible ? "text" : "password"} />
77-
{props.disabled ? null : (
78-
<VisibilityButton iconOnly onClick={() => setIsContentVisible(!isContentVisible)} type="button">
79-
<FontAwesomeIcon icon={isContentVisible ? faEyeSlash : faEye} />
80-
</VisibilityButton>
81-
)}
82-
</Container>
83-
);
84-
}
85-
86-
return <InputComponent {...props} />;
81+
const { formatMessage } = useIntl();
82+
const [isContentVisible, setIsContentVisible] = useToggle(false);
83+
const [focused, toggleFocused] = useToggle(false);
84+
85+
const isPassword = props.type === "password";
86+
const isVisibilityButtonVisible = isPassword && !props.disabled;
87+
const type = isPassword ? (isContentVisible ? "text" : "password") : props.type;
88+
const onInputFocusChange = () => toggleFocused();
89+
90+
return (
91+
<InputContainer {...props} className={focused ? "input-container--focused" : undefined}>
92+
<InputComponent
93+
{...props}
94+
type={type}
95+
isPassword={isPassword}
96+
onFocus={onInputFocusChange}
97+
onBlur={onInputFocusChange}
98+
data-testid="input"
99+
/>
100+
{isVisibilityButtonVisible ? (
101+
<VisibilityButton
102+
iconOnly
103+
onClick={() => setIsContentVisible()}
104+
type="button"
105+
aria-label={formatMessage({
106+
id: `ui.input.${isContentVisible ? "hide" : "show"}Password`,
107+
})}
108+
data-testid="toggle-password-visibility-button"
109+
>
110+
<FontAwesomeIcon icon={isContentVisible ? faEyeSlash : faEye} fixedWidth />
111+
</VisibilityButton>
112+
) : null}
113+
</InputContainer>
114+
);
87115
};
88116

89117
export default Input;

airbyte-webapp/src/locales/en.json

+2
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,8 @@
490490
"errorView.notFound": "Resource not found",
491491
"errorView.unknown": "Unknown",
492492

493+
"ui.input.showPassword": "Show password",
494+
"ui.input.hidePassword": "Hide password",
493495
"ui.keyValuePair": "{key}: {value}",
494496
"ui.keyValuePairV2": "{key} ({value})",
495497
"ui.keyValuePairV3": "{key}, {value}",

airbyte-webapp/src/utils/testutils.tsx

+8-15
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import { act, Queries, render as rtlRender, RenderResult } from "@testing-library/react";
2-
import { History } from "history";
1+
import { act, Queries, queries, render as rtlRender, RenderOptions, RenderResult } from "@testing-library/react";
32
import React from "react";
43
import { IntlProvider } from "react-intl";
54
import { MemoryRouter } from "react-router-dom";
@@ -9,20 +8,14 @@ import { configContext, defaultConfig } from "config";
98
import { FeatureService } from "hooks/services/Feature";
109
import en from "locales/en.json";
1110

12-
export type RenderOptions = {
13-
// optionally pass in a history object to control routes in the test
14-
history?: History;
15-
container?: HTMLElement;
16-
};
17-
1811
type WrapperProps = {
19-
children?: React.ReactNode;
12+
children?: React.ReactElement;
2013
};
2114

22-
export async function render(
23-
ui: React.ReactNode,
24-
renderOptions?: RenderOptions
25-
): Promise<RenderResult<Queries, HTMLElement>> {
15+
export async function render<
16+
Q extends Queries = typeof queries,
17+
Container extends Element | DocumentFragment = HTMLElement
18+
>(ui: React.ReactNode, renderOptions?: RenderOptions<Q, Container>): Promise<RenderResult<Q, Container>> {
2619
function Wrapper({ children }: WrapperProps) {
2720
return (
2821
<TestWrapper>
@@ -35,9 +28,9 @@ export async function render(
3528
);
3629
}
3730

38-
let renderResult: RenderResult<Queries, HTMLElement>;
31+
let renderResult: RenderResult<Q, Container>;
3932
await act(async () => {
40-
renderResult = await rtlRender(<div>{ui}</div>, { wrapper: Wrapper, ...renderOptions });
33+
renderResult = await rtlRender<Q, Container>(<div>{ui}</div>, { wrapper: Wrapper, ...renderOptions });
4134
});
4235

4336
return renderResult!;

0 commit comments

Comments
 (0)