Skip to content

Commit

Permalink
elements: Fix OTPInput selection logic, support single input (#2656)
Browse files Browse the repository at this point in the history
* feat: OTP input selection fixes

* chore(elements): Refactor OTPInput, move props generation into useInput(). Support single input mode

* chore(repo): Add changeset

* chore(elements): Add otp-playground for testing
  • Loading branch information
brkalow authored Jan 25, 2024
1 parent 8c0e159 commit da4090f
Show file tree
Hide file tree
Showing 5 changed files with 239 additions and 180 deletions.
2 changes: 2 additions & 0 deletions .changeset/six-doors-teach.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
53 changes: 53 additions & 0 deletions packages/elements/examples/nextjs/app/otp-playground/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
'use client';

import { Field, Input, Label, SignIn, SignInStart } from '@clerk/elements';
import clsx from 'clsx';
import { AnimatePresence, motion } from 'framer-motion';

export default function Page() {
return (
<SignIn>
<SignInStart>
<div className='h-dvh flex items-center justify-center bg-neutral-800'>
<Field name='code'>
<Label className='sr-only'>Label</Label>
<Input
className='flex gap-3 text-white isolate font-semibold text-2xl'
data-1p-ignore
type='otp'
render={({ value, status }) => (
<div
className={clsx(
'relative flex h-14 w-12 items-center justify-center rounded-md bg-neutral-900 shadow-[0_10px_19px_4px_theme(colors.black/16%),_0_-10px_16px_-4px_theme(colors.white/4%),_0_0_0_1px_theme(colors.white/1%),_0_1px_0_0_theme(colors.white/2%)]',
)}
>
<AnimatePresence>
{value && (
<motion.span
initial={{ opacity: 0, scale: 0.75 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.75 }}
className='absolute inset-0 flex items-center justify-center text-white'
>
{value}
</motion.span>
)}
{value}
</AnimatePresence>

{(status === 'cursor' || status === 'selected') && (
<motion.div
layoutId='tab-trigger-underline'
transition={{ duration: 0.2, type: 'spring', damping: 20, stiffness: 200 }}
className='absolute inset-0 border border-sky-400 shadow-[0_0_8px_2px_theme(colors.sky.400/30%)] z-10 rounded-[inherit] bg-sky-400/10'
/>
)}
</div>
)}
/>
</Field>
</div>
</SignInStart>
</SignIn>
);
}
3 changes: 2 additions & 1 deletion packages/elements/examples/nextjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"@clerk/nextjs": "file:../../nextjs",
"@radix-ui/react-form": "^0.0.3",
"clsx": "^2.0.0",
"framer-motion": "^11.0.2",
"next": "14.0.4",
"react": "^18",
"react-dom": "^18"
Expand All @@ -27,7 +28,7 @@
"eslint": "^8",
"eslint-config-next": "14.0.4",
"postcss": "^8",
"tailwindcss": "^3.3.0",
"tailwindcss": "^3.4.1",
"typescript": "^5"
}
}
212 changes: 33 additions & 179 deletions packages/elements/src/react/common/form/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,8 @@ import {
Label as RadixLabel,
Submit,
} from '@radix-ui/react-form';
import type { ComponentProps, CSSProperties, HTMLProps, ReactNode } from 'react';
import React, {
createContext,
forwardRef,
useCallback,
useContext,
useEffect,
useImperativeHandle,
useLayoutEffect,
useRef,
} from 'react';
import type { ComponentProps, ReactNode } from 'react';
import React, { createContext, useCallback, useContext, useEffect } from 'react';
import type { BaseActorRef } from 'xstate';

import type { ClerkElementsError } from '~/internals/errors/error';
Expand All @@ -37,7 +28,9 @@ import {
} from '~/internals/machines/form/form.context';
import type { FieldDetails } from '~/internals/machines/form/form.types';

import type { ClerkInputType, FieldStates } from './types';
import type { OTPInputProps } from './otp';
import { OTPInput } from './otp';
import type { FieldStates } from './types';

const FieldContext = createContext<Pick<FieldDetails, 'name'> | null>(null);
const useFieldContext = () => useContext(FieldContext);
Expand Down Expand Up @@ -116,14 +109,11 @@ const determineInputTypeFromName = (name: string) => {
return 'text' as const;
};

const useInput = ({
name: inputName,
value: initialValue,
type: inputType,
}: Partial<Pick<FieldDetails, 'name' | 'value'> & { type: ClerkInputType }>) => {
const useInput = ({ name: inputName, value: initialValue, type: inputType, ...passthroughProps }: ClerkInputProps) => {
// Inputs can be used outside of a <Field> wrapper if desired, so safely destructure here
const fieldContext = useFieldContext();
const name = inputName || fieldContext?.name;
const onChangeProp = passthroughProps?.onChange;

const ref = useFormStore();
const value = useFormSelector(fieldValueSelector(name));
Expand All @@ -141,10 +131,11 @@ const useInput = ({
// Register the onChange handler for field updates to persist to the machine context
const onChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
onChangeProp?.(event);
if (!name) return;
ref.send({ type: 'FIELD.UPDATE', field: { name, value: event.target.value } });
},
[ref, name],
[ref, name, onChangeProp],
);

if (!name) {
Expand All @@ -155,14 +146,35 @@ const useInput = ({
const shouldBeHidden = false;
const type = inputType ?? determineInputTypeFromName(name);

const Element = inputType === 'otp' ? OTPInput : RadixControl;

let props = {};
if (inputType === 'otp') {
props = {
'data-otp-input': true,
autoComplete: 'one-time-code',
inputMode: 'numeric',
pattern: '[0-9]*',
maxLength: 6,
onChange: (event: React.ChangeEvent<HTMLInputElement>) => {
// Only accept numbers
event.currentTarget.value = event.currentTarget.value.replace(/\D+/g, '');
onChange(event);
},
};
}

return {
type,
Element,
props: {
type,
value: value ?? '',
onChange,
'data-hidden': shouldBeHidden ? true : undefined,
'data-has-value': hasValue ? true : undefined,
tabIndex: shouldBeHidden ? -1 : 0,
...props,
...passthroughProps,
},
};
};
Expand Down Expand Up @@ -214,169 +226,11 @@ function FieldState({ children }: { children: (state: { state: FieldStates }) =>
type ClerkInputProps = FormControlProps | ({ type: 'otp' } & OTPInputProps);

function Input(props: ClerkInputProps) {
const { name, value, type, ...passthroughProps } = props;
const field = useInput({ name, value, type });

let propsForType = {};
if (field.type === 'otp') {
propsForType = {
type: 'text',
asChild: true,
// @ts-expect-error -- render is passed opaquely by RadixControl
children: <OTPInput />,
};
}
const field = useInput(props);

return (
<RadixControl
type={field.type}
{...field.props}
{...(passthroughProps as FormControlProps)}
{...propsForType}
/>
);
return <field.Element {...field.props} />;
}

type OTPInputProps = Exclude<
HTMLProps<HTMLInputElement>,
'type' | 'autoComplete' | 'maxLength' | 'inputMode' | 'pattern'
> & { render: (props: { value: string; status: 'cursor' | 'selected' | 'none'; index: number }) => ReactNode };

/**
* A custom input component to handle accepting OTP codes. An invisible input element is used to capture input and handle native input
* interactions, while the provided render prop is used to visually render the input's contents.
*/
const OTPInput = forwardRef<HTMLInputElement, OTPInputProps>(function OTPInput(props, ref) {
const length = 6;
const { className, render, ...rest } = props;

const innerRef = useRef<HTMLInputElement>(null);
const [selectionRange, setSelectionRange] = React.useState<[number, number]>([0, 0]);

// This ensures we can access innerRef internally while still exposing it via the ref prop
useImperativeHandle(ref, () => innerRef.current as HTMLInputElement, []);

// A layout effect is used here to avoid any perceived visual lag when changing the selection
useLayoutEffect(() => {
setSelectionRange(cur => {
const updated: [number, number] = [innerRef.current?.selectionStart ?? 0, innerRef.current?.selectionEnd ?? 0];

// When navigating backwards, ensure we select the previous character instead of only moving the cursor
if (updated[0] === cur[0] && updated[1] < cur[1]) {
updated[0] = updated[0] - 1;
}

// Only update the selection if it has changed to avoid unnecessary updates
if (updated[0] !== cur[0] || updated[1] !== cur[1]) {
innerRef.current?.setSelectionRange(updated[0], updated[1]);
return updated;
}

return cur;
});
}, [props.value]);

return (
<div
style={
{
position: 'relative',
} as CSSProperties
}
>
{/* We can't target pseud-elements with the style prop, so we inject a tag here */}
<style>{`
input[data-otp-input]::selection {
color: transparent;
background-color: none;
}
`}</style>
<input
data-otp-input
ref={innerRef}
type='text'
autoComplete='one-time-code'
maxLength={length}
inputMode='numeric'
pattern='[0-9]*'
{...rest}
onChange={event => {
// Only accept numbers
event.currentTarget.value = event.currentTarget.value.replace(/\D+/g, '');

rest?.onChange?.(event);
}}
onSelect={() => {
setSelectionRange(cur => {
let direction: 'forward' | 'backward' = 'forward' as const;
let updated: [number, number] = [
innerRef.current?.selectionStart ?? 0,
innerRef.current?.selectionEnd ?? 0,
];

// Abort unnecessary updates
if (cur[0] === updated[0] && cur[1] === updated[1]) {
return cur;
}

// When moving the selection, we want to select either the previous or next character instead of only moving the cursor.
// If the start and end indices are the same, it means only the cursor has moved and we need to make a decision on which character to select.
if (updated[0] === updated[1]) {
if (updated[0] > 0 && cur[0] === updated[0] && cur[1] === updated[0] + 1) {
direction = 'backward' as const;
updated = [updated[0] - 1, updated[1]];
} else if (typeof innerRef.current?.value[updated[0]] !== 'undefined') {
updated = [updated[0], updated[1] + 1];
}
}

innerRef.current?.setSelectionRange(updated[0], updated[1], direction);

return updated;
});
}}
style={{
display: 'block',
// Attempt to add some padding to let autocomplete overlays show without overlap
width: '110%',
height: '100%',
background: 'none',
outline: 'none',
appearance: 'none',
color: 'transparent',
inset: 0,
position: 'absolute',
}}
/>
<div
className={className}
aria-hidden
style={{
display: 'flex',
justifyContent: 'space-between',
gap: '1em',
zIndex: 1,
height: '100%',
pointerEvents: 'none',
}}
>
{Array.from({ length }).map((_, i) =>
render({
value: String(props.value)[i] || '',
status:
selectionRange[0] === selectionRange[1] && selectionRange[0] === i
? 'cursor'
: selectionRange[0] <= i && selectionRange[1] > i
? 'selected'
: 'none',
index: i,
}),
)}
</div>
</div>
);
});

function Label(props: FormLabelProps) {
return <RadixLabel {...props} />;
}
Expand Down
Loading

0 comments on commit da4090f

Please sign in to comment.