Skip to content

Commit

Permalink
fix(inputs): add dynamic validation
Browse files Browse the repository at this point in the history
  • Loading branch information
morewings committed Jul 16, 2024
1 parent a414be8 commit 113cdf0
Show file tree
Hide file tree
Showing 27 changed files with 219 additions and 53 deletions.
2 changes: 1 addition & 1 deletion .storybook/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ const preview: Preview = {
},
options: {
storySort: {
order: ['Intro', 'Layout', 'Inputs', 'Typography', 'Components'],
order: ['Intro', 'Theme', 'Layout', 'Inputs', 'Typography', 'Components'],
},
},
viewport: {viewports: customViewports},
Expand Down
6 changes: 6 additions & 0 deletions src/internal/inputs/ValidationProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ export enum ValidationState {
}

export type ValidationProps = {
/** Enable to re-run validation when any field in the form changes */
revalidateOnFormChange?: boolean;
/**
* Provide callback function to validate input.
* @see https://koval.support/inputs/input-validation
*/
validatorFn?: (
value: unknown,
validityState: ValidityState,
Expand Down
1 change: 1 addition & 0 deletions src/internal/inputs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@ export {
validatorSyncBoolean,
} from './validatorMocks.ts';
export {useHandleFormReset} from './useHandleFormReset.ts';
export {useRevalidateOnFormChange} from './useRevalidateOnFormChange.ts';
25 changes: 25 additions & 0 deletions src/internal/inputs/useRevalidateOnFormChange.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type {MutableRefObject} from 'react';
import {type FormEvent, useEffect} from 'react';

const ChangeEvent = new Event('change', {bubbles: false});

export const useRevalidateOnFormChange = (
inputRef: MutableRefObject<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement | null>,
validateInput: (event: FormEvent) => unknown,
condition?: boolean
) => {
useEffect(() => {
if (condition) {
const inputElement = inputRef?.current;
const formElement = inputElement?.form;
const revalidateInput = () => {
inputElement && inputElement?.dispatchEvent(ChangeEvent);
inputElement && validateInput(ChangeEvent as unknown as FormEvent);
};
formElement && formElement.addEventListener('change', revalidateInput);
return () => {
formElement && formElement.removeEventListener('change', revalidateInput);
};
}
}, [inputRef, condition, validateInput]);
};
28 changes: 11 additions & 17 deletions src/lib/Form/Form.mdx
Original file line number Diff line number Diff line change
@@ -1,32 +1,26 @@
import { Meta, ArgTypes, Story, Canvas, Source, Markdown } from "@storybook/blocks";
import { Meta, ArgTypes, Story, Canvas, Source, Markdown, Primary } from "@storybook/blocks";
import * as FormStories from "./Form.stories";
import { Form } from "./Form";

<Meta title="Inputs/Form" component={Form} />
<Meta title="Inputs/Form" of={FormStories} />

# Form

Form wraps `HTMLFormElement` with some useful features.

## Icon props

<ArgTypes of={Form} />

## Description

`Icon` is an icon.

## Imports

<Source format={true} code={`
const a = 'b';
import a from 'b';
`} />
<Markdown>{`
\`\`\`ts
import {Form} from 'koval-ui';
\`\`\`
`}</Markdown>

## Form props

<ArgTypes of={Form} />

## Demo

### Basic example
<Canvas of={FormStories.Example} />
<Primary />


46 changes: 43 additions & 3 deletions src/lib/Form/Form.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,6 @@ const meta = {
onReset: fn(),
onChange: fn(),
onInvalid: fn(),
autoComplete: 'off',
autoCapitalize: 'off',
noValidate: false,
},
argTypes: {
Expand Down Expand Up @@ -92,7 +90,7 @@ const meta = {
export default meta;
type Story = StoryObj<typeof meta>;

export const Example: Story = {
export const Primary: Story = {
args: {
onSubmit: (event, state) => {
event.preventDefault();
Expand Down Expand Up @@ -173,3 +171,45 @@ export const Example: Story = {
),
},
};

export const ComplexValidation: Story = {
name: 'Complex validation',
args: {
onSubmit: (event, state) => {
event.preventDefault();
console.log('onSubmit', state);
},
},
render: args => {
const validatorFn = (
value: unknown,
_: unknown,
formState: Record<string, FormDataEntryValue>
) => {
console.log('formState', formState);
if (formState['case-selector'] === 'lowercase') {
const isLowerCase = (value as string).toLowerCase() === value;
return isLowerCase ? '' : 'Only lower case allowed.';
} else if (formState['case-selector'] === 'uppercase') {
const isUpperCase = (value as string).toUpperCase() === value;
return isUpperCase ? '' : 'Only upper case allowed.';
}
return '';
};
return (
<Form {...args}>
<InputGroup name="case-selector">
<InputRadio defaultChecked={true} label="Allow uppercase" value="uppercase" />
<InputRadio label="Allow lowercase" value="lowercase" />
</InputGroup>
<InputText
revalidateOnFormChange={true}
validatorFn={validatorFn}
name="text"
placeholder="Validity changes according to user choise"
/>
<Button type="submit">Submit</Button>
</Form>
);
},
};
5 changes: 5 additions & 0 deletions src/lib/InputCheckbox/InputCheckbox.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,11 @@ const meta = {
disable: true,
},
},
revalidateOnFormChange: {
table: {
disable: true,
},
},
validatorFn: {
options: ['noValidator', 'syncValidator', 'asyncValidator'], // An array of serializable values
mapping: {
Expand Down
13 changes: 12 additions & 1 deletion src/lib/InputCheckbox/InputCheckbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,20 @@ import {forwardRef, useCallback} from 'react';
import classNames from 'classnames';

import type {DataAttributes, LibraryProps} from '@/internal/LibraryAPI';
import {ValidationState, defaultValidator, useValidation} from '@/internal/inputs';
import {
ValidationState,
defaultValidator,
useValidation,
useRevalidateOnFormChange,
} from '@/internal/inputs';
import type {
NativePropsInteractive,
CallbackPropsInteractive,
ValidationProps,
} from '@/internal/inputs';
import {IconError, IconLoader, IconValid} from '@/internal/Icons';
import {useInternalId} from '@/internal/hooks/useInternalId.ts';
import {useInternalRef} from '@/internal/hooks/useInternalRef.ts';

import classes from './InputCheckbox.module.css';

Expand Down Expand Up @@ -39,12 +45,17 @@ export const InputCheckbox = forwardRef<HTMLInputElement, Props>(
label,
validatorFn = defaultValidator,
required,
revalidateOnFormChange,
...nativeProps
},
ref
) => {
const id = useInternalId(idProp);
const {validateInteractive, validity, setValidity} = useValidation({validatorFn});

const inputRef = useInternalRef(ref);
useRevalidateOnFormChange(inputRef, validateInteractive, revalidateOnFormChange);

const ValidationIcon = {
[ValidationState.error]: IconError,
[ValidationState.valid]: IconValid,
Expand Down
5 changes: 5 additions & 0 deletions src/lib/InputDate/InputDate.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,11 @@ const meta = {
disable: true,
},
},
revalidateOnFormChange: {
table: {
disable: true,
},
},
validatorFn: {
options: ['noValidator', 'syncValidator', 'asyncValidator'], // An array of serializable values
mapping: {
Expand Down
7 changes: 7 additions & 0 deletions src/lib/InputDate/InputDate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ import classNames from 'classnames';
import {IconError, IconValid, IconLoader, IconCalendar} from '@/internal/Icons';
import type {DataAttributes, LibraryProps} from '@/internal/LibraryAPI';
import type {NativePropsTextual, CallbackPropsTextual, ValidationProps} from '@/internal/inputs';
import {useRevalidateOnFormChange} from '@/internal/inputs';
import {defaultValidator} from '@/internal/inputs';
import {ValidationState, useValidation} from '@/internal/inputs';
import {useInternalId} from '@/internal/hooks/useInternalId.ts';
import {useInternalRef} from '@/internal/hooks/useInternalRef.ts';

import classes from './InputDate.module.css';

Expand All @@ -33,6 +35,7 @@ export const InputDate = forwardRef<HTMLInputElement, Props>(
onKeyUp = () => {},
defaultValue,
validatorFn = defaultValidator,
revalidateOnFormChange,
...nativeProps
},
ref
Expand All @@ -46,6 +49,10 @@ export const InputDate = forwardRef<HTMLInputElement, Props>(
[ValidationState.valid]: IconValid,
[ValidationState.inProgress]: IconLoader,
}[validity!];

const inputRef = useInternalRef(ref);
useRevalidateOnFormChange(inputRef, validateTextual, revalidateOnFormChange);

const displayValue = (value ?? defaultValue) as string;
const handleChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
Expand Down
18 changes: 1 addition & 17 deletions src/lib/InputGroup/InputGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {
import classNames from 'classnames';

import type {LibraryProps, DataAttributes} from '@/internal/LibraryAPI';
import type {ValidationState} from '@/internal/inputs';

import classes from './InputGroup.module.css';

Expand All @@ -23,7 +22,6 @@ type ChildProps = {

export type Props = DataAttributes &
LibraryProps & {
validation?: keyof typeof ValidationState;
label?: string;
children: ReactElement<ChildProps & unknown>[];
name: string;
Expand All @@ -37,21 +35,7 @@ export type Props = DataAttributes &
};

export const InputGroup = forwardRef<HTMLFieldSetElement, Props>(
(
{
className,
validation,
id,
label,
children,
name,
disabled,
hint,
required,
...nativeProps
},
ref
) => {
({className, id, label, children, name, disabled, hint, required, ...nativeProps}, ref) => {
const childrenWithProps = useMemo(() => {
return Children.map(children, element => {
if (isValidElement(element)) {
Expand Down
5 changes: 5 additions & 0 deletions src/lib/InputNumber/InputNumber.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,11 @@ const meta = {
disable: true,
},
},
revalidateOnFormChange: {
table: {
disable: true,
},
},
validatorFn: {
options: ['noValidator', 'syncValidator', 'asyncValidator'], // An array of serializable values
mapping: {
Expand Down
8 changes: 6 additions & 2 deletions src/lib/InputNumber/InputNumber.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {useLocalTheme} from 'css-vars-hook';
import {IconError, IconValid, IconLoader, IconArrowUp, IconArrowDown} from '@/internal/Icons';
import type {DataAttributes, LibraryProps} from '@/internal/LibraryAPI';
import type {NativePropsNumeric, CallbackPropsTextual, ValidationProps} from '@/internal/inputs';
import {useRevalidateOnFormChange} from '@/internal/inputs';
import {ValidationState, defaultValidator, useValidation} from '@/internal/inputs';
import {useInternalRef} from '@/internal/hooks/useInternalRef.ts';

Expand Down Expand Up @@ -41,11 +42,16 @@ export const InputNumber = forwardRef<HTMLInputElement, Props>(
validatorFn = defaultValidator,
size = 10,
step = 1,
revalidateOnFormChange,
...nativeProps
},
ref
) => {
const {validateTextual, validity, setValidity} = useValidation({validatorFn});

const inputRef = useInternalRef(ref);
useRevalidateOnFormChange(inputRef, validateTextual, revalidateOnFormChange);

const ValidationIcon = {
[ValidationState.error]: IconError,
[ValidationState.valid]: IconValid,
Expand All @@ -62,8 +68,6 @@ export const InputNumber = forwardRef<HTMLInputElement, Props>(
setValidity(ValidationState.error);
}, [setValidity]);

const inputRef = useInternalRef(ref);

const handleDecrement = useCallback(() => {
inputRef.current!.stepDown();
inputRef.current!.dispatchEvent(ChangeEventSpinner);
Expand Down
5 changes: 5 additions & 0 deletions src/lib/InputPassword/InputPassword.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,11 @@ const meta = {
disable: true,
},
},
revalidateOnFormChange: {
table: {
disable: true,
},
},
validatorFn: {
options: ['noValidator', 'syncValidator', 'asyncValidator'], // An array of serializable values
mapping: {
Expand Down
7 changes: 7 additions & 0 deletions src/lib/InputPassword/InputPassword.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import classNames from 'classnames';
import {IconError, IconValid, IconLoader, IconLock, IconLockOpen} from '@/internal/Icons';
import type {DataAttributes, LibraryProps} from '@/internal/LibraryAPI';
import type {NativePropsTextual, CallbackPropsTextual, ValidationProps} from '@/internal/inputs';
import {useRevalidateOnFormChange} from '@/internal/inputs';
import {ValidationState, defaultValidator, useValidation} from '@/internal/inputs';
import {useInternalId} from '@/internal/hooks/useInternalId.ts';
import {useInternalRef} from '@/internal/hooks/useInternalRef.ts';

import classes from './InputPassword.module.css';

Expand Down Expand Up @@ -40,11 +42,16 @@ export const InputPassword = forwardRef<HTMLInputElement, Props>(
id,
readOnly,
size = 16,
revalidateOnFormChange,
...nativeProps
},
ref
) => {
const {validateTextual, validity, setValidity} = useValidation({validatorFn});

const inputRef = useInternalRef(ref);
useRevalidateOnFormChange(inputRef, validateTextual, revalidateOnFormChange);

const ValidationIcon = {
[ValidationState.error]: IconError,
[ValidationState.valid]: IconValid,
Expand Down
5 changes: 5 additions & 0 deletions src/lib/InputRange/InputRange.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,11 @@ const meta = {
disable: true,
},
},
revalidateOnFormChange: {
table: {
disable: true,
},
},
validatorFn: {
options: ['noValidator', 'syncValidator', 'asyncValidator'], // An array of serializable values
mapping: {
Expand Down
Loading

0 comments on commit 113cdf0

Please sign in to comment.