Skip to content

Commit fc4e595

Browse files
edmunditojhammarstedt
authored andcommitted
Improve behavior of password input field (airbytehq#16011)
* Improve behavior of password input field * Show / hide button now focuses back on the text at the previous cursor location * On comonent blur, it hides the information again * Fix logic on input blur * Add hide password on blur test to input * Clear the input selection start on blur * Remove defaultFocus and replace with autoFocus
1 parent 5dae4eb commit fc4e595

File tree

3 files changed

+115
-28
lines changed

3 files changed

+115
-28
lines changed

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

+61-3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { fireEvent } from "@testing-library/react";
1+
import { fireEvent, waitFor } from "@testing-library/react";
2+
import { act } from "react-dom/test-utils";
23

34
import { render } from "utils/testutils";
45

@@ -39,11 +40,68 @@ describe("<Input />", () => {
3940

4041
getByTestId("toggle-password-visibility-button")?.click();
4142

42-
expect(getByTestId("input")).toHaveAttribute("type", "text");
43-
expect(getByTestId("input")).toHaveValue(value);
43+
const inputEl = getByTestId("input") as HTMLInputElement;
44+
45+
expect(inputEl).toHaveAttribute("type", "text");
46+
expect(inputEl).toHaveValue(value);
47+
expect(inputEl.selectionStart).toBe(value.length);
4448
expect(getByRole("img", { hidden: true })).toHaveAttribute("data-icon", "eye-slash");
4549
});
4650

51+
test("showing password should remember cursor position", async () => {
52+
const value = "eight888";
53+
const selectionStart = Math.round(value.length / 2);
54+
55+
const { getByTestId } = await render(<Input type="password" defaultValue={value} />);
56+
const inputEl = getByTestId("input") as HTMLInputElement;
57+
58+
act(() => {
59+
inputEl.selectionStart = selectionStart;
60+
});
61+
62+
getByTestId("toggle-password-visibility-button")?.click();
63+
64+
expect(inputEl.selectionStart).toBe(selectionStart);
65+
});
66+
67+
test("hides password on blur", async () => {
68+
const value = "eight888";
69+
const { getByTestId, getByRole } = await render(<Input type="password" defaultValue={value} />);
70+
71+
getByTestId("toggle-password-visibility-button").click();
72+
73+
const inputEl = getByTestId("input");
74+
75+
expect(inputEl).toHaveFocus();
76+
act(() => inputEl.blur());
77+
78+
await waitFor(() => {
79+
expect(inputEl).toHaveAttribute("type", "password");
80+
expect(getByRole("img", { hidden: true })).toHaveAttribute("data-icon", "eye");
81+
});
82+
});
83+
84+
test("cursor position should be at the end after blur and and clicking on show password button", async () => {
85+
const value = "eight888";
86+
const { getByTestId } = await render(<Input type="password" defaultValue={value} />);
87+
const inputEl = getByTestId("input") as HTMLInputElement;
88+
89+
getByTestId("toggle-password-visibility-button").click();
90+
expect(inputEl).toHaveFocus();
91+
act(() => {
92+
inputEl.selectionStart = value.length / 2;
93+
inputEl.blur();
94+
});
95+
96+
await waitFor(() => {
97+
expect(inputEl).toHaveAttribute("type", "password");
98+
});
99+
100+
getByTestId("toggle-password-visibility-button").click();
101+
expect(inputEl).toHaveFocus();
102+
expect(inputEl.selectionStart).toBe(value.length);
103+
});
104+
47105
test("should trigger onChange once", async () => {
48106
const onChange = jest.fn();
49107
const { getByTestId } = await render(<Input onChange={onChange} />);

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

+53-24
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { faEye, faEyeSlash } from "@fortawesome/free-regular-svg-icons";
22
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
33
import classNames from "classnames";
4-
import React, { useEffect, useRef, useState } from "react";
4+
import React, { useCallback, useRef, useState } from "react";
55
import { useIntl } from "react-intl";
66
import { useToggle } from "react-use";
77
import styled from "styled-components";
@@ -24,7 +24,6 @@ const getBackgroundColor = (props: IStyleProps) => {
2424
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
2525
error?: boolean;
2626
light?: boolean;
27-
defaultFocus?: boolean;
2827
}
2928

3029
const InputContainer = styled.div<InputProps>`
@@ -83,46 +82,76 @@ const VisibilityButton = styled(Button)`
8382
border: none;
8483
`;
8584

86-
const Input: React.FC<InputProps> = ({ defaultFocus = false, onFocus, onBlur, ...props }) => {
85+
const Input: React.FC<InputProps> = ({ ...props }) => {
8786
const { formatMessage } = useIntl();
87+
8888
const inputRef = useRef<HTMLInputElement | null>(null);
89-
const [isContentVisible, setIsContentVisible] = useToggle(false);
89+
const buttonRef = useRef<HTMLButtonElement | null>(null);
90+
const inputSelectionStartRef = useRef<number | null>(null);
91+
92+
const [isContentVisible, toggleIsContentVisible] = useToggle(false);
9093
const [focused, setFocused] = useState(false);
9194

9295
const isPassword = props.type === "password";
9396
const isVisibilityButtonVisible = isPassword && !props.disabled;
9497
const type = isPassword ? (isContentVisible ? "text" : "password") : props.type;
9598

96-
useEffect(() => {
97-
if (defaultFocus && inputRef.current !== null) {
98-
inputRef.current.focus();
99+
const focusOnInputElement = useCallback(() => {
100+
if (!inputRef.current) {
101+
return;
102+
}
103+
104+
const { current: element } = inputRef;
105+
const selectionStart = inputSelectionStartRef.current ?? inputRef.current?.value.length;
106+
107+
element.focus();
108+
109+
if (selectionStart) {
110+
// Update input cursor position to where it was before
111+
window.setTimeout(() => {
112+
element.setSelectionRange(selectionStart, selectionStart);
113+
}, 0);
114+
}
115+
}, []);
116+
117+
const onContainerFocus: React.FocusEventHandler<HTMLDivElement> = () => {
118+
setFocused(true);
119+
};
120+
121+
const onContainerBlur: React.FocusEventHandler<HTMLDivElement> = (event) => {
122+
if (isVisibilityButtonVisible && event.target === inputRef.current) {
123+
// Save the previous selection
124+
inputSelectionStartRef.current = inputRef.current.selectionStart;
125+
}
126+
127+
setFocused(false);
128+
129+
if (isPassword) {
130+
window.setTimeout(() => {
131+
if (document.activeElement !== inputRef.current && document.activeElement !== buttonRef.current) {
132+
toggleIsContentVisible(false);
133+
inputSelectionStartRef.current = null;
134+
}
135+
}, 0);
99136
}
100-
}, [inputRef, defaultFocus]);
137+
};
101138

102139
return (
103140
<InputContainer
104141
className={classNames("input-container", { "input-container--focused": focused })}
105142
data-testid="input-container"
143+
onFocus={onContainerFocus}
144+
onBlur={onContainerBlur}
106145
>
107-
<InputComponent
108-
data-testid="input"
109-
{...props}
110-
ref={inputRef}
111-
type={type}
112-
isPassword={isPassword}
113-
onFocus={(event) => {
114-
setFocused(true);
115-
onFocus?.(event);
116-
}}
117-
onBlur={(event) => {
118-
setFocused(false);
119-
onBlur?.(event);
120-
}}
121-
/>
146+
<InputComponent data-testid="input" {...props} ref={inputRef} type={type} isPassword={isPassword} />
122147
{isVisibilityButtonVisible ? (
123148
<VisibilityButton
149+
ref={buttonRef}
124150
iconOnly
125-
onClick={() => setIsContentVisible()}
151+
onClick={() => {
152+
toggleIsContentVisible();
153+
focusOnInputElement();
154+
}}
126155
type="button"
127156
aria-label={formatMessage({
128157
id: `ui.input.${isContentVisible ? "hide" : "show"}Password`,

airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/components/ConnectionName.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ const ConnectionName: React.FC<ConnectionNameProps> = ({ connection }) => {
7979
onEscape={onEscape}
8080
onEnter={onEnter}
8181
disabled={loading}
82-
defaultFocus
82+
autoFocus
8383
/>
8484
</div>
8585
</div>

0 commit comments

Comments
 (0)