Skip to content

Commit

Permalink
Refactor input focusing after transition (#1071) (#1079)
Browse files Browse the repository at this point in the history
  • Loading branch information
gzdunek authored Aug 3, 2022
1 parent 4479bae commit 0aee186
Show file tree
Hide file tree
Showing 9 changed files with 141 additions and 162 deletions.
40 changes: 25 additions & 15 deletions web/packages/design/src/StepSlider/StepSlider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useState, useRef, useEffect } from 'react';
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { TransitionGroup, CSSTransition } from 'react-transition-group';
import styled from 'styled-components';
import { Box } from 'design';
Expand All @@ -29,13 +29,24 @@ export function StepSlider<T>(props: Props<T>) {
...stepProps
} = props;

const [hasTransitionEnded, setHasTransitionEnded] = useState<boolean>(false);

// step defines the current step we are in the current flow.
const [step, setStep] = useState(0);
// animationDirectionPrefix defines the prefix of the class name that contains
// the animations to apply when transitioning.
const [animationDirectionPrefix, setAnimationDirectionPrefix] = useState<
'next' | 'prev' | ''
>('');

const startTransitionInDirection = useCallback(
(direction: 'next' | 'prev') => {
setAnimationDirectionPrefix(direction);
setHasTransitionEnded(false);
},
[setAnimationDirectionPrefix, setHasTransitionEnded]
);

const [height, setHeight] = useState(0);

// preMount is used to invisibly render the next view so we
Expand All @@ -62,18 +73,22 @@ export function StepSlider<T>(props: Props<T>) {
// It preps data required for pre mounting and sets the
// next animation direction.
useEffect(() => {
if (!newFlow) return; // only true on initial render
// only true on initial render
if (!newFlow) {
setHasTransitionEnded(true);
return;
}

preMountState.current.step = 0; // reset step to 0 to start at beginning
preMountState.current.flow = newFlow.flow;
rootRef.current.style.height = `${height}px`;

setPreMount(true);
if (newFlow.applyNextAnimation) {
setAnimationDirectionPrefix('next');
startTransitionInDirection('next');
return;
}
setAnimationDirectionPrefix('prev');
startTransitionInDirection('prev');
}, [newFlow]);

// After pre mount, we can calculate the exact height of the next step.
Expand All @@ -99,18 +114,16 @@ export function StepSlider<T>(props: Props<T>) {
next={() => {
preMountState.current.step = step + 1;
setPreMount(true);
setAnimationDirectionPrefix('next');
startTransitionInDirection('next');
rootRef.current.style.height = `${height}px`;
}}
prev={() => {
preMountState.current.step = step - 1;
setPreMount(true);
setAnimationDirectionPrefix('prev');
startTransitionInDirection('prev');
rootRef.current.style.height = `${height}px`;
}}
willTransition={
!preMount && Number.isInteger(preMountState?.current?.step)
}
hasTransitionEnded={hasTransitionEnded}
{...stepProps}
/>
);
Expand Down Expand Up @@ -167,8 +180,9 @@ export function StepSlider<T>(props: Props<T>) {
// that may want it to be overflowed e.g. long drop down menu in a small card.
rootRef.current.style.overflow = 'auto';
// Set height back to auto to allow the parent component to grow as needed
// e.g. rendering of a error banner
// e.g. rendering of an error banner
rootRef.current.style.height = 'auto';
setHasTransitionEnded(true);
}}
>
{$content}
Expand Down Expand Up @@ -272,11 +286,7 @@ export type StepComponentProps = {
next(): void;
// prev goes back a step in the flow.
prev(): void;
// willTransition is a flag that when true, transition will take place on click.
// Example of where this flag can be used:
// - FieldInput.tsx: this flag is used to tell this component to autoFocus
// after some transition property has ended.
willTransition: boolean;
hasTransitionEnded: boolean;
};

// NewFlow defines fields for a new flow.
Expand Down
176 changes: 64 additions & 112 deletions web/packages/shared/components/FieldInput/FieldInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,135 +14,87 @@ See the License for the specific language governing permissions and
limitations under the License.
*/

import React, { useRef, useEffect } from 'react';
import React, { forwardRef } from 'react';
import { Box, Input, LabelInput, Text } from 'design';
import { useRule } from 'shared/components/Validation';

export default function FieldInput({
label,
labelTip,
value,
onChange,
onKeyPress,
placeholder,
defaultValue,
min,
max,
rule = defaultRule,
type = 'text',
autoFocus = false,
transitionPropertyName = '',
refocusIndicator = '',
autoComplete = 'off',
inputMode = 'text',
readonly = false,
...styles
}: Props) {
const { valid, message } = useRule(rule(value));
const hasError = !valid;
const labelText = hasError ? message : label;

const inputRef = useRef<HTMLInputElement>();

useEffect(() => {
if (!autoFocus) return;

if (!transitionPropertyName) {
inputRef.current.focus();
return;
}

// autoFocusOnTransitionEnd focus's the input element after transition property name
// defined by 'transitionPropertyName' has ended. This prevents auto focusing during
// transitioning which causes transition to be jumpy caused by trying to bring focused
// element into view. This also prevents prematurely showing the browser password
// manager icons and tooltips while transitioing.
function autoFocusOnTransitionEnd(e: TransitionEvent) {
if (e.propertyName !== transitionPropertyName) return;
inputRef.current.focus();
// Since we only need to auto focus one time, the listener's are no longer needed.
removeListeners();
}

// autoFocusOnTransitionCancel is fallback to autoFocusOnTransitionEnd when the transition
// we are expecting gets canceled (sometimes happens in chrome, but strangely not in firefox).
function autoFocusOnTransitionCancel(e: TransitionEvent) {
if (e.propertyName !== transitionPropertyName) return;
inputRef.current.focus();
// Since we only need to auto focus one time, the listener is no longer needed.
removeListeners();
}

function removeListeners() {
window.removeEventListener('transitionend', autoFocusOnTransitionEnd);
window.removeEventListener(
'transitioncancel',
autoFocusOnTransitionCancel
);
}

window.addEventListener('transitionend', autoFocusOnTransitionEnd);
window.addEventListener('transitioncancel', autoFocusOnTransitionCancel);

return () => {
removeListeners();
};
}, [refocusIndicator]);

const $inputElement = (
<Input
mt={1}
ref={inputRef}
type={type}
hasError={hasError}
placeholder={placeholder}
value={value}
min={min}
max={max}
autoComplete={autoComplete}
onChange={onChange}
onKeyPress={onKeyPress}
readOnly={readonly}
inputMode={inputMode}
defaultValue={defaultValue}
/>
);

return (
<Box mb="4" {...styles}>
{label ? (
<LabelInput mb={0} hasError={hasError}>
{labelText}
{labelTip && <LabelTip text={labelTip} />}
{$inputElement}
</LabelInput>
) : (
$inputElement
)}
</Box>
);
}
const FieldInput = forwardRef<HTMLInputElement, Props>(
(
{
label,
labelTip,
value,
onChange,
onKeyPress,
placeholder,
defaultValue,
min,
max,
rule = defaultRule,
type = 'text',
autoFocus = false,
autoComplete = 'off',
inputMode = 'text',
readonly = false,
...styles
},
ref
) => {
const { valid, message } = useRule(rule(value));
const hasError = !valid;
const labelText = hasError ? message : label;

const $inputElement = (
<Input
mt={1}
ref={ref}
type={type}
hasError={hasError}
placeholder={placeholder}
autoFocus={autoFocus}
value={value}
min={min}
max={max}
autoComplete={autoComplete}
onChange={onChange}
onKeyPress={onKeyPress}
readOnly={readonly}
inputMode={inputMode}
defaultValue={defaultValue}
/>
);

return (
<Box mb="4" {...styles}>
{label ? (
<LabelInput mb={0} hasError={hasError}>
{labelText}
{labelTip && <LabelTip text={labelTip} />}
{$inputElement}
</LabelInput>
) : (
$inputElement
)}
</Box>
);
}
);

const defaultRule = () => () => ({ valid: true });

const LabelTip = ({ text }) => (
<Text as="span" style={{ fontWeight: 'normal' }}>{` - ${text}`}</Text>
);

export default FieldInput;

type Props = {
value?: string;
label?: string;
labelTip?: string;
placeholder?: string;
autoFocus?: boolean;
autoComplete?: 'off' | 'on' | 'one-time-code';
// transitionPropertyName if defined with flag 'autoFocus', is used
// to determine if input element should be auto focused after
// a transition has ended.
transitionPropertyName?: string;
// refocusIndicator is used as a listener for change (with any text value)
// for the useEffect that handles the auto-focusing.
refocusIndicator?: string;
type?: 'email' | 'text' | 'password' | 'number' | 'date' | 'week';
inputMode?: 'text' | 'numeric';
rule?: (options: unknown) => () => unknown;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ const sliderProps: SliderProps & {
prev: () => null,
changeFlow: () => null,
refCallback: () => null,
willTransition: false,
hasTransitionEnded: true,
password: '',
updatePassword: () => null,
};
Expand Down
21 changes: 11 additions & 10 deletions web/packages/teleport/src/Welcome/NewCredentials/NewMfaDevice.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* limitations under the License.
*/

import React, { useState, useEffect } from 'react';
import React, { useState } from 'react';
import styled from 'styled-components';
import { Text, ButtonPrimary, Flex, Box, Link, Image } from 'design';
import { Danger } from 'design/Alert';
Expand All @@ -26,9 +26,12 @@ import {
requiredToken,
} from 'shared/components/Validation/rules';
import createMfaOptions from 'shared/utils/createMfaOptions';

import { useRefAutoFocus } from 'shared/hooks';
import { Auth2faType } from 'shared/services';

import { Props as CredentialsProps, SliderProps } from './NewCredentials';
import secKeyGraphic from './sec-key-with-bg.png';
import { Auth2faType } from 'shared/services';

export function NewMfaDevice(props: Props) {
const {
Expand All @@ -41,21 +44,21 @@ export function NewMfaDevice(props: Props) {
password,
prev,
refCallback,
hasTransitionEnded,
} = props;
const [otp, setOtp] = useState('');
const mfaOptions = createMfaOptions({
auth2faType: auth2faType,
});
const [transitionPropertyName, setTransitionPropertyName] =
useState('height');
const [mfaType, setMfaType] = useState(mfaOptions[0]);
const [deviceName, setDeviceName] = useState(() =>
getDefaultDeviceName(mfaType.value)
);

useEffect(() => {
setTransitionPropertyName('');
}, []);
const deviceNameInputRef = useRefAutoFocus<HTMLInputElement>({
shouldFocus: hasTransitionEnded,
refocusDeps: [mfaType.value],
});

function onBtnClick(
e: React.MouseEvent<HTMLButtonElement>,
Expand Down Expand Up @@ -193,9 +196,7 @@ export function NewMfaDevice(props: Props) {
rule={requiredField('Device name is required')}
label="Device name"
placeholder="Name"
autoFocus
transitionPropertyName={transitionPropertyName}
refocusIndicator={mfaType.value}
ref={deviceNameInputRef}
width={mfaType?.value === 'otp' ? '50%' : '100%'}
value={deviceName}
type="text"
Expand Down
Loading

0 comments on commit 0aee186

Please sign in to comment.