From 3bc6e27e17181b6a92c652b49b7874fd97d0b854 Mon Sep 17 00:00:00 2001 From: Ruggero Date: Tue, 12 Dec 2023 09:47:16 +0100 Subject: [PATCH] feat: APP-2612 - Implement InputContainer and InputText components (#48) --- CHANGELOG.md | 7 +- README.md | 6 +- .../alerts/alertInline/alertInline.tsx | 9 +- src/components/index.ts | 1 + src/components/input/index.ts | 2 + src/components/input/inputContainer/index.ts | 2 + .../inputContainer/inputContainer.api.ts | 72 ++++++++++++++++ .../inputContainer/inputContainer.stories.tsx | 25 ++++++ .../inputContainer/inputContainer.test.tsx | 62 ++++++++++++++ .../input/inputContainer/inputContainer.tsx | 85 +++++++++++++++++++ src/components/input/inputText/index.ts | 1 + .../input/inputText/inputText.stories.tsx | 46 ++++++++++ .../input/inputText/inputText.test.tsx | 27 ++++++ src/components/input/inputText/inputText.tsx | 14 +++ src/components/input/useInputProps.test.ts | 65 ++++++++++++++ src/components/input/useInputProps.ts | 78 +++++++++++++++++ src/styles/primitives/shadows.css | 3 + tailwind.config.js | 25 +++++- 18 files changed, 523 insertions(+), 7 deletions(-) create mode 100644 src/components/input/index.ts create mode 100644 src/components/input/inputContainer/index.ts create mode 100644 src/components/input/inputContainer/inputContainer.api.ts create mode 100644 src/components/input/inputContainer/inputContainer.stories.tsx create mode 100644 src/components/input/inputContainer/inputContainer.test.tsx create mode 100644 src/components/input/inputContainer/inputContainer.tsx create mode 100644 src/components/input/inputText/index.ts create mode 100644 src/components/input/inputText/inputText.stories.tsx create mode 100644 src/components/input/inputText/inputText.test.tsx create mode 100644 src/components/input/inputText/inputText.tsx create mode 100644 src/components/input/useInputProps.test.ts create mode 100644 src/components/input/useInputProps.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 91cb3de36..3007035db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added -- Implement `Tag` component +- Implement `Tag`, `InputContainer` and `InputText` components - Documentation on how to handle library dependencies +- `shadow-none` and `shake` Tailwind CSS utility classes ### Changed @@ -20,6 +21,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Prettier configuration to propertly format markdown files - Bump `@adobe/css-tools` from 4.3.1 to 4.3.2 +### Fixed + +- Correctly format `README.md` links on Storybook + ## [1.0.5] - 2023-11-20 ### Changed diff --git a/README.md b/README.md index 7388d229d..44eafe78c 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,11 @@

Aragon Website - • +  •  Developer Portal - • +  •  Join our Developer Community - • +  •  Contribute

diff --git a/src/components/alerts/alertInline/alertInline.tsx b/src/components/alerts/alertInline/alertInline.tsx index d035ce1cb..2e074c64b 100644 --- a/src/components/alerts/alertInline/alertInline.tsx +++ b/src/components/alerts/alertInline/alertInline.tsx @@ -6,9 +6,13 @@ import { Icon } from '../../icon'; import { alertVariantToIconType, type AlertVariant } from '../utils'; export interface IAlertInlineProps extends HTMLAttributes { - /** Alert text content. */ + /** + * Alert text content. + */ message: string; - /** Defines the variant of the alert. */ + /** + * Defines the variant of the alert. + */ variant: AlertVariant; } @@ -29,6 +33,7 @@ const variantToTextClassNames: Record = { /** AlertInline UI Component */ export const AlertInline: React.FC = (props) => { const { className, message, variant, ...rest } = props; + return (
; +} + +export interface IInputContainerProps { + /** + * Label of the input. + */ + label?: string; + /** + * Variant of the input. + * @default default + */ + variant?: InputVariant; + /** + * Help text displayed above the input. + */ + helpText?: string; + /** + * Displays the optional tag when set to true. + */ + isOptional?: boolean; + /** + * Displays the input as disabled when set to true. + */ + isDisabled?: boolean; + /** + * Alert displayed below the input. + */ + alert?: IInputContainerAlert; + /** + * Id of the input field. + */ + id: string; + /** + * Displays an input length counter when set. + */ + maxLength?: number; + /** + * Current input length displayed when maxLength property is set. + */ + inputLength?: number; + /** + * Children of the component. + */ + children?: ReactNode; + /** + * Classes for the component. + */ + className?: string; +} + +export interface IInputComponentProps + extends Omit, + Omit, 'type'> { + /** + * Classes for the input element. + */ + inputClassName?: string; +} diff --git a/src/components/input/inputContainer/inputContainer.stories.tsx b/src/components/input/inputContainer/inputContainer.stories.tsx new file mode 100644 index 000000000..7f4a82aa3 --- /dev/null +++ b/src/components/input/inputContainer/inputContainer.stories.tsx @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { InputContainer } from './inputContainer'; + +const meta: Meta = { + title: 'components/Input/InputContainer', + component: InputContainer, + tags: ['autodocs'], + parameters: { + design: { + type: 'figma', + url: 'https://www.figma.com/file/jfKRr1V9evJUp1uBeyP3Zz/v1.0.0?type=design&node-id=10055-28606&mode=design&t=dehPZplRn0YEdOuB-4', + }, + }, +}; + +type Story = StoryObj; + +/** + * Default usage example of the InputContainer component. + */ +export const Default: Story = { + args: {}, +}; + +export default meta; diff --git a/src/components/input/inputContainer/inputContainer.test.tsx b/src/components/input/inputContainer/inputContainer.test.tsx new file mode 100644 index 000000000..da9022db6 --- /dev/null +++ b/src/components/input/inputContainer/inputContainer.test.tsx @@ -0,0 +1,62 @@ +import { render, screen } from '@testing-library/react'; +import { InputContainer } from './inputContainer'; +import type { IInputContainerProps } from './inputContainer.api'; + +describe(' component', () => { + const createTestComponent = (props?: Partial) => { + const completeProps = { + id: 'test', + ...props, + }; + + return ; + }; + + it('renders the input label when specified', () => { + const label = 'input-label'; + + // The getByLabelText requires a form control to be associated to the label + const id = 'input-id'; + const children = ; + + render(createTestComponent({ label, children, id })); + expect(screen.getByLabelText(label)).toBeInTheDocument(); + }); + + it('renders the optional tag when the label is set and isOptional prop is set to true', () => { + const label = 'label-test'; + const isOptional = true; + render(createTestComponent({ label, isOptional })); + expect(screen.getByText('Optional')).toBeInTheDocument(); + }); + + it('renders the help text when defined', () => { + const helpText = 'help-text-test'; + render(createTestComponent({ helpText })); + expect(screen.getByText(helpText)).toBeInTheDocument(); + }); + + it('renders the input value counter when maxLength is defined', () => { + const maxLength = 100; + const inputLength = 47; + render(createTestComponent({ maxLength, inputLength })); + expect(screen.getByText(`[${inputLength}/${maxLength}]`)).toBeInTheDocument(); + }); + + it('adds a shake animation when the input length is equal to the max length property', () => { + const maxLength = 10; + const inputLength = 10; + render(createTestComponent({ maxLength, inputLength })); + expect(screen.getByText(`[${inputLength}/${maxLength}]`).className).toContain('shake'); + }); + + it('renders the input alert when defined', () => { + const alert = { + message: 'input-alert-message', + variant: 'critical' as const, + }; + render(createTestComponent({ alert })); + expect(screen.getByRole('alert')).toBeInTheDocument(); + expect(screen.getByText(alert.message)).toBeInTheDocument(); + }); +}); diff --git a/src/components/input/inputContainer/inputContainer.tsx b/src/components/input/inputContainer/inputContainer.tsx new file mode 100644 index 000000000..285677279 --- /dev/null +++ b/src/components/input/inputContainer/inputContainer.tsx @@ -0,0 +1,85 @@ +import classNames from 'classnames'; +import { AlertInline } from '../../alerts'; +import { Tag } from '../../tag'; +import type { IInputContainerProps, InputVariant } from './inputContainer.api'; + +const variantToClassNames: Record = { + default: [ + 'border-neutral-100 bg-neutral-0', // Default state + 'hover:border-neutral-200 hover:shadow-neutral-md', // Hover state + 'focus-within:outline-primary-400 focus-within:border-primary-400 focus-within:shadow-primary-md', // Focus state + 'focus-within:hover:border-primary-400 focus-within:hover:shadow-primary-md', // Focus + Hover state + ], + warning: [ + 'border-warning-500 bg-neutral-0', // Default state + 'hover:border-warning-600 hover:shadow-warning-md', // Hover state + 'focus-within:outline-warning-600 focus-within:border-warning-600 focus-within:shadow-warning-md', // Focus state + 'focus-within:hover:border-warning-600 focus-within:hover:shadow-warning-md', // Focus + Hover state + ], + critical: [ + 'border-critical-500 bg-neutral-0', // Default state + 'hover:border-critical-600 hover:shadow-critical-md', // Hover state + 'focus-within:outline-critical-600 focus-within:border-critical-600 focus-within:shadow-critical-md', // Focus state + 'focus-within:hover:border-critical-600 focus-within:hover:shadow-critical-md', // Focus + Hover state + ], + disabled: ['border-neutral-200 bg-neutral-100'], +}; + +/** + * The InputContainer component provides a consistent and shared styling foundation for various input components, such + * as `InputText`, `InputNumber` and others. It also manages properties that are shared across all input components, + * including `label`, `helpText` and more. + */ +export const InputContainer: React.FC = (props) => { + const { + label, + variant = 'default', + helpText, + isOptional, + maxLength, + inputLength = 0, + alert, + isDisabled, + children, + className, + id, + } = props; + + const processedVariant = isDisabled ? 'disabled' : variant; + const containerClasses = classNames( + 'h-12 w-full rounded-xl border text-neutral-600 transition-all', // Default + 'outline-1 focus-within:outline', // Outline on focus + 'text-base font-normal leading-tight', // Typography + variantToClassNames[processedVariant], + ); + + const counterClasses = classNames('text-sm font-normal leading-tight text-neutral-600', { + 'animate-shake': inputLength === maxLength, + }); + + return ( +
+ {(label != null || helpText != null) && ( + + )} +
{children}
+ {maxLength != null && ( +

+ [{inputLength}/{maxLength}] +

+ )} + {alert && } +
+ ); +}; diff --git a/src/components/input/inputText/index.ts b/src/components/input/inputText/index.ts new file mode 100644 index 000000000..e27d4326b --- /dev/null +++ b/src/components/input/inputText/index.ts @@ -0,0 +1 @@ +export { InputText, type IInputTextProps } from './inputText'; diff --git a/src/components/input/inputText/inputText.stories.tsx b/src/components/input/inputText/inputText.stories.tsx new file mode 100644 index 000000000..fe7868ca4 --- /dev/null +++ b/src/components/input/inputText/inputText.stories.tsx @@ -0,0 +1,46 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useState, type ChangeEvent } from 'react'; +import { InputText, type IInputTextProps } from './inputText'; + +const meta: Meta = { + title: 'components/Input/InputText', + component: InputText, + tags: ['autodocs'], + parameters: { + design: { + type: 'figma', + url: 'https://www.figma.com/file/jfKRr1V9evJUp1uBeyP3Zz/v1.0.0?type=design&node-id=17-292&mode=design&t=dehPZplRn0YEdOuB-4', + }, + }, +}; + +type Story = StoryObj; + +/** + * Default uncontrolled usage example of the InputText component. + */ +export const Default: Story = { + args: { + placeholder: 'Uncontrolled input', + }, +}; + +const ControlledComponent = (props: IInputTextProps) => { + const [value, setValue] = useState(''); + + const handleChange = (event: ChangeEvent) => setValue(event.target.value); + + return ; +}; + +/** + * Usage example of a controlled input. + */ +export const Controlled: Story = { + render: (props) => , + args: { + placeholder: 'Controlled input', + }, +}; + +export default meta; diff --git a/src/components/input/inputText/inputText.test.tsx b/src/components/input/inputText/inputText.test.tsx new file mode 100644 index 000000000..25bbf61ad --- /dev/null +++ b/src/components/input/inputText/inputText.test.tsx @@ -0,0 +1,27 @@ +import { render, screen } from '@testing-library/react'; +import { InputText, type IInputTextProps } from './inputText'; + +describe(' component', () => { + const createTestComponent = (props?: Partial) => { + const completeProps = { ...props }; + + return ; + }; + + it('renders a text input element', () => { + render(createTestComponent()); + expect(screen.getByRole('textbox')).toBeInTheDocument(); + }); + + it('disables the text input when isDisabled is set to true', () => { + const isDisabled = true; + render(createTestComponent({ isDisabled })); + expect(screen.getByRole('textbox').disabled).toBeTruthy(); + }); + + it('applies the input class names when defined', () => { + const inputClassName = 'class-test'; + render(createTestComponent({ inputClassName })); + expect(screen.getByRole('textbox').className).toContain(inputClassName); + }); +}); diff --git a/src/components/input/inputText/inputText.tsx b/src/components/input/inputText/inputText.tsx new file mode 100644 index 000000000..4fc0bad5d --- /dev/null +++ b/src/components/input/inputText/inputText.tsx @@ -0,0 +1,14 @@ +import { InputContainer, type IInputComponentProps } from '../inputContainer'; +import { useInputProps } from '../useInputProps'; + +export interface IInputTextProps extends IInputComponentProps {} + +export const InputText: React.FC = (props) => { + const { containerProps, inputProps } = useInputProps(props); + + return ( + + + + ); +}; diff --git a/src/components/input/useInputProps.test.ts b/src/components/input/useInputProps.test.ts new file mode 100644 index 000000000..5f1fec2f8 --- /dev/null +++ b/src/components/input/useInputProps.test.ts @@ -0,0 +1,65 @@ +import { act, renderHook } from '@testing-library/react'; +import type { ChangeEvent } from 'react'; +import { useInputProps } from './useInputProps'; + +describe('useInputProps hook', () => { + it('splits the container and input properties', () => { + const containerProps = { + label: 'input-label', + variant: 'warning' as const, + helpText: 'help-text', + isOptional: true, + alert: { message: 'alert-message', variant: 'critical' as const }, + className: 'container-classname', + maxLength: 10, + }; + + const inputProps = { + placeholder: 'input-placeholder', + value: 'input-value', + maxLength: 10, + }; + + const { result } = renderHook(() => useInputProps({ ...containerProps, ...inputProps })); + expect(result.current.containerProps).toEqual(expect.objectContaining(containerProps)); + expect(result.current.inputProps).toEqual(expect.objectContaining(inputProps)); + }); + + it('process the id prop and sets it on the container and input props', () => { + const inputId = 'input-id'; + const props = { id: inputId }; + const { result } = renderHook(() => useInputProps(props)); + expect(result.current.containerProps.id).toEqual(inputId); + expect(result.current.inputProps.id).toEqual(inputId); + }); + + it('forward the disabled property to the input component when set', () => { + const isDisabled = true; + const props = { isDisabled }; + const { result } = renderHook(() => useInputProps(props)); + expect(result.current.inputProps.disabled).toBeTruthy(); + }); + + it('sets a random id to the input property when the id prop is not set', () => { + const { result } = renderHook(() => useInputProps({})); + expect(result.current.containerProps.id).toBeDefined(); + expect(result.current.inputProps.id).toBeDefined(); + }); + + it('tracks the current input length for the container props', () => { + const newValue = 'newValue'; + const changeEvent = { target: { value: newValue } } as ChangeEvent; + const { result } = renderHook(() => useInputProps({})); + expect(result.current.containerProps.inputLength).toEqual(0); + act(() => result.current.inputProps.onChange?.(changeEvent)); + expect(result.current.containerProps.inputLength).toEqual(newValue.length); + }); + + it('calls the onChange property on input value change', () => { + const onChange = jest.fn(); + const { result } = renderHook(() => useInputProps({ onChange })); + const changeEvent = { target: { value: '' } } as ChangeEvent; + act(() => result.current.inputProps.onChange?.(changeEvent)); + expect(onChange).toHaveBeenCalledWith(changeEvent); + }); +}); diff --git a/src/components/input/useInputProps.ts b/src/components/input/useInputProps.ts new file mode 100644 index 000000000..409c1997e --- /dev/null +++ b/src/components/input/useInputProps.ts @@ -0,0 +1,78 @@ +import classNames from 'classnames'; +import { useId, useState, type ChangeEvent, type InputHTMLAttributes } from 'react'; +import type { IInputComponentProps, IInputContainerProps } from './inputContainer'; + +export interface IUseInputPropsResult { + /** + * Properties for the InputContainer component. + */ + containerProps: IInputContainerProps; + /** + * Properties for the input element. + */ + inputProps: InputHTMLAttributes; +} + +/** + * Processes the InputComponent properties object to split it into container-specific and input-element-specific properties. + * @param props The InputComponent properties + * @returns The InputContainer and input element properties. + */ +export const useInputProps = (props: IInputComponentProps): IUseInputPropsResult => { + const { + label, + variant, + helpText, + isOptional, + alert, + isDisabled, + inputClassName, + id, + className, + maxLength, + onChange, + ...inputElementProps + } = props; + + // Set a random generated id to the input field when id property is not defined to properly link the + // input with the label + const randomId = useId(); + const processedId = id ?? randomId; + + const [inputLength, setInputLength] = useState(0); + + const handleOnChange = (event: ChangeEvent) => { + setInputLength(event.target.value.length); + onChange?.(event); + }; + + const containerProps = { + label, + variant, + helpText, + isOptional, + alert, + isDisabled, + id: processedId, + maxLength, + className, + inputLength, + }; + + const inputClasses = classNames( + 'h-full w-full rounded-xl px-4 py-3 caret-neutral-500 outline-none', // Default + 'placeholder:text-base placeholder:font-normal placeholder:leading-tight placeholder:text-neutral-300', // Placeholder + inputClassName, // Prop + ); + + const inputProps = { + id: processedId, + disabled: isDisabled, + className: inputClasses, + onChange: handleOnChange, + maxLength, + ...inputElementProps, + }; + + return { containerProps, inputProps }; +}; diff --git a/src/styles/primitives/shadows.css b/src/styles/primitives/shadows.css index 190ca91e8..314b474fd 100644 --- a/src/styles/primitives/shadows.css +++ b/src/styles/primitives/shadows.css @@ -38,4 +38,7 @@ --ods-shadow-critical-lg: 0px 10px 15px -3px rgba(214, 39, 54, 0.1), 0px 4px 6px -4px rgba(214, 39, 54, 0.1); --ods-shadow-critical-xl: 0px 20px 25px -5px rgba(214, 39, 54, 0.1), 0px 8px 10px -6px rgba(214, 39, 54, 0.1); --ods-shadow-critical-2xl: 0px 25px 50px -12px rgba(214, 39, 54, 0.24); + + /* None */ + --ods-shadow-none: 0px 0px #0000; } diff --git a/tailwind.config.js b/tailwind.config.js index 86a4a11b2..a60f4bb2a 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,6 +1,6 @@ /** @type {import('tailwindcss').Config} */ module.exports = { - content: ['./src/**/*.{jsx,tsx,mdx}', './docs/**/*.{jsx,tsx,mdx}', '.storybook/*.{jsx,tsx}'], + content: ['./src/**/*.{jsx,ts,tsx,mdx}', './docs/**/*.{jsx,tsx,mdx}', '.storybook/*.{jsx,tsx}'], theme: { colors: { primary: { @@ -165,6 +165,8 @@ module.exports = { 'critical-lg': 'var(--ods-shadow-critical-lg)', 'critical-xl': 'var(--ods-shadow-critical-xl)', 'critical-2xl': 'var(--ods-shadow-critical-2xl)', + + none: 'var(--ods-shadow-none)', }, screens: { sm: '640px', @@ -196,5 +198,26 @@ module.exports = { tight: 'var(--ods-line-height-tight)', relaxed: 'var(--ods-line-height-relaxed)', }, + extend: { + animation: { + shake: 'shake 0.82s cubic-bezier(0.36,0.07,0.19,0.97) both', + }, + keyframes: { + shake: { + '10%, 90%': { + transform: 'translate3d(-1px, 0, 0)', + }, + '20%, 80%': { + transform: 'translate3d(2px, 0, 0)', + }, + '30%, 50%, 70%': { + transform: 'translate3d(-4px, 0, 0)', + }, + '40%, 60%': { + transform: 'translate3d(4px, 0, 0)', + }, + }, + }, + }, }, };