diff --git a/packages/assets/src/scss/inputs/_input-text.scss b/packages/assets/src/scss/inputs/_input-text.scss index aaa40e9..be4e528 100644 --- a/packages/assets/src/scss/inputs/_input-text.scss +++ b/packages/assets/src/scss/inputs/_input-text.scss @@ -2,6 +2,7 @@ .ids-input-text { position: relative; + width: fit-content; &__actions { position: absolute; diff --git a/packages/components/src/formControls/InputText/InputText.stories.ts b/packages/components/src/formControls/InputText/InputText.stories.ts new file mode 100644 index 0000000..be619bf --- /dev/null +++ b/packages/components/src/formControls/InputText/InputText.stories.ts @@ -0,0 +1,91 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { action } from 'storybook/actions'; + +import { FormControlInputTextStateful } from './InputText'; + +const meta: Meta = { + component: FormControlInputTextStateful, + parameters: { + layout: 'centered', + }, + tags: ['autodocs', 'foundation', 'inputs'], + argTypes: { + className: { + control: 'text', + }, + title: { + control: 'text', + }, + value: { + control: 'text', + }, + onChange: { + control: false, + }, + onValidate: { + control: false, + }, + input: { + control: false, + }, + }, + args: { + id: 'default-input', + name: 'default-input', + onChange: action('on-change'), + onValidate: action('on-validate'), + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + name: 'Default', + args: { + helperText: 'This is a helper text', + label: 'Input Label', + input: { + size: 'medium', + type: 'text', + }, + }, +}; + +export const Required: Story = { + name: 'Required', + args: { + helperText: 'This is a helper text', + label: 'Input Label', + required: true, + input: { + size: 'medium', + type: 'text', + }, + }, +}; + +export const Small: Story = { + name: 'Small', + args: { + helperText: 'This is a helper text', + label: 'Input Label', + input: { + size: 'small', + type: 'text', + }, + }, +}; + +export const Number: Story = { + name: 'Number', + args: { + helperText: 'This is a helper text', + label: 'Input Label', + input: { + size: 'medium', + type: 'number', + }, + }, +}; diff --git a/packages/components/src/formControls/InputText/InputText.test.stories.ts b/packages/components/src/formControls/InputText/InputText.test.stories.ts new file mode 100644 index 0000000..070b861 --- /dev/null +++ b/packages/components/src/formControls/InputText/InputText.test.stories.ts @@ -0,0 +1,84 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { expect, fn, userEvent, within } from 'storybook/test'; + +import { FormControlInputTextStateful } from './InputText'; + +const meta: Meta = { + component: FormControlInputTextStateful, + parameters: { + layout: 'centered', + }, + tags: ['!dev'], + args: { + name: 'default-input', + onChange: fn(), + onValidate: fn(), + }, +}; + +export default meta; + +type Story = StoryObj; + +export const NotRequired: Story = { + name: 'Not required', + play: async ({ canvasElement, step, args }) => { + const canvas = within(canvasElement); + const input = canvas.getByRole('textbox'); + + await step('InputText handles change event', async () => { + const insertText = 'Lorem Ipsum'; + const insertTextLength = insertText.length; + + await userEvent.type(input, insertText); + + await expect(args.onChange).toHaveBeenCalledTimes(insertTextLength); + await expect(input).toHaveValue(insertText); + await expect(args.onValidate).toHaveBeenCalledWith(true, []); + }); + + const clearBtn = canvas.getByRole('button'); + + await step('InputText handles clear event', async () => { + await userEvent.click(clearBtn); + + await expect(args.onChange).toHaveBeenLastCalledWith(''); + await expect(input).toHaveValue(''); + await expect(args.onValidate).toHaveBeenCalledWith(true, []); + }); + }, +}; + +export const Required: Story = { + name: 'Required', + args: { + required: true, + }, + play: async ({ canvasElement, step, args }) => { + const canvas = within(canvasElement); + const input = canvas.getByRole('textbox'); + + await step('InputText handles change event', async () => { + const insertText = 'Lorem Ipsum'; + const insertTextLength = insertText.length; + + await userEvent.type(input, insertText); + + await expect(args.onChange).toHaveBeenCalledTimes(insertTextLength); + await expect(input).toHaveValue(insertText); + await expect(args.onValidate).toHaveBeenCalledWith(true, []); + await expect(input).toHaveAttribute('aria-invalid', 'false'); + }); + + const clearBtn = canvas.getByRole('button'); + + await step('InputText handles clear event', async () => { + await userEvent.click(clearBtn); + + await expect(args.onChange).toHaveBeenLastCalledWith(''); + await expect(input).toHaveValue(''); + await expect(args.onValidate).toHaveBeenLastCalledWith(false, expect.anything()); + await expect(input).toHaveAttribute('aria-invalid', 'true'); + }); + }, +}; diff --git a/packages/components/src/formControls/InputText/InputText.tsx b/packages/components/src/formControls/InputText/InputText.tsx new file mode 100644 index 0000000..146c355 --- /dev/null +++ b/packages/components/src/formControls/InputText/InputText.tsx @@ -0,0 +1,59 @@ +import React, { useEffect } from 'react'; + +import { useInitValidators, useValidateInput } from './InputText.utils'; +import BaseFormControl from '@ids-internal/partials/BaseFormControl'; +import InputText from '../../inputs/InputText'; +import withStateValue from '@ids-internal/hoc/withStateValue'; + +import { FormControlInputTextProps, ValueType } from './InputText.types'; + +const FormControlInputText = ({ + helperText, + helperTextExtra = {}, + id, + input = {}, + label, + labelExtra = {}, + name, + onChange = () => undefined, + onValidate = () => undefined, + required = false, + value = '', +}: FormControlInputTextProps) => { + const validators = useInitValidators({ required }); + const { isValid, messages } = useValidateInput({ validators, value }); + const helperTextProps = { + children: isValid ? helperText : messages.join(', '), + type: isValid ? ('default' as const) : ('error' as const), + ...helperTextExtra, + }; + const labelProps = { + children: label, + error: !isValid, + htmlFor: id, + required, + ...labelExtra, + }; + const inputProps = { + ...input, + error: !isValid, + id, + name, + onChange, + value, + }; + + useEffect(() => { + onValidate(isValid, messages); + }, [isValid, messages, onValidate]); + + return ( + + + + ); +}; + +export default FormControlInputText; + +export const FormControlInputTextStateful = withStateValue(FormControlInputText); diff --git a/packages/components/src/formControls/InputText/InputText.types.ts b/packages/components/src/formControls/InputText/InputText.types.ts new file mode 100644 index 0000000..82f54b0 --- /dev/null +++ b/packages/components/src/formControls/InputText/InputText.types.ts @@ -0,0 +1,23 @@ +import { BaseComponentAttributes } from '@ids-types/general'; + +import { InputTextProps as BasicInputTextProps } from '../../inputs/InputText/InputText.types'; +import { HelperTextProps } from '../../HelperText/HelperText.types'; +import { LabelProps } from '../../Label/Label.types'; + +export interface FormControlInputTextProps extends BaseComponentAttributes { + id: string; + name: BasicInputTextProps['name']; + input?: Omit; + helperText?: HelperTextProps['children']; + helperTextExtra?: Omit; + label?: LabelProps['children']; + labelExtra?: Omit; + onChange?: BasicInputTextProps['onChange']; + onValidate?: (isValid: boolean, messages: string[]) => void; + required?: boolean; + value?: BasicInputTextProps['value']; +} + +export type OnChangeArgsType = Parameters>; + +export type ValueType = NonNullable; diff --git a/packages/components/src/formControls/InputText/InputText.utils.ts b/packages/components/src/formControls/InputText/InputText.utils.ts new file mode 100644 index 0000000..60a3cc2 --- /dev/null +++ b/packages/components/src/formControls/InputText/InputText.utils.ts @@ -0,0 +1,45 @@ +import { useContext, useEffect, useMemo, useRef, useState } from 'react'; + +import BaseValidator from '@ibexa/ids-core/validators/BaseValidator'; +import IsEmptyStringValidator from '@ibexa/ids-core/validators/IsEmptyStringValidator'; +import { TranslatorContext } from '@ids-context/Translator'; +import { ValidationResult } from '@ibexa/ids-core/types/validation'; +import { validateInput } from '@ids-internal/shared/validators'; + +import { ValueType } from './InputText.types'; + +export const useInitValidators = ({ required }: { required: boolean }) => { + const translator = useContext(TranslatorContext); + const validators = useMemo(() => { + const validatorsList: BaseValidator[] = []; + + if (required) { + validatorsList.push(new IsEmptyStringValidator(translator)); + } + + return validatorsList; + }, [required, translator]); + + return validators; +}; + +export const useValidateInput = ({ validators, value }: { validators: BaseValidator[]; value: ValueType }): ValidationResult => { + const initialValue = useRef(value); + const [isDirty, setIsDirty] = useState(false); + + useEffect(() => { + if (initialValue.current !== value) { + setIsDirty(true); + } + + initialValue.current = value; + }, [value]); + + return useMemo(() => { + if (!isDirty) { + return { isValid: true, messages: [] }; + } + + return validateInput(value, validators); + }, [initialValue.current, value, validators]); +}; diff --git a/packages/components/src/formControls/InputText/index.ts b/packages/components/src/formControls/InputText/index.ts new file mode 100644 index 0000000..9d5943d --- /dev/null +++ b/packages/components/src/formControls/InputText/index.ts @@ -0,0 +1,6 @@ +import FormControlInputText, { FormControlInputTextStateful } from './InputText'; +import { FormControlInputTextProps } from './InputText.types'; + +export default FormControlInputText; +export { FormControlInputTextStateful }; +export type { FormControlInputTextProps };