Skip to content

Commit f347735

Browse files
authored
Merge pull request #59 from DouglasNeuroInformatics/dev
update
2 parents 4e73617 + 2340dfa commit f347735

File tree

8 files changed

+175
-11
lines changed

8 files changed

+175
-11
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",

src/components/Form/Form.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ const Form = <TSchema extends z.ZodType<FormDataType>, TData extends z.TypeOf<TS
142142
const isGrouped = Array.isArray(content);
143143

144144
const revalidate = () => {
145-
const hasErrors = Object.keys(errors).length > 0;
145+
const hasErrors = Object.keys(errors).length > 0 || rootErrors.length;
146146
if (hasErrors) {
147147
validationSchema
148148
.safeParseAsync(values)
@@ -156,7 +156,8 @@ const Form = <TSchema extends z.ZodType<FormDataType>, TData extends z.TypeOf<TS
156156
};
157157

158158
useEffect(() => {
159-
revalidate();
159+
setErrors({});
160+
setRootErrors([]);
160161
}, [resolvedLanguage]);
161162

162163
const isSuspended = Boolean(suspendWhileSubmitting && isSubmitting);
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { render, screen } from '@testing-library/react';
2+
import { describe, expect, it } from 'vitest';
3+
4+
import { OneTimePasswordInput } from './OneTimePasswordInput';
5+
6+
type Props = React.ComponentPropsWithoutRef<typeof OneTimePasswordInput>;
7+
8+
const TEST_ID = 'OneTimePasswordInput';
9+
10+
const TestOneTimePasswordInput: React.FC<Partial<Props>> = (props) => {
11+
return <OneTimePasswordInput data-testid={TEST_ID} {...(props as Props)} />;
12+
};
13+
14+
describe('OneTimePasswordInput', () => {
15+
it('should render', () => {
16+
render(<TestOneTimePasswordInput />);
17+
expect(screen.getByTestId(TEST_ID)).toBeInTheDocument();
18+
});
19+
});
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: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
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+
[key: `data-${string}`]: unknown;
15+
className?: string;
16+
onComplete: (code: number) => Promisable<void>;
17+
};
18+
19+
function getUpdatedDigits(digits: (null | number)[], index: number, value: null | number) {
20+
const updatedDigits = [...digits];
21+
updatedDigits[index] = value;
22+
return updatedDigits;
23+
}
24+
25+
export const OneTimePasswordInput = ({ className, onComplete, ...props }: OneTimePasswordInputProps) => {
26+
const notifications = useNotificationsStore();
27+
const { t } = useTranslation('libui');
28+
const [digits, setDigits] = useState<(null | number)[]>([...EMPTY_CODE]);
29+
const inputRefs = digits.map(() => useRef<HTMLInputElement>(null));
30+
31+
useEffect(() => {
32+
const isComplete = digits.every((value) => Number.isInteger(value));
33+
if (isComplete) {
34+
void onComplete(parseInt(digits.join('')));
35+
setDigits([...EMPTY_CODE]);
36+
}
37+
}, [digits]);
38+
39+
const focusNext = (index: number) => inputRefs[index + 1 === digits.length ? 0 : index + 1]?.current?.focus();
40+
41+
const focusPrev = (index: number) => inputRefs[index - 1 >= 0 ? index - 1 : digits.length - 1]?.current?.focus();
42+
43+
const handleChange = (e: ChangeEvent<HTMLInputElement>, index: number) => {
44+
let value: null | number;
45+
if (e.target.value === '') {
46+
value = null;
47+
} else if (Number.isInteger(parseInt(e.target.value))) {
48+
value = parseInt(e.target.value);
49+
} else {
50+
return;
51+
}
52+
setDigits((prevDigits) => getUpdatedDigits(prevDigits, index, value));
53+
focusNext(index);
54+
};
55+
56+
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>, index: number) => {
57+
switch (e.key) {
58+
case 'ArrowLeft':
59+
focusPrev(index);
60+
break;
61+
case 'ArrowRight':
62+
focusNext(index);
63+
break;
64+
case 'Backspace':
65+
setDigits((prevDigits) => getUpdatedDigits(prevDigits, index - 1, null));
66+
focusPrev(index);
67+
}
68+
};
69+
70+
const handlePaste = (e: ClipboardEvent<HTMLInputElement>) => {
71+
e.preventDefault();
72+
const pastedDigits = e.clipboardData
73+
.getData('text/plain')
74+
.split('')
75+
.slice(0, CODE_LENGTH)
76+
.map((value) => parseInt(value));
77+
const isValid = pastedDigits.length === CODE_LENGTH && pastedDigits.every((value) => Number.isInteger(value));
78+
if (isValid) {
79+
setDigits(pastedDigits);
80+
} else {
81+
notifications.addNotification({
82+
message: t('oneTimePasswordInput.invalidCodeFormat'),
83+
type: 'warning'
84+
});
85+
}
86+
};
87+
88+
return (
89+
<div className={cn('flex gap-2', className)} {...props}>
90+
{digits.map((_, index) => (
91+
<input
92+
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"
93+
key={index}
94+
maxLength={1}
95+
ref={inputRefs[index]}
96+
type="text"
97+
value={digits[index] ?? ''}
98+
onChange={(e) => {
99+
handleChange(e, index);
100+
}}
101+
onKeyDown={(e) => {
102+
handleKeyDown(e, index);
103+
}}
104+
onPaste={handlePaste}
105+
/>
106+
))}
107+
</div>
108+
);
109+
};
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)