aboutsummaryrefslogtreecommitdiffstats
path: root/src/components/atoms
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/atoms')
-rw-r--r--src/components/atoms/forms/boolean-field.module.scss5
-rw-r--r--src/components/atoms/forms/boolean-field.test.tsx60
-rw-r--r--src/components/atoms/forms/boolean-field.tsx44
-rw-r--r--src/components/atoms/forms/field.tsx111
-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.tsx (renamed from src/components/atoms/forms/boolean-field.stories.tsx)56
-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.scss (renamed from src/components/atoms/forms/forms.module.scss)15
-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.tsx (renamed from src/components/atoms/forms/field.stories.tsx)120
-rw-r--r--src/components/atoms/forms/fields/input/input.test.tsx (renamed from src/components/atoms/forms/field.test.tsx)18
-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.tsx (renamed from src/components/atoms/forms/select.stories.tsx)72
-rw-r--r--src/components/atoms/forms/fields/select/select.test.tsx (renamed from src/components/atoms/forms/select.test.tsx)31
-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
-rw-r--r--src/components/atoms/forms/fieldset/fieldset.module.scss17
-rw-r--r--src/components/atoms/forms/fieldset/fieldset.stories.tsx63
-rw-r--r--src/components/atoms/forms/fieldset/fieldset.test.tsx35
-rw-r--r--src/components/atoms/forms/fieldset/fieldset.tsx68
-rw-r--r--src/components/atoms/forms/fieldset/index.ts1
-rw-r--r--src/components/atoms/forms/form.test.tsx13
-rw-r--r--src/components/atoms/forms/form.tsx80
-rw-r--r--src/components/atoms/forms/form/form.test.tsx13
-rw-r--r--src/components/atoms/forms/form/form.tsx28
-rw-r--r--src/components/atoms/forms/form/index.ts1
-rw-r--r--src/components/atoms/forms/index.ts6
-rw-r--r--src/components/atoms/forms/label.tsx40
-rw-r--r--src/components/atoms/forms/label/index.ts1
-rw-r--r--src/components/atoms/forms/label/label.module.scss (renamed from src/components/atoms/forms/label.module.scss)4
-rw-r--r--src/components/atoms/forms/label/label.stories.tsx (renamed from src/components/atoms/forms/label.stories.tsx)25
-rw-r--r--src/components/atoms/forms/label/label.test.tsx (renamed from src/components/atoms/forms/label.test.tsx)4
-rw-r--r--src/components/atoms/forms/label/label.tsx62
-rw-r--r--src/components/atoms/forms/legend/index.ts1
-rw-r--r--src/components/atoms/forms/legend/legend.module.scss6
-rw-r--r--src/components/atoms/forms/legend/legend.stories.tsx27
-rw-r--r--src/components/atoms/forms/legend/legend.test.tsx17
-rw-r--r--src/components/atoms/forms/legend/legend.tsx21
-rw-r--r--src/components/atoms/forms/select.tsx79
-rw-r--r--src/components/atoms/index.ts1
-rw-r--r--src/components/atoms/modal/index.ts1
-rw-r--r--src/components/atoms/modal/modal.module.scss66
-rw-r--r--src/components/atoms/modal/modal.stories.tsx59
-rw-r--r--src/components/atoms/modal/modal.test.tsx25
-rw-r--r--src/components/atoms/modal/modal.tsx49
58 files changed, 1339 insertions, 607 deletions
diff --git a/src/components/atoms/forms/boolean-field.module.scss b/src/components/atoms/forms/boolean-field.module.scss
deleted file mode 100644
index f299ddd..0000000
--- a/src/components/atoms/forms/boolean-field.module.scss
+++ /dev/null
@@ -1,5 +0,0 @@
-@use "../../../styles/abstracts/mixins" as mix;
-
-.hidden {
- @include mix.visually-hidden;
-}
diff --git a/src/components/atoms/forms/boolean-field.test.tsx b/src/components/atoms/forms/boolean-field.test.tsx
deleted file mode 100644
index 503d1ce..0000000
--- a/src/components/atoms/forms/boolean-field.test.tsx
+++ /dev/null
@@ -1,60 +0,0 @@
-import { render, screen } from '../../../../tests/utils';
-import { BooleanField } from './boolean-field';
-
-describe('BooleanField', () => {
- it('renders an unchecked checkbox', () => {
- render(
- <BooleanField
- checked={false}
- id="jest-checkbox"
- name="jest-checkbox"
- onChange={() => null}
- type="checkbox"
- value="checkbox"
- />
- );
- expect(screen.getByRole('checkbox')).not.toBeChecked();
- });
-
- it('renders a checked checkbox', () => {
- render(
- <BooleanField
- checked={true}
- id="jest-checkbox"
- name="jest-checkbox"
- onChange={() => null}
- type="checkbox"
- value="checkbox"
- />
- );
- expect(screen.getByRole('checkbox')).toBeChecked();
- });
-
- it('renders an unchecked radio', () => {
- render(
- <BooleanField
- checked={false}
- id="jest-radio"
- name="jest-radio"
- onChange={() => null}
- type="radio"
- value="radio"
- />
- );
- expect(screen.getByRole('radio')).not.toBeChecked();
- });
-
- it('renders a checked radio', () => {
- render(
- <BooleanField
- checked={true}
- id="jest-radio"
- name="jest-radio"
- onChange={() => null}
- type="radio"
- value="radio"
- />
- );
- expect(screen.getByRole('radio')).toBeChecked();
- });
-});
diff --git a/src/components/atoms/forms/boolean-field.tsx b/src/components/atoms/forms/boolean-field.tsx
deleted file mode 100644
index 8f33a42..0000000
--- a/src/components/atoms/forms/boolean-field.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-import { FC, InputHTMLAttributes } from 'react';
-import styles from './boolean-field.module.scss';
-
-export type BooleanFieldProps = Omit<
- InputHTMLAttributes<HTMLInputElement>,
- 'checked' | 'hidden' | 'name' | 'type' | 'value'
-> & {
- /**
- * True if the field should be checked.
- */
- checked: boolean;
- /**
- * True if the field should be visually hidden. Default: false.
- */
- hidden?: 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 = '',
- hidden = false,
- ...props
-}) => {
- const modifier = hidden ? 'hidden' : '';
- const inputClass = `${styles[modifier]} ${className}`;
-
- return <input {...props} className={inputClass} />;
-};
diff --git a/src/components/atoms/forms/field.tsx b/src/components/atoms/forms/field.tsx
deleted file mode 100644
index a4553e3..0000000
--- a/src/components/atoms/forms/field.tsx
+++ /dev/null
@@ -1,111 +0,0 @@
-import {
- ChangeEvent,
- forwardRef,
- ForwardRefRenderFunction,
- SetStateAction,
-} 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;
-};
-
-const FieldWithRef: ForwardRefRenderFunction<HTMLInputElement, FieldProps> = (
- { className = '', setValue, type, ...props },
- ref
-) => {
- /**
- * 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
- {...props}
- className={`${styles.field} ${styles['field--textarea']} ${className}`}
- onChange={updateValue}
- />
- ) : (
- <input
- {...props}
- className={`${styles.field} ${className}`}
- onChange={updateValue}
- ref={ref}
- type={type}
- />
- );
-};
-
-/**
- * Field component.
- *
- * Render either an input or a textarea.
- */
-export const Field = forwardRef(FieldWithRef);
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/boolean-field.stories.tsx b/src/components/atoms/forms/fields/boolean-field/boolean-field.stories.tsx
index 3d6f8c3..cb017da 100644
--- a/src/components/atoms/forms/boolean-field.stories.tsx
+++ b/src/components/atoms/forms/fields/boolean-field/boolean-field.stories.tsx
@@ -6,10 +6,10 @@ import { BooleanField } from './boolean-field';
* BooleanField - Storybook Meta
*/
export default {
- title: 'Atoms/Forms',
+ title: 'Atoms/Forms/Fields',
component: BooleanField,
args: {
- hidden: false,
+ isHidden: false,
},
argTypes: {
'aria-labelledby': {
@@ -25,16 +25,6 @@ export default {
required: false,
},
},
- checked: {
- control: {
- type: null,
- },
- description: 'The field state: true if checked.',
- type: {
- name: 'boolean',
- required: true,
- },
- },
className: {
control: {
type: 'text',
@@ -48,7 +38,27 @@ export default {
required: false,
},
},
- hidden: {
+ 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',
},
@@ -62,16 +72,6 @@ export default {
required: false,
},
},
- id: {
- control: {
- type: 'text',
- },
- description: 'The field id.',
- type: {
- name: 'string',
- required: true,
- },
- },
name: {
control: {
type: 'text',
@@ -133,15 +133,15 @@ export default {
} as ComponentMeta<typeof BooleanField>;
const Template: ComponentStory<typeof BooleanField> = ({
- checked,
+ isChecked: checked,
onChange: _onChange,
...args
}) => {
- const [isChecked, setIsChecked] = useState<boolean>(checked);
+ const [isChecked, setIsChecked] = useState(checked);
return (
<BooleanField
- checked={isChecked}
+ isChecked={isChecked}
onChange={() => {
setIsChecked(!isChecked);
}}
@@ -156,7 +156,7 @@ const Template: ComponentStory<typeof BooleanField> = ({
export const Checkbox = Template.bind({});
Checkbox.args = {
id: 'checkbox',
- checked: false,
+ isChecked: false,
name: 'checkbox',
type: 'checkbox',
value: 'checkbox',
@@ -168,7 +168,7 @@ Checkbox.args = {
export const Radio = Template.bind({});
Radio.args = {
id: 'radio',
- checked: false,
+ 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/forms.module.scss b/src/components/atoms/forms/fields/fields.module.scss
index ece26e5..f09117d 100644
--- a/src/components/atoms/forms/forms.module.scss
+++ b/src/components/atoms/forms/fields/fields.module.scss
@@ -1,13 +1,8 @@
-@use "../../../styles/abstracts/functions" as fun;
-@use "../../../styles/abstracts/mixins" as mix;
-
-.item {
- margin: var(--spacing-xs) 0;
- width: 100%;
- max-width: 45ch;
-}
+@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);
@@ -47,7 +42,9 @@
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;
+ 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/field.stories.tsx b/src/components/atoms/forms/fields/input/input.stories.tsx
index 27fd3be..35657f8 100644
--- a/src/components/atoms/forms/field.stories.tsx
+++ b/src/components/atoms/forms/fields/input/input.stories.tsx
@@ -1,16 +1,16 @@
import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { useState } from 'react';
-import { Field } from './field';
+import { ChangeEvent, useCallback, useState } from 'react';
+import { Input } from './input';
/**
- * Field - Storybook Meta
+ * Input - Storybook Meta
*/
export default {
title: 'Atoms/Forms/Fields',
- component: Field,
+ component: Input,
args: {
- disabled: false,
- required: false,
+ isDisabled: false,
+ isRequired: false,
},
argTypes: {
'aria-labelledby': {
@@ -39,11 +39,21 @@ export default {
required: false,
},
},
- disabled: {
+ id: {
+ control: {
+ type: 'text',
+ },
+ description: 'Input id.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ isDisabled: {
control: {
type: 'boolean',
},
- description: 'Field state: either enabled or disabled.',
+ description: 'Input state: either enabled or disabled.',
table: {
category: 'Options',
defaultValue: { summary: false },
@@ -53,14 +63,18 @@ export default {
required: false,
},
},
- id: {
+ isRequired: {
control: {
- type: 'text',
+ type: 'boolean',
+ },
+ description: 'Determine if the field is required.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: false },
},
- description: 'Field id.',
type: {
- name: 'string',
- required: true,
+ name: 'boolean',
+ required: false,
},
},
max: {
@@ -93,7 +107,7 @@ export default {
control: {
type: 'text',
},
- description: 'Field name.',
+ description: 'Input name.',
type: {
name: 'string',
required: true,
@@ -112,38 +126,11 @@ export default {
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.',
+ description: 'Input incremental values that are valid.',
table: {
category: 'Options',
},
@@ -156,7 +143,7 @@ export default {
control: {
type: 'select',
},
- description: 'Field type: input type or textarea.',
+ description: 'Input type: input type or textarea.',
options: [
'datetime-local',
'email',
@@ -177,27 +164,30 @@ export default {
control: {
type: null,
},
- description: 'Field value.',
+ description: 'Input value.',
type: {
name: 'string',
required: true,
},
},
},
-} as ComponentMeta<typeof Field>;
+} as ComponentMeta<typeof Input>;
-const Template: ComponentStory<typeof Field> = ({
- value: _value,
- setValue: _setValue,
+const Template: ComponentStory<typeof Input> = ({
+ value: initialValue,
+ onChange: _onChange,
...args
}) => {
- const [value, setValue] = useState<string>('');
+ const [value, setValue] = useState(initialValue);
+ const updateValue = useCallback((e: ChangeEvent<HTMLInputElement>) => {
+ setValue(e.target.value);
+ }, []);
- return <Field value={value} setValue={setValue} {...args} />;
+ return <Input value={value} onChange={updateValue} {...args} />;
};
/**
- * Field Story - DateTime
+ * Input Story - DateTime
*/
export const DateTime = Template.bind({});
DateTime.args = {
@@ -207,7 +197,7 @@ DateTime.args = {
};
/**
- * Field Story - Email
+ * Input Story - Email
*/
export const Email = Template.bind({});
Email.args = {
@@ -217,37 +207,27 @@ Email.args = {
};
/**
- * Field Story - Text
+ * Input Story - Numeric
*/
-export const Text = Template.bind({});
-Text.args = {
- id: 'field-storybook',
- name: 'field-storybook',
- type: 'text',
-};
-
-/**
- * Field Story - Number
- */
-export const Number = Template.bind({});
-Number.args = {
+export const Numeric = Template.bind({});
+Numeric.args = {
id: 'field-storybook',
name: 'field-storybook',
type: 'number',
};
/**
- * Field Story - TextArea
+ * Input Story - Text
*/
-export const TextArea = Template.bind({});
-TextArea.args = {
+export const Text = Template.bind({});
+Text.args = {
id: 'field-storybook',
name: 'field-storybook',
- type: 'textarea',
+ type: 'text',
};
/**
- * Field Story - Time
+ * Input Story - Time
*/
export const Time = Template.bind({});
Time.args = {
diff --git a/src/components/atoms/forms/field.test.tsx b/src/components/atoms/forms/fields/input/input.test.tsx
index 492aa48..1692c9e 100644
--- a/src/components/atoms/forms/field.test.tsx
+++ b/src/components/atoms/forms/fields/input/input.test.tsx
@@ -1,15 +1,19 @@
-import { render, screen } from '../../../../tests/utils';
-import { Field } from './field';
+import { render, screen } from '../../../../../../tests/utils';
+import { Input } from './input';
-describe('Field', () => {
+const doNothing = () => {
+ // do nothing
+};
+
+describe('Input', () => {
it('renders a text input', () => {
render(
- <Field
+ <Input
id="text-field"
name="text-field"
+ onChange={doNothing}
type="text"
value=""
- setValue={() => null}
/>
);
expect(screen.getByRole('textbox')).toHaveAttribute('type', 'text');
@@ -17,12 +21,12 @@ describe('Field', () => {
it('renders a search input', () => {
render(
- <Field
+ <Input
id="search-field"
name="search-field"
+ onChange={doNothing}
type="search"
value=""
- setValue={() => null}
/>
);
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/select.stories.tsx b/src/components/atoms/forms/fields/select/select.stories.tsx
index b98ebed..c9e02d2 100644
--- a/src/components/atoms/forms/select.stories.tsx
+++ b/src/components/atoms/forms/fields/select/select.stories.tsx
@@ -1,5 +1,5 @@
import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { useState } from 'react';
+import { ChangeEvent, useCallback, useState } from 'react';
import { Select as SelectComponent } from './select';
const selectOptions = [
@@ -12,11 +12,11 @@ const selectOptions = [
* Select - Storybook Meta
*/
export default {
- title: 'Atoms/Forms',
+ title: 'Atoms/Forms/Fields',
component: SelectComponent,
args: {
- disabled: false,
- required: false,
+ isDisabled: false,
+ isRequired: false,
},
argTypes: {
'aria-labelledby': {
@@ -45,7 +45,17 @@ export default {
required: false,
},
},
- disabled: {
+ id: {
+ control: {
+ type: 'text',
+ },
+ description: 'Field id.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ isDisabled: {
control: {
type: 'boolean',
},
@@ -59,14 +69,18 @@ export default {
required: false,
},
},
- id: {
+ isRequired: {
control: {
- type: 'text',
+ type: 'boolean',
+ },
+ description: 'Determine if the field is required.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: false },
},
- description: 'Field id.',
type: {
- name: 'string',
- required: true,
+ name: 'boolean',
+ required: false,
},
},
name: {
@@ -89,33 +103,6 @@ export default {
},
},
},
- 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,
@@ -130,13 +117,18 @@ export default {
} as ComponentMeta<typeof SelectComponent>;
const Template: ComponentStory<typeof SelectComponent> = ({
+ onChange: _onChange,
value,
- setValue: _setValue,
...args
}) => {
- const [selected, setSelected] = useState<string>(value);
+ const [selected, setSelected] = useState(value);
+ const updateSelection = useCallback((e: ChangeEvent<HTMLSelectElement>) => {
+ setSelected(e.target.value);
+ }, []);
- return <SelectComponent value={selected} setValue={setSelected} {...args} />;
+ return (
+ <SelectComponent {...args} onChange={updateSelection} value={selected} />
+ );
};
/**
diff --git a/src/components/atoms/forms/select.test.tsx b/src/components/atoms/forms/fields/select/select.test.tsx
index 53d9b1f..088cc9e 100644
--- a/src/components/atoms/forms/select.test.tsx
+++ b/src/components/atoms/forms/fields/select/select.test.tsx
@@ -1,6 +1,10 @@
-import { render, screen } from '../../../../tests/utils';
+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' },
@@ -12,19 +16,28 @@ describe('Select', () => {
it('should correctly set default option', () => {
render(
<Select
- id="jest-select"
- name="jest-select"
+ id="select-1"
+ name="select-1"
+ onChange={doNothing}
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
+ });
+
+ 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);
diff --git a/src/components/atoms/forms/fieldset/fieldset.module.scss b/src/components/atoms/forms/fieldset/fieldset.module.scss
new file mode 100644
index 0000000..ed545a7
--- /dev/null
+++ b/src/components/atoms/forms/fieldset/fieldset.module.scss
@@ -0,0 +1,17 @@
+.fieldset {
+ display: flex;
+ gap: var(--spacing-2xs);
+ max-width: 100%;
+ margin: 0;
+ padding: 0;
+ border: none;
+
+ &--inline {
+ flex-flow: row wrap;
+ align-items: center;
+ }
+
+ &--stack {
+ flex-flow: column wrap;
+ }
+}
diff --git a/src/components/atoms/forms/fieldset/fieldset.stories.tsx b/src/components/atoms/forms/fieldset/fieldset.stories.tsx
new file mode 100644
index 0000000..faf355f
--- /dev/null
+++ b/src/components/atoms/forms/fieldset/fieldset.stories.tsx
@@ -0,0 +1,63 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { Fieldset as FieldsetComponent } from './fieldset';
+import { Input } from '../fields';
+import { Legend } from '../legend';
+
+/**
+ * Fieldset - Storybook Meta
+ */
+export default {
+ title: 'Atoms/Forms',
+ component: FieldsetComponent,
+ args: {
+ isDisabled: false,
+ },
+ argTypes: {
+ isDisabled: {
+ control: {
+ type: 'boolean',
+ },
+ description:
+ 'Define if the fields inside the fieldset should be disabled.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: false },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof FieldsetComponent>;
+
+const Template: ComponentStory<typeof FieldsetComponent> = (args) => {
+ return (
+ <FieldsetComponent {...args}>
+ <div>
+ <Input
+ aria-label="A field example"
+ id="field1"
+ name="field1"
+ type="text"
+ />
+ </div>
+ <div>
+ <Input
+ aria-label="Another field example"
+ id="field2"
+ name="field2"
+ type="text"
+ />
+ </div>
+ </FieldsetComponent>
+ );
+};
+
+/**
+ * Fieldset Story
+ */
+export const Fieldset = Template.bind({});
+Fieldset.args = {
+ legend: <Legend>The fieldset legend</Legend>,
+};
diff --git a/src/components/atoms/forms/fieldset/fieldset.test.tsx b/src/components/atoms/forms/fieldset/fieldset.test.tsx
new file mode 100644
index 0000000..08a0aaa
--- /dev/null
+++ b/src/components/atoms/forms/fieldset/fieldset.test.tsx
@@ -0,0 +1,35 @@
+import { render, screen } from '../../../../../tests/utils';
+import { Input } from '../fields';
+import { Fieldset } from './fieldset';
+
+describe('fieldset', () => {
+ it('renders a fieldset', () => {
+ render(
+ <Fieldset>
+ <Input
+ aria-label="A field example"
+ id="field"
+ name="field"
+ type="text"
+ />
+ </Fieldset>
+ );
+ expect(screen.getByRole('group')).toBeInTheDocument();
+ expect(screen.getByRole('textbox')).not.toBeDisabled();
+ });
+
+ it('renders a disabled fieldset', () => {
+ render(
+ <Fieldset isDisabled>
+ <Input
+ aria-label="A field example"
+ id="field"
+ name="field"
+ type="text"
+ />
+ </Fieldset>
+ );
+ expect(screen.getByRole('group')).toBeInTheDocument();
+ expect(screen.getByRole('textbox')).toBeDisabled();
+ });
+});
diff --git a/src/components/atoms/forms/fieldset/fieldset.tsx b/src/components/atoms/forms/fieldset/fieldset.tsx
new file mode 100644
index 0000000..eb42961
--- /dev/null
+++ b/src/components/atoms/forms/fieldset/fieldset.tsx
@@ -0,0 +1,68 @@
+import {
+ forwardRef,
+ type FieldsetHTMLAttributes,
+ ForwardRefRenderFunction,
+ ReactElement,
+} from 'react';
+import styles from './fieldset.module.scss';
+import { LegendProps } from '../legend';
+
+export type FieldsetProps = Omit<
+ FieldsetHTMLAttributes<HTMLFieldSetElement>,
+ 'disabled' | 'hidden'
+> & {
+ /**
+ * Should the fieldset be disabled?
+ *
+ * @default false
+ */
+ isDisabled?: boolean;
+ /**
+ * Should the fieldset contents be inlined?
+ *
+ * @default false
+ */
+ isInline?: boolean;
+ /**
+ * The fieldset legend.
+ */
+ legend?: ReactElement<LegendProps>;
+};
+
+/**
+ * Fieldset component.
+ */
+const FieldsetWithRef: ForwardRefRenderFunction<
+ HTMLFieldSetElement,
+ FieldsetProps
+> = (
+ {
+ children,
+ className = '',
+ isDisabled = false,
+ isInline = false,
+ legend,
+ ...props
+ },
+ ref
+) => {
+ const layoutModifier = isInline
+ ? styles['fieldset--inline']
+ : styles['fieldset--stack'];
+ const legendModifier = legend ? styles['fieldset--has-legend'] : '';
+ const fieldsetClass = `${styles.fieldset} ${legendModifier} ${layoutModifier} ${className}`;
+
+ return (
+ <fieldset
+ {...props}
+ className={fieldsetClass}
+ disabled={isDisabled}
+ ref={ref}
+ >
+ {legend}
+ {children}
+ </fieldset>
+ );
+};
+
+export const Fieldset = forwardRef(FieldsetWithRef);
diff --git a/src/components/atoms/forms/fieldset/index.ts b/src/components/atoms/forms/fieldset/index.ts
new file mode 100644
index 0000000..00ef1f8
--- /dev/null
+++ b/src/components/atoms/forms/fieldset/index.ts
@@ -0,0 +1 @@
+export * from './fieldset';
diff --git a/src/components/atoms/forms/form.test.tsx b/src/components/atoms/forms/form.test.tsx
deleted file mode 100644
index b040665..0000000
--- a/src/components/atoms/forms/form.test.tsx
+++ /dev/null
@@ -1,13 +0,0 @@
-import { render, screen } from '../../../../tests/utils';
-import { Form } from './form';
-
-describe('Form', () => {
- it('renders a form', () => {
- render(
- <Form aria-label="Jest form" onSubmit={() => null}>
- Fields
- </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
deleted file mode 100644
index 85ff8fd..0000000
--- a/src/components/atoms/forms/form.tsx
+++ /dev/null
@@ -1,80 +0,0 @@
-import {
- Children,
- FC,
- FormEvent,
- FormHTMLAttributes,
- Fragment,
- ReactNode,
-} from 'react';
-import styles from './forms.module.scss';
-
-export type FormProps = Omit<
- FormHTMLAttributes<HTMLFormElement>,
- 'onSubmit'
-> & {
- /**
- * The form body.
- */
- children: ReactNode;
- /**
- * Wrap each items with a div. Default: true.
- */
- grouped?: boolean;
- /**
- * If grouped, set additional classnames to the items wrapper.
- */
- itemsClassName?: string;
- /**
- * A callback function to execute on submit.
- */
- onSubmit: () => void;
-};
-
-/**
- * Form component.
- *
- * Render children wrapped in a form element.
- */
-export const Form: FC<FormProps> = ({
- children,
- grouped = true,
- itemsClassName = '',
- 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} ${itemsClassName}`}
- >
- {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 {...props} onSubmit={handleSubmit}>
- {getFormItems()}
- </form>
- );
-};
diff --git a/src/components/atoms/forms/form/form.test.tsx b/src/components/atoms/forms/form/form.test.tsx
new file mode 100644
index 0000000..08165f5
--- /dev/null
+++ b/src/components/atoms/forms/form/form.test.tsx
@@ -0,0 +1,13 @@
+import { render, screen } from '../../../../../tests/utils';
+import { Form } from './form';
+
+describe('Form', () => {
+ it('renders a form', () => {
+ render(
+ <Form aria-label="A form name" onSubmit={() => null}>
+ Fields
+ </Form>
+ );
+ expect(screen.getByRole('form')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/atoms/forms/form/form.tsx b/src/components/atoms/forms/form/form.tsx
new file mode 100644
index 0000000..86481d2
--- /dev/null
+++ b/src/components/atoms/forms/form/form.tsx
@@ -0,0 +1,28 @@
+import {
+ type FormHTMLAttributes,
+ forwardRef,
+ type ForwardRefRenderFunction,
+} from 'react';
+
+export type FormRole = 'form' | 'search' | 'none' | 'presentation';
+
+export type FormProps = FormHTMLAttributes<HTMLFormElement> & {
+ /**
+ * An accessible role.
+ */
+ role?: FormRole;
+};
+
+const FormWithRef: ForwardRefRenderFunction<HTMLFormElement, FormProps> = (
+ { children, ...props },
+ ref
+) => (
+ <form {...props} ref={ref}>
+ {children}
+ </form>
+);
+
+/**
+ * Form component.
+ */
+export const Form = forwardRef(FormWithRef);
diff --git a/src/components/atoms/forms/form/index.ts b/src/components/atoms/forms/form/index.ts
new file mode 100644
index 0000000..698d687
--- /dev/null
+++ b/src/components/atoms/forms/form/index.ts
@@ -0,0 +1 @@
+export * from './form';
diff --git a/src/components/atoms/forms/index.ts b/src/components/atoms/forms/index.ts
index 0af138f..7e444c2 100644
--- a/src/components/atoms/forms/index.ts
+++ b/src/components/atoms/forms/index.ts
@@ -1,5 +1,5 @@
-export * from './boolean-field';
-export * from './field';
+export * from './fields';
+export * from './fieldset';
export * from './form';
export * from './label';
-export * from './select';
+export * from './legend';
diff --git a/src/components/atoms/forms/label.tsx b/src/components/atoms/forms/label.tsx
deleted file mode 100644
index 6764579..0000000
--- a/src/components/atoms/forms/label.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-import { FC, LabelHTMLAttributes, ReactNode } from 'react';
-import styles from './label.module.scss';
-
-export type LabelProps = LabelHTMLAttributes<HTMLLabelElement> & {
- /**
- * The label body.
- */
- children: ReactNode;
- /**
- * Is the field required? Default: false.
- */
- required?: boolean;
- /**
- * The label size. Default: small.
- */
- size?: 'medium' | 'small';
-};
-
-/**
- * Label Component
- *
- * Render a HTML label element.
- */
-export const Label: FC<LabelProps> = ({
- children,
- className = '',
- required = false,
- size = 'small',
- ...props
-}) => {
- const sizeClass = styles[`label--${size}`];
- const labelClass = `${styles.label} ${sizeClass} ${className}`;
-
- return (
- <label {...props} className={labelClass}>
- {children}
- {required && <span className={styles.required}> *</span>}
- </label>
- );
-};
diff --git a/src/components/atoms/forms/label/index.ts b/src/components/atoms/forms/label/index.ts
new file mode 100644
index 0000000..301fbde
--- /dev/null
+++ b/src/components/atoms/forms/label/index.ts
@@ -0,0 +1 @@
+export * from './label';
diff --git a/src/components/atoms/forms/label.module.scss b/src/components/atoms/forms/label/label.module.scss
index aed1546..21ba9d3 100644
--- a/src/components/atoms/forms/label.module.scss
+++ b/src/components/atoms/forms/label/label.module.scss
@@ -3,12 +3,12 @@
font-weight: 600;
cursor: pointer;
- &--small {
+ &--sm {
font-size: var(--font-size-sm);
font-variant: small-caps;
}
- &--medium {
+ &--md {
font-size: var(--font-size-md);
}
}
diff --git a/src/components/atoms/forms/label.stories.tsx b/src/components/atoms/forms/label/label.stories.tsx
index 3adc92a..8460c45 100644
--- a/src/components/atoms/forms/label.stories.tsx
+++ b/src/components/atoms/forms/label/label.stories.tsx
@@ -8,8 +8,9 @@ export default {
title: 'Atoms/Forms',
component: LabelComponent,
args: {
- required: false,
- size: 'small',
+ isHidden: false,
+ isRequired: false,
+ size: 'sm',
},
argTypes: {
'aria-label': {
@@ -58,7 +59,21 @@ export default {
required: true,
},
},
- required: {
+ isHidden: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Set to true if the label should be visually hidden.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: false },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ isRequired: {
control: {
type: 'boolean',
},
@@ -77,10 +92,10 @@ export default {
type: 'select',
},
description: 'The label size.',
- options: ['medium', 'small'],
+ options: ['md', 'sm'],
table: {
category: 'Options',
- defaultValue: { summary: 'small' },
+ defaultValue: { summary: 'sm' },
},
type: {
name: 'string',
diff --git a/src/components/atoms/forms/label.test.tsx b/src/components/atoms/forms/label/label.test.tsx
index 091737b..afdbb94 100644
--- a/src/components/atoms/forms/label.test.tsx
+++ b/src/components/atoms/forms/label/label.test.tsx
@@ -1,9 +1,9 @@
-import { render, screen } from '../../../../tests/utils';
+import { render, screen } from '../../../../../tests/utils';
import { Label } from './label';
describe('Label', () => {
it('renders a field label', () => {
render(<Label>A label</Label>);
- expect(screen.getByText('A label')).toBeDefined();
+ expect(screen.getByText('A label')).toBeInTheDocument();
});
});
diff --git a/src/components/atoms/forms/label/label.tsx b/src/components/atoms/forms/label/label.tsx
new file mode 100644
index 0000000..5087325
--- /dev/null
+++ b/src/components/atoms/forms/label/label.tsx
@@ -0,0 +1,62 @@
+import { FC, LabelHTMLAttributes, ReactNode } from 'react';
+import styles from './label.module.scss';
+
+export type LabelSize = 'md' | 'sm';
+
+export type LabelProps = Omit<
+ LabelHTMLAttributes<HTMLLabelElement>,
+ 'hidden' | 'size'
+> & {
+ /**
+ * The label body.
+ */
+ children: ReactNode;
+ /**
+ * Should the label be hidden?
+ *
+ * @default false
+ */
+ isHidden?: boolean;
+ /**
+ * Is the field required?
+ *
+ * @default false
+ */
+ isRequired?: boolean;
+ /**
+ * The label size.
+ *
+ * @default 'sm'
+ */
+ size?: LabelSize;
+};
+
+/**
+ * Label Component
+ *
+ * Render a HTML label element.
+ */
+export const Label: FC<LabelProps> = ({
+ children,
+ className = '',
+ isHidden = false,
+ isRequired = false,
+ size = 'sm',
+ ...props
+}) => {
+ const visibilityClass = isHidden ? 'screen-reader-text' : '';
+ const sizeClass = styles[`label--${size}`];
+ const labelClass = `${styles.label} ${sizeClass} ${visibilityClass} ${className}`;
+ const requiredSymbol = ' *';
+
+ return (
+ <label {...props} className={labelClass}>
+ {children}
+ {isRequired ? (
+ <span aria-hidden className={styles.required}>
+ {requiredSymbol}
+ </span>
+ ) : null}
+ </label>
+ );
+};
diff --git a/src/components/atoms/forms/legend/index.ts b/src/components/atoms/forms/legend/index.ts
new file mode 100644
index 0000000..a0482ef
--- /dev/null
+++ b/src/components/atoms/forms/legend/index.ts
@@ -0,0 +1 @@
+export * from './legend';
diff --git a/src/components/atoms/forms/legend/legend.module.scss b/src/components/atoms/forms/legend/legend.module.scss
new file mode 100644
index 0000000..705e3fe
--- /dev/null
+++ b/src/components/atoms/forms/legend/legend.module.scss
@@ -0,0 +1,6 @@
+.legend {
+ float: left;
+ font-size: var(--font-size-md);
+ font-weight: 600;
+ color: var(--color-primary-darker);
+}
diff --git a/src/components/atoms/forms/legend/legend.stories.tsx b/src/components/atoms/forms/legend/legend.stories.tsx
new file mode 100644
index 0000000..cda7f09
--- /dev/null
+++ b/src/components/atoms/forms/legend/legend.stories.tsx
@@ -0,0 +1,27 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { Legend as LegendComponent } from './legend';
+import { Fieldset } from '../fieldset';
+
+/**
+ * Legend - Storybook Meta
+ */
+export default {
+ title: 'Atoms/Forms',
+ component: LegendComponent,
+ args: {},
+ argTypes: {},
+} as ComponentMeta<typeof LegendComponent>;
+
+const Template: ComponentStory<typeof LegendComponent> = (args) => (
+ <Fieldset>
+ <LegendComponent {...args} />
+ </Fieldset>
+);
+
+/**
+ * Legend Story
+ */
+export const Legend = Template.bind({});
+Legend.args = {
+ children: 'A fieldset legend',
+};
diff --git a/src/components/atoms/forms/legend/legend.test.tsx b/src/components/atoms/forms/legend/legend.test.tsx
new file mode 100644
index 0000000..7abb996
--- /dev/null
+++ b/src/components/atoms/forms/legend/legend.test.tsx
@@ -0,0 +1,17 @@
+import { render, screen } from '../../../../../tests/utils';
+import { Fieldset } from '../fieldset';
+import { Legend } from './legend';
+
+describe('legend', () => {
+ it('renders the fieldset legend', () => {
+ const body = 'deserunt';
+
+ render(
+ <Fieldset>
+ <Legend>{body}</Legend>
+ </Fieldset>
+ );
+
+ expect(screen.getByRole('group')).toHaveTextContent(body);
+ });
+});
diff --git a/src/components/atoms/forms/legend/legend.tsx b/src/components/atoms/forms/legend/legend.tsx
new file mode 100644
index 0000000..b517855
--- /dev/null
+++ b/src/components/atoms/forms/legend/legend.tsx
@@ -0,0 +1,21 @@
+import type { FC, HTMLAttributes } from 'react';
+import styles from './legend.module.scss';
+
+export type LegendProps = HTMLAttributes<HTMLLegendElement>;
+
+/**
+ * Legend component.
+ */
+export const Legend: FC<LegendProps> = ({
+ children,
+ className = '',
+ ...props
+}) => {
+ const legendClass = `${styles.legend} ${className}`;
+
+ return (
+ <legend {...props} className={legendClass}>
+ {children}
+ </legend>
+ );
+};
diff --git a/src/components/atoms/forms/select.tsx b/src/components/atoms/forms/select.tsx
deleted file mode 100644
index 14f85dc..0000000
--- a/src/components/atoms/forms/select.tsx
+++ /dev/null
@@ -1,79 +0,0 @@
-import { ChangeEvent, FC, SelectHTMLAttributes, SetStateAction } 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 = SelectHTMLAttributes<HTMLSelectElement> & {
- /**
- * Field id attribute.
- */
- id: string;
- /**
- * Field name attribute.
- */
- name: string;
- /**
- * True if the field is required. Default: false.
- */
- options: SelectOptions[];
- /**
- * Callback function to set field value.
- */
- setValue: (value: SetStateAction<string>) => void;
- /**
- * Field value.
- */
- value: string;
-};
-
-/**
- * Select component
- *
- * Render a HTML select element.
- */
-export const Select: FC<SelectProps> = ({
- className = '',
- options,
- setValue,
- ...props
-}) => {
- const selectClass = `${styles.field} ${styles['field--select']} ${className}`;
-
- /**
- * 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 {...props} className={selectClass} onChange={updateValue}>
- {getOptions()}
- </select>
- );
-};
diff --git a/src/components/atoms/index.ts b/src/components/atoms/index.ts
index 72b5598..d9cf865 100644
--- a/src/components/atoms/index.ts
+++ b/src/components/atoms/index.ts
@@ -7,3 +7,4 @@ export * from './layout';
export * from './links';
export * from './lists';
export * from './loaders';
+export * from './modal';
diff --git a/src/components/atoms/modal/index.ts b/src/components/atoms/modal/index.ts
new file mode 100644
index 0000000..133aa74
--- /dev/null
+++ b/src/components/atoms/modal/index.ts
@@ -0,0 +1 @@
+export * from './modal';
diff --git a/src/components/atoms/modal/modal.module.scss b/src/components/atoms/modal/modal.module.scss
new file mode 100644
index 0000000..6650235
--- /dev/null
+++ b/src/components/atoms/modal/modal.module.scss
@@ -0,0 +1,66 @@
+@use "../../../styles/abstracts/functions" as fun;
+@use "../../../styles/abstracts/mixins" as mix;
+
+.modal {
+ position: relative;
+ box-shadow:
+ fun.convert-px(0.2) fun.convert-px(0.2) fun.convert-px(0.3) 0
+ var(--color-shadow),
+ fun.convert-px(1.5) fun.convert-px(1.5) fun.convert-px(2.5)
+ fun.convert-px(-0.3) var(--color-shadow),
+ fun.convert-px(4.7) fun.convert-px(4.7) fun.convert-px(8) fun.convert-px(-1)
+ var(--color-shadow);
+
+ &--primary {
+ padding: clamp(var(--spacing-xs), 2.5vw, var(--spacing-md));
+ background: var(--color-bg-secondary);
+ border: fun.convert-px(3) solid;
+ border-image: radial-gradient(
+ ellipse at top,
+ var(--color-primary-lighter) 20%,
+ var(--color-primary) 100%
+ )
+ 1;
+
+ .title {
+ margin-bottom: var(--spacing-2xs);
+ }
+
+ @include mix.media("screen") {
+ @include mix.dimensions(null, "sm") {
+ border-left: none;
+ border-right: none;
+ }
+ }
+ }
+
+ &--secondary {
+ padding: clamp(var(--spacing-xs), 2.2vw, var(--spacing-sm));
+ background: var(--color-bg);
+ border: fun.convert-px(2) solid var(--color-primary-dark);
+ border-radius: fun.convert-px(3);
+
+ .title {
+ padding-inline: var(--spacing-xs);
+ background: var(--color-bg);
+ border: fun.convert-px(1) solid var(--color-primary-dark);
+ box-shadow: fun.convert-px(1) fun.convert-px(1) 0 0 var(--color-shadow);
+ color: var(--color-primary-darker);
+ font-variant: small-caps;
+
+ > * {
+ margin-block: 0;
+ }
+ }
+ }
+
+ &--secondary#{&}--has-title {
+ --title-height: #{fun.convert-px(40)};
+
+ .title {
+ width: fit-content;
+ height: var(--title-height);
+ margin: calc(var(--title-height) * -1) auto var(--spacing-xs);
+ }
+ }
+}
diff --git a/src/components/atoms/modal/modal.stories.tsx b/src/components/atoms/modal/modal.stories.tsx
new file mode 100644
index 0000000..d0c2f0b
--- /dev/null
+++ b/src/components/atoms/modal/modal.stories.tsx
@@ -0,0 +1,59 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { Modal } from './modal';
+import { Heading } from '../headings';
+
+/**
+ * Switch - Storybook Meta
+ */
+export default {
+ title: 'Atoms/Modals',
+ component: Modal,
+ args: {},
+ argTypes: {},
+} as ComponentMeta<typeof Modal>;
+
+const Template: ComponentStory<typeof Modal> = (args) => <Modal {...args} />;
+
+/**
+ * Modal Stories - Primary
+ */
+export const Primary = Template.bind({});
+Primary.args = {
+ children:
+ 'Inventore natus dignissimos aut illum modi asperiores. Et voluptatibus delectus.',
+};
+
+/**
+ * Modal Stories - Primary With Heading
+ */
+export const PrimaryWithHeading = Template.bind({});
+PrimaryWithHeading.args = {
+ children:
+ 'Inventore natus dignissimos aut illum modi asperiores. Et voluptatibus delectus.',
+ heading: <Heading level={3}>Aut provident eum</Heading>,
+};
+
+/**
+ * Modal Stories - Secondary
+ */
+export const Secondary = Template.bind({});
+Secondary.args = {
+ children:
+ 'Inventore natus dignissimos aut illum modi asperiores. Et voluptatibus delectus.',
+ kind: 'secondary',
+};
+
+/**
+ * Modal Stories - Secondary with heading
+ */
+export const SecondaryWithHeading = Template.bind({});
+SecondaryWithHeading.args = {
+ children:
+ 'Inventore natus dignissimos aut illum modi asperiores. Et voluptatibus delectus.',
+ heading: (
+ <Heading isFake level={4}>
+ Aut provident eum
+ </Heading>
+ ),
+ kind: 'secondary',
+};
diff --git a/src/components/atoms/modal/modal.test.tsx b/src/components/atoms/modal/modal.test.tsx
new file mode 100644
index 0000000..5f32d02
--- /dev/null
+++ b/src/components/atoms/modal/modal.test.tsx
@@ -0,0 +1,25 @@
+import { render, screen } from '../../../../tests/utils';
+import { Heading } from '../headings';
+import { Modal } from './modal';
+
+const title = 'A custom title';
+const children =
+ 'Labore ullam delectus sit modi quam dolores. Ratione id sint aliquid facilis ipsum. Unde necessitatibus provident minus.';
+
+describe('Modal', () => {
+ it('renders a title', () => {
+ const level = 2;
+
+ render(
+ <Modal heading={<Heading level={level}>{title}</Heading>}>
+ {children}
+ </Modal>
+ );
+ expect(screen.getByRole('heading', { level })).toHaveTextContent(title);
+ });
+
+ it('renders the modal body', () => {
+ render(<Modal>{children}</Modal>);
+ expect(screen.getByText(children)).toBeInTheDocument();
+ });
+});
diff --git a/src/components/atoms/modal/modal.tsx b/src/components/atoms/modal/modal.tsx
new file mode 100644
index 0000000..78b4f6e
--- /dev/null
+++ b/src/components/atoms/modal/modal.tsx
@@ -0,0 +1,49 @@
+import {
+ ForwardRefRenderFunction,
+ HTMLAttributes,
+ ReactElement,
+ ReactNode,
+ forwardRef,
+} from 'react';
+import { HeadingProps } from '../headings';
+import styles from './modal.module.scss';
+
+export type ModalProps = HTMLAttributes<HTMLDivElement> & {
+ /**
+ * The modal body.
+ */
+ children: ReactNode;
+ /**
+ * The modal title.
+ */
+ heading?: ReactElement<HeadingProps>;
+ /**
+ * The modal kind.
+ *
+ * @default 'primary'
+ */
+ kind?: 'primary' | 'secondary';
+};
+
+const ModalWithRef: ForwardRefRenderFunction<HTMLDivElement, ModalProps> = (
+ { children, className = '', heading, kind = 'primary', ...props },
+ ref
+) => {
+ const headingModifier = heading ? 'modal--has-title' : '';
+ const kindModifier = `modal--${kind}`;
+ const modalClass = `${styles.modal} ${styles[headingModifier]} ${styles[kindModifier]} ${className}`;
+
+ return (
+ <div {...props} className={modalClass} ref={ref}>
+ {heading ? <div className={styles.title}>{heading}</div> : null}
+ {children}
+ </div>
+ );
+};
+
+/**
+ * Modal component
+ *
+ * Render a modal component.
+ */
+export const Modal = forwardRef(ModalWithRef);