Skip to content

Commit

Permalink
Merge pull request #601 from WestpacGEL/551-new-component-passcode-view
Browse files Browse the repository at this point in the history
#551 new component passcode view
  • Loading branch information
samithaf authored Dec 19, 2023
2 parents 94f0f70 + f5a2829 commit 1c1fc3f
Show file tree
Hide file tree
Showing 17 changed files with 539 additions and 0 deletions.
6 changes: 6 additions & 0 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,12 @@
"./panel": {
"default": "./dist/components/panel/index.js"
},
"./pass-code": {
"default": "./dist/components/pass-code/index.js"
},
"./pass-code-view": {
"default": "./dist/components/pass-code-view/index.js"
},
"./pictogram": {
"default": "./dist/components/pictogram/index.js"
},
Expand Down
2 changes: 2 additions & 0 deletions packages/ui/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,5 @@ export * from './filter/index.js';
export * from './field/index.js';
export * from './bottom-sheet/index.js';
export * from './heading/index.js';
export * from './pass-code/index.js';
export * from './pass-code-view/index.js';
2 changes: 2 additions & 0 deletions packages/ui/src/components/pass-code-view/index.ts
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';
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>
);
}
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 packages/ui/src/components/pass-code-view/pass-code-view.spec.tsx
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');
});
});
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 packages/ui/src/components/pass-code-view/pass-code-view.styles.ts
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 packages/ui/src/components/pass-code-view/pass-code-view.types.ts
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>;
2 changes: 2 additions & 0 deletions packages/ui/src/components/pass-code/index.ts
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 packages/ui/src/components/pass-code/pass-code.component.tsx
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>
);
}
Loading

0 comments on commit 1c1fc3f

Please sign in to comment.