summaryrefslogtreecommitdiffstats
path: root/src/components/atoms/forms
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/atoms/forms')
-rw-r--r--src/components/atoms/forms/checkbox.stories.tsx96
-rw-r--r--src/components/atoms/forms/checkbox.test.tsx28
-rw-r--r--src/components/atoms/forms/checkbox.tsx46
-rw-r--r--src/components/atoms/forms/field.stories.tsx201
-rw-r--r--src/components/atoms/forms/field.test.tsx30
-rw-r--r--src/components/atoms/forms/field.tsx107
-rw-r--r--src/components/atoms/forms/form.test.tsx9
-rw-r--r--src/components/atoms/forms/form.tsx73
-rw-r--r--src/components/atoms/forms/forms.module.scss53
-rw-r--r--src/components/atoms/forms/label.module.scss17
-rw-r--r--src/components/atoms/forms/label.stories.tsx85
-rw-r--r--src/components/atoms/forms/label.test.tsx9
-rw-r--r--src/components/atoms/forms/label.tsx45
-rw-r--r--src/components/atoms/forms/select.stories.tsx145
-rw-r--r--src/components/atoms/forms/select.test.tsx30
-rw-r--r--src/components/atoms/forms/select.tsx99
16 files changed, 1073 insertions, 0 deletions
diff --git a/src/components/atoms/forms/checkbox.stories.tsx b/src/components/atoms/forms/checkbox.stories.tsx
new file mode 100644
index 0000000..7faf343
--- /dev/null
+++ b/src/components/atoms/forms/checkbox.stories.tsx
@@ -0,0 +1,96 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { useState } from 'react';
+import CheckboxComponent from './checkbox';
+
+export default {
+ title: 'Atoms/Forms',
+ component: CheckboxComponent,
+ argTypes: {
+ 'aria-labelledby': {
+ control: {
+ type: 'text',
+ },
+ description: 'One or more ids that refers to the checkbox name.',
+ table: {
+ category: 'Accessibility',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the checkbox.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ id: {
+ control: {
+ type: 'text',
+ },
+ description: 'The checkbox id.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ name: {
+ control: {
+ type: 'text',
+ },
+ description: 'The checkbox name.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ setValue: {
+ control: {
+ type: null,
+ },
+ description: 'A callback function to handle checkbox state.',
+ type: {
+ name: 'function',
+ required: true,
+ },
+ },
+ value: {
+ control: {
+ type: null,
+ },
+ description:
+ 'The checkbox state: either checked (true) or unchecked (false).',
+ type: {
+ name: 'boolean',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof CheckboxComponent>;
+
+const Template: ComponentStory<typeof CheckboxComponent> = ({
+ value,
+ setValue: _setValue,
+ ...args
+}) => {
+ const [isChecked, setIsChecked] = useState<boolean>(value);
+
+ return (
+ <CheckboxComponent value={isChecked} setValue={setIsChecked} {...args} />
+ );
+};
+
+export const Checkbox = Template.bind({});
+Checkbox.args = {
+ id: 'storybook-checkbox',
+ name: 'storybook-checkbox',
+ value: false,
+};
diff --git a/src/components/atoms/forms/checkbox.test.tsx b/src/components/atoms/forms/checkbox.test.tsx
new file mode 100644
index 0000000..3b54549
--- /dev/null
+++ b/src/components/atoms/forms/checkbox.test.tsx
@@ -0,0 +1,28 @@
+import { render, screen } from '@test-utils';
+import Checkbox from './checkbox';
+
+describe('Checkbox', () => {
+ it('renders an unchecked checkbox', () => {
+ render(
+ <Checkbox
+ id="jest-checkbox"
+ name="jest-checkbox"
+ value={false}
+ setValue={() => null}
+ />
+ );
+ expect(screen.getByRole('checkbox')).not.toBeChecked();
+ });
+
+ it('renders a checked checkbox', () => {
+ render(
+ <Checkbox
+ id="jest-checkbox"
+ name="jest-checkbox"
+ value={true}
+ setValue={() => null}
+ />
+ );
+ expect(screen.getByRole('checkbox')).toBeChecked();
+ });
+});
diff --git a/src/components/atoms/forms/checkbox.tsx b/src/components/atoms/forms/checkbox.tsx
new file mode 100644
index 0000000..8babcc8
--- /dev/null
+++ b/src/components/atoms/forms/checkbox.tsx
@@ -0,0 +1,46 @@
+import { SetStateAction, VFC } from 'react';
+
+export type CheckboxProps = {
+ /**
+ * One or more ids that refers to the checkbox name.
+ */
+ 'aria-labelledby'?: string;
+ /**
+ * Add classnames to the checkbox.
+ */
+ className?: string;
+ /**
+ * Checkbox id attribute.
+ */
+ id: string;
+ /**
+ * Checkbox name attribute.
+ */
+ name: string;
+ /**
+ * Callback function to set checkbox value.
+ */
+ setValue: (value: SetStateAction<boolean>) => void;
+ /**
+ * Checkbox value.
+ */
+ value: boolean;
+};
+
+/**
+ * Checkbox component
+ *
+ * Render a checkbox type input.
+ */
+const Checkbox: VFC<CheckboxProps> = ({ value, setValue, ...props }) => {
+ return (
+ <input
+ type="checkbox"
+ checked={value}
+ onChange={() => setValue(!value)}
+ {...props}
+ />
+ );
+};
+
+export default Checkbox;
diff --git a/src/components/atoms/forms/field.stories.tsx b/src/components/atoms/forms/field.stories.tsx
new file mode 100644
index 0000000..ec81922
--- /dev/null
+++ b/src/components/atoms/forms/field.stories.tsx
@@ -0,0 +1,201 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { useState } from 'react';
+import FieldComponent from './field';
+
+export default {
+ title: 'Atoms/Forms',
+ component: FieldComponent,
+ args: {
+ disabled: false,
+ required: false,
+ type: 'text',
+ },
+ 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,
+ },
+ },
+ disabled: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Field state: either enabled or disabled.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: false },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ id: {
+ control: {
+ type: 'text',
+ },
+ description: 'Field id.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ 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: 'Field name.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ placeholder: {
+ control: {
+ type: 'text',
+ },
+ description: 'A placeholder value.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ required: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Determine if the field is required.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: false },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ setValue: {
+ control: {
+ type: null,
+ },
+ description: 'Callback function to set field value.',
+ table: {
+ category: 'Events',
+ },
+ type: {
+ name: 'function',
+ required: true,
+ },
+ },
+ step: {
+ control: {
+ type: 'number',
+ },
+ description: 'Field incremental values that are valid.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'number',
+ required: false,
+ },
+ },
+ type: {
+ control: {
+ type: 'select',
+ },
+ description: 'Field 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: 'Field value.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof FieldComponent>;
+
+const Template: ComponentStory<typeof FieldComponent> = ({
+ value: _value,
+ setValue: _setValue,
+ ...args
+}) => {
+ const [value, setValue] = useState<string>('');
+
+ return <FieldComponent value={value} setValue={setValue} {...args} />;
+};
+
+export const Field = Template.bind({});
+Field.args = {
+ id: 'field-storybook',
+ name: 'field-storybook',
+};
diff --git a/src/components/atoms/forms/field.test.tsx b/src/components/atoms/forms/field.test.tsx
new file mode 100644
index 0000000..a04a976
--- /dev/null
+++ b/src/components/atoms/forms/field.test.tsx
@@ -0,0 +1,30 @@
+import { render, screen } from '@test-utils';
+import Field from './field';
+
+describe('Field', () => {
+ it('renders a text input', () => {
+ render(
+ <Field
+ id="text-field"
+ name="text-field"
+ type="text"
+ value=""
+ setValue={() => null}
+ />
+ );
+ expect(screen.getByRole('textbox')).toHaveAttribute('type', 'text');
+ });
+
+ it('renders a search input', () => {
+ render(
+ <Field
+ id="search-field"
+ name="search-field"
+ type="search"
+ value=""
+ setValue={() => null}
+ />
+ );
+ expect(screen.getByRole('searchbox')).toHaveAttribute('type', 'search');
+ });
+});
diff --git a/src/components/atoms/forms/field.tsx b/src/components/atoms/forms/field.tsx
new file mode 100644
index 0000000..2e75d0f
--- /dev/null
+++ b/src/components/atoms/forms/field.tsx
@@ -0,0 +1,107 @@
+import { ChangeEvent, SetStateAction, VFC } from 'react';
+import styles from './forms.module.scss';
+
+export type FieldType =
+ | 'datetime-local'
+ | 'email'
+ | 'number'
+ | 'search'
+ | 'tel'
+ | 'text'
+ | 'textarea'
+ | 'time'
+ | 'url';
+
+export type FieldProps = {
+ /**
+ * One or more ids that refers to the field name.
+ */
+ 'aria-labelledby'?: string;
+ /**
+ * Add classnames to the field.
+ */
+ className?: string;
+ /**
+ * Field state. Either enabled (false) or disabled (true).
+ */
+ disabled?: boolean;
+ /**
+ * Field id attribute.
+ */
+ id: string;
+ /**
+ * Field maximum value.
+ */
+ max?: number | string;
+ /**
+ * Field minimum value.
+ */
+ min?: number | string;
+ /**
+ * Field name attribute.
+ */
+ name: string;
+ /**
+ * Placeholder value.
+ */
+ placeholder?: string;
+ /**
+ * True if the field is required. Default: false.
+ */
+ required?: boolean;
+ /**
+ * Callback function to set field value.
+ */
+ setValue: (value: SetStateAction<string>) => void;
+ /**
+ * Field incremental values that are valid.
+ */
+ step?: number | string;
+ /**
+ * Field type. Default: text.
+ */
+ type: FieldType;
+ /**
+ * Field value.
+ */
+ value: string;
+};
+
+/**
+ * Field component.
+ *
+ * Render either an input or a textarea.
+ */
+const Field: VFC<FieldProps> = ({
+ className = '',
+ setValue,
+ type,
+ ...props
+}) => {
+ /**
+ * Update select value when an option is selected.
+ * @param e - The option change event.
+ */
+ const updateValue = (
+ e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
+ ) => {
+ setValue(e.target.value);
+ };
+
+ return type === 'textarea' ? (
+ <textarea
+ onChange={updateValue}
+ className={`${styles.field} ${styles['field--textarea']} ${className}`}
+ {...props}
+ />
+ ) : (
+ <input
+ type={type}
+ onChange={updateValue}
+ className={`${styles.field} ${className}`}
+ {...props}
+ />
+ );
+};
+
+export default Field;
diff --git a/src/components/atoms/forms/form.test.tsx b/src/components/atoms/forms/form.test.tsx
new file mode 100644
index 0000000..9cd3c58
--- /dev/null
+++ b/src/components/atoms/forms/form.test.tsx
@@ -0,0 +1,9 @@
+import { render, screen } from '@test-utils';
+import Form from './form';
+
+describe('Form', () => {
+ it('renders a form', () => {
+ render(<Form aria-label="Jest form" onSubmit={() => null}></Form>);
+ expect(screen.getByRole('form', { name: 'Jest form' })).toBeInTheDocument();
+ });
+});
diff --git a/src/components/atoms/forms/form.tsx b/src/components/atoms/forms/form.tsx
new file mode 100644
index 0000000..8e80930
--- /dev/null
+++ b/src/components/atoms/forms/form.tsx
@@ -0,0 +1,73 @@
+import { Children, FC, FormEvent, Fragment } from 'react';
+import styles from './forms.module.scss';
+
+export type FormProps = {
+ /**
+ * An accessible name.
+ */
+ 'aria-label'?: string;
+ /**
+ * One or more ids that refers to the form name.
+ */
+ 'aria-labelledby'?: string;
+ /**
+ * Set additional classnames to the form wrapper.
+ */
+ className?: string;
+ /**
+ * Wrap each items with a div. Default: true.
+ */
+ grouped?: boolean;
+ /**
+ * A callback function to execute on submit.
+ */
+ onSubmit: () => void;
+};
+
+/**
+ * Form component.
+ *
+ * Render children wrapped in a form element.
+ */
+const Form: FC<FormProps> = ({
+ children,
+ className = '',
+ grouped = true,
+ onSubmit,
+ ...props
+}) => {
+ const arrayChildren = Children.toArray(children);
+
+ /**
+ * Get the form items.
+ * @returns {JSX.Element[]} An array of child elements wrapped in a div.
+ */
+ const getFormItems = (): JSX.Element[] => {
+ return arrayChildren.map((child, index) =>
+ grouped ? (
+ <div key={`item-${index}`} className={styles.item}>
+ {child}
+ </div>
+ ) : (
+ <Fragment key={`item-${index}`}>{child}</Fragment>
+ )
+ );
+ };
+
+ /**
+ * Handle form submit.
+ * @param {FormEvent} e - The form event.
+ */
+ const handleSubmit = (e: FormEvent) => {
+ e.preventDefault();
+ onSubmit();
+ };
+
+ return (
+ <form onSubmit={handleSubmit} className={className} {...props}>
+ {getFormItems()}
+ </form>
+ );
+};
+
+export default Form;
diff --git a/src/components/atoms/forms/forms.module.scss b/src/components/atoms/forms/forms.module.scss
new file mode 100644
index 0000000..19c7aee
--- /dev/null
+++ b/src/components/atoms/forms/forms.module.scss
@@ -0,0 +1,53 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/mixins" as mix;
+
+.item {
+ margin: var(--spacing-xs) 0;
+ width: 100%;
+ max-width: 45ch;
+}
+
+.field {
+ 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/label.module.scss b/src/components/atoms/forms/label.module.scss
new file mode 100644
index 0000000..f900925
--- /dev/null
+++ b/src/components/atoms/forms/label.module.scss
@@ -0,0 +1,17 @@
+.label {
+ color: var(--color-primary-darker);
+ font-weight: 600;
+
+ &--small {
+ font-size: var(--font-size-sm);
+ font-variant: small-caps;
+ }
+
+ &--medium {
+ font-size: var(--font-size-md);
+ }
+}
+
+.required {
+ color: var(--color-secondary);
+}
diff --git a/src/components/atoms/forms/label.stories.tsx b/src/components/atoms/forms/label.stories.tsx
new file mode 100644
index 0000000..463e8ac
--- /dev/null
+++ b/src/components/atoms/forms/label.stories.tsx
@@ -0,0 +1,85 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import LabelComponent from './label';
+
+export default {
+ title: 'Atoms/Forms',
+ component: LabelComponent,
+ args: {
+ required: false,
+ size: 'small',
+ },
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Add classnames to the label.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ children: {
+ control: {
+ type: 'text',
+ },
+ description: 'The label body.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ htmlFor: {
+ control: {
+ type: 'text',
+ },
+ description: 'The field id.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ required: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Set to true if the field is required.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: false },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ size: {
+ control: {
+ type: 'select',
+ },
+ description: 'The label size.',
+ options: ['medium', 'small'],
+ table: {
+ category: 'Options',
+ defaultValue: { summary: 'small' },
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof LabelComponent>;
+
+const Template: ComponentStory<typeof LabelComponent> = ({
+ children,
+ ...args
+}) => <LabelComponent {...args}>{children}</LabelComponent>;
+
+export const Label = Template.bind({});
+Label.args = {
+ children: 'A label',
+};
diff --git a/src/components/atoms/forms/label.test.tsx b/src/components/atoms/forms/label.test.tsx
new file mode 100644
index 0000000..14257c3
--- /dev/null
+++ b/src/components/atoms/forms/label.test.tsx
@@ -0,0 +1,9 @@
+import { render, screen } from '@test-utils';
+import Label from './label';
+
+describe('Label', () => {
+ it('renders a field label', () => {
+ render(<Label>A label</Label>);
+ expect(screen.getByText('A label')).toBeDefined();
+ });
+});
diff --git a/src/components/atoms/forms/label.tsx b/src/components/atoms/forms/label.tsx
new file mode 100644
index 0000000..8d57ee2
--- /dev/null
+++ b/src/components/atoms/forms/label.tsx
@@ -0,0 +1,45 @@
+import { FC } from 'react';
+import styles from './label.module.scss';
+
+export type LabelProps = {
+ /**
+ * Add classnames to the label.
+ */
+ className?: string;
+ /**
+ * The field id.
+ */
+ htmlFor?: string;
+ /**
+ * Is the field required? Default: false.
+ */
+ required?: boolean;
+ /**
+ * The label size. Default: small.
+ */
+ size?: 'medium' | 'small';
+};
+
+/**
+ * Label Component
+ *
+ * Render a HTML label element.
+ */
+const Label: FC<LabelProps> = ({
+ children,
+ className = '',
+ required = false,
+ size = 'small',
+ ...props
+}) => {
+ const sizeClass = styles[`label--${size}`];
+
+ return (
+ <label className={`${styles.label} ${sizeClass} ${className}`} {...props}>
+ {children}
+ {required && <span className={styles.required}> *</span>}
+ </label>
+ );
+};
+
+export default Label;
diff --git a/src/components/atoms/forms/select.stories.tsx b/src/components/atoms/forms/select.stories.tsx
new file mode 100644
index 0000000..c2fb8c6
--- /dev/null
+++ b/src/components/atoms/forms/select.stories.tsx
@@ -0,0 +1,145 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { useState } from 'react';
+import 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' },
+];
+
+export default {
+ title: 'Atoms/Forms',
+ component: SelectComponent,
+ args: {
+ disabled: false,
+ required: 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,
+ },
+ },
+ disabled: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Field state: either enabled or disabled.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: false },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ id: {
+ control: {
+ type: 'text',
+ },
+ description: 'Field id.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ name: {
+ control: {
+ type: 'text',
+ },
+ description: 'Field name.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ options: {
+ description: 'Select options.',
+ type: {
+ name: 'array',
+ required: true,
+ value: {
+ name: 'string',
+ },
+ },
+ },
+ required: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Determine if the field is required.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: false },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ setValue: {
+ control: {
+ type: null,
+ },
+ description: 'Callback function to set field value.',
+ table: {
+ category: 'Events',
+ },
+ type: {
+ name: 'function',
+ required: true,
+ },
+ },
+ value: {
+ control: {
+ type: null,
+ },
+ description: 'Field value.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof SelectComponent>;
+
+const Template: ComponentStory<typeof SelectComponent> = ({
+ value,
+ setValue: _setValue,
+ ...args
+}) => {
+ const [selected, setSelected] = useState<string>(value);
+
+ return <SelectComponent value={selected} setValue={setSelected} {...args} />;
+};
+
+export const Select = Template.bind({});
+Select.args = {
+ id: 'storybook-select',
+ name: 'storybook-select',
+ options: selectOptions,
+ value: 'option2',
+};
diff --git a/src/components/atoms/forms/select.test.tsx b/src/components/atoms/forms/select.test.tsx
new file mode 100644
index 0000000..22efb86
--- /dev/null
+++ b/src/components/atoms/forms/select.test.tsx
@@ -0,0 +1,30 @@
+import { render, screen } from '@test-utils';
+import Select 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' },
+];
+const selected = selectOptions[0];
+
+describe('Select', () => {
+ it('should correctly set default option', () => {
+ render(
+ <Select
+ id="jest-select"
+ name="jest-select"
+ options={selectOptions}
+ value={selected.value}
+ setValue={() => null}
+ />
+ );
+ expect(screen.getByRole('combobox')).toHaveValue(selected.value);
+ expect(screen.queryByRole('combobox')).not.toHaveValue(
+ selectOptions[1].value
+ );
+ expect(screen.queryByRole('combobox')).not.toHaveValue(
+ selectOptions[2].value
+ );
+ });
+});
diff --git a/src/components/atoms/forms/select.tsx b/src/components/atoms/forms/select.tsx
new file mode 100644
index 0000000..25e86e0
--- /dev/null
+++ b/src/components/atoms/forms/select.tsx
@@ -0,0 +1,99 @@
+import { ChangeEvent, SetStateAction, VFC } from 'react';
+import styles from './forms.module.scss';
+
+export type SelectOptions = {
+ /**
+ * The option id.
+ */
+ id: string;
+ /**
+ * The option name.
+ */
+ name: string;
+ /**
+ * The option value.
+ */
+ value: string;
+};
+
+export type SelectProps = {
+ /**
+ * One or more ids that refers to the select field name.
+ */
+ 'aria-labelledby'?: string;
+ /**
+ * Add classnames to the select field.
+ */
+ className?: string;
+ /**
+ * Field state. Either enabled (false) or disabled (true).
+ */
+ disabled?: boolean;
+ /**
+ * Field id attribute.
+ */
+ id: string;
+ /**
+ * Field name attribute.
+ */
+ name: string;
+ /**
+ * True if the field is required. Default: false.
+ */
+ options: SelectOptions[];
+ /**
+ * True if the field is required. Default: false.
+ */
+ required?: boolean;
+ /**
+ * Callback function to set field value.
+ */
+ setValue: (value: SetStateAction<string>) => void;
+ /**
+ * Field value.
+ */
+ value: string;
+};
+
+/**
+ * Select component
+ *
+ * Render a HTML select element.
+ */
+const Select: VFC<SelectProps> = ({
+ className = '',
+ options,
+ setValue,
+ ...props
+}) => {
+ /**
+ * Update select value when an option is selected.
+ * @param e - The option change event.
+ */
+ const updateValue = (e: ChangeEvent<HTMLSelectElement>) => {
+ setValue(e.target.value);
+ };
+
+ /**
+ * Get the option elements.
+ * @returns {JSX.Element[]} An array of HTML option elements.
+ */
+ const getOptions = (): JSX.Element[] =>
+ options.map((option) => (
+ <option key={option.id} value={option.value}>
+ {option.name}
+ </option>
+ ));
+
+ return (
+ <select
+ className={`${styles.field} ${styles['field--select']} ${className}`}
+ onChange={updateValue}
+ {...props}
+ >
+ {getOptions()}
+ </select>
+ );
+};
+
+export default Select;