diff --git a/src/components/PinInput/PinInput.tsx b/src/components/PinInput/PinInput.tsx index 757299e861..e0c2998c5b 100644 --- a/src/components/PinInput/PinInput.tsx +++ b/src/components/PinInput/PinInput.tsx @@ -3,13 +3,17 @@ import React from 'react'; import {KeyCode} from '../../constants'; -import {useControlledState, useUniqId} from '../../hooks'; +import {useControlledState, useFocusWithin, useUniqId} from '../../hooks'; +import {useFormResetHandler} from '../../hooks/private'; import type {TextInputProps, TextInputSize} from '../controls'; import {TextInput} from '../controls'; import {OuterAdditionalContent} from '../controls/common/OuterAdditionalContent/OuterAdditionalContent'; import {useDirection} from '../theme'; -import type {AriaLabelingProps, DOMProps, QAProps} from '../types'; +import type {AriaLabelingProps, DOMProps, FocusEvents, QAProps} from '../types'; import {block} from '../utils/cn'; +import {filterDOMProps} from '../utils/filterDOMProps'; + +import i18n from './i18n'; import './PinInput.scss'; @@ -20,7 +24,7 @@ export interface PinInputApi { focus: () => void; } -export interface PinInputProps extends DOMProps, AriaLabelingProps, QAProps { +export interface PinInputProps extends DOMProps, AriaLabelingProps, QAProps, FocusEvents { value?: string[]; defaultValue?: string[]; onUpdate?: (value: string[]) => void; @@ -30,6 +34,7 @@ export interface PinInputProps extends DOMProps, AriaLabelingProps, QAProps { type?: PinInputType; id?: string; name?: string; + form?: string; placeholder?: string; disabled?: boolean; autoFocus?: boolean; @@ -60,11 +65,14 @@ export const PinInput = React.forwardRef((props, defaultValue, onUpdate, onUpdateComplete, + onFocus, + onBlur, length = 4, size = 'm', type = 'numeric', - id, + id: idProp, name, + form, placeholder, disabled, autoFocus, @@ -78,6 +86,7 @@ export const PinInput = React.forwardRef((props, className, style, qa, + ...otherProps } = props; const refs = React.useRef>({}); const [activeIndex, setActiveIndex] = React.useState(0); @@ -245,41 +254,84 @@ export const PinInput = React.forwardRef((props, [activeIndex], ); + const formInputRef = useFormResetHandler({initialValue: values, onReset: setValues}); + + const {focusWithinProps} = useFocusWithin({ + onFocusWithin: onFocus, + onBlurWithin: onBlur, + }); + + let id = useUniqId(); + if (idProp) { + id = idProp; + } + return ( -
+
- {Array.from({length}).map((__, i) => ( -
- -
- ))} + {Array.from({length}).map((__, i) => { + const inputId = `${id}-${i}`; + const ariaLabelledBy = + props['aria-labelledby'] || props['aria-label'] + ? [inputId, props['aria-labelledby'] || id].join(' ') + : undefined; + return ( +
+ +
+ ); + })} + {name ? ( + + ) : null}
void` | | -| onUpdateComplete | Callback fired when any of inputs change and all of them are filled | `(value: string[]) => void` | | -| otp | When set to `true` adds `autocomplete="one-time-code"` to inputs | `boolean` | | -| placeholder | Placeholder for inputs | `string` | | -| qa | HTML `data-qa` attribute, for test purposes | `string` | | -| responsive | Parent's width distributed evenly between inputs | `boolean` | | -| size | Size of input fields | `"s"` `"m"` `"l"` `"xl"` | `"m"` | -| style | HTML `style` attribute | `React.CSSProperties` | | -| type | What type of input value is allowed | `"numeric"` `"alphanumeric"` | `"numeric"` | -| validationState | Validation state. Affect component's appearance | `"invalid"` | | -| value | Current value for controlled component | `string[]` | | +| Name | Description | Type | Default | +| :--------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------: | :---------: | +| apiRef | Ref to the [API](#api) | `React.RefObject` | | +| aria-describedby | HTML `aria-describedby` attribute | `string` | | +| aria-label | HTML `aria-label` attribute | `string` | | +| aria-labelledby | HTML `aria-labelledby` attribute | `string` | | +| autoFocus | Whether or not to focus the first input on initial render | `boolean` | | +| className | HTML `class` attribute | `string` | | +| defaultValue | Initial value for uncontrolled component | `string[]` | | +| disabled | Toggles `disabled` state | `boolean` | | +| errorMessage | Error text placed under the bottom-start corner that shares space with the note container. Only visible when `validationState` is set to `"invalid"` | `React.ReactNode` | | +| id | HTML `id` attribute prefix for inputs. Resulting id will also contain `"-${index}"` part | `string` | | +| length | Number of input fields | `number` | `4` | +| mask | When set to `true` mask input values like password field | `boolean` | | +| name | HTML `name` attribute for input | `string` | | +| form | The associate form of the underlying input element. | `string` | | +| note | An element placed under the bottom-end corner that shares space with the error container | `React.ReactNode` | | +| onUpdate | Callback fired when any of inputs change | `(value: string[]) => void` | | +| onUpdateComplete | Callback fired when any of inputs change and all of them are filled | `(value: string[]) => void` | | +| otp | When set to `true` adds `autocomplete="one-time-code"` to inputs | `boolean` | | +| placeholder | Placeholder for inputs | `string` | | +| qa | HTML `data-qa` attribute, for test purposes | `string` | | +| responsive | Parent's width distributed evenly between inputs | `boolean` | | +| size | Size of input fields | `"s"` `"m"` `"l"` `"xl"` | `"m"` | +| style | HTML `style` attribute | `React.CSSProperties` | | +| type | What type of input value is allowed | `"numeric"` `"alphanumeric"` | `"numeric"` | +| validationState | Validation state. Affect component's appearance | `"invalid"` | | +| value | Current value for controlled component | `string[]` | | +| `onFocus` | Callback fired when the component receives focus | `(event: React.FocusEvent) => void` | | +| `onBlur` | Callback fired when the component loses focus | `(event: React.FocusEvent) => void` | | diff --git a/src/components/PinInput/__stories__/PinInput.stories.tsx b/src/components/PinInput/__stories__/PinInput.stories.tsx index 7f76309747..025c6e5880 100644 --- a/src/components/PinInput/__stories__/PinInput.stories.tsx +++ b/src/components/PinInput/__stories__/PinInput.stories.tsx @@ -5,7 +5,8 @@ import type {Meta, StoryObj} from '@storybook/react'; import {Showcase} from '../../../demo/Showcase'; import {ShowcaseItem} from '../../../demo/ShowcaseItem'; -import type {PinInputProps} from '../PinInput'; +import {Flex} from '../../layout'; +import type {PinInputApi, PinInputProps} from '../PinInput'; import {PinInput} from '../PinInput'; export default { @@ -19,6 +20,8 @@ export const Default: Story = { args: { onUpdate: action('onUpdate'), onUpdateComplete: action('onUpdateComplete'), + onFocus: action('onFocus'), + onBlur: action('onBlur'), 'aria-label': 'PIN code', }, }; @@ -133,3 +136,36 @@ export const Responsive: Story = { responsive: true, }, }; + +export const WithLabel = { + render: function WithLabel(args) { + const id = args.id ?? 'pin-input'; + const labelId = React.useId(); + const refApi = React.useRef(null); + /* eslint-disable jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions */ + return ( + + + + + ); + /* eslint-enable jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions */ + }, + args: { + ...Default.args, + }, +} satisfies Story; diff --git a/src/components/PinInput/__tests__/PinInput.test.tsx b/src/components/PinInput/__tests__/PinInput.test.tsx index 6e92c2a56a..0c1aa48dd9 100644 --- a/src/components/PinInput/__tests__/PinInput.test.tsx +++ b/src/components/PinInput/__tests__/PinInput.test.tsx @@ -287,4 +287,93 @@ describe('PinInput', () => { expect(inputs[1]).toHaveFocus(); }); }); + + describe('Form', () => { + test('should submit empty value by default', async () => { + let value; + const onSubmit = jest.fn((e) => { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + value = [...formData.entries()]; + }); + render( +
+ + + , + ); + await userEvent.click(screen.getByTestId('submit')); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(value).toEqual([['pin-field', '']]); + }); + + test('should submit default value', async () => { + let value; + const onSubmit = jest.fn((e) => { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + value = [...formData.entries()]; + }); + + render( +
+ + + , + ); + await userEvent.click(screen.getByTestId('submit')); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(value).toEqual([['pin-field', '123']]); + }); + + test('should submit controlled value', async () => { + let value; + const onSubmit = jest.fn((e) => { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + value = [...formData.entries()]; + }); + render( +
+ + + , + ); + await userEvent.click(screen.getByTestId('submit')); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(value).toEqual([['pin-field', '123']]); + }); + test('supports form reset', async () => { + function Test() { + const [value, setValue] = React.useState(['1', '2', '3']); + return ( +
+ + + + ); + } + + render(); + // eslint-disable-next-line testing-library/no-node-access + const inputs = document.querySelectorAll('[name=pin-field]'); + expect(inputs.length).toBe(1); + expect(inputs[0]).toHaveValue('123'); + + await userEvent.tab(); + await userEvent.keyboard('4587'); + + expect(inputs[0]).toHaveValue('4587'); + + const button = screen.getByTestId('reset'); + await userEvent.click(button); + expect(inputs[0]).toHaveValue('123'); + }); + }); }); diff --git a/src/components/PinInput/i18n/en.json b/src/components/PinInput/i18n/en.json new file mode 100644 index 0000000000..33fd0a2240 --- /dev/null +++ b/src/components/PinInput/i18n/en.json @@ -0,0 +1,3 @@ +{ + "label_one-of": "{{number}} of {{count}}, " +} diff --git a/src/components/PinInput/i18n/index.ts b/src/components/PinInput/i18n/index.ts new file mode 100644 index 0000000000..c6fc95f1e8 --- /dev/null +++ b/src/components/PinInput/i18n/index.ts @@ -0,0 +1,8 @@ +import {addComponentKeysets} from '../../utils/addComponentKeysets'; + +import en from './en.json'; +import ru from './ru.json'; + +const COMPONENT = 'PinInput'; + +export default addComponentKeysets({en, ru}, COMPONENT); diff --git a/src/components/PinInput/i18n/ru.json b/src/components/PinInput/i18n/ru.json new file mode 100644 index 0000000000..e2d264141f --- /dev/null +++ b/src/components/PinInput/i18n/ru.json @@ -0,0 +1,3 @@ +{ + "label_one-of": "{{number}} из {{count}}, " +} diff --git a/src/components/types.ts b/src/components/types.ts index af8f0aab5c..822997285c 100644 --- a/src/components/types.ts +++ b/src/components/types.ts @@ -90,3 +90,10 @@ export interface AriaLabelingProps { */ 'aria-details'?: string; } + +export interface FocusEvents { + /** Handler that is called when the element receives focus. */ + onFocus?(e: React.FocusEvent): void; + /** Handler that is called when the element loses focus. */ + onBlur?(e: React.FocusEvent): void; +}