diff --git a/package.json b/package.json index 6b06abfe..7ecc4397 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "scripts": { "build": "rm -rf dist && tsup --config tsup.config.mts", "format": "prettier --write src", - "format:translations": "find src/translations -name '*.json' -exec pnpm exec sort-json {} \\;", + "format:translations": "find src/i18n/translations -name '*.json' -exec pnpm exec sort-json {} \\;", "lint": "tsc && eslint --fix src", "prepare": "husky", "storybook": "storybook dev --no-open -p 6006", diff --git a/src/components/Form/Form.tsx b/src/components/Form/Form.tsx index da4d2526..504778cc 100644 --- a/src/components/Form/Form.tsx +++ b/src/components/Form/Form.tsx @@ -142,7 +142,7 @@ const Form = , TData extends z.TypeOf { - const hasErrors = Object.keys(errors).length > 0; + const hasErrors = Object.keys(errors).length > 0 || rootErrors.length; if (hasErrors) { validationSchema .safeParseAsync(values) @@ -156,7 +156,8 @@ const Form = , TData extends z.TypeOf { - revalidate(); + setErrors({}); + setRootErrors([]); }, [resolvedLanguage]); const isSuspended = Boolean(suspendWhileSubmitting && isSubmitting); diff --git a/src/components/OneTimePasswordInput/OneTimePasswordInput.spec.tsx b/src/components/OneTimePasswordInput/OneTimePasswordInput.spec.tsx new file mode 100644 index 00000000..ec72dcc4 --- /dev/null +++ b/src/components/OneTimePasswordInput/OneTimePasswordInput.spec.tsx @@ -0,0 +1,19 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { OneTimePasswordInput } from './OneTimePasswordInput'; + +type Props = React.ComponentPropsWithoutRef; + +const TEST_ID = 'OneTimePasswordInput'; + +const TestOneTimePasswordInput: React.FC> = (props) => { + return ; +}; + +describe('OneTimePasswordInput', () => { + it('should render', () => { + render(); + expect(screen.getByTestId(TEST_ID)).toBeInTheDocument(); + }); +}); diff --git a/src/components/OneTimePasswordInput/OneTimePasswordInput.stories.tsx b/src/components/OneTimePasswordInput/OneTimePasswordInput.stories.tsx new file mode 100644 index 00000000..f28d781e --- /dev/null +++ b/src/components/OneTimePasswordInput/OneTimePasswordInput.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { NotificationHub } from '../NotificationHub'; +import { OneTimePasswordInput } from './OneTimePasswordInput'; + +type Story = StoryObj; + +export default { + args: { + onComplete: (code) => { + alert(`Code: ${code}`); + } + }, + component: OneTimePasswordInput, + decorators: [ + (Story) => { + return ( + <> + + + + ); + } + ] +} as Meta; + +export const Default: Story = {}; diff --git a/src/components/OneTimePasswordInput/OneTimePasswordInput.tsx b/src/components/OneTimePasswordInput/OneTimePasswordInput.tsx new file mode 100644 index 00000000..769fee13 --- /dev/null +++ b/src/components/OneTimePasswordInput/OneTimePasswordInput.tsx @@ -0,0 +1,109 @@ +import { useEffect, useRef, useState } from 'react'; +import type { ChangeEvent, ClipboardEvent, KeyboardEvent } from 'react'; + +import type { Promisable } from 'type-fest'; + +import { useNotificationsStore, useTranslation } from '@/hooks'; +import { cn } from '@/utils'; + +const CODE_LENGTH = 6; + +const EMPTY_CODE = Object.freeze(Array(CODE_LENGTH).fill(null)); + +type OneTimePasswordInputProps = { + [key: `data-${string}`]: unknown; + className?: string; + onComplete: (code: number) => Promisable; +}; + +function getUpdatedDigits(digits: (null | number)[], index: number, value: null | number) { + const updatedDigits = [...digits]; + updatedDigits[index] = value; + return updatedDigits; +} + +export const OneTimePasswordInput = ({ className, onComplete, ...props }: OneTimePasswordInputProps) => { + const notifications = useNotificationsStore(); + const { t } = useTranslation('libui'); + const [digits, setDigits] = useState<(null | number)[]>([...EMPTY_CODE]); + const inputRefs = digits.map(() => useRef(null)); + + useEffect(() => { + const isComplete = digits.every((value) => Number.isInteger(value)); + if (isComplete) { + void onComplete(parseInt(digits.join(''))); + setDigits([...EMPTY_CODE]); + } + }, [digits]); + + const focusNext = (index: number) => inputRefs[index + 1 === digits.length ? 0 : index + 1]?.current?.focus(); + + const focusPrev = (index: number) => inputRefs[index - 1 >= 0 ? index - 1 : digits.length - 1]?.current?.focus(); + + const handleChange = (e: ChangeEvent, index: number) => { + let value: null | number; + if (e.target.value === '') { + value = null; + } else if (Number.isInteger(parseInt(e.target.value))) { + value = parseInt(e.target.value); + } else { + return; + } + setDigits((prevDigits) => getUpdatedDigits(prevDigits, index, value)); + focusNext(index); + }; + + const handleKeyDown = (e: KeyboardEvent, index: number) => { + switch (e.key) { + case 'ArrowLeft': + focusPrev(index); + break; + case 'ArrowRight': + focusNext(index); + break; + case 'Backspace': + setDigits((prevDigits) => getUpdatedDigits(prevDigits, index - 1, null)); + focusPrev(index); + } + }; + + const handlePaste = (e: ClipboardEvent) => { + e.preventDefault(); + const pastedDigits = e.clipboardData + .getData('text/plain') + .split('') + .slice(0, CODE_LENGTH) + .map((value) => parseInt(value)); + const isValid = pastedDigits.length === CODE_LENGTH && pastedDigits.every((value) => Number.isInteger(value)); + if (isValid) { + setDigits(pastedDigits); + } else { + notifications.addNotification({ + message: t('oneTimePasswordInput.invalidCodeFormat'), + type: 'warning' + }); + } + }; + + return ( +
+ {digits.map((_, index) => ( + { + handleChange(e, index); + }} + onKeyDown={(e) => { + handleKeyDown(e, index); + }} + onPaste={handlePaste} + /> + ))} +
+ ); +}; diff --git a/src/components/OneTimePasswordInput/index.ts b/src/components/OneTimePasswordInput/index.ts new file mode 100644 index 00000000..bcfd76d2 --- /dev/null +++ b/src/components/OneTimePasswordInput/index.ts @@ -0,0 +1 @@ +export * from './OneTimePasswordInput'; diff --git a/src/components/index.ts b/src/components/index.ts index 2d2f262f..ff18399b 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -32,6 +32,7 @@ export * from './LineGraph'; export * from './ListboxDropdown'; export * from './MenuBar'; export * from './NotificationHub'; +export * from './OneTimePasswordInput'; export * from './Pagination'; export * from './Popover'; export * from './Progress'; diff --git a/src/i18n/translations/libui.json b/src/i18n/translations/libui.json index 86047a31..103df399 100644 --- a/src/i18n/translations/libui.json +++ b/src/i18n/translations/libui.json @@ -131,11 +131,25 @@ } } }, + "oneTimePasswordInput": { + "invalidCodeFormat": { + "en": "Invalid code format", + "fr": "Format de code invalide" + } + }, "pagination": { + "firstPage": { + "en": "<< First", + "fr": "<< Première" + }, "info": { "en": "Showing {{first}} to {{last}} of {{total}} results", "fr": "Affichage de {{first}} à {{last}} sur {{total}} résultats" }, + "lastPage": { + "en": "Last >>", + "fr": "Dernière >>" + }, "next": { "en": "Next", "fr": "Suivant" @@ -143,14 +157,6 @@ "previous": { "en": "Previous", "fr": "Précédent" - }, - "firstPage": { - "en": "<< First", - "fr": "<< Première" - }, - "lastPage": { - "en": "Last >>", - "fr": "Dernière >>" } }, "searchBar": {