aboutsummaryrefslogtreecommitdiffstats
path: root/src/components/atoms/forms/fields
diff options
context:
space:
mode:
authorArmand Philippot <git@armandphilippot.com>2023-09-22 19:34:01 +0200
committerArmand Philippot <git@armandphilippot.com>2023-10-24 12:23:48 +0200
commita6ff5eee45215effb3344cb5d631a27a7c0369aa (patch)
tree5051747acf72318b4fc5c18d603e3757fbefdfdb /src/components/atoms/forms/fields
parent651ea4fc992e77d2f36b3c68f8e7a70644246067 (diff)
refactor(components): rewrite form components
Diffstat (limited to 'src/components/atoms/forms/fields')
-rw-r--r--src/components/atoms/forms/fields/boolean-field/boolean-field.module.scss7
-rw-r--r--src/components/atoms/forms/fields/boolean-field/boolean-field.stories.tsx175
-rw-r--r--src/components/atoms/forms/fields/boolean-field/boolean-field.test.tsx36
-rw-r--r--src/components/atoms/forms/fields/boolean-field/boolean-field.tsx86
-rw-r--r--src/components/atoms/forms/fields/boolean-field/index.ts1
-rw-r--r--src/components/atoms/forms/fields/checkbox/checkbox.test.tsx33
-rw-r--r--src/components/atoms/forms/fields/checkbox/checkbox.tsx13
-rw-r--r--src/components/atoms/forms/fields/checkbox/index.ts1
-rw-r--r--src/components/atoms/forms/fields/fields.module.scss50
-rw-r--r--src/components/atoms/forms/fields/index.ts6
-rw-r--r--src/components/atoms/forms/fields/input/index.ts1
-rw-r--r--src/components/atoms/forms/fields/input/input.stories.tsx237
-rw-r--r--src/components/atoms/forms/fields/input/input.test.tsx34
-rw-r--r--src/components/atoms/forms/fields/input/input.tsx72
-rw-r--r--src/components/atoms/forms/fields/radio/index.ts1
-rw-r--r--src/components/atoms/forms/fields/radio/radio.test.tsx28
-rw-r--r--src/components/atoms/forms/fields/radio/radio.tsx13
-rw-r--r--src/components/atoms/forms/fields/select/index.ts1
-rw-r--r--src/components/atoms/forms/fields/select/select.stories.tsx143
-rw-r--r--src/components/atoms/forms/fields/select/select.test.tsx43
-rw-r--r--src/components/atoms/forms/fields/select/select.tsx76
-rw-r--r--src/components/atoms/forms/fields/text-area/index.ts1
-rw-r--r--src/components/atoms/forms/fields/text-area/text-area.stories.tsx136
-rw-r--r--src/components/atoms/forms/fields/text-area/text-area.test.tsx20
-rw-r--r--src/components/atoms/forms/fields/text-area/text-area.tsx69
25 files changed, 1283 insertions, 0 deletions
diff --git a/src/components/atoms/forms/fields/boolean-field/boolean-field.module.scss b/src/components/atoms/forms/fields/boolean-field/boolean-field.module.scss
new file mode 100644
index 0000000..7e13e43
--- /dev/null
+++ b/src/components/atoms/forms/fields/boolean-field/boolean-field.module.scss
@@ -0,0 +1,7 @@
+@use "../../../../../styles/abstracts/mixins" as mix;
+
+.field {
+ &--hidden {
+ @include mix.visually-hidden;
+ }
+}
diff --git a/src/components/atoms/forms/fields/boolean-field/boolean-field.stories.tsx b/src/components/atoms/forms/fields/boolean-field/boolean-field.stories.tsx
new file mode 100644
index 0000000..cb017da
--- /dev/null
+++ b/src/components/atoms/forms/fields/boolean-field/boolean-field.stories.tsx
@@ -0,0 +1,175 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { useState } from 'react';
+import { BooleanField } from './boolean-field';
+
+/**
+ * BooleanField - Storybook Meta
+ */
+export default {
+ title: 'Atoms/Forms/Fields',
+ component: BooleanField,
+ args: {
+ isHidden: false,
+ },
+ argTypes: {
+ 'aria-labelledby': {
+ control: {
+ type: 'text',
+ },
+ description: 'One or more ids that refers to the field name.',
+ table: {
+ category: 'Accessibility',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the field.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ id: {
+ control: {
+ type: 'text',
+ },
+ description: 'The field id.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ isChecked: {
+ control: {
+ type: null,
+ },
+ description: 'The field state: true if checked.',
+ type: {
+ name: 'boolean',
+ required: true,
+ },
+ },
+ isHidden: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Define if the field should be visually hidden.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: false },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ name: {
+ control: {
+ type: 'text',
+ },
+ description: 'The field name.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ onChange: {
+ control: {
+ type: null,
+ },
+ description: 'A callback function to handle field state change.',
+ table: {
+ category: 'Events',
+ },
+ type: {
+ name: 'function',
+ required: true,
+ },
+ },
+ onClick: {
+ control: {
+ type: null,
+ },
+ description: 'A callback function to handle click on field.',
+ table: {
+ category: 'Events',
+ },
+ type: {
+ name: 'function',
+ required: false,
+ },
+ },
+ type: {
+ control: {
+ type: 'select',
+ },
+ description: 'The field type. Either checkbox or radio.',
+ options: ['checkbox', 'radio'],
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ value: {
+ control: {
+ type: 'text',
+ },
+ description: 'The field value.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof BooleanField>;
+
+const Template: ComponentStory<typeof BooleanField> = ({
+ isChecked: checked,
+ onChange: _onChange,
+ ...args
+}) => {
+ const [isChecked, setIsChecked] = useState(checked);
+
+ return (
+ <BooleanField
+ isChecked={isChecked}
+ onChange={() => {
+ setIsChecked(!isChecked);
+ }}
+ {...args}
+ />
+ );
+};
+
+/**
+ * Checkbox Story
+ */
+export const Checkbox = Template.bind({});
+Checkbox.args = {
+ id: 'checkbox',
+ isChecked: false,
+ name: 'checkbox',
+ type: 'checkbox',
+ value: 'checkbox',
+};
+
+/**
+ * Radio Story
+ */
+export const Radio = Template.bind({});
+Radio.args = {
+ id: 'radio',
+ isChecked: false,
+ name: 'radio',
+ type: 'radio',
+ value: 'radio',
+};
diff --git a/src/components/atoms/forms/fields/boolean-field/boolean-field.test.tsx b/src/components/atoms/forms/fields/boolean-field/boolean-field.test.tsx
new file mode 100644
index 0000000..fcd15ad
--- /dev/null
+++ b/src/components/atoms/forms/fields/boolean-field/boolean-field.test.tsx
@@ -0,0 +1,36 @@
+import { render, screen } from '../../../../../../tests/utils';
+import { BooleanField } from './boolean-field';
+
+const handleChange = () => {
+ /**
+ * Do nothing.
+ */
+};
+
+describe('boolean field', () => {
+ it('renders a checkbox', () => {
+ render(
+ <BooleanField
+ id="checkbox"
+ name="checkbox"
+ onChange={handleChange}
+ type="checkbox"
+ value="checkbox"
+ />
+ );
+ expect(screen.getByRole('checkbox')).toBeInTheDocument();
+ });
+
+ it('renders a radio button', () => {
+ render(
+ <BooleanField
+ id="radio"
+ name="radio"
+ onChange={handleChange}
+ type="radio"
+ value="checkbox"
+ />
+ );
+ expect(screen.getByRole('radio')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/atoms/forms/fields/boolean-field/boolean-field.tsx b/src/components/atoms/forms/fields/boolean-field/boolean-field.tsx
new file mode 100644
index 0000000..7985c0b
--- /dev/null
+++ b/src/components/atoms/forms/fields/boolean-field/boolean-field.tsx
@@ -0,0 +1,86 @@
+import { FC, InputHTMLAttributes } from 'react';
+import styles from './boolean-field.module.scss';
+
+export type BooleanFieldProps = Omit<
+ InputHTMLAttributes<HTMLInputElement>,
+ | 'checked'
+ | 'disabled'
+ | 'hidden'
+ | 'name'
+ | 'readOnly'
+ | 'required'
+ | 'type'
+ | 'value'
+> & {
+ /**
+ * Should the field be checked?
+ *
+ * @default false
+ */
+ isChecked?: boolean;
+ /**
+ * Should the field be disabled?
+ *
+ * @default false
+ */
+ isDisabled?: boolean;
+ /**
+ * Should the field be visually hidden?
+ *
+ * @default false
+ */
+ isHidden?: boolean;
+ /**
+ * Should the field be readonly?
+ *
+ * @default false
+ */
+ isReadOnly?: boolean;
+ /**
+ * Should the field be required?
+ *
+ * @default false
+ */
+ isRequired?: boolean;
+ /**
+ * Field name attribute.
+ */
+ name: string;
+ /**
+ * The input type.
+ */
+ type: 'checkbox' | 'radio';
+ /**
+ * Field name attribute.
+ */
+ value: string;
+};
+
+/**
+ * BooleanField component
+ *
+ * Render a checkbox or a radio input type.
+ */
+export const BooleanField: FC<BooleanFieldProps> = ({
+ className = '',
+ isChecked = false,
+ isDisabled = false,
+ isHidden = false,
+ isReadOnly = false,
+ isRequired = false,
+ ...props
+}) => {
+ const visibilityClass = isHidden ? styles['field--hidden'] : '';
+ const inputClass = `${visibilityClass} ${className}`;
+
+ return (
+ <input
+ {...props}
+ checked={isChecked}
+ className={inputClass}
+ disabled={isDisabled}
+ readOnly={isReadOnly}
+ required={isRequired}
+ />
+ );
+};
diff --git a/src/components/atoms/forms/fields/boolean-field/index.ts b/src/components/atoms/forms/fields/boolean-field/index.ts
new file mode 100644
index 0000000..a49d77b
--- /dev/null
+++ b/src/components/atoms/forms/fields/boolean-field/index.ts
@@ -0,0 +1 @@
+export * from './boolean-field';
diff --git a/src/components/atoms/forms/fields/checkbox/checkbox.test.tsx b/src/components/atoms/forms/fields/checkbox/checkbox.test.tsx
new file mode 100644
index 0000000..658799a
--- /dev/null
+++ b/src/components/atoms/forms/fields/checkbox/checkbox.test.tsx
@@ -0,0 +1,33 @@
+import { render, screen } from '../../../../../../tests/utils';
+import { Checkbox } from './checkbox';
+
+const doNothing = () => {
+ // Do nothing
+};
+
+describe('Checkbox', () => {
+ it('renders an unchecked checkbox', () => {
+ render(
+ <Checkbox
+ id="checkbox"
+ name="checkbox"
+ onChange={doNothing}
+ value="checkbox"
+ />
+ );
+ expect(screen.getByRole('checkbox')).not.toBeChecked();
+ });
+
+ it('renders a checked checkbox', () => {
+ render(
+ <Checkbox
+ id="checkbox"
+ isChecked
+ name="checkbox"
+ onChange={doNothing}
+ value="checkbox"
+ />
+ );
+ expect(screen.getByRole('checkbox')).toBeChecked();
+ });
+});
diff --git a/src/components/atoms/forms/fields/checkbox/checkbox.tsx b/src/components/atoms/forms/fields/checkbox/checkbox.tsx
new file mode 100644
index 0000000..2ac3809
--- /dev/null
+++ b/src/components/atoms/forms/fields/checkbox/checkbox.tsx
@@ -0,0 +1,13 @@
+import { FC } from 'react';
+import { BooleanField, BooleanFieldProps } from '../boolean-field';
+
+export type CheckboxProps = Omit<BooleanFieldProps, 'type'>;
+
+/**
+ * Checkbox component
+ *
+ * Render a checkbox input type.
+ */
+export const Checkbox: FC<CheckboxProps> = (props) => (
+ <BooleanField {...props} type="checkbox" />
+);
diff --git a/src/components/atoms/forms/fields/checkbox/index.ts b/src/components/atoms/forms/fields/checkbox/index.ts
new file mode 100644
index 0000000..8d78b3e
--- /dev/null
+++ b/src/components/atoms/forms/fields/checkbox/index.ts
@@ -0,0 +1 @@
+export * from './checkbox';
diff --git a/src/components/atoms/forms/fields/fields.module.scss b/src/components/atoms/forms/fields/fields.module.scss
new file mode 100644
index 0000000..f09117d
--- /dev/null
+++ b/src/components/atoms/forms/fields/fields.module.scss
@@ -0,0 +1,50 @@
+@use "../../../../styles/abstracts/functions" as fun;
+@use "../../../../styles/abstracts/mixins" as mix;
+
+.field {
+ width: 100%;
+ padding: var(--spacing-2xs) var(--spacing-xs);
+ background: var(--color-bg-tertiary);
+ border: fun.convert-px(2) solid var(--color-border);
+ box-shadow: fun.convert-px(3) fun.convert-px(3) 0 0 var(--color-shadow);
+ transition: all 0.25s linear 0s;
+
+ &--select {
+ cursor: pointer;
+
+ @include mix.pointer("fine") {
+ padding: fun.convert-px(3) var(--spacing-xs);
+ }
+ }
+
+ &--textarea {
+ min-height: fun.convert-px(200);
+ }
+
+ &:disabled {
+ background: var(--color-bg-secondary);
+ border: fun.convert-px(2) solid var(--color-border-light);
+ box-shadow: fun.convert-px(3) fun.convert-px(3) 0 0
+ var(--color-shadow-light);
+ cursor: not-allowed;
+ }
+
+ &:not(:disabled) {
+ &:hover {
+ box-shadow: fun.convert-px(5) fun.convert-px(5) 0 fun.convert-px(1)
+ var(--color-shadow);
+ transform: translate(#{fun.convert-px(-3)}, #{fun.convert-px(-3)});
+ }
+
+ &:focus {
+ background: var(--color-bg);
+ border-color: var(--color-primary);
+ box-shadow: 0 0 0 0 var(--color-shadow);
+ transform: translate(#{fun.convert-px(3)}, #{fun.convert-px(3)});
+ outline: none;
+ transition:
+ all 0.2s ease-in-out 0s,
+ transform 0.3s ease-out 0s;
+ }
+ }
+}
diff --git a/src/components/atoms/forms/fields/index.ts b/src/components/atoms/forms/fields/index.ts
new file mode 100644
index 0000000..7fafba1
--- /dev/null
+++ b/src/components/atoms/forms/fields/index.ts
@@ -0,0 +1,6 @@
+export * from './boolean-field';
+export * from './checkbox';
+export * from './input';
+export * from './radio';
+export * from './select';
+export * from './text-area';
diff --git a/src/components/atoms/forms/fields/input/index.ts b/src/components/atoms/forms/fields/input/index.ts
new file mode 100644
index 0000000..e3365cb
--- /dev/null
+++ b/src/components/atoms/forms/fields/input/index.ts
@@ -0,0 +1 @@
+export * from './input';
diff --git a/src/components/atoms/forms/fields/input/input.stories.tsx b/src/components/atoms/forms/fields/input/input.stories.tsx
new file mode 100644
index 0000000..35657f8
--- /dev/null
+++ b/src/components/atoms/forms/fields/input/input.stories.tsx
@@ -0,0 +1,237 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { ChangeEvent, useCallback, useState } from 'react';
+import { Input } from './input';
+
+/**
+ * Input - Storybook Meta
+ */
+export default {
+ title: 'Atoms/Forms/Fields',
+ component: Input,
+ args: {
+ isDisabled: false,
+ isRequired: false,
+ },
+ argTypes: {
+ 'aria-labelledby': {
+ control: {
+ type: 'text',
+ },
+ description: 'One or more ids that refers to the field name.',
+ table: {
+ category: 'Accessibility',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Add classnames to the field.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ id: {
+ control: {
+ type: 'text',
+ },
+ description: 'Input id.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ isDisabled: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Input state: either enabled or disabled.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: false },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ isRequired: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Determine if the field is required.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: false },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ max: {
+ control: {
+ type: 'number',
+ },
+ description: 'Maximum value.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'number',
+ required: false,
+ },
+ },
+ min: {
+ control: {
+ type: 'number',
+ },
+ description: 'Minimum value.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'number',
+ required: false,
+ },
+ },
+ name: {
+ control: {
+ type: 'text',
+ },
+ description: 'Input name.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ placeholder: {
+ control: {
+ type: 'text',
+ },
+ description: 'A placeholder value.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ step: {
+ control: {
+ type: 'number',
+ },
+ description: 'Input incremental values that are valid.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'number',
+ required: false,
+ },
+ },
+ type: {
+ control: {
+ type: 'select',
+ },
+ description: 'Input type: input type or textarea.',
+ options: [
+ 'datetime-local',
+ 'email',
+ 'number',
+ 'search',
+ 'tel',
+ 'text',
+ 'textarea',
+ 'time',
+ 'url',
+ ],
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ value: {
+ control: {
+ type: null,
+ },
+ description: 'Input value.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof Input>;
+
+const Template: ComponentStory<typeof Input> = ({
+ value: initialValue,
+ onChange: _onChange,
+ ...args
+}) => {
+ const [value, setValue] = useState(initialValue);
+ const updateValue = useCallback((e: ChangeEvent<HTMLInputElement>) => {
+ setValue(e.target.value);
+ }, []);
+
+ return <Input value={value} onChange={updateValue} {...args} />;
+};
+
+/**
+ * Input Story - DateTime
+ */
+export const DateTime = Template.bind({});
+DateTime.args = {
+ id: 'field-storybook',
+ name: 'field-storybook',
+ type: 'datetime-local',
+};
+
+/**
+ * Input Story - Email
+ */
+export const Email = Template.bind({});
+Email.args = {
+ id: 'field-storybook',
+ name: 'field-storybook',
+ type: 'email',
+};
+
+/**
+ * Input Story - Numeric
+ */
+export const Numeric = Template.bind({});
+Numeric.args = {
+ id: 'field-storybook',
+ name: 'field-storybook',
+ type: 'number',
+};
+
+/**
+ * Input Story - Text
+ */
+export const Text = Template.bind({});
+Text.args = {
+ id: 'field-storybook',
+ name: 'field-storybook',
+ type: 'text',
+};
+
+/**
+ * Input Story - Time
+ */
+export const Time = Template.bind({});
+Time.args = {
+ id: 'field-storybook',
+ name: 'field-storybook',
+ type: 'time',
+};
diff --git a/src/components/atoms/forms/fields/input/input.test.tsx b/src/components/atoms/forms/fields/input/input.test.tsx
new file mode 100644
index 0000000..1692c9e
--- /dev/null
+++ b/src/components/atoms/forms/fields/input/input.test.tsx
@@ -0,0 +1,34 @@
+import { render, screen } from '../../../../../../tests/utils';
+import { Input } from './input';
+
+const doNothing = () => {
+ // do nothing
+};
+
+describe('Input', () => {
+ it('renders a text input', () => {
+ render(
+ <Input
+ id="text-field"
+ name="text-field"
+ onChange={doNothing}
+ type="text"
+ value=""
+ />
+ );
+ expect(screen.getByRole('textbox')).toHaveAttribute('type', 'text');
+ });
+
+ it('renders a search input', () => {
+ render(
+ <Input
+ id="search-field"
+ name="search-field"
+ onChange={doNothing}
+ type="search"
+ value=""
+ />
+ );
+ expect(screen.getByRole('searchbox')).toHaveAttribute('type', 'search');
+ });
+});
diff --git a/src/components/atoms/forms/fields/input/input.tsx b/src/components/atoms/forms/fields/input/input.tsx
new file mode 100644
index 0000000..0f0736c
--- /dev/null
+++ b/src/components/atoms/forms/fields/input/input.tsx
@@ -0,0 +1,72 @@
+import {
+ type ForwardedRef,
+ forwardRef,
+ type InputHTMLAttributes,
+ type HTMLInputTypeAttribute,
+} from 'react';
+import styles from '../fields.module.scss';
+
+export type InputProps = Omit<
+ InputHTMLAttributes<HTMLInputElement>,
+ 'disabled' | 'hidden' | 'readonly' | 'required' | 'type'
+> &
+ Required<Pick<InputHTMLAttributes<HTMLInputElement>, 'id' | 'name'>> & {
+ /**
+ * Should the field be disabled?
+ *
+ * @default false
+ */
+ isDisabled?: boolean;
+ /**
+ * Should the field be hidden?
+ *
+ * @default false
+ */
+ isHidden?: boolean;
+ /**
+ * Should the field be readonly?
+ *
+ * @default false
+ */
+ isReadOnly?: boolean;
+ /**
+ * Should the field be required?
+ *
+ * @default false
+ */
+ isRequired?: boolean;
+ /**
+ * The input type.
+ */
+ type: Exclude<HTMLInputTypeAttribute, 'checkbox' | 'radio' | 'range'>;
+ };
+
+const InputWithRef = (
+ {
+ className = '',
+ isDisabled = false,
+ isHidden = false,
+ isReadOnly = false,
+ isRequired = false,
+ ...props
+ }: InputProps,
+ ref: ForwardedRef<HTMLInputElement>
+) => {
+ const fieldClassName = `${styles.field} ${className}`;
+
+ return (
+ <input
+ {...props}
+ className={fieldClassName}
+ disabled={isDisabled}
+ readOnly={isReadOnly}
+ ref={ref}
+ required={isRequired}
+ />
+ );
+};
+
+/**
+ * Input component.
+ */
+export const Input = forwardRef(InputWithRef);
diff --git a/src/components/atoms/forms/fields/radio/index.ts b/src/components/atoms/forms/fields/radio/index.ts
new file mode 100644
index 0000000..1140e08
--- /dev/null
+++ b/src/components/atoms/forms/fields/radio/index.ts
@@ -0,0 +1 @@
+export * from './radio';
diff --git a/src/components/atoms/forms/fields/radio/radio.test.tsx b/src/components/atoms/forms/fields/radio/radio.test.tsx
new file mode 100644
index 0000000..42df991
--- /dev/null
+++ b/src/components/atoms/forms/fields/radio/radio.test.tsx
@@ -0,0 +1,28 @@
+import { render, screen } from '../../../../../../tests/utils';
+import { Radio } from './radio';
+
+const doNothing = () => {
+ // Do nothing
+};
+
+describe('Radio', () => {
+ it('renders an unchecked radio', () => {
+ render(
+ <Radio id="radio" name="radio" onChange={doNothing} value="radio" />
+ );
+ expect(screen.getByRole('radio')).not.toBeChecked();
+ });
+
+ it('renders a checked radio', () => {
+ render(
+ <Radio
+ id="radio"
+ isChecked
+ name="radio"
+ onChange={doNothing}
+ value="radio"
+ />
+ );
+ expect(screen.getByRole('radio')).toBeChecked();
+ });
+});
diff --git a/src/components/atoms/forms/fields/radio/radio.tsx b/src/components/atoms/forms/fields/radio/radio.tsx
new file mode 100644
index 0000000..6430b4a
--- /dev/null
+++ b/src/components/atoms/forms/fields/radio/radio.tsx
@@ -0,0 +1,13 @@
+import { FC } from 'react';
+import { BooleanField, BooleanFieldProps } from '../boolean-field';
+
+export type RadioProps = Omit<BooleanFieldProps, 'type'>;
+
+/**
+ * Radio component
+ *
+ * Render a radio input type.
+ */
+export const Radio: FC<RadioProps> = (props) => (
+ <BooleanField {...props} type="radio" />
+);
diff --git a/src/components/atoms/forms/fields/select/index.ts b/src/components/atoms/forms/fields/select/index.ts
new file mode 100644
index 0000000..c739673
--- /dev/null
+++ b/src/components/atoms/forms/fields/select/index.ts
@@ -0,0 +1 @@
+export * from './select';
diff --git a/src/components/atoms/forms/fields/select/select.stories.tsx b/src/components/atoms/forms/fields/select/select.stories.tsx
new file mode 100644
index 0000000..c9e02d2
--- /dev/null
+++ b/src/components/atoms/forms/fields/select/select.stories.tsx
@@ -0,0 +1,143 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { ChangeEvent, useCallback, useState } from 'react';
+import { Select as SelectComponent } from './select';
+
+const selectOptions = [
+ { id: 'option1', name: 'Option 1', value: 'option1' },
+ { id: 'option2', name: 'Option 2', value: 'option2' },
+ { id: 'option3', name: 'Option 3', value: 'option3' },
+];
+
+/**
+ * Select - Storybook Meta
+ */
+export default {
+ title: 'Atoms/Forms/Fields',
+ component: SelectComponent,
+ args: {
+ isDisabled: false,
+ isRequired: false,
+ },
+ argTypes: {
+ 'aria-labelledby': {
+ control: {
+ type: 'text',
+ },
+ description: 'One or more ids that refers to the select field name.',
+ table: {
+ category: 'Accessibility',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Add classnames to the select field.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ id: {
+ control: {
+ type: 'text',
+ },
+ description: 'Field id.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ isDisabled: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Field state: either enabled or disabled.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: false },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ isRequired: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Determine if the field is required.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: false },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ name: {
+ control: {
+ type: 'text',
+ },
+ description: 'Field name.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ options: {
+ description: 'Select options.',
+ type: {
+ name: 'array',
+ required: true,
+ value: {
+ name: 'string',
+ },
+ },
+ },
+ value: {
+ control: {
+ type: null,
+ },
+ description: 'Field value.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof SelectComponent>;
+
+const Template: ComponentStory<typeof SelectComponent> = ({
+ onChange: _onChange,
+ value,
+ ...args
+}) => {
+ const [selected, setSelected] = useState(value);
+ const updateSelection = useCallback((e: ChangeEvent<HTMLSelectElement>) => {
+ setSelected(e.target.value);
+ }, []);
+
+ return (
+ <SelectComponent {...args} onChange={updateSelection} value={selected} />
+ );
+};
+
+/**
+ * Select Story
+ */
+export const Select = Template.bind({});
+Select.args = {
+ id: 'storybook-select',
+ name: 'storybook-select',
+ options: selectOptions,
+ value: 'option2',
+};
diff --git a/src/components/atoms/forms/fields/select/select.test.tsx b/src/components/atoms/forms/fields/select/select.test.tsx
new file mode 100644
index 0000000..088cc9e
--- /dev/null
+++ b/src/components/atoms/forms/fields/select/select.test.tsx
@@ -0,0 +1,43 @@
+import { render, screen } from '../../../../../../tests/utils';
+import { Select } from './select';
+
+const doNothing = () => {
+ // do nothing
+};
+
+const selectOptions = [
+ { id: 'option1', name: 'Option 1', value: 'option1' },
+ { id: 'option2', name: 'Option 2', value: 'option2' },
+ { id: 'option3', name: 'Option 3', value: 'option3' },
+];
+const selected = selectOptions[0];
+
+describe('Select', () => {
+ it('should correctly set default option', () => {
+ render(
+ <Select
+ id="select-1"
+ name="select-1"
+ onChange={doNothing}
+ options={selectOptions}
+ value={selected.value}
+ />
+ );
+
+ expect(screen.getByRole('combobox')).toHaveValue(selected.value);
+ });
+
+ it('renders the select options', () => {
+ render(
+ <Select
+ id="select-2"
+ name="select-2"
+ onChange={doNothing}
+ options={selectOptions}
+ value={selected.value}
+ />
+ );
+
+ expect(screen.getAllByRole('option')).toHaveLength(selectOptions.length);
+ });
+});
diff --git a/src/components/atoms/forms/fields/select/select.tsx b/src/components/atoms/forms/fields/select/select.tsx
new file mode 100644
index 0000000..887dacc
--- /dev/null
+++ b/src/components/atoms/forms/fields/select/select.tsx
@@ -0,0 +1,76 @@
+import { FC, SelectHTMLAttributes } from 'react';
+import styles from '../fields.module.scss';
+
+export type SelectOptions = {
+ /**
+ * The option id.
+ */
+ id: string;
+ /**
+ * The option name.
+ */
+ name: string;
+ /**
+ * The option value.
+ */
+ value: string;
+};
+
+export type SelectProps = Omit<
+ SelectHTMLAttributes<HTMLSelectElement>,
+ 'disabled' | 'hidden' | 'required'
+> & {
+ /**
+ * Should the select field be disabled?
+ *
+ * @default false
+ */
+ isDisabled?: boolean;
+ /**
+ * Should the select field be hidden?
+ *
+ * @default false
+ */
+ isHidden?: boolean;
+ /**
+ * Is the select field required?
+ *
+ * @default false
+ */
+ isRequired?: boolean;
+ /**
+ * True if the field is required. Default: false.
+ */
+ options: SelectOptions[];
+};
+
+/**
+ * Select component
+ *
+ * Render a HTML select element.
+ */
+export const Select: FC<SelectProps> = ({
+ className = '',
+ isDisabled = false,
+ isHidden = false,
+ isRequired = false,
+ options,
+ ...props
+}) => {
+ const selectClass = `${styles.field} ${styles['field--select']} ${className}`;
+
+ return (
+ <select
+ {...props}
+ className={selectClass}
+ disabled={isDisabled}
+ required={isRequired}
+ >
+ {options.map((option) => (
+ <option key={option.id} id={option.id} value={option.value}>
+ {option.name}
+ </option>
+ ))}
+ </select>
+ );
+};
diff --git a/src/components/atoms/forms/fields/text-area/index.ts b/src/components/atoms/forms/fields/text-area/index.ts
new file mode 100644
index 0000000..e18b325
--- /dev/null
+++ b/src/components/atoms/forms/fields/text-area/index.ts
@@ -0,0 +1 @@
+export * from './text-area';
diff --git a/src/components/atoms/forms/fields/text-area/text-area.stories.tsx b/src/components/atoms/forms/fields/text-area/text-area.stories.tsx
new file mode 100644
index 0000000..2e77cb7
--- /dev/null
+++ b/src/components/atoms/forms/fields/text-area/text-area.stories.tsx
@@ -0,0 +1,136 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { ChangeEvent, useCallback, useState } from 'react';
+import { TextArea as TextAreaComponent } from './text-area';
+
+/**
+ * TextArea - Storybook Meta
+ */
+export default {
+ title: 'Atoms/Forms/Fields',
+ component: TextAreaComponent,
+ args: {
+ isDisabled: false,
+ isRequired: false,
+ },
+ argTypes: {
+ 'aria-labelledby': {
+ control: {
+ type: 'text',
+ },
+ description: 'One or more ids that refers to the field name.',
+ table: {
+ category: 'Accessibility',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Add classnames to the field.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ id: {
+ control: {
+ type: 'text',
+ },
+ description: 'TextArea id.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ isDisabled: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'TextArea state: either enabled or disabled.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: false },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ isRequired: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Determine if the field is required.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: false },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ name: {
+ control: {
+ type: 'text',
+ },
+ description: 'TextArea name.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ placeholder: {
+ control: {
+ type: 'text',
+ },
+ description: 'A placeholder value.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ value: {
+ control: {
+ type: null,
+ },
+ description: 'TextArea value.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof TextAreaComponent>;
+
+const Template: ComponentStory<typeof TextAreaComponent> = ({
+ value: initialValue,
+ onChange: _onChange,
+ ...args
+}) => {
+ const [value, setValue] = useState(initialValue);
+ const updateValue = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
+ setValue(e.target.value);
+ }, []);
+
+ return <TextAreaComponent value={value} onChange={updateValue} {...args} />;
+};
+
+/**
+ * TextArea Story - TextArea
+ */
+export const TextArea = Template.bind({});
+TextArea.args = {
+ id: 'field-storybook',
+ name: 'field-storybook',
+};
diff --git a/src/components/atoms/forms/fields/text-area/text-area.test.tsx b/src/components/atoms/forms/fields/text-area/text-area.test.tsx
new file mode 100644
index 0000000..37a1d1c
--- /dev/null
+++ b/src/components/atoms/forms/fields/text-area/text-area.test.tsx
@@ -0,0 +1,20 @@
+import { render, screen } from '../../../../../../tests/utils';
+import { TextArea } from './text-area';
+
+const doNothing = () => {
+ // do nothing
+};
+
+describe('TextArea', () => {
+ it('renders a textarea', () => {
+ render(
+ <TextArea
+ id="textarea-field"
+ name="textarea-field"
+ onChange={doNothing}
+ value=""
+ />
+ );
+ expect(screen.getByRole('textbox')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/atoms/forms/fields/text-area/text-area.tsx b/src/components/atoms/forms/fields/text-area/text-area.tsx
new file mode 100644
index 0000000..bd99d7d
--- /dev/null
+++ b/src/components/atoms/forms/fields/text-area/text-area.tsx
@@ -0,0 +1,69 @@
+import {
+ type ForwardedRef,
+ forwardRef,
+ type TextareaHTMLAttributes,
+} from 'react';
+import styles from '../fields.module.scss';
+
+type AllowedTextAreaProps = Omit<
+ TextareaHTMLAttributes<HTMLTextAreaElement>,
+ 'disabled' | 'readOnly' | 'required'
+> &
+ Required<Pick<TextareaHTMLAttributes<HTMLTextAreaElement>, 'id' | 'name'>>;
+
+export type TextAreaProps = AllowedTextAreaProps & {
+ /**
+ * Should the field be disabled?
+ *
+ * @default false
+ */
+ isDisabled?: boolean;
+ /**
+ * Should the field be hidden?
+ *
+ * @default false
+ */
+ isHidden?: boolean;
+ /**
+ * Should the field be readonly?
+ *
+ * @default false
+ */
+ isReadOnly?: boolean;
+ /**
+ * Should the field be required?
+ *
+ * @default false
+ */
+ isRequired?: boolean;
+};
+
+const TextAreaWithRef = (
+ {
+ className = '',
+ isDisabled = false,
+ isHidden = false,
+ isReadOnly = false,
+ isRequired = false,
+ ...props
+ }: TextAreaProps,
+ ref: ForwardedRef<HTMLTextAreaElement>
+) => {
+ const fieldClassName = `${styles.field} ${styles['field--textarea']} ${className}`;
+
+ return (
+ <textarea
+ {...props}
+ className={fieldClassName}
+ disabled={isDisabled}
+ readOnly={isReadOnly}
+ ref={ref}
+ required={isRequired}
+ />
+ );
+};
+
+/**
+ * TextArea component.
+ */
+export const TextArea = forwardRef(TextAreaWithRef);