diff --git a/package-lock.json b/package-lock.json index 2cb4417a..5c5d56c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,8 @@ "@storybook/addon-a11y": "^6.5.9", "classnames": "^2.3.1", "react": "^17.0.2", - "react-dom": "^17.0.2" + "react-dom": "^17.0.2", + "zxcvbn": "^4.4.2" }, "devDependencies": { "@babel/core": "^7.18.5", @@ -34,6 +35,7 @@ "@types/node": "^17.0.18", "@types/react": "^17.0.39", "@types/webpack": "^5.28.0", + "@types/zxcvbn": "^4.4.1", "babel-loader": "^8.2.3", "file-loader": "^6.2.0", "fork-ts-checker-webpack-plugin": "^7.2.1", @@ -11113,6 +11115,12 @@ "integrity": "sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==", "dev": true }, + "node_modules/@types/zxcvbn": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@types/zxcvbn/-/zxcvbn-4.4.1.tgz", + "integrity": "sha512-3NoqvZC2W5gAC5DZbTpCeJ251vGQmgcWIHQJGq2J240HY6ErQ9aWKkwfoKJlHLx+A83WPNTZ9+3cd2ILxbvr1w==", + "dev": true + }, "node_modules/@webassemblyjs/ast": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz", @@ -30569,6 +30577,11 @@ "type": "github", "url": "https://github.com/sponsors/wooorm" } + }, + "node_modules/zxcvbn": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/zxcvbn/-/zxcvbn-4.4.2.tgz", + "integrity": "sha512-Bq0B+ixT/DMyG8kgX2xWcI5jUvCwqrMxSFam7m0lAf78nf04hv6lNCsyLYdyYTrCVMqNDY/206K7eExYCeSyUQ==" } }, "dependencies": { @@ -39049,6 +39062,12 @@ "integrity": "sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==", "dev": true }, + "@types/zxcvbn": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@types/zxcvbn/-/zxcvbn-4.4.1.tgz", + "integrity": "sha512-3NoqvZC2W5gAC5DZbTpCeJ251vGQmgcWIHQJGq2J240HY6ErQ9aWKkwfoKJlHLx+A83WPNTZ9+3cd2ILxbvr1w==", + "dev": true + }, "@webassemblyjs/ast": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz", @@ -54126,6 +54145,11 @@ "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-1.0.5.tgz", "integrity": "sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw==", "dev": true + }, + "zxcvbn": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/zxcvbn/-/zxcvbn-4.4.2.tgz", + "integrity": "sha512-Bq0B+ixT/DMyG8kgX2xWcI5jUvCwqrMxSFam7m0lAf78nf04hv6lNCsyLYdyYTrCVMqNDY/206K7eExYCeSyUQ==" } } } diff --git a/package.json b/package.json index db2324e6..bbc2e71e 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "@types/node": "^17.0.18", "@types/react": "^17.0.39", "@types/webpack": "^5.28.0", + "@types/zxcvbn": "^4.4.1", "babel-loader": "^8.2.3", "file-loader": "^6.2.0", "fork-ts-checker-webpack-plugin": "^7.2.1", @@ -79,6 +80,7 @@ "@storybook/addon-a11y": "^6.5.9", "classnames": "^2.3.1", "react": "^17.0.2", - "react-dom": "^17.0.2" + "react-dom": "^17.0.2", + "zxcvbn": "^4.4.2" } } diff --git a/src/components/core/text-field/stories/text-field.stories.tsx b/src/components/core/text-field/stories/text-field.stories.tsx new file mode 100644 index 00000000..17797b6e --- /dev/null +++ b/src/components/core/text-field/stories/text-field.stories.tsx @@ -0,0 +1,150 @@ +import type { Meta, Story } from '@storybook/react'; +import LocationPin from '@assets/svg/location-pin.svg'; +import type { TextFieldProps } from '../text-field'; +import TextField from '../text-field'; + +export default { + title: 'TextField', + argTypes: { + label: { + description: 'Displays a label for the input field', + }, + inline_prefix_element: { + description: 'Displays the provided inline-prefix element', + }, + inline_suffix_element: { + description: 'Displays the provided inline-suffix element', + }, + hint: { + description: 'Displays a hint text', + }, + error: { + description: 'Displays an error text', + }, + success: { + description: 'Displays a success text', + }, + max_length: { + description: 'Max length that the field can accept', + }, + type: { + defaultValue: 'text', + control: { + type: 'select', + options: ['email', 'number', 'password', 'tel', 'text', 'textarea'], + }, + description: 'Type of input field', + }, + disabled: { + description: + 'Extends the style of HTML [disabled](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/disabled) attribute.', + table: { + type: { summary: 'boolean' }, + defaultValue: { summary: false }, + }, + }, + readOnly: { + description: 'Makes the field still focusable and functional but value cannot be edited', + table: { + type: { summary: 'boolean' }, + defaultValue: { summary: false }, + }, + }, + dark: { + description: 'Displays content in dark theme', + table: { + type: { summary: 'boolean' }, + defaultValue: { summary: false }, + }, + }, + }, +} as Meta; + +const Template: Story = (args) => ; + +export const SimpleTextField = Template.bind({}); +SimpleTextField.args = { + label: 'Text field', + type: 'text', + success: '', + error: '', + hint: '', + disabled: false, + dark: false, + readOnly: false, +}; + +export const TextFieldWithSuffixText = Template.bind({}); +TextFieldWithSuffixText.args = { + label: 'Currency', + type: 'number', + success: '', + error: '', + hint: '', + inline_suffix_element:
USD
, + disabled: false, + dark: false, + readOnly: false, +}; + +export const TextFieldWithPrefixText = Template.bind({}); +TextFieldWithPrefixText.args = { + label: 'Phone no.', + type: 'tel', + success: '', + error: '', + hint: '', + inline_prefix_element:
+971
, + disabled: false, + readOnly: false, + dark: false, +}; + +export const TextFieldWithSuffixIcon = Template.bind({}); +TextFieldWithSuffixIcon.args = { + label: 'Location', + type: 'text', + success: '', + error: '', + hint: '', + inline_suffix_element: location-icon, + disabled: false, + readOnly: false, + dark: false, +}; + +export const TextFieldWithCharacterLimit = Template.bind({}); +TextFieldWithCharacterLimit.args = { + label: 'Description', + type: 'text', + success: '', + error: '', + hint: '', + max_length: 10, + disabled: false, + readOnly: false, + dark: false, +}; + +export const PasswordField = Template.bind({}); +PasswordField.args = { + label: 'Password', + type: 'password', + success: '', + error: '', + hint: '', + disabled: false, + readOnly: false, + dark: false, +}; + +export const TextAreaField = Template.bind({}); +TextAreaField.args = { + label: 'Instruction', + type: 'textarea', + hint: '', + max_length: 250, + disabled: false, + readOnly: false, + dark: false, +}; diff --git a/src/components/core/text-field/text-field.tsx b/src/components/core/text-field/text-field.tsx new file mode 100644 index 00000000..0f9cb8b8 --- /dev/null +++ b/src/components/core/text-field/text-field.tsx @@ -0,0 +1,462 @@ +import { forwardRef, Fragment, InputHTMLAttributes, ReactNode, useState, useRef, useEffect } from 'react'; +import { styled } from 'Styles/stitches.config'; + +type InputTypes = 'text' | 'number' | 'email' | 'password' | 'tel' | 'textarea'; +type TWordCountProps = { count: number; max_length: number }; +type TPasswordStrengthProps = { user_input: string; disable_meter: boolean; dark: boolean }; +type THintTextProps = { error: string; success: string; hint: string }; + +export type TextFieldProps = InputHTMLAttributes & { + inline_prefix_element?: ReactNode; + inline_suffix_element?: ReactNode; + show_character_limit?: boolean; + label?: string; + type?: InputTypes; + max_length?: number; + hint_text?: THintTextProps; + dark?: boolean; +}; + +/* + PasswordStrengthMeter - This designs the section that displays the strength of password input +*/ +const StyledPasswordMeterWrapper = styled('div', { + height: '0.25rem', + width: '100%', + variants: { + dark: { + true: { background: '$greyDark500' }, + false: { background: '$greyLight300' }, + }, + }, +}); +const StyledPasswordMeter = styled(StyledPasswordMeterWrapper, { + transition: 'width 0.25s ease-in-out', +}); +const PasswordStrengthMeter = ({ user_input, disable_meter, dark }: TPasswordStrengthProps) => { + const zxcvbn = useRef(); + let test_result = { score: 0 }; + + useEffect(() => { + async function loadLibrary() { + const { default: lib } = await import('zxcvbn'); + zxcvbn.current = lib; + } + loadLibrary(); + }, []); + + if (typeof zxcvbn.current === 'function') { + test_result = zxcvbn.current(user_input); + } + const score: number = (test_result.score * 100) / 4; + const meter_color: any = Object.freeze({ + 0: dark ? '$greyDark500' : '$greyLight300', + 1: dark ? '$redDark' : '$redLight', + 2: dark ? '$yellowDark' : '$yellowLight', + 3: dark ? '$greenDark' : '$greenLight', + 4: dark ? '$greenDark' : '$greenLight', + }); + const generatePasswordStrengthColor = () => { + return { + width: `${score}%`, + background: meter_color[test_result.score], + height: disable_meter ? '0' : '0.25rem', + }; + }; + return ( + + + + ); +}; + +const HintText = ({ children }: { children: React.ReactNode }) =>
{children}
; + +/* + Word Count component - Displays the total count of characters in the input field against a max allowed character length +*/ +const StyledWordCount = styled('div', { + marginLeft: 'auto', +}); +const WordCount = ({ count, max_length }: TWordCountProps) => ( + + {count}/{max_length} + +); + +/* + HelperSection - This section displays hint, error or success text +*/ +const HelperSection = styled('section', { + paddingLeft: '1rem', + fontSize: '$2xs', + display: 'inline-flex', + width: '-webkit-fill-available', + marginTop: '0.125rem', + variants: { + dark: { + true: { color: '$greyDark200' }, + false: { color: '$greyLight600' }, + }, + error: { + true: { color: '$coral500' }, + }, + success: { + true: { color: '$greenLight' }, + }, + }, +}); + +/* + TextFieldWrapper - This acts as a wrapper and styles the input field section +*/ +const TextFieldWrapper = styled('section', { + position: 'relative', + width: '100%', + display: 'inline-flex', + flexDirection: 'column', + borderRadius: '$default', + borderWidth: '$1', + borderStyle: 'solid', + variants: { + dark: { + true: { + borderColor: '$greyDark400', + backgroundColor: '$greyDark700', + + '&:hover': { + borderColor: '$greyDark200', + }, + }, + false: { + borderColor: '$greyLight400', + backgroundColor: '$greyLight100', + + '&:hover': { + borderColor: '$greyLight600', + }, + }, + }, + active: { + true: { + borderColor: '$blue500', + }, + }, + disabled: { + true: { + opacity: '0.32', + cursor: 'not-allowed', + }, + }, + error: { + true: { + borderColor: '$coral500', + color: '$coral500', + }, + }, + success: { + true: { + borderColor: '$greenLight', + color: '$greenLight', + }, + }, + }, + compoundVariants: [ + { + dark: true, + error: true, + css: { + borderColor: '$redDark', + color: '$redDark', + }, + }, + { + dark: true, + success: true, + css: { + borderColor: '$greenDark', + color: '$greenDark', + }, + }, + ], +}); + +/* + LabelSection - Styles the input field label +*/ +const LabelSection = styled('label', { + whiteSpace: 'nowrap', + fontSize: '$xs', + position: 'absolute', + pointerEvents: 'none', + left: '1rem', + transition: '0.25s ease all', + transformOrigin: 'top left', + variants: { + dark: { + true: { + color: '$greyDark100', + backgroundColor: '$greyDark600', + }, + false: { + color: '$greyLight600', + backgroundColor: '$greyLight100', + }, + }, + error: { + true: { color: '$coral500' }, + }, + success: { + true: { color: '$greenLight' }, + }, + }, +}); + +/* + InputFieldSection - Styles the input field and wraps prefix, suffix and input field +*/ +const InputFieldSection = styled('div', { + position: 'relative', + display: 'inline-flex', + width: '100%', + alignItems: 'center', + lineHeight: '$lineHeight20', +}); + +/* + SupportingInfoSection - Styles the prefix and suffix elements of input field +*/ +const SupportingInfoSection = styled('div', { + display: 'block', + variants: { + prefix: { true: { paddingLeft: '1rem' } }, + suffix: { true: { paddingRight: '1rem' } }, + }, +}); + +/* + TextAreaField - Styles the Text area field +*/ +const TextAreaField = styled('textarea', { + resize: 'none', + height: '6rem', + overflow: 'auto', + overflowWrap: 'break-word', + padding: '1rem 1rem', + textAlign: 'justify', + borderRadius: '$default', + background: 'none', + fontSize: '$xs', + fontWeight: '$regular', + width: '100%', + display: 'block', + minWidth: '0', + boxSizing: 'border-box', + border: 'none', + outline: 'none', + + variants: { + dark: { + true: { + color: '$greyLight100', + + '&:readonly': { + color: '$greyDark200', + }, + }, + false: { + color: '$greyLight700', + + '&:readonly': { + color: '$greyLight600', + }, + }, + }, + }, + + '& ~ label': { + top: '1rem', + }, + + '&:focus:not(textarea:read-only) ~ label': { + transform: 'translateY(-1.5rem) scale(0.75)', + padding: '0 4px', + }, +}); + +/* + InputField - Styles the input field +*/ +const InputField = styled('input', { + borderRadius: '$default', + background: 'none', + fontSize: '$xs', + fontWeight: '$regular', + width: '100%', + height: '2.5rem', + display: 'block', + minWidth: '0', + boxSizing: 'border-box', + border: 'none', + outline: 'none', + padding: '0 1rem', + textOverflow: 'ellipsis', + + variants: { + dark: { + true: { + color: '$greyLight100', + + '&:readonly': { + color: '$greyDark200', + }, + }, + false: { + color: '$greyLight700', + + '&:readonly': { + color: '$greyLight600', + }, + }, + }, + }, + + '&:focus:not(input:read-only) ~ label': { + transform: 'translateY(-1.2rem) scale(0.75)', + padding: '0 4px', + }, +}); + +const TextField = forwardRef( + ( + { + inline_prefix_element, + inline_suffix_element, + show_character_limit, + label, + type, + id, + max_length, + hint_text, + dark, + ...props + }: TextFieldProps, + ref: any, + ) => { + const [is_active, setIsActive] = useState(false); + const [value, setValue] = useState(''); + const [count, setCount] = useState(0); + + const { error, hint, success } = hint_text ?? {}; + + const handleTextChange = (text: string) => { + if (props.disabled || props.readOnly) { + return; + } + if (max_length && text.length > max_length) { + return; + } + setValue(text); + setCount(text.length); + if (text !== '') { + setIsActive(true); + } else { + setIsActive(false); + } + }; + + const generateHintText = () => { + if (success) return {success}; + else if (error) return {error}; + else if (hint) return {hint}; + }; + + const styleLabelFloat = () => { + if (is_active && type !== 'textarea') { + return { + transform: 'translate(0, -1.2rem) scale(0.75)', + padding: '0 4px', + }; + } else if (is_active && type === 'textarea') { + return { + transform: 'translate(0, -1.5rem) scale(0.75)', + padding: '0 4px', + }; + } + }; + + const styleTextFieldWrapper = () => { + if (label?.trim()?.length === 0) { + return dark ? { borderColor: '$greyDark700' } : { borderColor: '$greyLight100' }; + } + }; + + return ( + + + + {type === 'textarea' ? ( + handleTextChange(e.target.value)} + /> + ) : ( + + {inline_prefix_element && ( + {inline_prefix_element} + )} + handleTextChange(e.target.value)} + /> + {inline_suffix_element && ( + {inline_suffix_element} + )} + + )} + {label && ( + + {label} + + )} + + {type === 'password' && ( + + )} + + + {generateHintText()} + {max_length && max_length > 0 && } + + + ); + }, +); + +export default TextField; diff --git a/src/images/svg/location-pin.svg b/src/images/svg/location-pin.svg new file mode 100644 index 00000000..bd2fb18b --- /dev/null +++ b/src/images/svg/location-pin.svg @@ -0,0 +1 @@ + \ No newline at end of file