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

feat: add PinInput component #1557

Merged
merged 5 commits into from
Jun 11, 2024
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
1 change: 1 addition & 0 deletions CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
/src/components/Menu @NikitaCG
/src/components/Modal @amje
/src/components/Pagination @jhoncool
/src/components/PinInput @amje
/src/components/Popover @kseniya57
/src/components/Popup @amje
/src/components/Portal @amje
Expand Down
46 changes: 46 additions & 0 deletions src/components/PinInput/PinInput.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
@use '../variables';

$block: '.#{variables.$ns}pin-input';

#{$block} {
display: inline-block;

&__items {
display: inline-flex;
gap: var(--_--gap);
}

&__item {
flex: 0 0 auto;
width: var(--_--item-width);
}

&__control {
// stylelint-disable declaration-no-important
padding-inline: 0 !important;
text-align: center;
appearance: none;
}

&_size {
&_s {
--_--item-width: 22px;
--_--gap: 6px;
}

&_m {
--_--item-width: 26px;
--_--gap: 8px;
}

&_l {
--_--item-width: 34px;
--_--gap: 10px;
}

&_xl {
--_--item-width: 42px;
--_--gap: 12px;
}
}
}
275 changes: 275 additions & 0 deletions src/components/PinInput/PinInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
import React from 'react';

import {KeyCode} from '../../constants';
import {useControlledState, useUniqId} from '../../hooks';
import type {TextInputProps, TextInputSize} from '../controls';
import {TextInput} from '../controls';
import {OuterAdditionalContent} from '../controls/common/OuterAdditionalContent/OuterAdditionalContent';
import {useDirection} from '../theme';
import type {DOMProps, QAProps} from '../types';
import {block} from '../utils/cn';

import './PinInput.scss';

export type PinInputSize = TextInputSize;
export type PinInputType = 'numeric' | 'alphanumeric';

export interface PinInputProps extends DOMProps, QAProps {
value?: string[];
defaultValue?: string[];
onUpdate?: (value: string[]) => void;
onUpdateComplete?: (value: string[]) => void;
length?: number;
size?: PinInputSize;
type?: PinInputType;
id?: string;
name?: string;
placeholder?: string;
disabled?: boolean;
autoFocus?: boolean;
otp?: boolean;
mask?: boolean;
note?: TextInputProps['note'];
validationState?: TextInputProps['validationState'];
errorMessage?: TextInputProps['errorMessage'];
'aria-label'?: string;
'aria-labelledby'?: string;
'aria-describedby'?: string;
}

const b = block('pin-input');
const NUMERIC_REGEXP = /[0-9]+/;
const ALPHANUMERIC_REGEXP = /[0-9a-z]+/i;

const validate = (type: PinInputType, newValue: string) => {
if (type === 'numeric') {
return NUMERIC_REGEXP.test(newValue);
} else {
return ALPHANUMERIC_REGEXP.test(newValue);
}
};

export const PinInput = React.forwardRef<HTMLDivElement, PinInputProps>((props, ref) => {
const {
value,
defaultValue,
onUpdate,
onUpdateComplete,
length = 4,
size = 'm',
type = 'numeric',
id,
name,
placeholder,
disabled,
autoFocus,
otp,
mask,
note,
validationState,
errorMessage,
className,
style,
qa,
} = props;
const refs = React.useRef<Record<number, HTMLInputElement | null>>({});
const [activeIndex, setActiveIndex] = React.useState(0);
const [focusedIndex, setFocusedIndex] = React.useState(-1);
const updateCallback = React.useCallback(
(newValue: string[]) => {
if (onUpdate) {
onUpdate(newValue);
}

if (onUpdateComplete && newValue.every((v) => Boolean(v))) {
korvin89 marked this conversation as resolved.
Show resolved Hide resolved
onUpdateComplete(newValue);
}
},
[onUpdate, onUpdateComplete],
);
const [values, setValues] = useControlledState(
value,
defaultValue ?? Array.from({length}, () => ''),
updateCallback,
);
const direction = useDirection();
const errorMessageId = useUniqId();
const noteId = useUniqId();
const isErrorMsgVisible = validationState === 'invalid' && errorMessage;
const ariaDescribedBy = [
props?.['aria-describedby'],
note ? noteId : undefined,
isErrorMsgVisible ? errorMessageId : undefined,
]
.filter(Boolean)
.join(' ');

const handleRef = (index: number, inputRef: HTMLInputElement | null) => {
refs.current[index] = inputRef;
};

const focus = (index: number) => {
setActiveIndex(index);
refs.current[index]?.focus();
};

const focusPrev = (index: number) => {
if (index > 0) {
focus(index - 1);
}
};

const focusNext = (index: number) => {
if (index < length - 1) {
focus(index + 1);
}
};

const setValuesAtIndex = (index: number, nextValue: string) => {
// Normalize array size to length prop
const newValues = Array.from({length}, (__, i) => values[i] ?? '');

if (nextValue.length > 0) {
// Fill the subsequent inputs as well as the target input
for (let k = 0; k < nextValue.length && index + k < newValues.length; k++) {
newValues[index + k] = nextValue[k];
}
} else {
newValues[index] = '';
}

// If values are the same then do not update
if (newValues.every((__, i) => newValues[i] === values[i])) {
return;
}

setValues(newValues);
};

const handleInputChange = (i: number, event: React.ChangeEvent<HTMLInputElement>) => {
let nextValue = event.currentTarget.value;
const currentValue = values[i];

if (currentValue) {
// Remove the current value from the new value
if (currentValue === nextValue[0]) {
nextValue = nextValue.slice(1);
} else if (currentValue === nextValue[nextValue.length - 1]) {
nextValue = nextValue.slice(0, -1);
}
}

if (!validate(type, nextValue)) {
return;
}

// If value's length greater than 1, then it's a paste so inserting at the start
if (nextValue.length > 1) {
setValuesAtIndex(0, nextValue);
focusNext(nextValue.length - 1);
} else {
setValuesAtIndex(i, nextValue);
focusNext(i);
}
};

const handleInputKeyDown = (i: number, event: React.KeyboardEvent<HTMLInputElement>) => {
switch (event.code) {
case KeyCode.BACKSPACE:
event.preventDefault();

if (event.currentTarget.value) {
setValuesAtIndex(i, '');
} else if (i > 0) {
setValuesAtIndex(i - 1, '');
focusPrev(i);
}

break;
case KeyCode.ARROW_LEFT:
case KeyCode.ARROW_UP:
event.preventDefault();

if (direction === 'rtl' && event.code === KeyCode.ARROW_LEFT) {
focusNext(i);
} else {
focusPrev(i);
}

break;
case KeyCode.ARROW_RIGHT:
case KeyCode.ARROW_DOWN:
event.preventDefault();

if (direction === 'rtl' && event.code === KeyCode.ARROW_RIGHT) {
focusPrev(i);
} else {
focusNext(i);
}

break;
}
};

const handleFocus = (index: number) => {
setFocusedIndex(index);
};

const handleBlur = () => {
setFocusedIndex(-1);
};

React.useEffect(() => {
if (autoFocus) {
focus(0);
}
// We only care about autofocus on initial render
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

return (
<div ref={ref} className={b({size}, className)} style={style} data-qa={qa}>
<div className={b('items')}>
{Array.from({length}).map((__, i) => (
<div key={i} className={b('item')}>
korvin89 marked this conversation as resolved.
Show resolved Hide resolved
<TextInput
// Only pick first symbol while keeping input always controlled
value={values[i]?.[0] ?? ''}
tabIndex={activeIndex === i ? 0 : -1}
type={mask ? 'password' : 'text'}
size={size}
id={id ? `${id}-${i}` : undefined}
name={name}
disabled={disabled}
placeholder={focusedIndex === i ? undefined : placeholder}
autoComplete={otp ? 'one-time-code' : 'off'}
validationState={validationState}
controlProps={{
inputMode: type === 'numeric' ? 'numeric' : 'text',
pattern: type === 'numeric' ? '[0-9]*' : '[0-9a-zA-Z]*',
className: b('control'),
'aria-label': props['aria-label'],
'aria-labelledby': props['aria-labelledby'],
'aria-describedby': ariaDescribedBy,
'aria-invalid': validationState === 'invalid' ? true : undefined,
}}
controlRef={handleRef.bind(null, i)}
korvin89 marked this conversation as resolved.
Show resolved Hide resolved
onChange={handleInputChange.bind(null, i)}
onKeyDown={handleInputKeyDown.bind(null, i)}
onFocus={handleFocus.bind(null, i)}
onBlur={handleBlur}
/>
</div>
))}
</div>
<OuterAdditionalContent
note={note}
errorMessage={isErrorMsgVisible ? errorMessage : null}
noteId={noteId}
errorMessageId={errorMessageId}
/>
</div>
);
});

PinInput.displayName = 'PinInput';
Loading
Loading