From 0063311ef323aab30857557d76118d2dff72c8d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Grabowski?= Date: Tue, 26 Aug 2025 16:39:55 +0200 Subject: [PATCH 1/3] IBX-10307: Form Control - Input Text --- .../assets/src/scss/inputs/_input-text.scss | 1 + .../InputText/InputText.stories.ts | 82 ++++++++++++++++++ .../InputText/InputText.test.stories.ts | 86 +++++++++++++++++++ .../src/formControls/InputText/InputText.tsx | 74 ++++++++++++++++ .../formControls/InputText/InputText.types.ts | 18 ++++ .../src/formControls/InputText/index.ts | 6 ++ 6 files changed, 267 insertions(+) create mode 100644 packages/components/src/formControls/InputText/InputText.stories.ts create mode 100644 packages/components/src/formControls/InputText/InputText.test.stories.ts create mode 100644 packages/components/src/formControls/InputText/InputText.tsx create mode 100644 packages/components/src/formControls/InputText/InputText.types.ts create mode 100644 packages/components/src/formControls/InputText/index.ts 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..4f085c4 --- /dev/null +++ b/packages/components/src/formControls/InputText/InputText.stories.ts @@ -0,0 +1,82 @@ +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', + }, + }, + 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', + input: { + size: 'medium', + required: true, + 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', + }, + }, +}; \ No newline at end of file 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..db7cb0f --- /dev/null +++ b/packages/components/src/formControls/InputText/InputText.test.stories.ts @@ -0,0 +1,86 @@ +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: { + input: { + 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'); + }); + }, +}; \ No newline at end of file diff --git a/packages/components/src/formControls/InputText/InputText.tsx b/packages/components/src/formControls/InputText/InputText.tsx new file mode 100644 index 0000000..62a5aab --- /dev/null +++ b/packages/components/src/formControls/InputText/InputText.tsx @@ -0,0 +1,74 @@ +import React, { useContext, useEffect, useMemo } from 'react'; + +import BaseFormControl from '@ids-internal/partials/BaseFormControl'; +import InputText from '../../inputs/InputText'; +import IsEmptyStringValidator from '@ibexa/ids-core/validators/IsEmptyStringValidator'; +import { TranslatorContext } from '@ids-context/Translator'; +import { validateInput } from '@ids-internal/shared/validators'; +import withStateValue from '@ids-internal/hoc/withStateValue'; + +import { FormControlInputTextProps } from './InputText.types'; +import { ValidationResult } from '@ibexa/ids-core/types/validation'; + +const FormControlInputText = ({ + helperText, + helperTextExtra = {}, + id, + input = {}, + label, + labelExtra = {}, + name, + onChange = () => undefined, + onValidate = () => undefined, + value = '', +}: FormControlInputTextProps) => { + const translator = useContext(TranslatorContext); + const required = input.required ?? false; + const validators = useMemo(() => { + const validatorsList = []; + + if (required) { + validatorsList.push(new IsEmptyStringValidator(translator)); + } + + return validatorsList; + }, [required, translator]); + const { isValid, messages } = useMemo( + () => validateInput(value, validators), + [value, validators], + ); + 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..07a0ff3 --- /dev/null +++ b/packages/components/src/formControls/InputText/InputText.types.ts @@ -0,0 +1,18 @@ +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; + value?: BasicInputTextProps['value']; +} \ No newline at end of file diff --git a/packages/components/src/formControls/InputText/index.ts b/packages/components/src/formControls/InputText/index.ts new file mode 100644 index 0000000..6072565 --- /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 }; \ No newline at end of file From 4014b12f65c6f5f083faf7f8378f6137665e38b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Grabowski?= Date: Wed, 27 Aug 2025 15:04:35 +0200 Subject: [PATCH 2/3] added isDirty flag --- .../InputText/InputText.stories.ts | 11 ++++- .../InputText/InputText.test.stories.ts | 2 +- .../src/formControls/InputText/InputText.tsx | 27 +++-------- .../formControls/InputText/InputText.types.ts | 6 ++- .../formControls/InputText/InputText.utils.ts | 45 +++++++++++++++++++ .../src/formControls/InputText/index.ts | 2 +- 6 files changed, 68 insertions(+), 25 deletions(-) create mode 100644 packages/components/src/formControls/InputText/InputText.utils.ts diff --git a/packages/components/src/formControls/InputText/InputText.stories.ts b/packages/components/src/formControls/InputText/InputText.stories.ts index 4f085c4..1cc973f 100644 --- a/packages/components/src/formControls/InputText/InputText.stories.ts +++ b/packages/components/src/formControls/InputText/InputText.stories.ts @@ -19,6 +19,15 @@ const meta: Meta = { value: { control: 'text', }, + onChange: { + control: false, + }, + onValidate: { + control: false, + }, + input: { + control: false, + }, }, args: { id: 'default-input', @@ -79,4 +88,4 @@ export const Number: Story = { type: 'number', }, }, -}; \ No newline at end of file +}; diff --git a/packages/components/src/formControls/InputText/InputText.test.stories.ts b/packages/components/src/formControls/InputText/InputText.test.stories.ts index db7cb0f..d017910 100644 --- a/packages/components/src/formControls/InputText/InputText.test.stories.ts +++ b/packages/components/src/formControls/InputText/InputText.test.stories.ts @@ -83,4 +83,4 @@ export const Required: Story = { await expect(input).toHaveAttribute('aria-invalid', 'true'); }); }, -}; \ No newline at end of file +}; diff --git a/packages/components/src/formControls/InputText/InputText.tsx b/packages/components/src/formControls/InputText/InputText.tsx index 62a5aab..c470920 100644 --- a/packages/components/src/formControls/InputText/InputText.tsx +++ b/packages/components/src/formControls/InputText/InputText.tsx @@ -1,14 +1,11 @@ -import React, { useContext, useEffect, useMemo } from 'react'; +import React, { useEffect } from 'react'; +import { useInitValidators, useValidateInput } from './InputText.utils'; import BaseFormControl from '@ids-internal/partials/BaseFormControl'; import InputText from '../../inputs/InputText'; -import IsEmptyStringValidator from '@ibexa/ids-core/validators/IsEmptyStringValidator'; -import { TranslatorContext } from '@ids-context/Translator'; -import { validateInput } from '@ids-internal/shared/validators'; import withStateValue from '@ids-internal/hoc/withStateValue'; -import { FormControlInputTextProps } from './InputText.types'; -import { ValidationResult } from '@ibexa/ids-core/types/validation'; +import { FormControlInputTextProps, ValueType } from './InputText.types'; const FormControlInputText = ({ helperText, @@ -22,21 +19,9 @@ const FormControlInputText = ({ onValidate = () => undefined, value = '', }: FormControlInputTextProps) => { - const translator = useContext(TranslatorContext); const required = input.required ?? false; - const validators = useMemo(() => { - const validatorsList = []; - - if (required) { - validatorsList.push(new IsEmptyStringValidator(translator)); - } - - return validatorsList; - }, [required, translator]); - const { isValid, messages } = useMemo( - () => validateInput(value, validators), - [value, validators], - ); + 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), @@ -71,4 +56,4 @@ const FormControlInputText = ({ export default FormControlInputText; -export const FormControlInputTextStateful = withStateValue(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 index 07a0ff3..9b9e82c 100644 --- a/packages/components/src/formControls/InputText/InputText.types.ts +++ b/packages/components/src/formControls/InputText/InputText.types.ts @@ -15,4 +15,8 @@ export interface FormControlInputTextProps extends BaseComponentAttributes { onChange?: BasicInputTextProps['onChange']; onValidate?: (isValid: boolean, messages: string[]) => void; value?: BasicInputTextProps['value']; -} \ No newline at end of file +} + +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 index 6072565..9d5943d 100644 --- a/packages/components/src/formControls/InputText/index.ts +++ b/packages/components/src/formControls/InputText/index.ts @@ -3,4 +3,4 @@ import { FormControlInputTextProps } from './InputText.types'; export default FormControlInputText; export { FormControlInputTextStateful }; -export type { FormControlInputTextProps }; \ No newline at end of file +export type { FormControlInputTextProps }; From 4cc814c49901b5193f8c393b0a92a2cad9261f80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Grabowski?= Date: Thu, 28 Aug 2025 10:13:25 +0200 Subject: [PATCH 3/3] move required to main --- .../src/formControls/InputText/InputText.stories.ts | 2 +- .../src/formControls/InputText/InputText.test.stories.ts | 4 +--- packages/components/src/formControls/InputText/InputText.tsx | 2 +- .../components/src/formControls/InputText/InputText.types.ts | 3 ++- 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/components/src/formControls/InputText/InputText.stories.ts b/packages/components/src/formControls/InputText/InputText.stories.ts index 1cc973f..be619bf 100644 --- a/packages/components/src/formControls/InputText/InputText.stories.ts +++ b/packages/components/src/formControls/InputText/InputText.stories.ts @@ -58,9 +58,9 @@ export const Required: Story = { args: { helperText: 'This is a helper text', label: 'Input Label', + required: true, input: { size: 'medium', - required: true, type: 'text', }, }, diff --git a/packages/components/src/formControls/InputText/InputText.test.stories.ts b/packages/components/src/formControls/InputText/InputText.test.stories.ts index d017910..070b861 100644 --- a/packages/components/src/formControls/InputText/InputText.test.stories.ts +++ b/packages/components/src/formControls/InputText/InputText.test.stories.ts @@ -52,9 +52,7 @@ export const NotRequired: Story = { export const Required: Story = { name: 'Required', args: { - input: { - required: true, - }, + required: true, }, play: async ({ canvasElement, step, args }) => { const canvas = within(canvasElement); diff --git a/packages/components/src/formControls/InputText/InputText.tsx b/packages/components/src/formControls/InputText/InputText.tsx index c470920..146c355 100644 --- a/packages/components/src/formControls/InputText/InputText.tsx +++ b/packages/components/src/formControls/InputText/InputText.tsx @@ -17,9 +17,9 @@ const FormControlInputText = ({ name, onChange = () => undefined, onValidate = () => undefined, + required = false, value = '', }: FormControlInputTextProps) => { - const required = input.required ?? false; const validators = useInitValidators({ required }); const { isValid, messages } = useValidateInput({ validators, value }); const helperTextProps = { diff --git a/packages/components/src/formControls/InputText/InputText.types.ts b/packages/components/src/formControls/InputText/InputText.types.ts index 9b9e82c..82f54b0 100644 --- a/packages/components/src/formControls/InputText/InputText.types.ts +++ b/packages/components/src/formControls/InputText/InputText.types.ts @@ -7,13 +7,14 @@ import { LabelProps } from '../../Label/Label.types'; export interface FormControlInputTextProps extends BaseComponentAttributes { id: string; name: BasicInputTextProps['name']; - input?: Omit; + 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']; }