-
Notifications
You must be signed in to change notification settings - Fork 6
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #601 from WestpacGEL/551-new-component-passcode-view
#551 new component passcode view
- Loading branch information
Showing
17 changed files
with
539 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
export { PassCodeView } from './pass-code-view.component.js'; | ||
export { type PassCodeViewProps } from './pass-code-view.types.js'; |
67 changes: 67 additions & 0 deletions
67
packages/ui/src/components/pass-code-view/pass-code-view.component.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,67 @@ | ||
import React from 'react'; | ||
|
||
import { AlertIcon, PadlockIcon } from '../icon/index.js'; | ||
import { Button, Link } from '../index.js'; | ||
import { PassCode } from '../pass-code/pass-code.component.js'; | ||
|
||
import { PassCodeViewLoader } from './pass-code-view.loader.js'; | ||
import { styles as passCodeViewStyles } from './pass-code-view.styles.js'; | ||
import { type PassCodeViewProps } from './pass-code-view.types.js'; | ||
|
||
export function PassCodeView({ | ||
className, | ||
tag: Tag = 'div', | ||
headerIcon: HeaderIcon = PadlockIcon, | ||
header, | ||
description, | ||
updateButtonLabel = 'update', | ||
resendButtonLabel = 'Resend code', | ||
onUpdate = () => { | ||
return; | ||
}, | ||
onResend = () => { | ||
return; | ||
}, | ||
onComplete = () => { | ||
return; | ||
}, | ||
errorMessage, | ||
passCodeLength = 6, | ||
loading = false, | ||
...props | ||
}: PassCodeViewProps) { | ||
const styles = passCodeViewStyles({}); | ||
|
||
return ( | ||
<Tag className={styles.base({ className })} {...props}> | ||
{HeaderIcon && <HeaderIcon className={styles.icon()} />} | ||
{header && <h3 className={styles.heading()}>{header}</h3>} | ||
{loading ? ( | ||
<PassCodeViewLoader length={passCodeLength} /> | ||
) : ( | ||
<> | ||
{description && ( | ||
<p> | ||
{description} | ||
<Link type="inline" className={styles.link()} onPress={onUpdate}> | ||
{updateButtonLabel} | ||
</Link> | ||
</p> | ||
)} | ||
{errorMessage && ( | ||
<p className="mt-2 flex items-center gap-1 text-danger"> | ||
<AlertIcon size="small" look="outlined" color="danger" /> | ||
{errorMessage} | ||
</p> | ||
)} | ||
<PassCode className={styles.passCode()} length={passCodeLength} onComplete={onComplete} /> | ||
{resendButtonLabel && ( | ||
<Button look="link" onClick={onResend}> | ||
{resendButtonLabel} | ||
</Button> | ||
)} | ||
</> | ||
)} | ||
</Tag> | ||
); | ||
} |
14 changes: 14 additions & 0 deletions
14
packages/ui/src/components/pass-code-view/pass-code-view.loader.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
import React from 'react'; | ||
|
||
export function PassCodeViewLoader({ length }: { length: number }) { | ||
return ( | ||
<div className="w-full px-4"> | ||
<div className="linear-gradient-style -mx-4 mb-2 h-4 rounded" /> | ||
<div className="flex justify-center gap-2"> | ||
{Array.from({ length }).map((_, index) => ( | ||
<div key={index} className="linear-gradient-style h-7 w-6 rounded-md" /> | ||
))} | ||
</div> | ||
</div> | ||
); | ||
} |
20 changes: 20 additions & 0 deletions
20
packages/ui/src/components/pass-code-view/pass-code-view.spec.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,20 @@ | ||
import { render } from '@testing-library/react'; | ||
|
||
import { PassCodeView } from './pass-code-view.component.js'; | ||
import { styles } from './pass-code-view.styles.js'; | ||
|
||
describe('PassCodeView', () => { | ||
it('renders the component', () => { | ||
const { container } = render(<PassCodeView />); | ||
expect(container).toBeInTheDocument(); | ||
}); | ||
it('renders the style correctly', () => { | ||
const style = styles(); | ||
// TODO: use some variants for test | ||
expect(style.base()).toBe('flex flex-col items-center'); | ||
expect(style.heading()).toBe('typography-body-5 mb-3 font-bold'); | ||
expect(style.link()).toBe('ml-1 cursor-pointer'); | ||
expect(style.passCode()).toBe('my-3'); | ||
expect(style.icon()).toBe('mb-3'); | ||
}); | ||
}); |
46 changes: 46 additions & 0 deletions
46
packages/ui/src/components/pass-code-view/pass-code-view.stories.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,46 @@ | ||
import { action } from '@storybook/addon-actions'; | ||
import { type Meta, StoryFn, type StoryObj } from '@storybook/react'; | ||
|
||
import { PassCodeView } from './pass-code-view.component.js'; | ||
|
||
const meta: Meta<typeof PassCodeView> = { | ||
title: 'Components/PassCodeView', | ||
component: PassCodeView, | ||
tags: ['autodocs'], | ||
decorators: [(Story: StoryFn) => <Story />], | ||
parameters: { | ||
layout: 'centered', | ||
}, | ||
}; | ||
|
||
export default meta; | ||
type Story = StoryObj<typeof meta>; | ||
|
||
/** | ||
* > Default usage example | ||
*/ | ||
export const DefaultStory: Story = { | ||
args: { | ||
header: 'Enter SMS code', | ||
description: 'Send to mobile ending ...XXXX', | ||
passCodeLength: 6, | ||
onComplete: action('onComplete'), | ||
onResend: action('onResend'), | ||
onUpdate: action('onUpdate'), | ||
}, | ||
}; | ||
|
||
/** | ||
* > With error example | ||
*/ | ||
export const WithErrorStory: Story = { | ||
args: { | ||
header: 'Enter SMS code', | ||
description: 'Send to mobile ending ...XXXX', | ||
passCodeLength: 6, | ||
errorMessage: 'Try again (2 attempts remaining)', | ||
onComplete: action('onComplete'), | ||
onResend: action('onResend'), | ||
onUpdate: action('onUpdate'), | ||
}, | ||
}; |
15 changes: 15 additions & 0 deletions
15
packages/ui/src/components/pass-code-view/pass-code-view.styles.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
import { tv } from 'tailwind-variants'; | ||
|
||
export const styles = tv( | ||
{ | ||
slots: { | ||
base: 'flex flex-col items-center', | ||
heading: 'typography-body-5 mb-3 font-bold', | ||
link: 'ml-1 cursor-pointer', | ||
passCode: 'my-3', | ||
icon: 'mb-3', | ||
}, | ||
variants: {}, | ||
}, | ||
{ responsiveVariants: ['xsl', 'sm', 'md', 'lg', 'xl'] }, | ||
); |
58 changes: 58 additions & 0 deletions
58
packages/ui/src/components/pass-code-view/pass-code-view.types.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,58 @@ | ||
import { HTMLAttributes, ReactNode } from 'react'; | ||
import { type VariantProps } from 'tailwind-variants'; | ||
|
||
import { PassCodeProps } from '../pass-code/pass-code.types.js'; | ||
|
||
import { styles } from './pass-code-view.styles.js'; | ||
|
||
export type PassCodeViewProps = { | ||
/** | ||
* description text | ||
*/ | ||
description?: ReactNode; | ||
/** | ||
* error message | ||
*/ | ||
errorMessage?: ReactNode; | ||
/** | ||
* header text | ||
*/ | ||
header?: ReactNode; | ||
/** | ||
* icon on the header | ||
*/ | ||
headerIcon?: React.ElementType; | ||
/** | ||
* boolean to show skeleton | ||
*/ | ||
loading?: boolean; | ||
/** | ||
* callback when the value is completely typed | ||
*/ | ||
onComplete?: PassCodeProps['onComplete']; | ||
/** | ||
* on click the resend button | ||
*/ | ||
onResend?: () => any; | ||
/** | ||
* on click the update button | ||
*/ | ||
onUpdate?: () => any; | ||
/** | ||
* length of the passcode | ||
*/ | ||
passCodeLength?: number; | ||
/** | ||
* label for resend button | ||
*/ | ||
resendButtonLabel?: ReactNode; | ||
/** | ||
* Tag to render | ||
*/ | ||
tag?: keyof JSX.IntrinsicElements; | ||
/** | ||
* label for update button | ||
*/ | ||
updateButtonLabel?: ReactNode; | ||
} & VariantProps<typeof styles> & | ||
HTMLAttributes<Element>; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
export { PassCode } from './pass-code.component.js'; | ||
export { type PassCodeProps } from './pass-code.types.js'; |
97 changes: 97 additions & 0 deletions
97
packages/ui/src/components/pass-code/pass-code.component.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,97 @@ | ||
'use client'; | ||
|
||
import React, { ChangeEvent, ClipboardEvent, KeyboardEvent, useCallback, useRef, useState } from 'react'; | ||
|
||
import { Input } from '../index.js'; | ||
|
||
import { styles as passCodeStyles } from './pass-code.styles.js'; | ||
import { type PassCodeProps } from './pass-code.types.js'; | ||
|
||
export function PassCode({ length, onComplete, className, ...props }: PassCodeProps) { | ||
const [passcode, setPasscode] = useState<(string | undefined)[]>(Array.from({ length }).map(() => undefined)); | ||
const inputRefs = useRef<Array<HTMLInputElement | null>>([]); | ||
|
||
const styles = passCodeStyles({}); | ||
|
||
const handleChange = useCallback( | ||
(index: number, event: ChangeEvent<HTMLInputElement>) => { | ||
const value = event.target.value.slice(-1); | ||
|
||
// Update the passcode state | ||
const newPasscode = [...passcode.slice(0, index), value, ...passcode.slice(index + 1)]; | ||
setPasscode(newPasscode); | ||
|
||
// Move to the next input if available | ||
if (index < length - 1 && value !== '') { | ||
inputRefs.current[index + 1]?.focus(); | ||
} | ||
|
||
// Call onComplete when passcode is complete | ||
if (newPasscode.filter(passcode => !passcode).length === 0) { | ||
onComplete(newPasscode.join('')); | ||
} | ||
}, | ||
[onComplete, inputRefs, passcode], | ||
); | ||
|
||
const handlePaste = useCallback( | ||
(index: number, event: ClipboardEvent<HTMLInputElement>) => { | ||
event.preventDefault(); | ||
const pastedData = event.clipboardData.getData('text'); | ||
const validData = pastedData.slice(0, length - index).split(''); | ||
const previousSlice = passcode.slice(0, index); | ||
const afterSlice = passcode.slice(index); | ||
const newPasscode = [...previousSlice, ...[...validData, ...afterSlice.slice(validData.length)]].slice(0, length); | ||
setPasscode(newPasscode); | ||
if (newPasscode.filter(passcode => !passcode).length === 0) { | ||
onComplete(newPasscode.join('')); | ||
} | ||
}, | ||
[passcode], | ||
); | ||
|
||
const handleKeyDown = useCallback( | ||
(index: number, event: KeyboardEvent<HTMLInputElement>) => { | ||
if (event.key === 'Backspace' && index > 0) { | ||
event.preventDefault(); | ||
const newPasscode = [...passcode.slice(0, index), undefined, ...passcode.slice(index + 1)]; | ||
setPasscode(newPasscode); | ||
const previousInput = inputRefs.current[index - 1]; | ||
const currentInput = inputRefs.current[index]; | ||
if (previousInput) { | ||
previousInput.focus(); | ||
} | ||
if (currentInput) { | ||
currentInput.value = ''; | ||
} | ||
} | ||
}, | ||
[inputRefs], | ||
); | ||
|
||
const handleFocus = useCallback( | ||
(index: number) => { | ||
inputRefs.current[index]?.select(); | ||
}, | ||
[inputRefs], | ||
); | ||
|
||
return ( | ||
<div {...props} className={styles.base({ className })}> | ||
{passcode.map((digit, index) => ( | ||
<Input | ||
size="large" | ||
key={index} | ||
value={digit} | ||
onChange={e => handleChange(index, e)} | ||
onPaste={e => handlePaste(index, e)} | ||
onKeyDown={e => handleKeyDown(index, e)} | ||
onFocus={() => handleFocus(index)} | ||
ref={input => (inputRefs.current[index] = input)} | ||
className={styles.input({})} | ||
aria-label={`Passcode digit ${index + 1}`} | ||
/> | ||
))} | ||
</div> | ||
); | ||
} |
Oops, something went wrong.