Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/assets/src/scss/inputs/_input-text.scss
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

.ids-input-text {
position: relative;
width: fit-content;

&__actions {
position: absolute;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import type { Meta, StoryObj } from '@storybook/react';
import { action } from 'storybook/actions';

import { FormControlInputTextStateful } from './InputText';

const meta: Meta<typeof FormControlInputTextStateful> = {
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<typeof FormControlInputTextStateful>;

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',
},
},
};
Original file line number Diff line number Diff line change
@@ -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<typeof FormControlInputTextStateful> = {
component: FormControlInputTextStateful,
parameters: {
layout: 'centered',
},
tags: ['!dev'],
args: {
name: 'default-input',
onChange: fn(),
onValidate: fn(),
},
};

export default meta;

type Story = StoryObj<typeof FormControlInputTextStateful>;

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');
});
},
};
59 changes: 59 additions & 0 deletions packages/components/src/formControls/InputText/InputText.tsx
Original file line number Diff line number Diff line change
@@ -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,
value = '',
}: FormControlInputTextProps) => {
const required = input.required ?? false;
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 (
<BaseFormControl helperText={helperTextProps} label={labelProps} type="input-text">
<InputText {...inputProps} />
</BaseFormControl>
);
};

export default FormControlInputText;

export const FormControlInputTextStateful = withStateValue<ValueType>(FormControlInputText);
22 changes: 22 additions & 0 deletions packages/components/src/formControls/InputText/InputText.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
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<BasicInputTextProps, 'error' | 'name' | 'onChange' | 'value'>;
helperText?: HelperTextProps['children'];
helperTextExtra?: Omit<HelperTextProps, 'children' | 'type'>;
label?: LabelProps['children'];
labelExtra?: Omit<LabelProps, 'children' | 'error' | 'htmlFor' | 'required'>;
onChange?: BasicInputTextProps['onChange'];
onValidate?: (isValid: boolean, messages: string[]) => void;
value?: BasicInputTextProps['value'];
}

export type OnChangeArgsType = Parameters<NonNullable<FormControlInputTextProps['onChange']>>;

export type ValueType = NonNullable<FormControlInputTextProps['value']>;
45 changes: 45 additions & 0 deletions packages/components/src/formControls/InputText/InputText.utils.ts
Original file line number Diff line number Diff line change
@@ -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<ValueType>[] = [];

if (required) {
validatorsList.push(new IsEmptyStringValidator(translator));
}

return validatorsList;
}, [required, translator]);

return validators;
};

export const useValidateInput = ({ validators, value }: { validators: BaseValidator<ValueType>[]; 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<ValueType>(value, validators);
}, [initialValue.current, value, validators]);
};
6 changes: 6 additions & 0 deletions packages/components/src/formControls/InputText/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import FormControlInputText, { FormControlInputTextStateful } from './InputText';
import { FormControlInputTextProps } from './InputText.types';

export default FormControlInputText;
export { FormControlInputTextStateful };
export type { FormControlInputTextProps };