diff --git a/playroom/snippets.tsx b/playroom/snippets.tsx index f8419e0352..d52c9e17cc 100644 --- a/playroom/snippets.tsx +++ b/playroom/snippets.tsx @@ -274,6 +274,8 @@ const formSnippets: Array = [ ' \n' + '', ], + ['PinField', ''], + ['PinField (hideCode)', ''], [ 'Form', `
{ await page.click(await screen.findByLabelText('Borrar búsqueda')); expect(await getValue(field)).toBe(''); }); + +test.each(STORY_TYPES)('PinField (%s)', async (storyType) => { + await openStoryPage(getStoryOfType(storyType)); + + const fieldGroup = await screen.findByLabelText('OTP'); + const firstDigitField = await within(fieldGroup).findByLabelText('Dígito 1 de 6'); + await firstDigitField.type('123456'); + + await screen.findByText("onChange: (string) '123456'"); + await screen.findByText("onChangeValue: (string) '123456'"); +}); + +test.each(STORY_TYPES)('PinField (hideCode) (%s)', async (storyType) => { + await openStoryPage(getStoryOfType(storyType)); + + const fieldGroup = await screen.findByLabelText('PIN'); + const firstDigitField = await within(fieldGroup).findByLabelText('Dígito 1 de 6'); + await firstDigitField.type('123456'); + + await screen.findByText("onChange: (string) '123456'"); + await screen.findByText("onChangeValue: (string) '123456'"); +}); + +test('PinField focus management', async () => { + await openStoryPage(CONTROLLED_STORY); + + const fieldGroup = await screen.findByLabelText('OTP'); + + const firstDigitField = await within(fieldGroup).findByLabelText('Dígito 1 de 6'); + const secondDigitField = await within(fieldGroup).findByLabelText('Dígito 2 de 6'); + const thirdDigitField = await within(fieldGroup).findByLabelText('Dígito 3 de 6'); + const forthDigitField = await within(fieldGroup).findByLabelText('Dígito 4 de 6'); + + // try to focus forth field, but the first one is focused instead + await forthDigitField.focus(); + expect(await forthDigitField.evaluate((el) => el === document.activeElement)).toBe(false); + expect(await firstDigitField.evaluate((el) => el === document.activeElement)).toBe(true); + + // focus is moved to second field after typing + await firstDigitField.type('1'); + expect(await secondDigitField.evaluate((el) => el === document.activeElement)).toBe(true); + + await secondDigitField.evaluate((el) => (el as HTMLInputElement).blur()); + expect(await secondDigitField.evaluate((el) => el === document.activeElement)).toBe(false); + + // try to focus forth field, but the second one is focused instead + await forthDigitField.focus(); + expect(await secondDigitField.evaluate((el) => el === document.activeElement)).toBe(true); + + // focus is moved to third field after typing + await secondDigitField.type('2'); + expect(await thirdDigitField.evaluate((el) => el === document.activeElement)).toBe(true); + + // move to previous field with left arrow + await thirdDigitField.press('ArrowLeft'); + expect(await secondDigitField.evaluate((el) => el === document.activeElement)).toBe(true); + await secondDigitField.press('ArrowLeft'); + expect(await firstDigitField.evaluate((el) => el === document.activeElement)).toBe(true); + + // type a number to overwrite the first field value + await firstDigitField.type('9'); + await screen.findByText("onChange: (string) '92'"); + expect(await secondDigitField.evaluate((el) => el === document.activeElement)).toBe(true); + + // move to next field with right arrow + await secondDigitField.press('ArrowRight'); + expect(await thirdDigitField.evaluate((el) => el === document.activeElement)).toBe(true); + + // type a new number + await thirdDigitField.type('3'); + await screen.findByText("onChange: (string) '923'"); + expect(await forthDigitField.evaluate((el) => el === document.activeElement)).toBe(true); + + // go back with Backspace + await forthDigitField.press('Backspace'); + await screen.findByText("onChange: (string) '923'"); + expect(await thirdDigitField.evaluate((el) => el === document.activeElement)).toBe(true); + + // delete with Backspace + await thirdDigitField.press('Backspace'); + await screen.findByText("onChange: (string) '92'"); + expect(await secondDigitField.evaluate((el) => el === document.activeElement)).toBe(true); + + // move left with left arrow and delete with Delete key + await secondDigitField.press('ArrowLeft'); + expect(await firstDigitField.evaluate((el) => el === document.activeElement)).toBe(true); + await firstDigitField.press('Delete'); + await screen.findByText("onChange: (string) '2'"); +}, 1200000); diff --git a/src/__screenshot_tests__/__image_snapshots__/form-fields-screenshot-test-tsx-pin-field-1-snap.png b/src/__screenshot_tests__/__image_snapshots__/form-fields-screenshot-test-tsx-pin-field-1-snap.png new file mode 100644 index 0000000000..6c8a258b76 Binary files /dev/null and b/src/__screenshot_tests__/__image_snapshots__/form-fields-screenshot-test-tsx-pin-field-1-snap.png differ diff --git a/src/__screenshot_tests__/__image_snapshots__/form-fields-screenshot-test-tsx-pin-field-2-snap.png b/src/__screenshot_tests__/__image_snapshots__/form-fields-screenshot-test-tsx-pin-field-2-snap.png new file mode 100644 index 0000000000..bb0acf544a Binary files /dev/null and b/src/__screenshot_tests__/__image_snapshots__/form-fields-screenshot-test-tsx-pin-field-2-snap.png differ diff --git a/src/__screenshot_tests__/__image_snapshots__/form-fields-screenshot-test-tsx-pin-field-3-snap.png b/src/__screenshot_tests__/__image_snapshots__/form-fields-screenshot-test-tsx-pin-field-3-snap.png new file mode 100644 index 0000000000..616b253072 Binary files /dev/null and b/src/__screenshot_tests__/__image_snapshots__/form-fields-screenshot-test-tsx-pin-field-3-snap.png differ diff --git a/src/__screenshot_tests__/__image_snapshots__/form-fields-screenshot-test-tsx-pin-field-hide-code-1-snap.png b/src/__screenshot_tests__/__image_snapshots__/form-fields-screenshot-test-tsx-pin-field-hide-code-1-snap.png new file mode 100644 index 0000000000..48b2b6374f Binary files /dev/null and b/src/__screenshot_tests__/__image_snapshots__/form-fields-screenshot-test-tsx-pin-field-hide-code-1-snap.png differ diff --git a/src/__screenshot_tests__/form-fields-screenshot-test.tsx b/src/__screenshot_tests__/form-fields-screenshot-test.tsx index f7080eb7c8..8f9d1067bc 100644 --- a/src/__screenshot_tests__/form-fields-screenshot-test.tsx +++ b/src/__screenshot_tests__/form-fields-screenshot-test.tsx @@ -1,4 +1,5 @@ import {openStoryPage, screen} from '../test-utils'; +import {within} from '@telefonica/acceptance-testing'; import type {Device} from '../test-utils'; @@ -219,3 +220,33 @@ test('Very long label should show ellipsis', async () => { expect(await fieldWrapper.screenshot()).toMatchImageSnapshot(); }); + +test('PinField', async () => { + await openStoryPage({ + id: 'components-input-fields--types-uncontrolled', + device: 'MOBILE_IOS', + }); + + const fieldGroup = await screen.findByLabelText('OTP'); + expect(await fieldGroup.screenshot()).toMatchImageSnapshot(); + + const firstDigitField = await within(fieldGroup).findByLabelText('Dígito 1 de 6'); + await firstDigitField.focus(); + expect(await fieldGroup.screenshot()).toMatchImageSnapshot(); + + await firstDigitField.type('1'); + expect(await fieldGroup.screenshot()).toMatchImageSnapshot(); +}); + +test('PinField (hideCode)', async () => { + await openStoryPage({ + id: 'components-input-fields--types-uncontrolled', + device: 'MOBILE_IOS', + }); + + const fieldGroup = await screen.findByLabelText('PIN'); + + const firstDigitField = await within(fieldGroup).findByLabelText('Dígito 1 de 6'); + await firstDigitField.type('1'); + expect(await fieldGroup.screenshot()).toMatchImageSnapshot(); +}); diff --git a/src/__stories__/field-story.tsx b/src/__stories__/field-story.tsx index 8f7bf004fe..92e38c5726 100644 --- a/src/__stories__/field-story.tsx +++ b/src/__stories__/field-story.tsx @@ -22,6 +22,7 @@ import { Form, Title1, Stack, + PinField, } from '..'; import {inspect} from 'util'; import IconMusicRegular from '../generated/mistica-icons/icon-music-regular'; @@ -461,6 +462,31 @@ export const TypesUncontrolled: StoryComponent = () => ( /> )} + + + {(handleChange, handleChangeValue) => ( + + )} + + + + {(handleChange, handleChangeValue) => ( + + )} + ); @@ -708,6 +734,31 @@ export const TypesControlled = (): React.ReactNode => ( )} + + + {(handleChange, handleChangeValue, value) => ( + + )} + + + + {(handleChange, handleChangeValue, value) => ( + + )} + ); diff --git a/src/__tests__/sheet-test.tsx b/src/__tests__/sheet-test.tsx index e416c072d3..734125474a 100644 --- a/src/__tests__/sheet-test.tsx +++ b/src/__tests__/sheet-test.tsx @@ -45,7 +45,7 @@ test('Sheet', async () => { await userEvent.click(closeButton); await waitForElementToBeRemoved(sheet); -}); +}, 20000); test('RadioListSheet', async () => { const selectSpy = jest.fn(); @@ -95,7 +95,7 @@ test('RadioListSheet', async () => { await waitForElementToBeRemoved(sheet); expect(selectSpy).toHaveBeenCalledWith('1'); -}, 15000); +}, 20000); test('ActionsListSheet', async () => { const selectSpy = jest.fn(); @@ -144,7 +144,7 @@ test('ActionsListSheet', async () => { await waitForElementToBeRemoved(sheet); expect(selectSpy).toHaveBeenCalledWith('1'); -}); +}, 20000); test('InfoSheet', async () => { const TestComponent = () => { @@ -193,7 +193,7 @@ test('InfoSheet', async () => { const items = await within(itemList).findAllByRole('listitem'); expect(items).toHaveLength(2); -}); +}, 20000); test('ActionsSheet', async () => { const onPressButtonSpy = jest.fn(); @@ -249,7 +249,7 @@ test('ActionsSheet', async () => { await waitForElementToBeRemoved(sheet); expect(onPressButtonSpy).toHaveBeenCalledWith('SECONDARY'); -}); +}, 20000); test('showSheet INFO', async () => { const resultSpy = jest.fn(); @@ -277,7 +277,7 @@ test('showSheet INFO', async () => { await waitForElementToBeRemoved(sheet); expect(resultSpy).toHaveBeenCalledWith(undefined); -}); +}, 20000); test('showSheet ACTIONS_LIST', async () => { const resultSpy = jest.fn(); @@ -309,7 +309,7 @@ test('showSheet ACTIONS_LIST', async () => { await waitForElementToBeRemoved(sheet); expect(resultSpy).toHaveBeenCalledWith({action: 'SUBMIT', selectedId: '2'}); -}); +}, 20000); test('showSheet ACTIONS_LIST dismiss', async () => { const resultSpy = jest.fn(); @@ -340,7 +340,7 @@ test('showSheet ACTIONS_LIST dismiss', async () => { await waitForElementToBeRemoved(sheet); expect(resultSpy).toHaveBeenCalledWith({action: 'DISMISS'}); -}); +}, 20000); test('showSheet RADIO_LIST', async () => { const resultSpy = jest.fn(); @@ -374,7 +374,7 @@ test('showSheet RADIO_LIST', async () => { await waitForElementToBeRemoved(sheet); expect(resultSpy).toHaveBeenCalledWith({action: 'SUBMIT', selectedId: '2'}); -}); +}, 20000); test('showSheet RADIO_LIST dismiss', async () => { const resultSpy = jest.fn(); @@ -405,7 +405,7 @@ test('showSheet RADIO_LIST dismiss', async () => { await waitForElementToBeRemoved(sheet); expect(resultSpy).toHaveBeenCalledWith({action: 'DISMISS'}); -}); +}, 20000); test('showSheet ACTIONS', async () => { const resultSpy = jest.fn(); @@ -444,7 +444,7 @@ test('showSheet ACTIONS', async () => { await waitForElementToBeRemoved(sheet); expect(resultSpy).toHaveBeenCalledWith({action: 'LINK'}); -}); +}, 20000); test('showSheet ACTIONS dismiss', async () => { const resultSpy = jest.fn(); @@ -476,7 +476,7 @@ test('showSheet ACTIONS dismiss', async () => { await waitForElementToBeRemoved(sheet); expect(resultSpy).toHaveBeenCalledWith({action: 'DISMISS'}); -}); +}, 20000); test('showSheet fails if SheetRoot is not rendered', async () => { await expect( @@ -743,4 +743,4 @@ test('showSheet with native implementation fallbacks to web if native fails', as await waitForElementToBeRemoved(sheet); expect(resultSpy).toHaveBeenCalledWith({action: 'LINK'}); -}); +}, 20000); diff --git a/src/form-context.tsx b/src/form-context.tsx index 2ba038008e..2c19f8f380 100644 --- a/src/form-context.tsx +++ b/src/form-context.tsx @@ -107,17 +107,17 @@ export const useFieldProps = ({ onChangeValue, }: { name: string; - value: string | undefined; - defaultValue: string | undefined; + value?: string; + defaultValue?: string; processValue: (value: string) => unknown; - helperText: string | undefined; - optional: boolean | undefined; - error: boolean | undefined; - disabled: boolean | undefined; + helperText?: string; + optional?: boolean; + error?: boolean; + disabled?: boolean; onBlur?: React.FocusEventHandler; - validate: undefined | ((value: any, rawValue: string) => string | undefined); - onChange: undefined | ((event: React.ChangeEvent) => void); - onChangeValue: undefined | ((value: any, rawValue: string) => void); + validate?: (value: any, rawValue: string) => string | undefined; + onChange?: (event: React.ChangeEvent) => void; + onChangeValue?: (value: any, rawValue: string) => void; }): { value?: string; defaultValue?: string; diff --git a/src/index.tsx b/src/index.tsx index b3169477e0..cfcd974751 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -123,6 +123,7 @@ export {default as StackingGroup} from './stacking-group'; export {default as Form} from './form'; export {default as Select} from './select'; export {default as TextField} from './text-field'; +export {default as PinField} from './pin-field'; export {TextFieldBase} from './text-field-base'; export {default as SearchField} from './search-field'; export {default as EmailField} from './email-field'; diff --git a/src/integer-field.tsx b/src/integer-field.tsx index 858e2a089d..dd69511c7f 100644 --- a/src/integer-field.tsx +++ b/src/integer-field.tsx @@ -5,8 +5,22 @@ import {TextFieldBaseAutosuggest} from './text-field-base'; import type {CommonFormFieldProps} from './text-field-base'; -export const IntegerInput: React.FC = ({inputRef, value, defaultValue, ...rest}: any) => { - const format = (v?: string) => String(v ?? '').replace(/[^\d]/g, ''); +type IntegerInputProps = Omit< + React.InputHTMLAttributes, + 'inputMode' | 'pattern' | 'onInput' | 'type' +> & { + inputRef: React.ForwardedRef; + type?: 'text' | 'password'; +}; + +export const IntegerInput = ({ + inputRef, + value, + defaultValue, + type = 'text', + ...rest +}: IntegerInputProps): React.ReactElement => { + const format = (v?: unknown) => String(v ?? '').replace(/[^\d]/g, ''); const handleInput = (e: React.FormEvent) => { // strip all non numeric characters @@ -19,7 +33,7 @@ export const IntegerInput: React.FC = ({inputRef, value, defaultValue, ...r pattern="[0-9]*" // shows numeric keypad in iOS onInput={handleInput} ref={inputRef} - type="text" + type={type} value={value === undefined ? undefined : format(value)} defaultValue={defaultValue === undefined ? undefined : format(defaultValue)} /> diff --git a/src/pin-field.css.ts b/src/pin-field.css.ts new file mode 100644 index 0000000000..bf7b1c82b1 --- /dev/null +++ b/src/pin-field.css.ts @@ -0,0 +1,80 @@ +import {vars} from './skins/skin-contract.css'; +import {sprinkles} from './sprinkles.css'; +import {keyframes, style} from '@vanilla-extract/css'; +import {desktopFontSize, mobileFontSize} from './text-field-base.css'; +import * as mq from './media-queries.css'; + +import type {Sprinkles} from './sprinkles.css'; + +export const disabled = style({ + opacity: 0.5, + cursor: 'default', +}); + +const fieldCommonStyles: Sprinkles = { + overflow: 'hidden', + border: 'regular', + display: 'flex', + borderRadius: vars.borderRadii.input, + position: 'relative', + width: 48, + height: 48, + background: vars.colors.backgroundContainer, +}; + +export const field = sprinkles(fieldCommonStyles); + +export const focusedField = style([ + field, + { + border: `1px solid ${vars.colors.controlActivated}`, + }, +]); + +export const readOnlyField = sprinkles({ + ...fieldCommonStyles, + background: vars.colors.neutralLow, +}); + +export const input = style({ + textAlign: 'center', +}); + +export const passwordInput = sprinkles({ + color: 'transparent', +}); + +const passwordDotAnimation = keyframes({ + '0%': { + scale: 0, + }, + '100%': { + scale: 1, + }, +}); + +export const passwordDot = style({ + userSelect: 'none', + pointerEvents: 'none', + fontFamily: 'Lucida Grande, Arial, sans-serif', // same font we use for password input + fontSize: mobileFontSize, + '@media': { + [mq.desktopOrBigger]: { + fontSize: desktopFontSize, + }, + }, + color: vars.colors.textPrimary, + animationName: passwordDotAnimation, + animationDuration: '0.3s', + animationTimingFunction: 'cubic-bezier(0.77, 0, 0.175, 1)', + transformOrigin: 'center', + position: 'absolute', + top: 0, + bottom: 0, + left: 0, + right: 0, + margin: 'auto', // center the div + width: 16, + height: 16, + textAlign: 'center', +}); diff --git a/src/pin-field.tsx b/src/pin-field.tsx new file mode 100644 index 0000000000..33feb7a1c0 --- /dev/null +++ b/src/pin-field.tsx @@ -0,0 +1,330 @@ +import classNames from 'classnames'; +import * as React from 'react'; +import Inline from './inline'; +import * as textFieldStyles from './text-field-base.css'; +import * as styles from './pin-field.css'; +import {useAriaId, useTheme} from './hooks'; +import ScreenReaderOnly from './screen-reader-only'; +import {IntegerInput} from './integer-field'; +import {useFieldProps} from './form-context'; +import {createChangeEvent} from './utils/dom'; +import {HelperText} from './text-field-components'; +import {flushSync} from 'react-dom'; + +// Protection for when there is more than one OtpField in the page. +// This should't be a supported use case, but we need it in storybook/playroom, and for some reason +// some Chrome versions crash when using navigator.credentials.get() more than once simultaneously. +let isWaitingForSms = false; + +type PinInputProps = { + length?: number; + hideCode?: boolean; + readSms?: boolean; + disabled?: boolean; + readOnly?: boolean; + value?: string; + defaultValue?: string; + onChange?: (event: React.ChangeEvent) => void; + inputRef: (field: HTMLInputElement | null) => void; +}; + +const PinInput = ({ + length = 6, + hideCode = false, + readSms, + disabled, + readOnly, + value, + defaultValue, + onChange, + inputRef, +}: PinInputProps): React.ReactElement => { + const {texts} = useTheme(); + const [selfValue, setSelfValue] = React.useState(defaultValue?.slice(0, length) ?? ''); + const [focusIndex, setFocusIndex] = React.useState(undefined); + + const inputsList: Array = React.useRef(Array.from({length}, () => null)).current; + + const isControlledByParent = typeof value !== 'undefined'; + const controlledValue: string = isControlledByParent ? value : selfValue; + + const changeValue = React.useCallback( + (newValue: string) => { + if (newValue === controlledValue) { + return; + } + if (!isControlledByParent) { + setSelfValue(newValue); + } + const firstInput = inputsList[0]; + if (firstInput) { + onChange?.(createChangeEvent({...firstInput}, newValue)); + } + }, + [controlledValue, inputsList, isControlledByParent, onChange] + ); + + // sync controlled value if length changes + React.useEffect(() => { + changeValue(controlledValue.slice(0, length)); + }, [length, controlledValue, changeValue]); + + React.useEffect(() => { + // https://developer.mozilla.org/en-US/docs/Web/API/WebOTP_API + if (readSms && 'OTPCredential' in window && !isWaitingForSms) { + isWaitingForSms = true; + const abortController = new AbortController(); + navigator.credentials + .get({ + // @ts-expect-error: otp is not in the types yet + otp: {transport: ['sms']}, + signal: abortController.signal, + }) + .then((otp) => { + if (otp) { + // @ts-expect-error: otp is not in the types yet + const code = otp.code.slice(0, length); + changeValue(code); + } + }) + .catch(() => { + // ignore; + }) + .finally(() => { + isWaitingForSms = false; + }); + return () => { + isWaitingForSms = false; + abortController.abort(); + }; + } + }, [changeValue, length, readSms]); + + const createInputChangeHandler = (index: number) => (event: React.ChangeEvent) => { + const eventValue = event.target.value; + + // digit was deleted + if (eventValue === '') { + // case already handled in onKeyDown + return; + } + + const currentValue = controlledValue[index]; + let newInputValue: string = eventValue; + if (!currentValue || currentValue === eventValue) { + newInputValue = eventValue; + } else if (currentValue === eventValue[0]) { + newInputValue = eventValue.slice(1); + } else if (currentValue === eventValue[eventValue.length - 1]) { + newInputValue = eventValue.slice(0, -1); + } + + let indexToFocus = index; + let newControlledValue = controlledValue; + + // in the case of an autocomplete or copy and paste + if (newInputValue.length >= 2) { + const toPaste = newInputValue.slice(0, length - index); + const prevChars = controlledValue.slice(0, index); + + newControlledValue = prevChars + toPaste; + indexToFocus = Math.min(index + toPaste.length, length - 1); + } else { + newControlledValue = + controlledValue.slice(0, index) + newInputValue + controlledValue.slice(index + 1); + indexToFocus = index + 1; + } + + if (newControlledValue === controlledValue) { + return; + } + // need to flush sync to commit the new values to the dom before changing the focus + flushSync(() => { + changeValue(newControlledValue); + }); + if (indexToFocus !== index && indexToFocus <= length - 1) { + inputsList[indexToFocus]?.focus(); + } + }; + + return ( + + {Array.from({length}).map((_, index) => ( +
+ controlledValue.length ? -1 : undefined} + required + onFocus={() => { + const firstIndexWithoutValue = + controlledValue.length === length ? -1 : controlledValue.length; + if (firstIndexWithoutValue >= 0 && firstIndexWithoutValue < index) { + inputsList[firstIndexWithoutValue]?.focus(); + } else { + setFocusIndex(index); + } + }} + onBlur={() => { + setFocusIndex(undefined); + }} + inputRef={(el) => { + inputsList[index] = el; + + if (index === 0) { + inputRef(el); + } + }} + className={classNames( + textFieldStyles.input, + textFieldStyles.inputWithoutLabel, + styles.input, + { + [styles.passwordInput]: hideCode, + } + )} + disabled={disabled} + readOnly={readOnly} + autoComplete={readSms ? 'one-time-code' : undefined} + value={controlledValue[index] ?? ''} + onChange={createInputChangeHandler(index)} + onKeyDown={(event) => { + switch (event.key) { + case 'Backspace': + case 'Delete': + if (event.currentTarget.value) { + // remove the char independently of caret position + changeValue( + controlledValue.slice(0, index) + controlledValue.slice(index + 1) + ); + } + if (index > 0 && index >= controlledValue.length - 1) { + const prevInput = inputsList[index - 1]; + prevInput?.focus(); + } + break; + case 'ArrowLeft': + if (index > 0) { + const prevInput = inputsList[index - 1]; + if (prevInput) { + prevInput.focus(); + } + } + break; + case 'ArrowRight': + if (index < length - 1) { + const nextInput = inputsList[index + 1]; + if (nextInput) { + nextInput.focus(); + } + } + break; + default: + // ignore + } + }} + /> + {hideCode && controlledValue[index] && ( +
+ • +
+ )} +
+ ))} +
+ ); +}; + +type OtpFieldProps = { + length?: number; + /** + * Whether to hide the input code (password like input), false by default. + */ + hideCode?: boolean; + /** + * Whether to read incoming SMS with OTP codes. It's true by default if hideCode is false, and false otherwise. + */ + readSms?: boolean; + disabled?: boolean; + readOnly?: boolean; + name: string; + value?: string; + defaultValue?: string; + helperText?: string; + error?: boolean; + onChangeValue?: (value: string, rawValue: string) => void; + onChange?: (event: React.ChangeEvent) => void; + 'aria-label'?: string; + 'aria-labelledby'?: string; +}; + +const PinField = ({ + length = 6, + hideCode = false, + readSms = !hideCode, // by default, don't read sms if the code is hidden (password input type) + disabled, + readOnly, + name, + value, + defaultValue, + helperText, + error, + onChangeValue, + onChange, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledBy, +}: OtpFieldProps): React.ReactElement => { + const fieldProps = useFieldProps({ + name, + value, + defaultValue, + processValue: (s) => s, + helperText, + optional: false, + error, + disabled, + onChangeValue, + onChange, + }); + + const otpLabelId = useAriaId(); + + return ( +
+ {ariaLabel && !ariaLabelledBy && ( + +
{ariaLabel}
+
+ )} + + +
+ ); +}; + +export default PinField; diff --git a/src/text-field-base.css.ts b/src/text-field-base.css.ts index c9a43fd84f..069900a9b2 100644 --- a/src/text-field-base.css.ts +++ b/src/text-field-base.css.ts @@ -3,6 +3,9 @@ import {sprinkles} from './sprinkles.css'; import {vars} from './skins/skin-contract.css'; import * as mq from './media-queries.css'; +export const mobileFontSize = 16; +export const desktopFontSize = 18; + const commonInputStyles = style([ sprinkles({ border: 'none', @@ -14,10 +17,10 @@ const commonInputStyles = style([ background: 'none', outline: 0, lineHeight: '24px', - fontSize: 16, + fontSize: mobileFontSize, '@media': { [mq.desktopOrBigger]: { - fontSize: 18, + fontSize: desktopFontSize, }, }, caretColor: vars.colors.controlActivated, diff --git a/src/text-field-components.tsx b/src/text-field-components.tsx index 7823f3d55f..d37310636b 100644 --- a/src/text-field-components.tsx +++ b/src/text-field-components.tsx @@ -81,7 +81,7 @@ export const HelperText: React.FC = ({leftText, rightText, erro const rightColor = isInverse ? vars.colors.textPrimaryInverse : vars.colors.textSecondary; return ( -
+ <> {leftText && (
@@ -96,7 +96,7 @@ export const HelperText: React.FC = ({leftText, rightText, erro
)} -
+ ); }; @@ -140,7 +140,7 @@ export const FieldContainer: React.FC = ({ > {children} - {helperText} + {helperText &&
{helperText}
} ); }; diff --git a/src/theme.tsx b/src/theme.tsx index 86d959cc95..134dd37a78 100644 --- a/src/theme.tsx +++ b/src/theme.tsx @@ -41,6 +41,7 @@ const TEXTS_ES = { playIconButtonLabel: 'Reproducir', pauseIconButtonLabel: 'Pausar', sheetConfirmButton: 'Continuar', + pinFieldInputLabel: 'Dígito 1$s de 2$s', }; const TEXTS_EN: ThemeTexts = { @@ -77,6 +78,7 @@ const TEXTS_EN: ThemeTexts = { playIconButtonLabel: 'Play', pauseIconButtonLabel: 'Pause', sheetConfirmButton: 'Continue', + pinFieldInputLabel: 'Digit 1$s of 2$s', }; const TEXTS_DE: ThemeTexts = { @@ -113,6 +115,7 @@ const TEXTS_DE: ThemeTexts = { playIconButtonLabel: 'Abspielen', pauseIconButtonLabel: 'Pausieren', sheetConfirmButton: 'Fortfahren', + pinFieldInputLabel: 'Ziffer 1$s von 2$s', }; const TEXTS_PT: ThemeTexts = { @@ -149,6 +152,7 @@ const TEXTS_PT: ThemeTexts = { playIconButtonLabel: 'Reproduzir', pauseIconButtonLabel: 'Pausar', sheetConfirmButton: 'Continuar', + pinFieldInputLabel: 'Dígito 1$s de 2$s', }; export const getTexts = (locale: Locale): typeof TEXTS_ES => {