Skip to content

Commit 44e4000

Browse files
committed
feat: add OneTimePasswordInput
1 parent 3d1dd0e commit 44e4000

File tree

6 files changed

+152
-9
lines changed

6 files changed

+152
-9
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@
5353
"scripts": {
5454
"build": "rm -rf dist && tsup --config tsup.config.mts",
5555
"format": "prettier --write src",
56-
"format:translations": "find src/translations -name '*.json' -exec pnpm exec sort-json {} \\;",
56+
"format:translations": "find src/i18n/translations -name '*.json' -exec pnpm exec sort-json {} \\;",
5757
"lint": "tsc && eslint --fix src",
5858
"prepare": "husky",
5959
"storybook": "storybook dev --no-open -p 6006",
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import type { Meta, StoryObj } from '@storybook/react';
2+
3+
import { NotificationHub } from '../NotificationHub';
4+
import { OneTimePasswordInput } from './OneTimePasswordInput';
5+
6+
type Story = StoryObj<typeof OneTimePasswordInput>;
7+
8+
export default {
9+
args: {
10+
onComplete: (code) => {
11+
alert(`Code: ${code}`);
12+
}
13+
},
14+
component: OneTimePasswordInput,
15+
decorators: [
16+
(Story) => {
17+
return (
18+
<>
19+
<NotificationHub />
20+
<Story />
21+
</>
22+
);
23+
}
24+
]
25+
} as Meta<typeof OneTimePasswordInput>;
26+
27+
export const Default: Story = {};
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { useEffect, useRef, useState } from 'react';
2+
import type { ChangeEvent, ClipboardEvent, KeyboardEvent } from 'react';
3+
4+
import type { Promisable } from 'type-fest';
5+
6+
import { useNotificationsStore, useTranslation } from '@/hooks';
7+
import { cn } from '@/utils';
8+
9+
const CODE_LENGTH = 6;
10+
11+
const EMPTY_CODE = Object.freeze(Array<null>(CODE_LENGTH).fill(null));
12+
13+
type OneTimePasswordInputProps = {
14+
className?: string;
15+
onComplete: (code: number) => Promisable<void>;
16+
};
17+
18+
function getUpdatedDigits(digits: (null | number)[], index: number, value: null | number) {
19+
const updatedDigits = [...digits];
20+
updatedDigits[index] = value;
21+
return updatedDigits;
22+
}
23+
24+
export const OneTimePasswordInput = ({ className, onComplete }: OneTimePasswordInputProps) => {
25+
const notifications = useNotificationsStore();
26+
const { t } = useTranslation('libui');
27+
const [digits, setDigits] = useState<(null | number)[]>([...EMPTY_CODE]);
28+
const inputRefs = digits.map(() => useRef<HTMLInputElement>(null));
29+
30+
useEffect(() => {
31+
const isComplete = digits.every((value) => Number.isInteger(value));
32+
if (isComplete) {
33+
void onComplete(parseInt(digits.join('')));
34+
setDigits([...EMPTY_CODE]);
35+
}
36+
}, [digits]);
37+
38+
const focusNext = (index: number) => inputRefs[index + 1 === digits.length ? 0 : index + 1]?.current?.focus();
39+
40+
const focusPrev = (index: number) => inputRefs[index - 1 >= 0 ? index - 1 : digits.length - 1]?.current?.focus();
41+
42+
const handleChange = (e: ChangeEvent<HTMLInputElement>, index: number) => {
43+
let value: null | number;
44+
if (e.target.value === '') {
45+
value = null;
46+
} else if (Number.isInteger(parseInt(e.target.value))) {
47+
value = parseInt(e.target.value);
48+
} else {
49+
return;
50+
}
51+
setDigits((prevDigits) => getUpdatedDigits(prevDigits, index, value));
52+
focusNext(index);
53+
};
54+
55+
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>, index: number) => {
56+
switch (e.key) {
57+
case 'ArrowLeft':
58+
focusPrev(index);
59+
break;
60+
case 'ArrowRight':
61+
focusNext(index);
62+
break;
63+
case 'Backspace':
64+
setDigits((prevDigits) => getUpdatedDigits(prevDigits, index - 1, null));
65+
focusPrev(index);
66+
}
67+
};
68+
69+
const handlePaste = (e: ClipboardEvent<HTMLInputElement>) => {
70+
e.preventDefault();
71+
const pastedDigits = e.clipboardData
72+
.getData('text/plain')
73+
.split('')
74+
.slice(0, CODE_LENGTH)
75+
.map((value) => parseInt(value));
76+
const isValid = pastedDigits.length === CODE_LENGTH && pastedDigits.every((value) => Number.isInteger(value));
77+
if (isValid) {
78+
setDigits(pastedDigits);
79+
} else {
80+
notifications.addNotification({
81+
message: t('oneTimePasswordInput.invalidCodeFormat'),
82+
type: 'warning'
83+
});
84+
}
85+
};
86+
87+
return (
88+
<div className={cn('flex gap-2', className)}>
89+
{digits.map((_, index) => (
90+
<input
91+
className="w-1/6 rounded-md border border-slate-300 bg-transparent p-2 shadow-xs hover:border-slate-300 focus:border-sky-800 focus:outline-hidden dark:border-slate-600 dark:hover:border-slate-400 dark:focus:border-sky-500"
92+
key={index}
93+
maxLength={1}
94+
ref={inputRefs[index]}
95+
type="text"
96+
value={digits[index] ?? ''}
97+
onChange={(e) => {
98+
handleChange(e, index);
99+
}}
100+
onKeyDown={(e) => {
101+
handleKeyDown(e, index);
102+
}}
103+
onPaste={handlePaste}
104+
/>
105+
))}
106+
</div>
107+
);
108+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './OneTimePasswordInput';

src/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export * from './LineGraph';
3232
export * from './ListboxDropdown';
3333
export * from './MenuBar';
3434
export * from './NotificationHub';
35+
export * from './OneTimePasswordInput';
3536
export * from './Pagination';
3637
export * from './Popover';
3738
export * from './Progress';

src/i18n/translations/libui.json

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -131,26 +131,32 @@
131131
}
132132
}
133133
},
134+
"oneTimePasswordInput": {
135+
"invalidCodeFormat": {
136+
"en": "Invalid code format",
137+
"fr": "Format de code invalide"
138+
}
139+
},
134140
"pagination": {
141+
"firstPage": {
142+
"en": "<< First",
143+
"fr": "<< Première"
144+
},
135145
"info": {
136146
"en": "Showing {{first}} to {{last}} of {{total}} results",
137147
"fr": "Affichage de {{first}} à {{last}} sur {{total}} résultats"
138148
},
149+
"lastPage": {
150+
"en": "Last >>",
151+
"fr": "Dernière >>"
152+
},
139153
"next": {
140154
"en": "Next",
141155
"fr": "Suivant"
142156
},
143157
"previous": {
144158
"en": "Previous",
145159
"fr": "Précédent"
146-
},
147-
"firstPage": {
148-
"en": "<< First",
149-
"fr": "<< Première"
150-
},
151-
"lastPage": {
152-
"en": "Last >>",
153-
"fr": "Dernière >>"
154160
}
155161
},
156162
"searchBar": {

0 commit comments

Comments
 (0)