aboutsummaryrefslogtreecommitdiffstats
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
parent651ea4fc992e77d2f36b3c68f8e7a70644246067 (diff)
refactor(components): rewrite form components
-rw-r--r--.cspell/project-words.txt1
-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.tsx (renamed from src/components/molecules/modals/modal.test.tsx)13
-rw-r--r--src/components/atoms/modal/modal.tsx49
-rw-r--r--src/components/molecules/forms/ackee-toggle.module.scss6
-rw-r--r--src/components/molecules/forms/ackee-toggle.stories.tsx125
-rw-r--r--src/components/molecules/forms/ackee-toggle.tsx147
-rw-r--r--src/components/molecules/forms/fieldset.fixture.tsx6
-rw-r--r--src/components/molecules/forms/fieldset.module.scss61
-rw-r--r--src/components/molecules/forms/fieldset.stories.tsx176
-rw-r--r--src/components/molecules/forms/fieldset.test.tsx22
-rw-r--r--src/components/molecules/forms/fieldset.tsx118
-rw-r--r--src/components/molecules/forms/flipping-label/flipping-label.module.scss (renamed from src/components/molecules/forms/flipping-label.module.scss)2
-rw-r--r--src/components/molecules/forms/flipping-label/flipping-label.stories.tsx (renamed from src/components/molecules/forms/flipping-label.stories.tsx)4
-rw-r--r--src/components/molecules/forms/flipping-label/flipping-label.test.tsx (renamed from src/components/molecules/forms/flipping-label.test.tsx)2
-rw-r--r--src/components/molecules/forms/flipping-label/flipping-label.tsx (renamed from src/components/molecules/forms/flipping-label.tsx)2
-rw-r--r--src/components/molecules/forms/flipping-label/index.ts1
-rw-r--r--src/components/molecules/forms/index.ts8
-rw-r--r--src/components/molecules/forms/labelled-boolean-field.fixture.tsx1
-rw-r--r--src/components/molecules/forms/labelled-boolean-field.module.scss15
-rw-r--r--src/components/molecules/forms/labelled-boolean-field.stories.tsx254
-rw-r--r--src/components/molecules/forms/labelled-boolean-field.test.tsx37
-rw-r--r--src/components/molecules/forms/labelled-boolean-field.tsx85
-rw-r--r--src/components/molecules/forms/labelled-field.module.scss9
-rw-r--r--src/components/molecules/forms/labelled-field.stories.tsx293
-rw-r--r--src/components/molecules/forms/labelled-field.test.tsx19
-rw-r--r--src/components/molecules/forms/labelled-field.tsx49
-rw-r--r--src/components/molecules/forms/labelled-field/index.ts1
-rw-r--r--src/components/molecules/forms/labelled-field/labelled-field.module.scss22
-rw-r--r--src/components/molecules/forms/labelled-field/labelled-field.stories.tsx130
-rw-r--r--src/components/molecules/forms/labelled-field/labelled-field.test.tsx32
-rw-r--r--src/components/molecules/forms/labelled-field/labelled-field.tsx63
-rw-r--r--src/components/molecules/forms/labelled-select.module.scss9
-rw-r--r--src/components/molecules/forms/labelled-select.stories.tsx236
-rw-r--r--src/components/molecules/forms/labelled-select.test.tsx25
-rw-r--r--src/components/molecules/forms/labelled-select.tsx66
-rw-r--r--src/components/molecules/forms/prism-theme-toggle.stories.tsx60
-rw-r--r--src/components/molecules/forms/radio-group.module.scss112
-rw-r--r--src/components/molecules/forms/radio-group.stories.tsx285
-rw-r--r--src/components/molecules/forms/radio-group.test.tsx30
-rw-r--r--src/components/molecules/forms/radio-group.tsx157
-rw-r--r--src/components/molecules/forms/radio-group/index.ts1
-rw-r--r--src/components/molecules/forms/radio-group/radio-group.fixture.tsx (renamed from src/components/molecules/forms/radio-group.fixture.tsx)10
-rw-r--r--src/components/molecules/forms/radio-group/radio-group.module.scss9
-rw-r--r--src/components/molecules/forms/radio-group/radio-group.stories.tsx75
-rw-r--r--src/components/molecules/forms/radio-group/radio-group.test.tsx59
-rw-r--r--src/components/molecules/forms/radio-group/radio-group.tsx110
-rw-r--r--src/components/molecules/forms/switch/index.ts1
-rw-r--r--src/components/molecules/forms/switch/switch.module.scss105
-rw-r--r--src/components/molecules/forms/switch/switch.stories.tsx48
-rw-r--r--src/components/molecules/forms/switch/switch.test.tsx49
-rw-r--r--src/components/molecules/forms/switch/switch.tsx132
-rw-r--r--src/components/molecules/forms/theme-toggle.stories.tsx60
-rw-r--r--src/components/molecules/index.ts2
-rw-r--r--src/components/molecules/modals/modal.module.scss34
-rw-r--r--src/components/molecules/modals/modal.stories.tsx96
-rw-r--r--src/components/molecules/modals/modal.tsx88
-rw-r--r--src/components/molecules/modals/tooltip.fixture.tsx4
-rw-r--r--src/components/molecules/modals/tooltip.module.scss46
-rw-r--r--src/components/molecules/modals/tooltip.stories.tsx84
-rw-r--r--src/components/molecules/modals/tooltip.test.tsx20
-rw-r--r--src/components/molecules/modals/tooltip.tsx67
-rw-r--r--src/components/molecules/tooltip/index.ts (renamed from src/components/molecules/modals/index.ts)1
-rw-r--r--src/components/molecules/tooltip/tooltip.module.scss72
-rw-r--r--src/components/molecules/tooltip/tooltip.stories.tsx42
-rw-r--r--src/components/molecules/tooltip/tooltip.test.tsx39
-rw-r--r--src/components/molecules/tooltip/tooltip.tsx92
-rw-r--r--src/components/organisms/forms/ackee-toggle/ackee-toggle.fixture.tsx (renamed from src/components/molecules/forms/ackee-toggle.fixture.tsx)0
-rw-r--r--src/components/organisms/forms/ackee-toggle/ackee-toggle.stories.tsx47
-rw-r--r--src/components/organisms/forms/ackee-toggle/ackee-toggle.test.tsx (renamed from src/components/molecules/forms/ackee-toggle.test.tsx)2
-rw-r--r--src/components/organisms/forms/ackee-toggle/ackee-toggle.tsx139
-rw-r--r--src/components/organisms/forms/ackee-toggle/index.ts1
-rw-r--r--src/components/organisms/forms/comment-form.module.scss8
-rw-r--r--src/components/organisms/forms/comment-form/comment-form.module.scss18
-rw-r--r--src/components/organisms/forms/comment-form/comment-form.stories.tsx (renamed from src/components/organisms/forms/comment-form.stories.tsx)0
-rw-r--r--src/components/organisms/forms/comment-form/comment-form.test.tsx (renamed from src/components/organisms/forms/comment-form.test.tsx)2
-rw-r--r--src/components/organisms/forms/comment-form/comment-form.tsx (renamed from src/components/organisms/forms/comment-form.tsx)152
-rw-r--r--src/components/organisms/forms/comment-form/index.ts1
-rw-r--r--src/components/organisms/forms/contact-form.module.scss8
-rw-r--r--src/components/organisms/forms/contact-form.tsx154
-rw-r--r--src/components/organisms/forms/contact-form/contact-form.module.scss15
-rw-r--r--src/components/organisms/forms/contact-form/contact-form.stories.tsx (renamed from src/components/organisms/forms/contact-form.stories.tsx)0
-rw-r--r--src/components/organisms/forms/contact-form/contact-form.test.tsx (renamed from src/components/organisms/forms/contact-form.test.tsx)2
-rw-r--r--src/components/organisms/forms/contact-form/contact-form.tsx210
-rw-r--r--src/components/organisms/forms/contact-form/index.ts1
-rw-r--r--src/components/organisms/forms/index.ts4
-rw-r--r--src/components/organisms/forms/motion-toggle/index.ts1
-rw-r--r--src/components/organisms/forms/motion-toggle/motion-toggle.fixture.tsx (renamed from src/components/molecules/forms/motion-toggle.fixture.tsx)0
-rw-r--r--src/components/organisms/forms/motion-toggle/motion-toggle.stories.tsx (renamed from src/components/molecules/forms/motion-toggle.stories.tsx)41
-rw-r--r--src/components/organisms/forms/motion-toggle/motion-toggle.test.tsx (renamed from src/components/molecules/forms/motion-toggle.test.tsx)2
-rw-r--r--src/components/organisms/forms/motion-toggle/motion-toggle.tsx (renamed from src/components/molecules/forms/motion-toggle.tsx)65
-rw-r--r--src/components/organisms/forms/prism-theme-toggle/index.ts1
-rw-r--r--src/components/organisms/forms/prism-theme-toggle/prism-theme-toggle.stories.tsx20
-rw-r--r--src/components/organisms/forms/prism-theme-toggle/prism-theme-toggle.test.tsx (renamed from src/components/molecules/forms/prism-theme-toggle.test.tsx)2
-rw-r--r--src/components/organisms/forms/prism-theme-toggle/prism-theme-toggle.tsx (renamed from src/components/molecules/forms/prism-theme-toggle.tsx)67
-rw-r--r--src/components/organisms/forms/search-form/index.ts1
-rw-r--r--src/components/organisms/forms/search-form/search-form.module.scss (renamed from src/components/organisms/forms/search-form.module.scss)31
-rw-r--r--src/components/organisms/forms/search-form/search-form.stories.tsx (renamed from src/components/organisms/forms/search-form.stories.tsx)6
-rw-r--r--src/components/organisms/forms/search-form/search-form.test.tsx (renamed from src/components/organisms/forms/search-form.test.tsx)2
-rw-r--r--src/components/organisms/forms/search-form/search-form.tsx (renamed from src/components/organisms/forms/search-form.tsx)56
-rw-r--r--src/components/organisms/forms/theme-toggle/index.ts1
-rw-r--r--src/components/organisms/forms/theme-toggle/theme-toggle.stories.tsx20
-rw-r--r--src/components/organisms/forms/theme-toggle/theme-toggle.test.tsx (renamed from src/components/molecules/forms/theme-toggle.test.tsx)2
-rw-r--r--src/components/organisms/forms/theme-toggle/theme-toggle.tsx (renamed from src/components/molecules/forms/theme-toggle.tsx)62
-rw-r--r--src/components/organisms/layout/no-results.tsx2
-rw-r--r--src/components/organisms/modals/search-modal.tsx13
-rw-r--r--src/components/organisms/modals/settings-modal.module.scss26
-rw-r--r--src/components/organisms/modals/settings-modal.stories.tsx4
-rw-r--r--src/components/organisms/modals/settings-modal.test.tsx4
-rw-r--r--src/components/organisms/modals/settings-modal.tsx60
-rw-r--r--src/components/organisms/toolbar/main-nav.stories.tsx2
-rw-r--r--src/components/organisms/toolbar/main-nav.tsx6
-rw-r--r--src/components/organisms/toolbar/search.stories.tsx2
-rw-r--r--src/components/organisms/toolbar/search.tsx6
-rw-r--r--src/components/organisms/toolbar/settings.stories.tsx2
-rw-r--r--src/components/organisms/toolbar/settings.tsx19
-rw-r--r--src/components/organisms/toolbar/toolbar.tsx1
-rw-r--r--src/components/templates/page/page-layout.tsx14
-rw-r--r--src/i18n/en.json12
-rw-r--r--src/i18n/fr.json12
-rw-r--r--src/pages/404.tsx2
-rw-r--r--src/pages/contact.tsx16
177 files changed, 3213 insertions, 4085 deletions
diff --git a/.cspell/project-words.txt b/.cspell/project-words.txt
index 7c8eb22..9e5b1c3 100644
--- a/.cspell/project-words.txt
+++ b/.cspell/project-words.txt
@@ -16,3 +16,4 @@ rehype
SAMEORIGIN
stylelint
svgr
+thematics
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/molecules/modals/modal.test.tsx b/src/components/atoms/modal/modal.test.tsx
index 5d55b3d..5f32d02 100644
--- a/src/components/molecules/modals/modal.test.tsx
+++ b/src/components/atoms/modal/modal.test.tsx
@@ -1,4 +1,5 @@
import { render, screen } from '../../../../tests/utils';
+import { Heading } from '../headings';
import { Modal } from './modal';
const title = 'A custom title';
@@ -7,12 +8,18 @@ const children =
describe('Modal', () => {
it('renders a title', () => {
- render(<Modal title={title}>{children}</Modal>);
- expect(screen.getByText(title)).toBeInTheDocument();
+ 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 title={title}>{children}</Modal>);
+ 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);
diff --git a/src/components/molecules/forms/ackee-toggle.module.scss b/src/components/molecules/forms/ackee-toggle.module.scss
deleted file mode 100644
index f238bda..0000000
--- a/src/components/molecules/forms/ackee-toggle.module.scss
+++ /dev/null
@@ -1,6 +0,0 @@
-.wrapper {
- display: flex;
- flex-flow: row wrap;
- align-items: center;
- position: relative;
-}
diff --git a/src/components/molecules/forms/ackee-toggle.stories.tsx b/src/components/molecules/forms/ackee-toggle.stories.tsx
deleted file mode 100644
index 779f49d..0000000
--- a/src/components/molecules/forms/ackee-toggle.stories.tsx
+++ /dev/null
@@ -1,125 +0,0 @@
-import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { AckeeToggle } from './ackee-toggle';
-import { storageKey } from './ackee-toggle.fixture';
-
-/**
- * AckeeToggle - Storybook Meta
- */
-export default {
- title: 'Molecules/Forms/Toggle',
- component: AckeeToggle,
- argTypes: {
- bodyClassName: {
- control: {
- type: 'text',
- },
- description: 'Set additional classnames to the fieldset body wrapper.',
- table: {
- category: 'Styles',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- buttonClassName: {
- control: {
- type: 'text',
- },
- description: 'Set additional classnames to the help button.',
- table: {
- category: 'Styles',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- className: {
- control: {
- type: 'text',
- },
- description: 'Set additional classnames to the toggle wrapper.',
- table: {
- category: 'Styles',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- defaultValue: {
- control: {
- type: 'select',
- },
- description: 'Set the default value.',
- options: ['full', 'partial'],
- type: {
- name: 'string',
- required: true,
- },
- },
- groupClassName: {
- control: {
- type: 'text',
- },
- description: 'Set additional classnames to the radio group wrapper.',
- table: {
- category: 'Styles',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- legendClassName: {
- control: {
- type: 'text',
- },
- description: 'Set additional classnames to the legend.',
- table: {
- category: 'Styles',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- storageKey: {
- control: {
- type: 'text',
- },
- description: 'Set local storage key.',
- type: {
- name: 'string',
- required: true,
- },
- },
- tooltipClassName: {
- control: {
- type: 'text',
- },
- description: 'Set additional classnames to the tooltip wrapper.',
- table: {
- category: 'Styles',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- },
-} as ComponentMeta<typeof AckeeToggle>;
-
-const Template: ComponentStory<typeof AckeeToggle> = (args) => (
- <AckeeToggle {...args} />
-);
-
-/**
- * Toggle Stories - Ackee
- */
-export const Ackee = Template.bind({});
-Ackee.args = {
- defaultValue: 'full',
- storageKey,
-};
diff --git a/src/components/molecules/forms/ackee-toggle.tsx b/src/components/molecules/forms/ackee-toggle.tsx
deleted file mode 100644
index 32949b2..0000000
--- a/src/components/molecules/forms/ackee-toggle.tsx
+++ /dev/null
@@ -1,147 +0,0 @@
-import { FC } from 'react';
-import { useIntl } from 'react-intl';
-import {
- type AckeeOptions,
- useLocalStorage,
- useUpdateAckeeOptions,
-} from '../../../utils/hooks';
-import {
- RadioGroup,
- type RadioGroupCallback,
- type RadioGroupCallbackProps,
- type RadioGroupOption,
- type RadioGroupProps,
-} from './radio-group';
-import { Tooltip, type TooltipProps } from '../modals/tooltip';
-
-export type AckeeToggleProps = Pick<
- RadioGroupProps,
- | 'bodyClassName'
- | 'buttonClassName'
- | 'groupClassName'
- | 'legendClassName'
- | 'legendPosition'
-> & {
- /**
- * Set additional classnames to the toggle wrapper.
- */
- className?: string;
- /**
- * True if motion should be reduced by default.
- */
- defaultValue: AckeeOptions;
- /**
- * The local storage key to save preference.
- */
- storageKey: string;
- /**
- * Set additional classnames to the tooltip wrapper.
- */
- tooltipClassName?: TooltipProps['className'];
-};
-
-/**
- * AckeeToggle component
- *
- * Render a Toggle component to set reduce motion.
- */
-export const AckeeToggle: FC<AckeeToggleProps> = ({
- defaultValue,
- storageKey,
- tooltipClassName,
- ...props
-}) => {
- const intl = useIntl();
- const { value, setValue } = useLocalStorage<AckeeOptions>(
- storageKey,
- defaultValue
- );
- useUpdateAckeeOptions(value);
-
- const ackeeLabel = intl.formatMessage({
- defaultMessage: 'Tracking:',
- description: 'AckeeToggle: select label',
- id: '0gVlI3',
- });
- const tooltipTitle = intl.formatMessage({
- defaultMessage: 'Ackee tracking (analytics)',
- description: 'AckeeToggle: tooltip title',
- id: 'nGss/j',
- });
- const tooltipContent = [
- intl.formatMessage({
- defaultMessage: 'Partial includes only page url, views and duration.',
- description: 'AckeeToggle: tooltip message',
- id: 'ZB/Aw2',
- }),
- intl.formatMessage({
- defaultMessage:
- 'Full includes all information from partial as well as information about referrer, operating system, device, browser, screen size and language.',
- description: 'AckeeToggle: tooltip message',
- id: '7zDlQo',
- }),
- ];
- const partialLabel = intl.formatMessage({
- defaultMessage: 'Partial',
- description: 'AckeeToggle: partial option name',
- id: 'tIZYpD',
- });
- const fullLabel = intl.formatMessage({
- defaultMessage: 'Full',
- description: 'AckeeToggle: full option name',
- id: '5eD6y2',
- });
-
- const options: RadioGroupOption[] = [
- {
- id: 'ackee-full',
- label: fullLabel,
- name: 'ackee',
- value: 'full',
- },
- {
- id: 'ackee-partial',
- label: partialLabel,
- name: 'ackee',
- value: 'partial',
- },
- ];
-
- /**
- * Handle change events.
- *
- * @param {RadioGroupCallbackProps} props - An object with choices.
- */
- const handleChange: RadioGroupCallback = ({
- choices,
- updateChoice,
- }: RadioGroupCallbackProps) => {
- let newChoice: AckeeOptions = choices.new as AckeeOptions;
-
- if (choices.new === choices.prev) {
- newChoice = choices.new === 'full' ? 'partial' : 'full';
- updateChoice(newChoice);
- }
-
- setValue(newChoice);
- };
-
- return (
- <RadioGroup
- {...props}
- kind="toggle"
- initialChoice={value}
- legend={ackeeLabel}
- onChange={handleChange}
- options={options}
- Tooltip={
- <Tooltip
- title={tooltipTitle}
- content={tooltipContent}
- icon="?"
- className={tooltipClassName}
- />
- }
- />
- );
-};
diff --git a/src/components/molecules/forms/fieldset.fixture.tsx b/src/components/molecules/forms/fieldset.fixture.tsx
deleted file mode 100644
index b94f340..0000000
--- a/src/components/molecules/forms/fieldset.fixture.tsx
+++ /dev/null
@@ -1,6 +0,0 @@
-import { TooltipProps } from '../modals/tooltip';
-import { Help } from '../modals/tooltip.stories';
-
-export const body = 'doloribus magni aut';
-export const legend = 'maiores autem est';
-export const Tooltip = <Help {...(Help.args as TooltipProps)} />;
diff --git a/src/components/molecules/forms/fieldset.module.scss b/src/components/molecules/forms/fieldset.module.scss
deleted file mode 100644
index 38327b4..0000000
--- a/src/components/molecules/forms/fieldset.module.scss
+++ /dev/null
@@ -1,61 +0,0 @@
-.legend {
- float: left;
- color: var(--color-primary-darker);
- font-size: var(--font-size-md);
- font-weight: 600;
-
- &#{&}--has-tooltip {
- margin: 0 var(--spacing-2xs) 0 0;
- }
-}
-
-.btn {
- margin: 0 var(--spacing-xs) var(--spacing-2xs) 0;
-
- &--activated {
- background: var(--color-primary);
-
- * {
- color: var(--color-fg-inverted);
- }
- }
-}
-
-.tooltip {
- top: calc(100% + var(--spacing-xs));
- transform-origin: top;
- transition: all 0.75s ease-in-out 0s;
-
- &--hidden {
- flex: 0 0 0;
- opacity: 0;
- visibility: hidden;
- transform: scale(0);
- }
-
- &--visible {
- opacity: 1;
- visibility: visible;
- transform: scale(1);
- }
-}
-
-.wrapper {
- display: flex;
- flex-flow: row wrap;
- align-items: center;
- max-width: 100%;
- padding: 0;
- position: relative;
- border: none;
-
- &--stacked {
- .body {
- flex: 1 0 100%;
- }
- }
-
- .tooltip {
- position: absolute;
- }
-}
diff --git a/src/components/molecules/forms/fieldset.stories.tsx b/src/components/molecules/forms/fieldset.stories.tsx
deleted file mode 100644
index d53a21a..0000000
--- a/src/components/molecules/forms/fieldset.stories.tsx
+++ /dev/null
@@ -1,176 +0,0 @@
-import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { Fieldset as FieldsetComponent } from './fieldset';
-import { body, legend, Tooltip } from './fieldset.fixture';
-
-/**
- * Fieldset - Storybook Meta
- */
-export default {
- title: 'Molecules/Forms/Fieldset',
- component: FieldsetComponent,
- args: {
- legendPosition: 'stacked',
- role: 'group',
- },
- argTypes: {
- bodyClassName: {
- control: {
- type: 'text',
- },
- description: 'Set additional classnames to the body wrapper.',
- table: {
- category: 'Styles',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- buttonClassName: {
- control: {
- type: 'text',
- },
- description: 'Set additional classnames to the help button.',
- table: {
- category: 'Styles',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- children: {
- control: {
- type: null,
- },
- description: 'The fieldset body.',
- type: {
- name: 'string',
- required: true,
- },
- },
- className: {
- control: {
- type: 'text',
- },
- description: 'Set additional classnames to the fieldset.',
- table: {
- category: 'Styles',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- legend: {
- control: {
- type: 'text',
- },
- description: 'The fieldset legend.',
- type: {
- name: 'string',
- required: true,
- },
- },
- legendClassName: {
- control: {
- type: 'text',
- },
- description: 'Set additional classnames to the legend.',
- table: {
- category: 'Styles',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- legendPosition: {
- control: {
- type: 'select',
- },
- description: 'Determine the legend position.',
- options: ['inline', 'stacked'],
- table: {
- category: 'Options',
- defaultValue: { summary: 'inline' },
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- role: {
- control: {
- type: 'select',
- },
- description: 'An accessible role.',
- table: {
- category: 'Accessibility',
- defaultValue: { summary: 'group' },
- },
- options: ['group', 'radiogroup', 'presentation', 'none'],
- type: {
- name: 'string',
- required: false,
- },
- },
- Tooltip: {
- control: {
- type: null,
- },
- description: 'Add an optional tooltip.',
- table: {
- category: 'Options',
- },
- type: {
- name: 'function',
- required: false,
- },
- },
- },
-} as ComponentMeta<typeof FieldsetComponent>;
-
-const Template: ComponentStory<typeof FieldsetComponent> = (args) => (
- <FieldsetComponent {...args} />
-);
-
-/**
- * Fieldset Stories - Stacked legend
- */
-export const StackedLegend = Template.bind({});
-StackedLegend.args = {
- children: body,
- legend: legend,
-};
-
-/**
- * Fieldset Stories - Inlined legend
- */
-export const InlinedLegend = Template.bind({});
-InlinedLegend.args = {
- children: body,
- legend: legend,
- legendPosition: 'inline',
-};
-
-/**
- * Fieldset Stories - Stacked legend with tooltip
- */
-export const StackedLegendWithTooltip = Template.bind({});
-StackedLegendWithTooltip.args = {
- children: body,
- legend: legend,
- Tooltip,
-};
-
-/**
- * Fieldset Stories - Inlined legend with tooltip
- */
-export const InlinedLegendWithTooltip = Template.bind({});
-InlinedLegendWithTooltip.args = {
- children: body,
- legend: legend,
- legendPosition: 'inline',
- Tooltip,
-};
diff --git a/src/components/molecules/forms/fieldset.test.tsx b/src/components/molecules/forms/fieldset.test.tsx
deleted file mode 100644
index f397bcd..0000000
--- a/src/components/molecules/forms/fieldset.test.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import { render, screen } from '../../../../tests/utils';
-import { Fieldset } from './fieldset';
-import { body, legend, Tooltip } from './fieldset.fixture';
-
-describe('Fieldset', () => {
- // Cannot use toBeInTheDocument because of body is not an HTMLElement.
-
- it('renders a legend and a body', () => {
- render(<Fieldset legend={legend}>{body}</Fieldset>);
- expect(screen.findByRole('group', { name: legend })).toBeTruthy();
- expect(screen.findByText(body)).toBeTruthy();
- });
-
- it('renders a button to open a tooltip', () => {
- render(
- <Fieldset legend={legend} Tooltip={Tooltip}>
- {body}
- </Fieldset>
- );
- expect(screen.findByRole('button', { name: /Help/i })).toBeTruthy();
- });
-});
diff --git a/src/components/molecules/forms/fieldset.tsx b/src/components/molecules/forms/fieldset.tsx
deleted file mode 100644
index 7564d14..0000000
--- a/src/components/molecules/forms/fieldset.tsx
+++ /dev/null
@@ -1,118 +0,0 @@
-import {
- cloneElement,
- FC,
- ReactComponentElement,
- ReactNode,
- useRef,
- useState,
-} from 'react';
-import { useOnClickOutside } from '../../../utils/hooks';
-import { HelpButton } from '../buttons';
-import { Tooltip } from '../modals';
-import styles from './fieldset.module.scss';
-
-export type FieldsetProps = {
- /**
- * Set additional classnames to the body wrapper.
- */
- bodyClassName?: string;
- /**
- * Set additional classnames to the help button.
- */
- buttonClassName?: string;
- /**
- * The fieldset body.
- */
- children: ReactNode | ReactNode[];
- /**
- * Set additional classnames to the fieldset wrapper.
- */
- className?: string;
- /**
- * The fieldset legend.
- */
- legend: string;
- /**
- * Set additional classnames to the legend.
- */
- legendClassName?: string;
- /**
- * The legend position.
- *
- * @default 'stacked'
- */
- legendPosition?: 'inline' | 'stacked';
- /**
- * An accessible role.
- *
- * @default 'group'
- */
- role?: 'group' | 'radiogroup' | 'presentation' | 'none';
- /**
- * An optional tooltip component.
- */
- Tooltip?: ReactComponentElement<typeof Tooltip>;
-};
-
-/**
- * Fieldset component
- *
- * Render a fieldset with a legend.
- */
-export const Fieldset: FC<FieldsetProps> = ({
- bodyClassName = '',
- buttonClassName = '',
- children,
- className = '',
- legend,
- legendClassName = '',
- legendPosition = 'stacked',
- Tooltip: TooltipComponent,
- ...props
-}) => {
- const [isTooltipOpened, setIsTooltipOpened] = useState<boolean>(false);
- const buttonRef = useRef<HTMLButtonElement>(null);
- const wrapperModifier = `wrapper--${legendPosition}`;
- const buttonModifier = isTooltipOpened ? styles['btn--activated'] : '';
- const legendModifier =
- TooltipComponent === undefined ? '' : 'legend--has-tooltip';
- const tooltipModifier = isTooltipOpened
- ? 'tooltip--visible'
- : 'tooltip--hidden';
-
- /**
- * Close the tooltip if the target is not the button.
- *
- * @param {Node} target - The event target.
- */
- const closeTooltip = (target: Node) => {
- if (buttonRef.current && !buttonRef.current.contains(target)) {
- setIsTooltipOpened(false);
- }
- };
-
- const tooltipRef = useOnClickOutside<HTMLDivElement>(closeTooltip);
- const fieldsetClass = `${styles.wrapper} ${styles[wrapperModifier]} ${className}`;
- const legendClass = `${styles.legend} ${styles[legendModifier]} ${legendClassName}`;
-
- return (
- <fieldset {...props} className={fieldsetClass}>
- <legend className={legendClass}>{legend}</legend>
- {TooltipComponent && (
- <>
- <HelpButton
- aria-pressed={isTooltipOpened}
- className={`${styles.btn} ${buttonModifier} ${buttonClassName}`}
- onClick={() => setIsTooltipOpened(!isTooltipOpened)}
- ref={buttonRef}
- />
- {cloneElement(TooltipComponent, {
- cloneClassName: `${styles.tooltip} ${styles[tooltipModifier]}`,
- ref: tooltipRef,
- })}
- </>
- )}
- <div className={`${styles.body} ${bodyClassName}`}>{children}</div>
- </fieldset>
- );
-};
diff --git a/src/components/molecules/forms/flipping-label.module.scss b/src/components/molecules/forms/flipping-label/flipping-label.module.scss
index b0452fe..88ef3ec 100644
--- a/src/components/molecules/forms/flipping-label.module.scss
+++ b/src/components/molecules/forms/flipping-label/flipping-label.module.scss
@@ -1,4 +1,4 @@
-@use "../../../styles/abstracts/functions" as fun;
+@use "../../../../styles/abstracts/functions" as fun;
.label {
display: block;
diff --git a/src/components/molecules/forms/flipping-label.stories.tsx b/src/components/molecules/forms/flipping-label/flipping-label.stories.tsx
index 7dad3cb..3ad3529 100644
--- a/src/components/molecules/forms/flipping-label.stories.tsx
+++ b/src/components/molecules/forms/flipping-label/flipping-label.stories.tsx
@@ -1,10 +1,10 @@
import { ComponentMeta, ComponentStory } from '@storybook/react';
import { useState } from 'react';
-import { MagnifyingGlass } from '../../atoms';
+import { MagnifyingGlass } from '../../../atoms';
import { FlippingLabel } from './flipping-label';
export default {
- title: 'Organisms/Forms/FlippingLabel',
+ title: 'Molecules/Forms/FlippingLabel',
component: FlippingLabel,
argTypes: {
'aria-label': {
diff --git a/src/components/molecules/forms/flipping-label.test.tsx b/src/components/molecules/forms/flipping-label/flipping-label.test.tsx
index 0f5dd30..7813855 100644
--- a/src/components/molecules/forms/flipping-label.test.tsx
+++ b/src/components/molecules/forms/flipping-label/flipping-label.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen } from '../../../../tests/utils';
+import { render, screen } from '../../../../../tests/utils';
import { FlippingLabel } from './flipping-label';
describe('FlippingLabel', () => {
diff --git a/src/components/molecules/forms/flipping-label.tsx b/src/components/molecules/forms/flipping-label/flipping-label.tsx
index c85642b..3e23915 100644
--- a/src/components/molecules/forms/flipping-label.tsx
+++ b/src/components/molecules/forms/flipping-label/flipping-label.tsx
@@ -1,5 +1,5 @@
import { FC } from 'react';
-import { Close, Label, type LabelProps } from '../../atoms';
+import { Close, Label, type LabelProps } from '../../../atoms';
import styles from './flipping-label.module.scss';
export type FlippingLabelProps = Pick<
diff --git a/src/components/molecules/forms/flipping-label/index.ts b/src/components/molecules/forms/flipping-label/index.ts
new file mode 100644
index 0000000..7b50c75
--- /dev/null
+++ b/src/components/molecules/forms/flipping-label/index.ts
@@ -0,0 +1 @@
+export * from './flipping-label';
diff --git a/src/components/molecules/forms/index.ts b/src/components/molecules/forms/index.ts
index 4d0497d..883a033 100644
--- a/src/components/molecules/forms/index.ts
+++ b/src/components/molecules/forms/index.ts
@@ -1,10 +1,4 @@
-export * from './ackee-toggle';
-export * from './fieldset';
export * from './flipping-label';
-export * from './labelled-boolean-field';
export * from './labelled-field';
-export * from './labelled-select';
-export * from './motion-toggle';
-export * from './prism-theme-toggle';
export * from './radio-group';
-export * from './theme-toggle';
+export * from './switch';
diff --git a/src/components/molecules/forms/labelled-boolean-field.fixture.tsx b/src/components/molecules/forms/labelled-boolean-field.fixture.tsx
deleted file mode 100644
index 6b06887..0000000
--- a/src/components/molecules/forms/labelled-boolean-field.fixture.tsx
+++ /dev/null
@@ -1 +0,0 @@
-export const label = 'Quas et natus';
diff --git a/src/components/molecules/forms/labelled-boolean-field.module.scss b/src/components/molecules/forms/labelled-boolean-field.module.scss
deleted file mode 100644
index 10a9eb2..0000000
--- a/src/components/molecules/forms/labelled-boolean-field.module.scss
+++ /dev/null
@@ -1,15 +0,0 @@
-.label {
- &--visible#{&}--left {
- margin-right: var(--spacing-2xs);
- }
-
- &--visible#{&}--right {
- margin-left: var(--spacing-2xs);
- }
-}
-
-.wrapper {
- display: inline-flex;
- flex-flow: row wrap;
- align-items: center;
-}
diff --git a/src/components/molecules/forms/labelled-boolean-field.stories.tsx b/src/components/molecules/forms/labelled-boolean-field.stories.tsx
deleted file mode 100644
index b1f8194..0000000
--- a/src/components/molecules/forms/labelled-boolean-field.stories.tsx
+++ /dev/null
@@ -1,254 +0,0 @@
-import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { useState } from 'react';
-import { LabelledBooleanField } from './labelled-boolean-field';
-import { label } from './labelled-boolean-field.fixture';
-
-/**
- * LabelledBooleanField - Storybook Meta
- */
-export default {
- title: 'Molecules/Forms/Boolean',
- component: LabelledBooleanField,
- args: {
- checked: false,
- hidden: false,
- label,
- labelSize: 'small',
- },
- argTypes: {
- checked: {
- control: {
- type: null,
- },
- description: 'Should the option be checked?',
- type: {
- name: 'boolean',
- required: true,
- },
- },
- className: {
- control: {
- type: 'text',
- },
- description: 'Set additional classnames to the labelled field wrapper.',
- table: {
- category: 'Styles',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- fieldClassName: {
- control: {
- type: 'text',
- },
- description: 'Set additional classnames to the field.',
- table: {
- category: 'Styles',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- hidden: {
- control: {
- type: 'boolean',
- },
- description: 'Define if the field should be visually hidden.',
- table: {
- category: 'Options',
- defaultValue: { summary: false },
- },
- type: {
- name: 'boolean',
- required: false,
- },
- },
- id: {
- control: {
- type: 'text',
- },
- description: 'The option id.',
- type: {
- name: 'string',
- required: true,
- },
- },
- label: {
- control: {
- type: 'text',
- },
- description: 'The radio label.',
- type: {
- name: 'string',
- required: true,
- },
- },
- labelClassName: {
- control: {
- type: 'text',
- },
- description: 'Set additional classnames to the label.',
- table: {
- category: 'Styles',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- labelPosition: {
- control: {
- type: 'select',
- },
- description: 'Determine the label position.',
- options: ['left', 'right'],
- table: {
- category: 'Options',
- defaultValue: { summary: 'left' },
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- labelSize: {
- control: {
- type: 'select',
- },
- description: 'The label size.',
- options: ['medium', 'small'],
- table: {
- category: 'Options',
- },
- type: {
- name: 'string',
- 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 LabelledBooleanField>;
-
-const Template: ComponentStory<typeof LabelledBooleanField> = ({
- checked,
- onChange: _onChange,
- ...args
-}) => {
- const [isChecked, setIsChecked] = useState<boolean>(checked);
-
- return (
- <LabelledBooleanField
- checked={isChecked}
- onChange={() => {
- setIsChecked(!isChecked);
- }}
- {...args}
- />
- );
-};
-
-/**
- * Labelled Boolean Field Stories - Checkbox with left label
- */
-export const CheckboxLeftLabel = Template.bind({});
-CheckboxLeftLabel.args = {
- id: 'checkbox',
- labelPosition: 'left',
- name: 'checkbox-left-label',
- type: 'checkbox',
- value: 'checkbox',
-};
-
-/**
- * Labelled Boolean Field Stories - Checkbox with right label
- */
-export const CheckboxRightLabel = Template.bind({});
-CheckboxRightLabel.args = {
- id: 'checkbox',
- labelPosition: 'right',
- name: 'checkbox-right-label',
- type: 'checkbox',
-};
-
-/**
- * Labelled Boolean Field Stories - Radio button with left label
- */
-export const RadioButtonLeftLabel = Template.bind({});
-RadioButtonLeftLabel.args = {
- id: 'radio',
- labelPosition: 'left',
- name: 'radio-left-label',
- type: 'radio',
- value: 'radio',
-};
-
-/**
- * Labelled Boolean Field Stories - Radio button with right label
- */
-export const RadioButtonRightLabel = Template.bind({});
-RadioButtonRightLabel.args = {
- id: 'radio',
- labelPosition: 'right',
- name: 'radio-right-label',
- type: 'radio',
- value: 'radio',
-};
diff --git a/src/components/molecules/forms/labelled-boolean-field.test.tsx b/src/components/molecules/forms/labelled-boolean-field.test.tsx
deleted file mode 100644
index 6916f95..0000000
--- a/src/components/molecules/forms/labelled-boolean-field.test.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-import { render, screen } from '../../../../tests/utils';
-import { LabelledBooleanField } from './labelled-boolean-field';
-import { label } from './labelled-boolean-field.fixture';
-
-describe('LabelledBooleanField', () => {
- it('renders a labelled checkbox', () => {
- render(
- <LabelledBooleanField
- checked={true}
- id="jest-checkbox-field"
- label={label}
- name="jest-checkbox-field"
- onChange={() => null}
- type="checkbox"
- value="checkbox"
- />
- );
- expect(screen.getByLabelText(label)).toBeInTheDocument();
- expect(screen.getByRole('checkbox')).toBeChecked();
- });
-
- it('renders a labelled radio option', () => {
- render(
- <LabelledBooleanField
- checked={true}
- id="jest-radio-field"
- label={label}
- name="jest-radio-field"
- onChange={() => null}
- type="radio"
- value="radio"
- />
- );
- expect(screen.getByLabelText(label)).toBeInTheDocument();
- expect(screen.getByRole('radio')).toBeChecked();
- });
-});
diff --git a/src/components/molecules/forms/labelled-boolean-field.tsx b/src/components/molecules/forms/labelled-boolean-field.tsx
deleted file mode 100644
index d110d45..0000000
--- a/src/components/molecules/forms/labelled-boolean-field.tsx
+++ /dev/null
@@ -1,85 +0,0 @@
-import { FC } from 'react';
-import {
- BooleanField,
- type BooleanFieldProps,
- Label,
- type LabelProps,
-} from '../../atoms';
-import styles from './labelled-boolean-field.module.scss';
-
-export type LabelledBooleanFieldProps = Omit<
- BooleanFieldProps,
- 'aria-labelledby' | 'className'
-> & {
- /**
- * Set additional classnames to the labelled field wrapper.
- */
- className?: string;
- /**
- * Set additional classnames to the field.
- */
- fieldClassName?: LabelledBooleanFieldProps['className'];
- /**
- * The field label.
- */
- label: LabelProps['children'];
- /**
- * Set additional classnames to the label.
- */
- labelClassName?: LabelProps['className'];
- /**
- * The label position. Default: left.
- */
- labelPosition?: 'left' | 'right';
- /**
- * The label size.
- */
- labelSize?: LabelProps['size'];
-};
-
-/**
- * LabelledBooleanField component
- *
- * Render a checkbox or radio button with a label.
- */
-export const LabelledBooleanField: FC<LabelledBooleanFieldProps> = ({
- className = '',
- fieldClassName,
- hidden,
- id,
- label,
- labelClassName,
- labelPosition = 'left',
- labelSize,
- ...props
-}) => {
- const labelHiddenModifier = hidden ? 'label--hidden' : 'label--visible';
- const labelPositionModifier = `label--${labelPosition}`;
- const labelClass = `${styles[labelPositionModifier]} ${styles[labelHiddenModifier]} ${labelClassName}`;
-
- return labelPosition === 'left' ? (
- <span className={`${styles.wrapper} ${className}`}>
- <Label className={labelClass} htmlFor={id} size={labelSize}>
- {label}
- </Label>
- <BooleanField
- {...props}
- className={fieldClassName}
- hidden={hidden}
- id={id}
- />
- </span>
- ) : (
- <span className={`${styles.wrapper} ${className}`}>
- <BooleanField
- {...props}
- className={fieldClassName}
- hidden={hidden}
- id={id}
- />
- <Label className={labelClass} htmlFor={id} size={labelSize}>
- {label}
- </Label>
- </span>
- );
-};
diff --git a/src/components/molecules/forms/labelled-field.module.scss b/src/components/molecules/forms/labelled-field.module.scss
deleted file mode 100644
index 64ef3d0..0000000
--- a/src/components/molecules/forms/labelled-field.module.scss
+++ /dev/null
@@ -1,9 +0,0 @@
-.label {
- &--left {
- margin-right: var(--spacing-2xs);
- }
-
- &--top {
- display: block;
- }
-}
diff --git a/src/components/molecules/forms/labelled-field.stories.tsx b/src/components/molecules/forms/labelled-field.stories.tsx
deleted file mode 100644
index 840421b..0000000
--- a/src/components/molecules/forms/labelled-field.stories.tsx
+++ /dev/null
@@ -1,293 +0,0 @@
-import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { useState } from 'react';
-import { LabelledField } from './labelled-field';
-
-/**
- * LabelledField - Storybook Meta
- */
-export default {
- title: 'Molecules/Forms/Field',
- component: LabelledField,
- args: {
- disabled: false,
- hideLabel: false,
- labelPosition: 'top',
- required: 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,
- },
- },
- 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,
- },
- },
- hideLabel: {
- control: {
- type: 'boolean',
- },
- description: 'Visually hide the field label.',
- table: {
- category: 'Options',
- defaultValue: { summary: false },
- },
- type: {
- name: 'boolean',
- required: false,
- },
- },
- label: {
- control: {
- type: 'text',
- },
- description: 'Field label.',
- type: {
- name: 'string',
- required: true,
- },
- },
- labelPosition: {
- control: {
- type: 'select',
- },
- description: 'The label position.',
- options: ['left', 'top'],
- table: {
- category: 'Options',
- defaultValue: { summary: 'top' },
- },
- type: {
- name: 'string',
- 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: '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 LabelledField>;
-
-const Template: ComponentStory<typeof LabelledField> = ({
- value: _value,
- setValue: _setValue,
- ...args
-}) => {
- const [value, setValue] = useState<string>('');
-
- return <LabelledField value={value} setValue={setValue} {...args} />;
-};
-
-/**
- * Labelled Field Stories - Left
- */
-export const Left = Template.bind({});
-Left.args = {
- id: 'labelled-field-storybook',
- label: 'Labelled field',
- labelPosition: 'left',
- name: 'labelled-field-storybook',
-};
-
-/**
- * Labelled Field Stories - Top
- */
-export const Top = Template.bind({});
-Top.args = {
- id: 'labelled-field-storybook',
- label: 'Labelled field',
- labelPosition: 'top',
- name: 'labelled-field-storybook',
-};
-
-/**
- * Labelled Field Stories - Required
- */
-export const Required = Template.bind({});
-Required.args = {
- id: 'labelled-field-storybook',
- label: 'Labelled field',
- name: 'labelled-field-storybook',
- required: true,
-};
-
-/**
- * Labelled Field Stories - Hidden label
- */
-export const HiddenLabel = Template.bind({});
-HiddenLabel.args = {
- hideLabel: true,
- id: 'labelled-field-storybook',
- label: 'Labelled field',
- name: 'labelled-field-storybook',
-};
-
-/**
- * Labelled Field Stories - Disabled
- */
-export const Disabled = Template.bind({});
-Disabled.args = {
- disabled: true,
- id: 'labelled-field-storybook',
- label: 'Labelled field',
- name: 'labelled-field-storybook',
-};
diff --git a/src/components/molecules/forms/labelled-field.test.tsx b/src/components/molecules/forms/labelled-field.test.tsx
deleted file mode 100644
index e16c129..0000000
--- a/src/components/molecules/forms/labelled-field.test.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import { render, screen } from '../../../../tests/utils';
-import { LabelledField } from './labelled-field';
-
-describe('LabelledField', () => {
- it('renders a labelled field', () => {
- render(
- <LabelledField
- type="text"
- id="jest-text-field"
- name="jest-text-field"
- label="Jest text field"
- value="test"
- setValue={() => null}
- />
- );
- expect(screen.getByLabelText('Jest text field')).toBeInTheDocument();
- expect(screen.getByRole('textbox')).toHaveValue('test');
- });
-});
diff --git a/src/components/molecules/forms/labelled-field.tsx b/src/components/molecules/forms/labelled-field.tsx
deleted file mode 100644
index fca1c54..0000000
--- a/src/components/molecules/forms/labelled-field.tsx
+++ /dev/null
@@ -1,49 +0,0 @@
-import { forwardRef, ForwardRefRenderFunction } from 'react';
-import { Field, type FieldProps, Label } from '../../atoms';
-import styles from './labelled-field.module.scss';
-
-export type LabelledFieldProps = FieldProps & {
- /**
- * Visually hide the field label. Default: false.
- */
- hideLabel?: boolean;
- /**
- * The field label.
- */
- label: string;
- /**
- * The label position. Default: top.
- */
- labelPosition?: 'left' | 'top';
-};
-
-const LabelledFieldWithRef: ForwardRefRenderFunction<
- HTMLInputElement,
- LabelledFieldProps
-> = (
- { hideLabel = false, id, label, labelPosition = 'top', required, ...props },
- ref
-) => {
- const positionModifier = `label--${labelPosition}`;
- const visibilityClass = hideLabel ? 'screen-reader-text' : '';
-
- return (
- <>
- <Label
- htmlFor={id}
- required={required}
- className={`${visibilityClass} ${styles[positionModifier]}`}
- >
- {label}
- </Label>
- <Field id={id} ref={ref} required={required} {...props} />
- </>
- );
-};
-
-/**
- * LabelledField component
- *
- * Render a field tied to a label.
- */
-export const LabelledField = forwardRef(LabelledFieldWithRef);
diff --git a/src/components/molecules/forms/labelled-field/index.ts b/src/components/molecules/forms/labelled-field/index.ts
new file mode 100644
index 0000000..b0d9889
--- /dev/null
+++ b/src/components/molecules/forms/labelled-field/index.ts
@@ -0,0 +1 @@
+export * from './labelled-field';
diff --git a/src/components/molecules/forms/labelled-field/labelled-field.module.scss b/src/components/molecules/forms/labelled-field/labelled-field.module.scss
new file mode 100644
index 0000000..bb37dc7
--- /dev/null
+++ b/src/components/molecules/forms/labelled-field/labelled-field.module.scss
@@ -0,0 +1,22 @@
+.wrapper {
+ display: flex;
+ gap: var(--spacing-2xs);
+ width: fit-content;
+
+ &--inline {
+ flex-flow: row wrap;
+ align-items: center;
+ }
+
+ &--inline#{&}--reverse {
+ flex-flow: row-reverse wrap;
+ }
+
+ &--stack {
+ flex-flow: column wrap;
+ }
+
+ &--stack#{&}--reverse {
+ flex-flow: column-reverse wrap;
+ }
+}
diff --git a/src/components/molecules/forms/labelled-field/labelled-field.stories.tsx b/src/components/molecules/forms/labelled-field/labelled-field.stories.tsx
new file mode 100644
index 0000000..1f29830
--- /dev/null
+++ b/src/components/molecules/forms/labelled-field/labelled-field.stories.tsx
@@ -0,0 +1,130 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { ChangeEvent, useState } from 'react';
+import { Input, Label } from '../../../atoms';
+import { LabelledField } from './labelled-field';
+
+/**
+ * LabelledField - Storybook Meta
+ */
+export default {
+ title: 'Molecules/Forms/Field',
+ component: LabelledField,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the field.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ field: {
+ control: {
+ type: null,
+ },
+ description: 'A component: Checkbox, Input, Select, Radio or TextArea.',
+ type: {
+ name: 'function',
+ required: true,
+ },
+ },
+ label: {
+ control: {
+ type: null,
+ },
+ description: 'A Label component.',
+ type: {
+ name: 'function',
+ required: true,
+ },
+ },
+ isInline: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Should the label and the field be inlined?',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: false },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ isReversedOrder: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Should the label and the field be reversed?',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: false },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof LabelledField>;
+
+const Template: ComponentStory<typeof LabelledField> = ({ ...args }) => {
+ const id = 'sunt';
+ const [value, setValue] = useState<string>('');
+ const updateValue = (e: ChangeEvent<HTMLInputElement>) => {
+ setValue(e.target.value);
+ };
+
+ return (
+ <LabelledField
+ {...args}
+ field={
+ <Input
+ id={id}
+ name={id}
+ onChange={updateValue}
+ type="text"
+ value={value}
+ />
+ }
+ label={<Label htmlFor={id}>A label</Label>}
+ />
+ );
+};
+
+/**
+ * Labelled Field Stories - Left
+ */
+export const Left = Template.bind({});
+Left.args = {
+ isInline: true,
+};
+
+/**
+ * Labelled Field Stories - Right
+ */
+export const Right = Template.bind({});
+Right.args = {
+ isInline: true,
+ isReversedOrder: true,
+};
+
+/**
+ * Labelled Field Stories - Top
+ */
+export const Top = Template.bind({});
+Top.args = {};
+
+/**
+ * Labelled Field Stories - Bottom
+ */
+export const Bottom = Template.bind({});
+Bottom.args = {
+ isReversedOrder: true,
+};
diff --git a/src/components/molecules/forms/labelled-field/labelled-field.test.tsx b/src/components/molecules/forms/labelled-field/labelled-field.test.tsx
new file mode 100644
index 0000000..9e39e1f
--- /dev/null
+++ b/src/components/molecules/forms/labelled-field/labelled-field.test.tsx
@@ -0,0 +1,32 @@
+import { render, screen } from '../../../../../tests/utils';
+import { Input, Label } from '../../../atoms';
+import { LabelledField } from './labelled-field';
+
+const doNothing = () => {
+ // Do nothing
+};
+
+describe('LabelledField', () => {
+ it('renders a labelled field', () => {
+ const id = 'enim';
+ const label = 'eum aliquam culpa';
+ const value = 'vitae';
+
+ render(
+ <LabelledField
+ field={
+ <Input
+ id={id}
+ name="text-field"
+ onChange={doNothing}
+ type="text"
+ value={value}
+ />
+ }
+ label={<Label htmlFor={id}>{label}</Label>}
+ />
+ );
+ expect(screen.getByLabelText(label)).toBeInTheDocument();
+ expect(screen.getByRole('textbox')).toHaveValue(value);
+ });
+});
diff --git a/src/components/molecules/forms/labelled-field/labelled-field.tsx b/src/components/molecules/forms/labelled-field/labelled-field.tsx
new file mode 100644
index 0000000..af492b3
--- /dev/null
+++ b/src/components/molecules/forms/labelled-field/labelled-field.tsx
@@ -0,0 +1,63 @@
+import { FC, HTMLAttributes, ReactElement } from 'react';
+import {
+ CheckboxProps,
+ InputProps,
+ LabelProps,
+ RadioProps,
+ SelectProps,
+ TextAreaProps,
+} from '../../../atoms';
+import styles from './labelled-field.module.scss';
+
+export type LabelledFieldProps = Omit<
+ HTMLAttributes<HTMLDivElement>,
+ 'children'
+> & {
+ /**
+ * The field.
+ */
+ field: ReactElement<
+ CheckboxProps | InputProps | RadioProps | SelectProps | TextAreaProps
+ >;
+ /**
+ * Should the label and the field be inlined?
+ *
+ * @default false
+ */
+ isInline?: boolean;
+ /**
+ * If true, the label is displayed after the field.
+ *
+ * @default false
+ */
+ isReversedOrder?: boolean;
+ /**
+ * The field label.
+ */
+ label: ReactElement<LabelProps>;
+};
+
+/**
+ * LabelledField component
+ *
+ * Render a field tied to a label.
+ */
+export const LabelledField: FC<LabelledFieldProps> = ({
+ className = '',
+ field,
+ isInline = false,
+ isReversedOrder = false,
+ label,
+ ...props
+}) => {
+ const layoutClass = isInline ? 'wrapper--inline' : 'wrapper--stack';
+ const orderClass = isReversedOrder ? 'wrapper--reverse' : '';
+ const wrapperClass = `${styles.wrapper} ${styles[layoutClass]} ${styles[orderClass]} ${className}`;
+
+ return (
+ <div {...props} className={wrapperClass}>
+ {label}
+ {field}
+ </div>
+ );
+};
diff --git a/src/components/molecules/forms/labelled-select.module.scss b/src/components/molecules/forms/labelled-select.module.scss
deleted file mode 100644
index 64ef3d0..0000000
--- a/src/components/molecules/forms/labelled-select.module.scss
+++ /dev/null
@@ -1,9 +0,0 @@
-.label {
- &--left {
- margin-right: var(--spacing-2xs);
- }
-
- &--top {
- display: block;
- }
-}
diff --git a/src/components/molecules/forms/labelled-select.stories.tsx b/src/components/molecules/forms/labelled-select.stories.tsx
deleted file mode 100644
index d6029a5..0000000
--- a/src/components/molecules/forms/labelled-select.stories.tsx
+++ /dev/null
@@ -1,236 +0,0 @@
-import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { useState } from 'react';
-import { LabelledSelect } from './labelled-select';
-
-const selectOptions = [
- { id: 'option1', name: 'Option 1', value: 'option1' },
- { id: 'option2', name: 'Option 2', value: 'option2' },
- { id: 'option3', name: 'Option 3', value: 'option3' },
-];
-
-/**
- * LabelledSelect - Storybook Meta
- */
-export default {
- title: 'Molecules/Forms/Select',
- component: LabelledSelect,
- args: {
- disabled: false,
- labelPosition: 'top',
- required: false,
- },
- argTypes: {
- 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,
- },
- },
- label: {
- control: {
- type: 'text',
- },
- description: 'Field label.',
- type: {
- name: 'string',
- required: true,
- },
- },
- labelClassName: {
- control: {
- type: 'text',
- },
- description: 'Set additional classnames to the label.',
- table: {
- category: 'Styles',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- labelPosition: {
- control: {
- type: 'select',
- },
- description: 'The label position.',
- options: ['left', 'top'],
- table: {
- category: 'Options',
- defaultValue: { summary: 'top' },
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- labelSize: {
- control: {
- type: 'select',
- },
- description: 'The label size.',
- options: ['medium', 'small'],
- table: {
- category: 'Options',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- name: {
- control: {
- type: 'text',
- },
- description: 'Field name.',
- type: {
- name: 'string',
- required: true,
- },
- },
- options: {
- control: {
- type: null,
- },
- 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,
- },
- },
- selectClassName: {
- control: {
- type: 'text',
- },
- description: 'Set additional classnames to the select field.',
- table: {
- category: 'Styles',
- },
- type: {
- name: 'string',
- 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 LabelledSelect>;
-
-const Template: ComponentStory<typeof LabelledSelect> = ({
- value,
- setValue: _setValue,
- ...args
-}) => {
- const [selected, setSelected] = useState<string>(value);
-
- return <LabelledSelect value={selected} setValue={setSelected} {...args} />;
-};
-
-/**
- * Labelled Select Stories - Left
- */
-export const Left = Template.bind({});
-Left.args = {
- id: 'labelled-select-storybook',
- label: 'Labelled select',
- labelPosition: 'left',
- name: 'labelled-select-storybook',
- options: selectOptions,
- value: 'option1',
-};
-
-/**
- * Labelled Select Stories - Top
- */
-export const Top = Template.bind({});
-Top.args = {
- id: 'labelled-select-storybook',
- label: 'Labelled select',
- labelPosition: 'top',
- name: 'labelled-select-storybook',
- options: selectOptions,
- value: 'option1',
-};
-
-/**
- * Labelled Select Stories - Disabled
- */
-export const Disabled = Template.bind({});
-Disabled.args = {
- disabled: true,
- id: 'labelled-select-storybook',
- label: 'Labelled select',
- name: 'labelled-select-storybook',
- options: selectOptions,
- value: 'option1',
-};
-
-/**
- * Labelled Select Stories - Required
- */
-export const Required = Template.bind({});
-Required.args = {
- id: 'labelled-select-storybook',
- label: 'Labelled select',
- labelPosition: 'top',
- name: 'labelled-select-storybook',
- options: selectOptions,
- required: true,
- value: 'option1',
-};
diff --git a/src/components/molecules/forms/labelled-select.test.tsx b/src/components/molecules/forms/labelled-select.test.tsx
deleted file mode 100644
index 1ef4a25..0000000
--- a/src/components/molecules/forms/labelled-select.test.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import { render, screen } from '../../../../tests/utils';
-import { LabelledSelect } from './labelled-select';
-
-const selectOptions = [
- { id: 'option1', name: 'Option 1', value: 'option1' },
- { id: 'option2', name: 'Option 2', value: 'option2' },
- { id: 'option3', name: 'Option 3', value: 'option3' },
-];
-
-describe('LabelledSelect', () => {
- it('renders a labelled select', () => {
- render(
- <LabelledSelect
- id="jest-select-field"
- name="jest-select-field"
- label="Jest select field"
- options={selectOptions}
- value="option1"
- setValue={() => null}
- />
- );
- expect(screen.getByLabelText('Jest select field')).toBeInTheDocument();
- expect(screen.getByRole('combobox')).toHaveValue('option1');
- });
-});
diff --git a/src/components/molecules/forms/labelled-select.tsx b/src/components/molecules/forms/labelled-select.tsx
deleted file mode 100644
index 300ae8a..0000000
--- a/src/components/molecules/forms/labelled-select.tsx
+++ /dev/null
@@ -1,66 +0,0 @@
-import { FC } from 'react';
-import { Label, type LabelProps, Select, type SelectProps } from '../../atoms';
-import styles from './labelled-select.module.scss';
-
-export type LabelledSelectProps = Omit<
- SelectProps,
- 'aria-labelledby' | 'className'
-> & {
- /**
- * The field label.
- */
- label: string;
- /**
- * Set additional classnames to the label.
- */
- labelClassName?: LabelProps['className'];
- /**
- * The label position. Default: top.
- */
- labelPosition?: 'left' | 'top';
- /**
- * The label size.
- */
- labelSize?: LabelProps['size'];
- /**
- * Set additional classnames to the select field.
- */
- selectClassName?: SelectProps['className'];
-};
-
-/**
- * LabelledSelect component
- *
- * Render a select with a label.
- */
-export const LabelledSelect: FC<LabelledSelectProps> = ({
- id,
- label,
- labelClassName = '',
- labelPosition = 'top',
- labelSize,
- required,
- selectClassName = '',
- ...props
-}) => {
- const positionModifier = `label--${labelPosition}`;
-
- return (
- <>
- <Label
- className={`${styles[positionModifier]} ${labelClassName}`}
- htmlFor={id}
- required={required}
- size={labelSize}
- >
- {label}
- </Label>
- <Select
- {...props}
- className={selectClassName}
- id={id}
- required={required}
- />
- </>
- );
-};
diff --git a/src/components/molecules/forms/prism-theme-toggle.stories.tsx b/src/components/molecules/forms/prism-theme-toggle.stories.tsx
deleted file mode 100644
index bf0f2df..0000000
--- a/src/components/molecules/forms/prism-theme-toggle.stories.tsx
+++ /dev/null
@@ -1,60 +0,0 @@
-import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { PrismThemeToggle } from './prism-theme-toggle';
-
-/**
- * PrismThemeToggle - Storybook Meta
- */
-export default {
- title: 'Molecules/Forms/Toggle',
- component: PrismThemeToggle,
- argTypes: {
- bodyClassName: {
- control: {
- type: 'text',
- },
- description: 'Set additional classnames to the fieldset body wrapper.',
- table: {
- category: 'Styles',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- groupClassName: {
- control: {
- type: 'text',
- },
- description: 'Set additional classnames to the radio group wrapper.',
- table: {
- category: 'Styles',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- legendClassName: {
- control: {
- type: 'text',
- },
- description: 'Set additional classnames to the legend.',
- table: {
- category: 'Styles',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- },
-} as ComponentMeta<typeof PrismThemeToggle>;
-
-const Template: ComponentStory<typeof PrismThemeToggle> = (args) => (
- <PrismThemeToggle {...args} />
-);
-
-/**
- * Toggle Stories - Prism theme
- */
-export const PrismTheme = Template.bind({});
diff --git a/src/components/molecules/forms/radio-group.module.scss b/src/components/molecules/forms/radio-group.module.scss
deleted file mode 100644
index cadff46..0000000
--- a/src/components/molecules/forms/radio-group.module.scss
+++ /dev/null
@@ -1,112 +0,0 @@
-@use "../../../styles/abstracts/functions" as fun;
-@use "../../../styles/abstracts/mixins" as mix;
-
-.wrapper {
- &--inline {
- .legend {
- margin-right: var(--spacing-2xs);
- }
- }
-
- &--regular {
- .option {
- &:not(:last-of-type) {
- margin-right: var(--spacing-xs);
- }
- }
- }
-}
-
-.toggle {
- display: inline-flex;
- flex-flow: row wrap;
- align-items: center;
- width: fit-content;
- position: relative;
- background: var(--color-shadow-light);
- border: fun.convert-px(2) solid var(--color-primary);
- border-radius: fun.convert-px(32);
- box-shadow: inset 0 0 fun.convert-px(1) fun.convert-px(1)
- var(--color-shadow-dark),
- inset 0 0 fun.convert-px(3) fun.convert-px(2) var(--color-shadow);
-
- .label {
- display: flex;
- align-items: center;
- min-height: 5ex;
- padding: fun.convert-px(6) var(--spacing-2xs);
- border-top: fun.convert-px(2) solid var(--color-border);
- border-bottom: fun.convert-px(2) solid var(--color-border);
- transition: all 0.15s linear 0s;
-
- @include mix.pointer("fine") {
- min-height: 3ex;
- }
- }
-
- &:focus-within {
- outline: fun.convert-px(2) solid var(--color-primary-light);
- }
-
- .option:first-of-type {
- .label {
- border-left: fun.convert-px(2) solid var(--color-border);
- border-top-left-radius: fun.convert-px(32);
- border-bottom-left-radius: fun.convert-px(32);
- }
- }
-
- .option:last-of-type {
- .label {
- border-right: fun.convert-px(2) solid var(--color-border);
- border-top-right-radius: fun.convert-px(32);
- border-bottom-right-radius: fun.convert-px(32);
- }
- }
-
- .radio {
- &:checked + .label {
- background: var(--color-primary);
- box-shadow: inset 0 0 fun.convert-px(1) fun.convert-px(2)
- var(--color-primary-dark),
- inset 0 0 fun.convert-px(3) fun.convert-px(2)
- var(--color-primary-darker);
- color: var(--color-fg-inverted);
-
- svg {
- fill: var(--color-fg-inverted);
- stroke: var(--color-fg-inverted);
- }
- }
-
- &:not(:checked) + .label {
- svg {
- fill: var(--color-primary-darker);
- }
- }
-
- &:checked + .label:hover {
- background: var(--color-primary-lighter);
- box-shadow: inset 0 0 fun.convert-px(1) fun.convert-px(2)
- var(--color-primary-light),
- inset 0 0 fun.convert-px(3) fun.convert-px(2) var(--color-primary);
- }
-
- &:not(:checked) + .label:hover {
- background: var(--color-shadow-light);
- box-shadow: inset 0 0 0 fun.convert-px(1) var(--color-shadow-dark),
- inset 0 0 fun.convert-px(3) fun.convert-px(2) var(--color-shadow);
- }
-
- &:not(:checked):focus + .label {
- background: var(--color-shadow-light);
- }
-
- &:checked:focus + .label {
- background: var(--color-primary-lighter);
- box-shadow: inset 0 0 fun.convert-px(1) fun.convert-px(2)
- var(--color-primary-light),
- inset 0 0 fun.convert-px(3) fun.convert-px(2) var(--color-primary);
- }
- }
-}
diff --git a/src/components/molecules/forms/radio-group.stories.tsx b/src/components/molecules/forms/radio-group.stories.tsx
deleted file mode 100644
index b727e28..0000000
--- a/src/components/molecules/forms/radio-group.stories.tsx
+++ /dev/null
@@ -1,285 +0,0 @@
-import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { RadioGroup } from './radio-group';
-import { getOptions, initialChoice, legend } from './radio-group.fixture';
-
-/**
- * RadioGroup - Storybook Meta
- */
-export default {
- title: 'Molecules/Forms/RadioGroup',
- component: RadioGroup,
- args: {
- kind: 'regular',
- labelSize: 'small',
- },
- argTypes: {
- bodyClassName: {
- control: {
- type: 'text',
- },
- description: 'Set additional classnames to the fieldset body wrapper.',
- table: {
- category: 'Styles',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- buttonClassName: {
- control: {
- type: 'text',
- },
- description: 'Set additional classnames to the help button.',
- table: {
- category: 'Styles',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- className: {
- control: {
- type: 'text',
- },
- description: 'Set additional classnames to the fieldset.',
- table: {
- category: 'Styles',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- groupClassName: {
- control: {
- type: 'text',
- },
- description: 'Set additional classnames to the radio group wrapper.',
- table: {
- category: 'Styles',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- initialChoice: {
- control: {
- type: 'text',
- },
- description: 'The default selected option id.',
- type: {
- name: 'string',
- required: true,
- },
- },
- kind: {
- control: {
- type: 'select',
- },
- description: 'The radio group kind.',
- options: ['regular', 'toggle'],
- table: {
- category: 'Options',
- defaultValue: { summary: 'regular' },
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- labelPosition: {
- control: {
- type: 'select',
- },
- description: 'Determine the label position.',
- options: ['left', 'right'],
- table: {
- category: 'Options',
- defaultValue: { summary: 'left' },
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- labelSize: {
- control: {
- type: 'select',
- },
- description: 'The label size.',
- options: ['medium', 'small'],
- table: {
- category: 'Options',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- legend: {
- control: {
- type: 'text',
- },
- description: 'The fieldset legend.',
- type: {
- name: 'string',
- required: true,
- },
- },
- legendClassName: {
- control: {
- type: 'text',
- },
- description: 'Set additional classnames to the legend.',
- table: {
- category: 'Styles',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- legendPosition: {
- control: {
- type: 'select',
- },
- description: 'Determine the legend position.',
- options: ['inline', 'stacked'],
- table: {
- category: 'Options',
- defaultValue: { summary: 'inline' },
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- onChange: {
- control: {
- type: null,
- },
- description: 'A callback function to handle selected option change.',
- table: {
- category: 'Events',
- },
- type: {
- name: 'function',
- required: false,
- },
- },
- onClick: {
- control: {
- type: null,
- },
- description: 'A callback function to handle click on a choice.',
- table: {
- category: 'Events',
- },
- type: {
- name: 'function',
- required: false,
- },
- },
- optionClassName: {
- control: {
- type: 'text',
- },
- description: 'Set additional classnames to the option wrapper.',
- table: {
- category: 'Styles',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- options: {
- description: 'An array of radio option object.',
- type: {
- name: 'object',
- required: true,
- value: {},
- },
- },
- Tooltip: {
- control: {
- type: null,
- },
- description: 'Add an optional tooltip.',
- table: {
- category: 'Options',
- },
- type: {
- name: 'function',
- required: false,
- },
- },
- },
-} as ComponentMeta<typeof RadioGroup>;
-
-const Template: ComponentStory<typeof RadioGroup> = (args) => (
- <RadioGroup {...args} />
-);
-
-/**
- * Radio Group Stories - Inlined legend & left label
- */
-export const InlinedLegendLeftLabel = Template.bind({});
-InlinedLegendLeftLabel.args = {
- initialChoice: initialChoice,
- labelPosition: 'left',
- legend: legend,
- legendPosition: 'inline',
- options: getOptions('group1'),
-};
-
-/**
- * Radio Group Stories - Inlined legend & left label
- */
-export const InlinedLegendRightLabel = Template.bind({});
-InlinedLegendRightLabel.args = {
- initialChoice: initialChoice,
- labelPosition: 'right',
- legend: legend,
- legendPosition: 'inline',
- options: getOptions('group2'),
-};
-
-/**
- * Radio Group Stories - Stacked legend & left label
- */
-export const StackedLegendLeftLabel = Template.bind({});
-StackedLegendLeftLabel.args = {
- initialChoice: initialChoice,
- labelPosition: 'left',
- legend: legend,
- legendPosition: 'stacked',
- options: getOptions('group3'),
-};
-
-/**
- * Radio Group Stories - Stacked legend & left label
- */
-export const StackedLegendRightLabel = Template.bind({});
-StackedLegendRightLabel.args = {
- initialChoice: initialChoice,
- labelPosition: 'right',
- legend: legend,
- legendPosition: 'stacked',
- options: getOptions('group4'),
-};
-
-/**
- * Radio Group Stories - Toggle
- */
-export const Toggle = Template.bind({});
-Toggle.args = {
- initialChoice: initialChoice,
- kind: 'toggle',
- labelPosition: 'right',
- legend: legend,
- options: getOptions('group5'),
-};
diff --git a/src/components/molecules/forms/radio-group.test.tsx b/src/components/molecules/forms/radio-group.test.tsx
deleted file mode 100644
index c4a01e4..0000000
--- a/src/components/molecules/forms/radio-group.test.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-import { render, screen } from '../../../../tests/utils';
-import { RadioGroup } from './radio-group';
-import { getOptions, initialChoice, legend } from './radio-group.fixture';
-
-describe('RadioGroup', () => {
- it('renders a legend', () => {
- render(
- <RadioGroup
- initialChoice={initialChoice}
- legend={legend}
- options={getOptions()}
- />
- );
- expect(screen.findByRole('radiogroup', { name: legend })).toBeDefined();
- });
-
- it('renders the correct number of radio', () => {
- const options = getOptions();
-
- render(
- <RadioGroup
- initialChoice={initialChoice}
- legend={legend}
- options={options}
- />
- );
- const radios = screen.getAllByRole('radio');
- expect(radios).toHaveLength(options.length);
- });
-});
diff --git a/src/components/molecules/forms/radio-group.tsx b/src/components/molecules/forms/radio-group.tsx
deleted file mode 100644
index a747395..0000000
--- a/src/components/molecules/forms/radio-group.tsx
+++ /dev/null
@@ -1,157 +0,0 @@
-import { ChangeEvent, FC, MouseEvent, SetStateAction } from 'react';
-import { useStateChange } from '../../../utils/hooks';
-import { Fieldset, type FieldsetProps } from './fieldset';
-import {
- LabelledBooleanField,
- type LabelledBooleanFieldProps,
-} from './labelled-boolean-field';
-import styles from './radio-group.module.scss';
-
-export type RadioGroupCallbackProps = {
- choices: {
- new: string;
- prev: string;
- };
- updateChoice: (value: SetStateAction<string>) => void;
-};
-
-export type RadioGroupCallback = (props: RadioGroupCallbackProps) => void;
-
-export type RadioGroupOption = Pick<
- LabelledBooleanFieldProps,
- 'id' | 'label' | 'name' | 'value'
->;
-
-export type RadioGroupProps = Pick<
- FieldsetProps,
- | 'bodyClassName'
- | 'buttonClassName'
- | 'className'
- | 'legend'
- | 'legendClassName'
- | 'Tooltip'
-> &
- Pick<LabelledBooleanFieldProps, 'labelPosition' | 'labelSize'> & {
- /**
- * Set additional classnames to the radio group wrapper when kind is toggle.
- */
- groupClassName?: string;
- /**
- * The default option value.
- */
- initialChoice: string;
- /**
- * The radio group kind.
- *
- * @default 'regular
- */
- kind?: 'regular' | 'toggle';
- /**
- * The legend position.
- *
- * @default 'inline'
- */
- legendPosition?: FieldsetProps['legendPosition'];
- /**
- * A callback function to execute when choice is changed.
- */
- onChange?: RadioGroupCallback;
- /**
- * A callback function to execute when clicking on a choice.
- */
- onClick?: RadioGroupCallback;
- /**
- * Set additional classnames to the labelled field wrapper.
- */
- optionClassName?: string;
- /**
- * The options.
- */
- options: RadioGroupOption[];
- };
-
-/**
- * RadioGroup component
- *
- * Render a group of labelled radio buttons.
- */
-export const RadioGroup: FC<RadioGroupProps> = ({
- className,
- groupClassName = '',
- initialChoice,
- kind = 'regular',
- labelPosition,
- labelSize,
- legendClassName,
- legendPosition = 'inline',
- onChange,
- optionClassName = '',
- options,
- ...props
-}) => {
- const [selectedChoice, setSelectedChoice] =
- useStateChange<string>(initialChoice);
- const isToggle = kind === 'toggle';
- const alignmentModifier = `wrapper--${legendPosition}`;
- const toggleModifier = isToggle ? 'wrapper--toggle' : 'wrapper--regular';
- const fieldsetClass = `${styles.wrapper} ${styles[alignmentModifier]} ${styles[toggleModifier]} ${className}`;
-
- /**
- * Update the selected choice on click or change event.
- */
- const updateChoice = (
- e:
- | ChangeEvent<HTMLInputElement>
- | MouseEvent<HTMLInputElement, globalThis.MouseEvent>
- ) => {
- const input = e.target as HTMLInputElement;
- onChange &&
- onChange({
- choices: { new: input.value, prev: selectedChoice },
- updateChoice: setSelectedChoice,
- });
- if (e.type === 'change') setSelectedChoice(input.value);
- };
-
- /**
- * Retrieve an array of radio buttons.
- *
- * @returns {JSX.Element[]} The radio buttons.
- */
- const getOptions = (): JSX.Element[] => {
- return options.map((option) => (
- <LabelledBooleanField
- {...option}
- key={option.id}
- checked={selectedChoice === option.value}
- className={`${styles.option} ${optionClassName}`}
- fieldClassName={styles.radio}
- hidden={isToggle}
- labelClassName={styles.label}
- labelPosition={kind === 'toggle' ? 'right' : labelPosition}
- labelSize={labelSize}
- onChange={updateChoice}
- onClick={updateChoice}
- type="radio"
- />
- ));
- };
-
- return (
- <Fieldset
- {...props}
- className={fieldsetClass}
- legendClassName={`${styles.legend} ${legendClassName}`}
- legendPosition={legendPosition}
- role="radiogroup"
- >
- {isToggle ? (
- <span className={`${styles.toggle} ${groupClassName}`}>
- {getOptions()}
- </span>
- ) : (
- getOptions()
- )}
- </Fieldset>
- );
-};
diff --git a/src/components/molecules/forms/radio-group/index.ts b/src/components/molecules/forms/radio-group/index.ts
new file mode 100644
index 0000000..ed40543
--- /dev/null
+++ b/src/components/molecules/forms/radio-group/index.ts
@@ -0,0 +1 @@
+export * from './radio-group';
diff --git a/src/components/molecules/forms/radio-group.fixture.tsx b/src/components/molecules/forms/radio-group/radio-group.fixture.tsx
index 686467c..f1cbc05 100644
--- a/src/components/molecules/forms/radio-group.fixture.tsx
+++ b/src/components/molecules/forms/radio-group/radio-group.fixture.tsx
@@ -1,4 +1,4 @@
-import { RadioGroupOption } from './radio-group';
+import { RadioGroupItem } from './radio-group';
export const getOptions = (name: string = 'group1') => {
const value1 = 'option1';
@@ -7,34 +7,29 @@ export const getOptions = (name: string = 'group1') => {
const value4 = 'option4';
const value5 = 'option5';
- const options: RadioGroupOption[] = [
+ const options: RadioGroupItem[] = [
{
id: `${name}-${value1}`,
- name: name,
label: 'Option 1',
value: value1,
},
{
id: `${name}-${value2}`,
- name: name,
label: 'Option 2',
value: value2,
},
{
id: `${name}-${value3}`,
- name: name,
label: 'Option 3',
value: value3,
},
{
id: `${name}-${value4}`,
- name: name,
label: 'Option 4',
value: value4,
},
{
id: `${name}-${value5}`,
- name: name,
label: 'Option 5',
value: value5,
},
@@ -44,4 +39,3 @@ export const getOptions = (name: string = 'group1') => {
};
export const initialChoice = 'option2';
-export const legend = 'Options:';
diff --git a/src/components/molecules/forms/radio-group/radio-group.module.scss b/src/components/molecules/forms/radio-group/radio-group.module.scss
new file mode 100644
index 0000000..ad09c78
--- /dev/null
+++ b/src/components/molecules/forms/radio-group/radio-group.module.scss
@@ -0,0 +1,9 @@
+.group {
+ &--inline {
+ .option {
+ &:not(:last-of-type) {
+ margin-right: var(--spacing-xs);
+ }
+ }
+ }
+}
diff --git a/src/components/molecules/forms/radio-group/radio-group.stories.tsx b/src/components/molecules/forms/radio-group/radio-group.stories.tsx
new file mode 100644
index 0000000..8e77c6e
--- /dev/null
+++ b/src/components/molecules/forms/radio-group/radio-group.stories.tsx
@@ -0,0 +1,75 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { Legend } from '../../../atoms';
+import { RadioGroup as RadioGroupComponent } from './radio-group';
+import { getOptions, initialChoice } from './radio-group.fixture';
+import { ChangeEventHandler, useCallback, useState } from 'react';
+
+/**
+ * RadioGroup - Storybook Meta
+ */
+export default {
+ title: 'Molecules/Forms',
+ component: RadioGroupComponent,
+ args: {},
+ argTypes: {
+ onChange: {
+ control: {
+ type: null,
+ },
+ description: 'A callback function to handle selected option change.',
+ table: {
+ category: 'Events',
+ },
+ type: {
+ name: 'function',
+ required: false,
+ },
+ },
+ options: {
+ description: 'An array of radio option object.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ value: {
+ control: {
+ type: 'text',
+ },
+ description: 'The default selected option id.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof RadioGroupComponent>;
+
+const Template: ComponentStory<typeof RadioGroupComponent> = ({
+ value,
+ ...args
+}) => {
+ const [selection, setSelection] = useState(value);
+
+ const handleChange: ChangeEventHandler<HTMLInputElement> = useCallback(
+ (e) => {
+ setSelection(e.target.value);
+ },
+ []
+ );
+
+ return (
+ <RadioGroupComponent {...args} onSwitch={handleChange} value={selection} />
+ );
+};
+
+/**
+ * Radio Group Story
+ */
+export const RadioGroup = Template.bind({});
+RadioGroup.args = {
+ legend: <Legend>Options:</Legend>,
+ options: getOptions('group1'),
+ value: initialChoice,
+};
diff --git a/src/components/molecules/forms/radio-group/radio-group.test.tsx b/src/components/molecules/forms/radio-group/radio-group.test.tsx
new file mode 100644
index 0000000..dba1541
--- /dev/null
+++ b/src/components/molecules/forms/radio-group/radio-group.test.tsx
@@ -0,0 +1,59 @@
+import { render, screen } from '../../../../../tests/utils';
+import { Legend } from '../../../atoms';
+import { RadioGroup } from './radio-group';
+import { getOptions, initialChoice } from './radio-group.fixture';
+
+const doNothing = () => {
+ /* Do nothing. */
+};
+
+describe('RadioGroup', () => {
+ it('renders a legend', () => {
+ const legend = 'Options:';
+
+ render(
+ <RadioGroup
+ legend={<Legend>{legend}</Legend>}
+ name="possimus"
+ onSwitch={doNothing}
+ options={getOptions()}
+ value={initialChoice}
+ />
+ );
+
+ expect(
+ screen.getByRole('radiogroup', { name: legend })
+ ).toBeInTheDocument();
+ });
+
+ it('renders the correct number of radio', () => {
+ const options = getOptions();
+
+ render(
+ <RadioGroup
+ name="eaque"
+ onSwitch={doNothing}
+ options={options}
+ value={initialChoice}
+ />
+ );
+
+ expect(screen.getAllByRole('radio')).toHaveLength(options.length);
+ });
+
+ it('can render an inlined radio group', () => {
+ const options = getOptions();
+
+ render(
+ <RadioGroup
+ isInline
+ name="architecto"
+ onSwitch={doNothing}
+ options={options}
+ value={initialChoice}
+ />
+ );
+
+ expect(screen.getByRole('radiogroup')).toHaveClass('group--inline');
+ });
+});
diff --git a/src/components/molecules/forms/radio-group/radio-group.tsx b/src/components/molecules/forms/radio-group/radio-group.tsx
new file mode 100644
index 0000000..0ca4dac
--- /dev/null
+++ b/src/components/molecules/forms/radio-group/radio-group.tsx
@@ -0,0 +1,110 @@
+import { ForwardRefRenderFunction, forwardRef } from 'react';
+import {
+ Fieldset,
+ FieldsetProps,
+ Label,
+ LabelProps,
+ Radio,
+ RadioProps,
+} from '../../../atoms';
+import { LabelledField } from '../labelled-field';
+import styles from './radio-group.module.scss';
+
+export type RadioGroupItem = {
+ /**
+ * The item id.
+ */
+ id: string;
+ /**
+ * Should the item be disabled?
+ */
+ isDisabled?: boolean;
+ /**
+ * The item label.
+ */
+ label: LabelProps['children'];
+ /**
+ * The item value.
+ */
+ value: string;
+};
+
+export type RadioGroupProps = Omit<FieldsetProps, 'children' | 'role'> & {
+ /**
+ * Should we display the radio buttons inlined?
+ *
+ * @default false
+ */
+ isInline?: boolean;
+ /**
+ * The radio group name.
+ */
+ name: string;
+ /**
+ * A function to handle selection change.
+ */
+ onSwitch?: RadioProps['onChange'];
+ /**
+ * The options.
+ */
+ options: RadioGroupItem[];
+ /**
+ * The selected value. It should match a RadioGroupItem value or be undefined.
+ */
+ value?: RadioGroupItem['value'];
+};
+
+const RadioGroupWithRef: ForwardRefRenderFunction<
+ HTMLFieldSetElement,
+ RadioGroupProps
+> = (
+ {
+ className = '',
+ isInline = false,
+ name,
+ onSwitch,
+ options,
+ value,
+ ...props
+ },
+ ref
+) => {
+ const layoutModifier = isInline ? styles['group--inline'] : '';
+ const groupClass = `${layoutModifier} ${className}`;
+
+ return (
+ <Fieldset
+ {...props}
+ className={groupClass}
+ isInline={isInline}
+ ref={ref}
+ role="radiogroup"
+ >
+ {options.map((option) => (
+ <LabelledField
+ className={styles.option}
+ field={
+ <Radio
+ id={option.id}
+ isChecked={value === option.value}
+ name={name}
+ onChange={onSwitch}
+ value={option.value}
+ />
+ }
+ isInline
+ isReversedOrder
+ key={option.id}
+ label={<Label htmlFor={option.id}>{option.label}</Label>}
+ />
+ ))}
+ </Fieldset>
+ );
+};
+
+/**
+ * RadioGroup component
+ *
+ * Render a group of labelled radio buttons.
+ */
+export const RadioGroup = forwardRef(RadioGroupWithRef);
diff --git a/src/components/molecules/forms/switch/index.ts b/src/components/molecules/forms/switch/index.ts
new file mode 100644
index 0000000..4dd2256
--- /dev/null
+++ b/src/components/molecules/forms/switch/index.ts
@@ -0,0 +1 @@
+export * from './switch';
diff --git a/src/components/molecules/forms/switch/switch.module.scss b/src/components/molecules/forms/switch/switch.module.scss
new file mode 100644
index 0000000..44244e7
--- /dev/null
+++ b/src/components/molecules/forms/switch/switch.module.scss
@@ -0,0 +1,105 @@
+@use "../../../../styles/abstracts/functions" as fun;
+@use "../../../../styles/abstracts/mixins" as mix;
+
+.fieldset {
+ position: relative;
+}
+
+.switch {
+ display: inline-flex;
+ flex-flow: row wrap;
+ align-items: center;
+ width: fit-content;
+ background: var(--color-shadow-light);
+ border: fun.convert-px(2) solid var(--color-primary);
+ border-radius: fun.convert-px(32);
+ box-shadow:
+ inset 0 0 fun.convert-px(1) fun.convert-px(1) var(--color-shadow-dark),
+ inset 0 0 fun.convert-px(3) fun.convert-px(2) var(--color-shadow);
+
+ &:focus-within {
+ outline: fun.convert-px(2) solid var(--color-primary-light);
+ }
+}
+
+.label {
+ display: flex;
+ align-items: center;
+ min-height: 5ex;
+ padding: fun.convert-px(6) var(--spacing-2xs);
+ border-top: fun.convert-px(2) solid var(--color-border);
+ border-bottom: fun.convert-px(2) solid var(--color-border);
+ transition: all 0.15s linear 0s;
+
+ @include mix.pointer("fine") {
+ min-height: 3ex;
+ }
+}
+
+.item:first-of-type {
+ .label {
+ border-left: fun.convert-px(2) solid var(--color-border);
+ border-top-left-radius: fun.convert-px(32);
+ border-bottom-left-radius: fun.convert-px(32);
+ }
+}
+
+.item:last-of-type {
+ .label {
+ border-right: fun.convert-px(2) solid var(--color-border);
+ border-top-right-radius: fun.convert-px(32);
+ border-bottom-right-radius: fun.convert-px(32);
+ }
+}
+
+.radio {
+ &:checked + .label {
+ background: var(--color-primary);
+ box-shadow:
+ inset 0 0 fun.convert-px(1) fun.convert-px(2) var(--color-primary-dark),
+ inset 0 0 fun.convert-px(3) fun.convert-px(2) var(--color-primary-darker);
+ color: var(--color-fg-inverted);
+
+ svg {
+ fill: var(--color-fg-inverted);
+ stroke: var(--color-fg-inverted);
+ }
+ }
+
+ &:not(:checked) + .label {
+ svg {
+ fill: var(--color-primary-darker);
+ }
+ }
+
+ &[disabled] + .label {
+ opacity: 0.8;
+ }
+}
+
+.radio:not([disabled]) {
+ &:checked + .label:hover {
+ background: var(--color-primary-lighter);
+ box-shadow:
+ inset 0 0 fun.convert-px(1) fun.convert-px(2) var(--color-primary-light),
+ inset 0 0 fun.convert-px(3) fun.convert-px(2) var(--color-primary);
+ }
+
+ &:not(:checked) + .label:hover {
+ background: var(--color-shadow-light);
+ box-shadow:
+ inset 0 0 0 fun.convert-px(1) var(--color-shadow-dark),
+ inset 0 0 fun.convert-px(3) fun.convert-px(2) var(--color-shadow);
+ }
+
+ &:not(:checked):focus + .label {
+ background: var(--color-shadow-light);
+ }
+
+ &:checked:focus + .label {
+ background: var(--color-primary-lighter);
+ box-shadow:
+ inset 0 0 fun.convert-px(1) fun.convert-px(2) var(--color-primary-light),
+ inset 0 0 fun.convert-px(3) fun.convert-px(2) var(--color-primary);
+ }
+}
diff --git a/src/components/molecules/forms/switch/switch.stories.tsx b/src/components/molecules/forms/switch/switch.stories.tsx
new file mode 100644
index 0000000..eb169ad
--- /dev/null
+++ b/src/components/molecules/forms/switch/switch.stories.tsx
@@ -0,0 +1,48 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { Switch as SwitchComponent, SwitchOption } from './switch';
+import { ChangeEventHandler, useCallback, useState } from 'react';
+import { Legend } from '../../../atoms';
+
+/**
+ * Switch - Storybook Meta
+ */
+export default {
+ title: 'Molecules/Forms',
+ component: SwitchComponent,
+ args: {},
+ argTypes: {},
+} as ComponentMeta<typeof SwitchComponent>;
+
+const Template: ComponentStory<typeof SwitchComponent> = ({
+ value,
+ ...args
+}) => {
+ const [selection, setSelection] = useState(value);
+
+ const handleChange: ChangeEventHandler<HTMLInputElement> = useCallback(
+ (e) => {
+ setSelection(e.target.value);
+ },
+ []
+ );
+
+ return (
+ <SwitchComponent {...args} onSwitch={handleChange} value={selection} />
+ );
+};
+
+const items: [SwitchOption, SwitchOption] = [
+ { id: 'option-1', label: 'Choice 1', value: 'option-1' },
+ { id: 'option-2', label: 'Choice 2', value: 'option-2' },
+];
+
+/**
+ * Radio Group Story
+ */
+export const Switch = Template.bind({});
+Switch.args = {
+ items,
+ legend: <Legend>Choose the best option:</Legend>,
+ name: 'example',
+ value: items[0].value,
+};
diff --git a/src/components/molecules/forms/switch/switch.test.tsx b/src/components/molecules/forms/switch/switch.test.tsx
new file mode 100644
index 0000000..6ccd525
--- /dev/null
+++ b/src/components/molecules/forms/switch/switch.test.tsx
@@ -0,0 +1,49 @@
+import { render, screen } from '../../../../../tests/utils';
+import { Legend } from '../../../atoms';
+import { Switch, SwitchOption } from './switch';
+
+const doNothing = () => {
+ /* Do nothing. */
+};
+
+const items: [SwitchOption, SwitchOption] = [
+ { id: 'item-1', label: 'Option 1', value: 'option-1' },
+ { id: 'item-2', label: 'Option 2', value: 'option-2' },
+];
+
+describe('Switch', () => {
+ it('renders a radio group with two choices', () => {
+ const legend = 'Options:';
+
+ render(
+ <Switch
+ items={items}
+ legend={<Legend>{legend}</Legend>}
+ name="possimus"
+ onSwitch={doNothing}
+ value={items[0].value}
+ />
+ );
+
+ expect(
+ screen.getByRole('radiogroup', { name: legend })
+ ).toBeInTheDocument();
+ expect(screen.getAllByRole('radio')).toHaveLength(items.length);
+ });
+
+ it('can render a disabled switch', () => {
+ render(
+ <Switch
+ isDisabled
+ items={items}
+ name="architecto"
+ onSwitch={doNothing}
+ value={items[1].value}
+ />
+ );
+
+ const radios = screen.getAllByRole<HTMLInputElement>('radio');
+ expect(radios.every((radio) => radio.disabled)).toBe(true);
+ expect(screen.getByRole('radiogroup')).toBeDisabled();
+ });
+});
diff --git a/src/components/molecules/forms/switch/switch.tsx b/src/components/molecules/forms/switch/switch.tsx
new file mode 100644
index 0000000..d340a0c
--- /dev/null
+++ b/src/components/molecules/forms/switch/switch.tsx
@@ -0,0 +1,132 @@
+import type { FC, ChangeEventHandler, ReactNode, ReactElement } from 'react';
+import {
+ Fieldset,
+ type FieldsetProps,
+ LabelProps,
+ RadioProps,
+ Label,
+ Radio,
+} from '../../../atoms';
+import styles from './switch.module.scss';
+import { TooltipProps } from '../../tooltip';
+
+type SwitchItemProps = Omit<LabelProps, 'children' | 'htmlFor' | 'isRequired'> &
+ Pick<RadioProps, 'isDisabled' | 'name'> & {
+ /**
+ * The item id.
+ */
+ id: string;
+ /**
+ * Is the item selected?
+ */
+ isSelected?: boolean;
+ /**
+ * The label used to describe the switch item.
+ */
+ label: ReactNode;
+ /**
+ * The event handler on value change.
+ */
+ onSwitch: ChangeEventHandler<HTMLInputElement>;
+ /**
+ * The item value.
+ */
+ value: string;
+ };
+
+/**
+ * SwitchItem component.
+ */
+const SwitchItem: FC<SwitchItemProps> = ({
+ className = '',
+ id,
+ isDisabled = false,
+ isSelected = false,
+ label,
+ name,
+ onSwitch,
+ value,
+ ...props
+}) => {
+ const selectedItemClass = isSelected ? styles['item--selected'] : '';
+ const disabledItemClass = isDisabled ? styles['item--disabled'] : '';
+ const itemClass = `${styles.item} ${selectedItemClass} ${disabledItemClass} ${className}`;
+
+ return (
+ <Label {...props} className={itemClass} htmlFor={id}>
+ <Radio
+ className={styles.radio}
+ id={id}
+ isChecked={isSelected}
+ isDisabled={isDisabled}
+ isHidden
+ name={name}
+ onChange={onSwitch}
+ value={value}
+ />
+ <span className={styles.label}>{label}</span>
+ </Label>
+ );
+};
+
+export type SwitchOption = Pick<SwitchItemProps, 'id' | 'label' | 'value'>;
+
+export type SwitchProps = Omit<FieldsetProps, 'children'> & {
+ /**
+ * The switch items.
+ */
+ items: [SwitchOption, SwitchOption];
+ /**
+ * The switch group name.
+ */
+ name: string;
+ /**
+ * A function to handle selection change.
+ */
+ onSwitch: ChangeEventHandler<HTMLInputElement>;
+ /**
+ * A tooltip to display before switch options.
+ */
+ tooltip?: ReactElement<TooltipProps>;
+ /**
+ * The selected item value.
+ */
+ value: SwitchOption['value'];
+};
+
+/**
+ * Switch component.
+ */
+export const Switch: FC<SwitchProps> = ({
+ className = '',
+ isDisabled = false,
+ items,
+ name,
+ onSwitch,
+ tooltip,
+ value,
+ ...props
+}) => {
+ return (
+ <Fieldset
+ {...props}
+ className={`${styles.fieldset} ${className}`}
+ isDisabled={isDisabled}
+ role="radiogroup"
+ >
+ {tooltip}
+ <div className={styles.switch}>
+ {items.map((item) => (
+ <SwitchItem
+ {...item}
+ isDisabled={isDisabled}
+ isSelected={value === item.value}
+ key={item.id}
+ name={name}
+ onSwitch={onSwitch}
+ />
+ ))}
+ </div>
+ </Fieldset>
+ );
+};
diff --git a/src/components/molecules/forms/theme-toggle.stories.tsx b/src/components/molecules/forms/theme-toggle.stories.tsx
deleted file mode 100644
index 40f4d8c..0000000
--- a/src/components/molecules/forms/theme-toggle.stories.tsx
+++ /dev/null
@@ -1,60 +0,0 @@
-import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { ThemeToggle } from './theme-toggle';
-
-/**
- * ThemeToggle - Storybook Meta
- */
-export default {
- title: 'Molecules/Forms/Toggle',
- component: ThemeToggle,
- argTypes: {
- bodyClassName: {
- control: {
- type: 'text',
- },
- description: 'Set additional classnames to the fieldset body wrapper.',
- table: {
- category: 'Styles',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- groupClassName: {
- control: {
- type: 'text',
- },
- description: 'Set additional classnames to the radio group wrapper.',
- table: {
- category: 'Styles',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- legendClassName: {
- control: {
- type: 'text',
- },
- description: 'Set additional classnames to the legend.',
- table: {
- category: 'Styles',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- },
-} as ComponentMeta<typeof ThemeToggle>;
-
-const Template: ComponentStory<typeof ThemeToggle> = (args) => (
- <ThemeToggle {...args} />
-);
-
-/**
- * Toggle Stories - Theme
- */
-export const Theme = Template.bind({});
diff --git a/src/components/molecules/index.ts b/src/components/molecules/index.ts
index 1a101e4..dae369b 100644
--- a/src/components/molecules/index.ts
+++ b/src/components/molecules/index.ts
@@ -2,5 +2,5 @@ export * from './buttons';
export * from './forms';
export * from './images';
export * from './layout';
-export * from './modals';
export * from './nav';
+export * from './tooltip';
diff --git a/src/components/molecules/modals/modal.module.scss b/src/components/molecules/modals/modal.module.scss
deleted file mode 100644
index 22ddb11..0000000
--- a/src/components/molecules/modals/modal.module.scss
+++ /dev/null
@@ -1,34 +0,0 @@
-@use "../../../styles/abstracts/functions" as fun;
-@use "../../../styles/abstracts/mixins" as mix;
-
-.wrapper {
- padding: var(--spacing-md);
- background: var(--color-bg-secondary);
- border: fun.convert-px(4) solid;
- border-image: radial-gradient(
- ellipse at top,
- var(--color-primary-lighter) 20%,
- var(--color-primary) 100%
- )
- 1;
- box-shadow: fun.convert-px(2) fun.convert-px(-2) fun.convert-px(3)
- fun.convert-px(-1) var(--color-shadow-dark);
-
- @include mix.media("screen") {
- @include mix.dimensions(null, "sm") {
- padding: var(--spacing-xs);
- border-left: none;
- border-right: none;
-
- .title {
- margin-bottom: var(--spacing-2xs);
- }
- }
- }
-}
-
-.icon {
- --icon-size: #{fun.convert-px(30)};
-
- margin-right: var(--spacing-2xs);
-}
diff --git a/src/components/molecules/modals/modal.stories.tsx b/src/components/molecules/modals/modal.stories.tsx
deleted file mode 100644
index 36e6bfc..0000000
--- a/src/components/molecules/modals/modal.stories.tsx
+++ /dev/null
@@ -1,96 +0,0 @@
-import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { Modal } from './modal';
-
-/**
- * Widget - Storybook Meta
- */
-export default {
- title: 'Molecules/Modals/Modal',
- component: Modal,
- argTypes: {
- children: {
- control: {
- type: 'text',
- },
- description: 'The modal body.',
- type: {
- name: 'string',
- required: true,
- },
- },
- className: {
- control: {
- type: 'text',
- },
- description: 'Set additional classnames to the modal.',
- table: {
- category: 'Styles',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- headingClassName: {
- control: {
- type: 'text',
- },
- description: 'Set additional classnames to the modal heading.',
- table: {
- category: 'Styles',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- icon: {
- control: {
- type: 'select',
- },
- description: 'The title icon.',
- options: ['', 'cogs', 'search'],
- table: {
- category: 'Options',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- title: {
- control: {
- type: 'text',
- },
- description: 'The modal title.',
- table: {
- category: 'Options',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- },
-} as ComponentMeta<typeof Modal>;
-
-const Template: ComponentStory<typeof Modal> = (args) => <Modal {...args} />;
-
-/**
- * Modal Stories - Default
- */
-export const Default = Template.bind({});
-Default.args = {
- children:
- 'Inventore natus dignissimos aut illum modi asperiores. Et voluptatibus delectus.',
-};
-
-/**
- * Modal Stories - With title
- */
-export const WithTitle = Template.bind({});
-WithTitle.args = {
- children:
- 'Inventore natus dignissimos aut illum modi asperiores. Et voluptatibus delectus.',
- title: 'Alias praesentium corporis',
-};
diff --git a/src/components/molecules/modals/modal.tsx b/src/components/molecules/modals/modal.tsx
deleted file mode 100644
index 344d5b9..0000000
--- a/src/components/molecules/modals/modal.tsx
+++ /dev/null
@@ -1,88 +0,0 @@
-import dynamic from 'next/dynamic';
-import { FC, ReactNode } from 'react';
-import {
- type CogProps,
- Heading,
- type HeadingProps,
- type MagnifyingGlassProps,
-} from '../../atoms';
-import styles from './modal.module.scss';
-
-export type Icons = 'cogs' | 'search';
-
-export type ModalProps = {
- /**
- * The modal body.
- */
- children: ReactNode;
- /**
- * Set additional classnames.
- */
- className?: string;
- /**
- * Set additional classnames to the heading.
- */
- headingClassName?: HeadingProps['className'];
- /**
- * A icon to illustrate the modal.
- */
- icon?: Icons;
- /**
- * The modal title.
- */
- title?: string;
-};
-
-const CogIcon = dynamic<CogProps>(
- () => import('../../atoms/icons/cog').then((mod) => mod.Cog),
- {
- ssr: false,
- }
-);
-const SearchIcon = dynamic<MagnifyingGlassProps>(
- () =>
- import('../../atoms/icons/magnifying-glass').then(
- (mod) => mod.MagnifyingGlass
- ),
- { ssr: false }
-);
-
-/**
- * Modal component
- *
- * Render a modal component with an optional title and icon.
- */
-export const Modal: FC<ModalProps> = ({
- children,
- className = '',
- headingClassName = '',
- icon,
- title,
-}) => {
- const getIcon = (id: Icons) => {
- switch (id) {
- case 'cogs':
- return <CogIcon />;
- case 'search':
- return <SearchIcon />;
- default:
- return <></>;
- }
- };
-
- return (
- <div className={`${styles.wrapper} ${className}`}>
- {title && (
- <Heading
- isFake={true}
- level={3}
- className={`${styles.title} ${headingClassName}`}
- >
- {icon && <span className={styles.icon}>{getIcon(icon)}</span>}
- {title}
- </Heading>
- )}
- {children}
- </div>
- );
-};
diff --git a/src/components/molecules/modals/tooltip.fixture.tsx b/src/components/molecules/modals/tooltip.fixture.tsx
deleted file mode 100644
index 5489f08..0000000
--- a/src/components/molecules/modals/tooltip.fixture.tsx
+++ /dev/null
@@ -1,4 +0,0 @@
-export const title = 'Illum eum at';
-export const content =
- 'Non accusantium ad. Est et impedit iste animi voluptas cum accusamus accusantium. Repellat ut sint pariatur cumque cupiditate. Animi occaecati odio ut debitis ipsam similique. Repudiandae aut earum occaecati consequatur laborum ut nobis iusto. Adipisci laboriosam id.';
-export const icon = '?';
diff --git a/src/components/molecules/modals/tooltip.module.scss b/src/components/molecules/modals/tooltip.module.scss
deleted file mode 100644
index 0a177e5..0000000
--- a/src/components/molecules/modals/tooltip.module.scss
+++ /dev/null
@@ -1,46 +0,0 @@
-@use "../../../styles/abstracts/functions" as fun;
-
-.wrapper {
- --title-height: #{fun.convert-px(40)};
-
- margin-top: calc(var(--title-height) / 2);
- padding: calc((var(--title-height) / 2) + var(--spacing-sm)) var(--spacing-sm)
- var(--spacing-sm);
- position: relative;
- background: var(--color-bg);
- border: fun.convert-px(2) solid var(--color-primary-dark);
- border-radius: fun.convert-px(3);
- box-shadow: fun.convert-px(1) fun.convert-px(1) 0 0 var(--color-shadow),
- fun.convert-px(2) fun.convert-px(2) fun.convert-px(1) fun.convert-px(1)
- var(--color-shadow-light);
-}
-
-.title {
- display: flex;
- align-items: center;
- height: var(--title-height);
- padding-right: var(--spacing-xs);
- position: absolute;
- top: calc(var(--title-height) / -2);
- left: 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-size: var(--font-size-sm);
- font-variant: small-caps;
- font-weight: 500;
-}
-
-.icon {
- display: flex;
- align-items: center;
- height: var(--title-height);
- margin-right: var(--spacing-xs);
- padding: 0 var(--spacing-2xs);
- background: var(--color-primary-dark);
- 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-fg-inverted);
- font-weight: 600;
-}
diff --git a/src/components/molecules/modals/tooltip.stories.tsx b/src/components/molecules/modals/tooltip.stories.tsx
deleted file mode 100644
index abc3526..0000000
--- a/src/components/molecules/modals/tooltip.stories.tsx
+++ /dev/null
@@ -1,84 +0,0 @@
-import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { Tooltip } from './tooltip';
-import { content, icon, title } from './tooltip.fixture';
-
-/**
- * Tooltip - Storybook Meta
- */
-export default {
- title: 'Molecules/Modals/Tooltip',
- component: Tooltip,
- argTypes: {
- className: {
- control: {
- type: 'text',
- },
- description: 'Set additional classnames to the tooltip.',
- table: {
- category: 'Styles',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- cloneClassName: {
- control: {
- type: 'text',
- },
- description:
- 'Set additional classnames to the tooltip when using cloneElement.',
- table: {
- category: 'Styles',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- content: {
- control: {
- type: 'text',
- },
- description: 'The tooltip body.',
- type: {
- name: 'string',
- required: true,
- },
- },
- icon: {
- control: {
- type: 'text',
- },
- description: 'The tooltip icon.',
- type: {
- name: 'string',
- required: true,
- },
- },
- title: {
- control: {
- type: 'text',
- },
- description: 'The tooltip title',
- type: {
- name: 'string',
- required: true,
- },
- },
- },
-} as ComponentMeta<typeof Tooltip>;
-
-const Template: ComponentStory<typeof Tooltip> = (args) => (
- <Tooltip {...args} />
-);
-
-/**
- * Tooltip Stories - Help
- */
-export const Help = Template.bind({});
-Help.args = {
- content,
- icon,
- title,
-};
diff --git a/src/components/molecules/modals/tooltip.test.tsx b/src/components/molecules/modals/tooltip.test.tsx
deleted file mode 100644
index eb23334..0000000
--- a/src/components/molecules/modals/tooltip.test.tsx
+++ /dev/null
@@ -1,20 +0,0 @@
-import { render, screen } from '../../../../tests/utils';
-import { Tooltip } from './tooltip';
-import { content, icon, title } from './tooltip.fixture';
-
-describe('Tooltip', () => {
- it('renders a title', () => {
- render(<Tooltip title={title} content={content} icon={icon} />);
- expect(screen.getByText(title)).toBeInTheDocument();
- });
-
- it('renders an explanation', () => {
- render(<Tooltip title={title} content={content} icon={icon} />);
- expect(screen.getByText(content)).toBeInTheDocument();
- });
-
- it('renders an icon', () => {
- render(<Tooltip title={title} content={content} icon={icon} />);
- expect(screen.getByText(icon)).toBeInTheDocument();
- });
-});
diff --git a/src/components/molecules/modals/tooltip.tsx b/src/components/molecules/modals/tooltip.tsx
deleted file mode 100644
index 3c8a5df..0000000
--- a/src/components/molecules/modals/tooltip.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-import { forwardRef, ForwardRefRenderFunction, ReactNode } from 'react';
-import { List, type ListItem } from '../../atoms';
-import styles from './tooltip.module.scss';
-
-export type TooltipProps = {
- /**
- * Set additional classnames to the tooltip wrapper.
- */
- className?: string;
- /**
- * Set more additional classnames to the tooltip wrapper. Required when using React.cloneElement.
- */
- cloneClassName?: string;
- /**
- * The tooltip body.
- */
- content: string | string[];
- /**
- * An icon to illustrate tooltip content.
- */
- icon: ReactNode;
- /**
- * The tooltip title.
- */
- title: string;
-};
-
-const TooltipWithRef: ForwardRefRenderFunction<HTMLDivElement, TooltipProps> = (
- { cloneClassName = '', className = '', content, icon, title },
- ref
-) => {
- /**
- * Format an array of strings to an array of object with id and value.
- *
- * @param {string[]} array - An array of strings.
- * @returns {ListItem[]} The array formatted to be used as list items.
- */
- const getListItems = (array: string[]): ListItem[] => {
- return array.map((string, index) => {
- return { id: `item-${index}`, value: string };
- });
- };
-
- return (
- <div
- className={`${styles.wrapper} ${cloneClassName} ${className}`}
- ref={ref}
- >
- <div className={styles.title}>
- <span className={styles.icon}>{icon}</span>
- {title}
- </div>
- {Array.isArray(content) ? (
- <List items={getListItems(content)} />
- ) : (
- content
- )}
- </div>
- );
-};
-
-/**
- * Tooltip component
- *
- * Render a tooltip modal.
- */
-export const Tooltip = forwardRef(TooltipWithRef);
diff --git a/src/components/molecules/modals/index.ts b/src/components/molecules/tooltip/index.ts
index 595be13..ed8326d 100644
--- a/src/components/molecules/modals/index.ts
+++ b/src/components/molecules/tooltip/index.ts
@@ -1,2 +1 @@
-export * from './modal';
export * from './tooltip';
diff --git a/src/components/molecules/tooltip/tooltip.module.scss b/src/components/molecules/tooltip/tooltip.module.scss
new file mode 100644
index 0000000..029767f
--- /dev/null
+++ b/src/components/molecules/tooltip/tooltip.module.scss
@@ -0,0 +1,72 @@
+@use "../../../styles/abstracts/functions" as fun;
+@use "../../../styles/abstracts/mixins" as mix;
+@use "../../../styles/abstracts/variables" as var;
+
+.btn {
+ margin-right: var(--spacing-xs);
+}
+
+.tooltip {
+ position: absolute;
+ z-index: 10;
+ font-size: var(--font-size-sm);
+ transition: all 0.75s ease-in-out 0s;
+
+ @media screen and (max-height: #{var.get-breakpoint("2xs")}) {
+ width: calc(97.5vw - var(--spacing-md));
+ right: 0;
+ }
+
+ &--down {
+ top: calc(100% + var(--spacing-xs));
+ transform-origin: top;
+ }
+
+ &--up {
+ bottom: calc(100% + var(--spacing-2xs));
+ transform-origin: bottom;
+ }
+
+ &--hidden {
+ flex: 0 0 0;
+ opacity: 0;
+ visibility: hidden;
+ transform: scale(0);
+ }
+
+ &--visible {
+ opacity: 1;
+ visibility: visible;
+ transform: scale(1);
+
+ & ~ .btn {
+ background: var(--color-primary);
+
+ * {
+ color: var(--color-fg-inverted);
+ }
+ }
+ }
+}
+
+.heading {
+ display: flex;
+ flex-flow: row nowrap;
+ align-items: center;
+ height: 100%;
+ margin-left: calc(var(--spacing-xs) * -1.1);
+ font-size: var(--font-size-sm);
+}
+
+.icon {
+ align-self: stretch;
+ display: flex;
+ align-items: center;
+ margin-right: var(--spacing-xs);
+ padding: 0 var(--spacing-2xs);
+ background: var(--color-primary-dark);
+ 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-fg-inverted);
+ font-weight: 600;
+}
diff --git a/src/components/molecules/tooltip/tooltip.stories.tsx b/src/components/molecules/tooltip/tooltip.stories.tsx
new file mode 100644
index 0000000..8a22a06
--- /dev/null
+++ b/src/components/molecules/tooltip/tooltip.stories.tsx
@@ -0,0 +1,42 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { Tooltip } from './tooltip';
+import { useState } from 'react';
+
+/**
+ * Switch - Storybook Meta
+ */
+export default {
+ title: 'Molecules/Tooltip',
+ component: Tooltip,
+ args: {},
+ argTypes: {},
+} as ComponentMeta<typeof Tooltip>;
+
+const Template: ComponentStory<typeof Tooltip> = ({
+ isOpen,
+ onToggle: _onToggle,
+ ...args
+}) => {
+ const [isOpened, setIsOpened] = useState(isOpen);
+
+ const toggle = () => {
+ setIsOpened((prev) => !prev);
+ };
+
+ return (
+ <div style={{ position: 'relative' }}>
+ <Tooltip {...args} isOpen={isOpened} onToggle={toggle} />
+ </div>
+ );
+};
+
+/**
+ * Tooltip Stories - Example
+ */
+export const Example = Template.bind({});
+Example.args = {
+ children:
+ 'Inventore natus dignissimos aut illum modi asperiores. Et voluptatibus delectus.',
+ heading: 'A title',
+ isOpen: false,
+};
diff --git a/src/components/molecules/tooltip/tooltip.test.tsx b/src/components/molecules/tooltip/tooltip.test.tsx
new file mode 100644
index 0000000..af2c7e4
--- /dev/null
+++ b/src/components/molecules/tooltip/tooltip.test.tsx
@@ -0,0 +1,39 @@
+import { render, screen } from '../../../../tests/utils';
+import { Tooltip } from './tooltip';
+
+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('Tooltip', () => {
+ it('renders a title and a body', () => {
+ render(<Tooltip heading={title}>{children}</Tooltip>);
+
+ expect(screen.getByText(title)).toBeInTheDocument();
+ expect(screen.getByText(children)).toBeInTheDocument();
+ });
+
+ it('can render a hidden modal', () => {
+ render(
+ <Tooltip heading={title} isOpen={false}>
+ {children}
+ </Tooltip>
+ );
+
+ // Neither toBeVisible or toHaveStyle are working.
+ //expect(screen.getByText(children)).not.toBeVisible();
+ //expect(screen.getByText(children)).toHaveStyle({ visibility: 'hidden' });
+ expect(screen.getByText(children)).toHaveClass('tooltip--hidden');
+ });
+
+ it('can render a visible modal', () => {
+ render(
+ <Tooltip heading={title} isOpen>
+ {children}
+ </Tooltip>
+ );
+
+ expect(screen.getByText(children)).toBeVisible();
+ expect(screen.getByText(children)).toHaveStyle({ visibility: 'visible' });
+ });
+});
diff --git a/src/components/molecules/tooltip/tooltip.tsx b/src/components/molecules/tooltip/tooltip.tsx
new file mode 100644
index 0000000..43ceced
--- /dev/null
+++ b/src/components/molecules/tooltip/tooltip.tsx
@@ -0,0 +1,92 @@
+import { FC, MouseEventHandler, useRef } from 'react';
+import { Heading, Modal, ModalProps } from '../../atoms';
+import { HelpButton } from '../buttons';
+import styles from './tooltip.module.scss';
+import { useOnClickOutside } from '../../../utils/hooks';
+
+export type TooltipProps = Omit<ModalProps, 'heading'> & {
+ /**
+ * The tooltip direction when opening.
+ *
+ * @default "downwards"
+ */
+ direction?: 'downwards' | 'upwards';
+ /**
+ * The tooltip heading.
+ */
+ heading: string;
+ /**
+ * Should the tooltip be opened?
+ *
+ * @default false
+ */
+ isOpen?: boolean;
+ /**
+ * A callback function to trigger when clicking outside the modal.
+ */
+ onClickOutside?: () => void;
+ /**
+ * An event handler when clicking on the help button.
+ */
+ onToggle?: MouseEventHandler<HTMLButtonElement>;
+};
+
+/**
+ * Tooltip component
+ *
+ * Render a button and a modal. Note: you should add a CSS rule
+ * `position: relative;` on the consumer.
+ */
+export const Tooltip: FC<TooltipProps> = ({
+ children,
+ className = '',
+ direction = 'downwards',
+ heading,
+ isOpen,
+ onClickOutside,
+ onToggle,
+ ...props
+}) => {
+ const directionModifier =
+ direction === 'upwards' ? 'tooltip--up' : 'tooltip--down';
+ const visibilityModifier = isOpen ? 'tooltip--visible' : 'tooltip--hidden';
+ const tooltipClass = `${styles.tooltip} ${styles[directionModifier]} ${styles[visibilityModifier]} ${className}`;
+ const btnRef = useRef<HTMLButtonElement>(null);
+
+ const closeModal = (target: Node) => {
+ if (!onClickOutside) return;
+
+ if (btnRef.current && !btnRef.current.contains(target)) {
+ onClickOutside();
+ }
+ };
+
+ const modalRef = useOnClickOutside<HTMLDivElement>(closeModal);
+
+ return (
+ <>
+ <Modal
+ {...props}
+ className={tooltipClass}
+ heading={
+ <Heading className={styles.heading} isFake level={6}>
+ <span aria-hidden className={styles.icon}>
+ ?
+ </span>
+ {heading}
+ </Heading>
+ }
+ kind="secondary"
+ ref={modalRef}
+ >
+ {children}
+ </Modal>
+ <HelpButton
+ aria-pressed={isOpen}
+ className={styles.btn}
+ onClick={onToggle}
+ ref={btnRef}
+ />
+ </>
+ );
+};
diff --git a/src/components/molecules/forms/ackee-toggle.fixture.tsx b/src/components/organisms/forms/ackee-toggle/ackee-toggle.fixture.tsx
index 04602f2..04602f2 100644
--- a/src/components/molecules/forms/ackee-toggle.fixture.tsx
+++ b/src/components/organisms/forms/ackee-toggle/ackee-toggle.fixture.tsx
diff --git a/src/components/organisms/forms/ackee-toggle/ackee-toggle.stories.tsx b/src/components/organisms/forms/ackee-toggle/ackee-toggle.stories.tsx
new file mode 100644
index 0000000..b5f8ef8
--- /dev/null
+++ b/src/components/organisms/forms/ackee-toggle/ackee-toggle.stories.tsx
@@ -0,0 +1,47 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { AckeeToggle } from './ackee-toggle';
+import { storageKey } from './ackee-toggle.fixture';
+
+/**
+ * AckeeToggle - Storybook Meta
+ */
+export default {
+ title: 'Organisms/Forms/Toggle',
+ component: AckeeToggle,
+ argTypes: {
+ defaultValue: {
+ control: {
+ type: 'select',
+ },
+ description: 'Set the default value.',
+ options: ['full', 'partial'],
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ storageKey: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set local storage key.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof AckeeToggle>;
+
+const Template: ComponentStory<typeof AckeeToggle> = (args) => (
+ <AckeeToggle {...args} />
+);
+
+/**
+ * Toggle Stories - Ackee
+ */
+export const Ackee = Template.bind({});
+Ackee.args = {
+ defaultValue: 'full',
+ storageKey,
+};
diff --git a/src/components/molecules/forms/ackee-toggle.test.tsx b/src/components/organisms/forms/ackee-toggle/ackee-toggle.test.tsx
index 97ebbe5..7784d5f 100644
--- a/src/components/molecules/forms/ackee-toggle.test.tsx
+++ b/src/components/organisms/forms/ackee-toggle/ackee-toggle.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen } from '../../../../tests/utils';
+import { render, screen } from '../../../../../tests/utils';
import { AckeeToggle } from './ackee-toggle';
import { storageKey } from './ackee-toggle.fixture';
diff --git a/src/components/organisms/forms/ackee-toggle/ackee-toggle.tsx b/src/components/organisms/forms/ackee-toggle/ackee-toggle.tsx
new file mode 100644
index 0000000..681d384
--- /dev/null
+++ b/src/components/organisms/forms/ackee-toggle/ackee-toggle.tsx
@@ -0,0 +1,139 @@
+import { ChangeEvent, FC, useState } from 'react';
+import { useIntl } from 'react-intl';
+import {
+ type AckeeOptions,
+ useLocalStorage,
+ useUpdateAckeeOptions,
+} from '../../../../utils/hooks';
+import { Legend, List } from '../../../atoms';
+import {
+ Switch,
+ SwitchOption,
+ SwitchProps,
+ Tooltip,
+ TooltipProps,
+} from '../../../molecules';
+
+export type AckeeToggleProps = Omit<
+ SwitchProps,
+ 'isInline' | 'items' | 'name' | 'onSwitch' | 'value'
+> &
+ Pick<TooltipProps, 'direction'> & {
+ /**
+ * Set additional classnames to the toggle wrapper.
+ */
+ className?: string;
+ /**
+ * True if motion should be reduced by default.
+ */
+ defaultValue: AckeeOptions;
+ /**
+ * The local storage key to save preference.
+ */
+ storageKey: string;
+ };
+
+/**
+ * AckeeToggle component
+ *
+ * Render a Toggle component to set reduce motion.
+ */
+export const AckeeToggle: FC<AckeeToggleProps> = ({
+ defaultValue,
+ direction,
+ storageKey,
+ ...props
+}) => {
+ const intl = useIntl();
+ const { value, setValue } = useLocalStorage<AckeeOptions>(
+ storageKey,
+ defaultValue
+ );
+ const [isTooltipOpened, setIsTooltipOpened] = useState(false);
+
+ useUpdateAckeeOptions(value);
+
+ const ackeeLabel = intl.formatMessage({
+ defaultMessage: 'Tracking:',
+ description: 'AckeeToggle: select label',
+ id: '0gVlI3',
+ });
+ const partialLabel = intl.formatMessage({
+ defaultMessage: 'Partial',
+ description: 'AckeeToggle: partial option name',
+ id: 'tIZYpD',
+ });
+ const fullLabel = intl.formatMessage({
+ defaultMessage: 'Full',
+ description: 'AckeeToggle: full option name',
+ id: '5eD6y2',
+ });
+ const tooltipTitle = intl.formatMessage({
+ defaultMessage: 'Ackee tracking (analytics)',
+ description: 'AckeeToggle: tooltip title',
+ id: 'nGss/j',
+ });
+ const tooltipPartial = intl.formatMessage({
+ defaultMessage: 'Partial includes only page url, views and duration.',
+ description: 'AckeeToggle: tooltip message',
+ id: 'ZB/Aw2',
+ });
+ const tooltipFull = intl.formatMessage({
+ defaultMessage:
+ 'Full includes all information from partial as well as information about referrer, operating system, device, browser, screen size and language.',
+ description: 'AckeeToggle: tooltip message',
+ id: '7zDlQo',
+ });
+
+ const options: [SwitchOption, SwitchOption] = [
+ {
+ id: 'ackee-full',
+ label: fullLabel,
+ value: 'full',
+ },
+ {
+ id: 'ackee-partial',
+ label: partialLabel,
+ value: 'partial',
+ },
+ ];
+
+ const updateSetting = (e: ChangeEvent<HTMLInputElement>) => {
+ setValue(e.target.value === 'full' ? 'full' : 'partial');
+ };
+
+ const closeTooltip = () => {
+ setIsTooltipOpened(false);
+ };
+ const toggleTooltip = () => {
+ setIsTooltipOpened((prev) => !prev);
+ };
+
+ return (
+ <Switch
+ {...props}
+ isInline
+ items={options}
+ legend={<Legend>{ackeeLabel}</Legend>}
+ name="ackee"
+ onSwitch={updateSetting}
+ tooltip={
+ <Tooltip
+ direction={direction}
+ heading={tooltipTitle}
+ isOpen={isTooltipOpened}
+ onClickOutside={closeTooltip}
+ onToggle={toggleTooltip}
+ >
+ <List
+ items={[
+ { id: 'partial', value: tooltipPartial },
+ { id: 'full', value: tooltipFull },
+ ]}
+ />
+ </Tooltip>
+ }
+ value={value}
+ />
+ );
+};
diff --git a/src/components/organisms/forms/ackee-toggle/index.ts b/src/components/organisms/forms/ackee-toggle/index.ts
new file mode 100644
index 0000000..7f6313c
--- /dev/null
+++ b/src/components/organisms/forms/ackee-toggle/index.ts
@@ -0,0 +1 @@
+export * from './ackee-toggle';
diff --git a/src/components/organisms/forms/comment-form.module.scss b/src/components/organisms/forms/comment-form.module.scss
deleted file mode 100644
index f3f2646..0000000
--- a/src/components/organisms/forms/comment-form.module.scss
+++ /dev/null
@@ -1,8 +0,0 @@
-.field {
- width: 100%;
-}
-
-.button {
- display: block;
- margin: auto;
-}
diff --git a/src/components/organisms/forms/comment-form/comment-form.module.scss b/src/components/organisms/forms/comment-form/comment-form.module.scss
new file mode 100644
index 0000000..fbf8c96
--- /dev/null
+++ b/src/components/organisms/forms/comment-form/comment-form.module.scss
@@ -0,0 +1,18 @@
+.form {
+ display: flex;
+ flex-flow: column wrap;
+ gap: var(--spacing-xs);
+
+ > * {
+ max-width: 45ch;
+ }
+}
+
+.field {
+ width: 100%;
+}
+
+.button {
+ display: block;
+ margin: var(--spacing-sm) auto 0;
+}
diff --git a/src/components/organisms/forms/comment-form.stories.tsx b/src/components/organisms/forms/comment-form/comment-form.stories.tsx
index a6069e6..a6069e6 100644
--- a/src/components/organisms/forms/comment-form.stories.tsx
+++ b/src/components/organisms/forms/comment-form/comment-form.stories.tsx
diff --git a/src/components/organisms/forms/comment-form.test.tsx b/src/components/organisms/forms/comment-form/comment-form.test.tsx
index f11c449..8aa38af 100644
--- a/src/components/organisms/forms/comment-form.test.tsx
+++ b/src/components/organisms/forms/comment-form/comment-form.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen } from '../../../../tests/utils';
+import { render, screen } from '../../../../../tests/utils';
import { CommentForm } from './comment-form';
const saveComment = async () => {
diff --git a/src/components/organisms/forms/comment-form.tsx b/src/components/organisms/forms/comment-form/comment-form.tsx
index e4140dd..be5d58f 100644
--- a/src/components/organisms/forms/comment-form.tsx
+++ b/src/components/organisms/forms/comment-form/comment-form.tsx
@@ -1,4 +1,4 @@
-import { FC, ReactNode, useState } from 'react';
+import { ChangeEvent, FC, FormEvent, ReactNode, useState } from 'react';
import { useIntl } from 'react-intl';
import {
Button,
@@ -8,14 +8,17 @@ import {
type HeadingLevel,
type HeadingProps,
Spinner,
-} from '../../atoms';
-import { LabelledField } from '../../molecules';
+ Input,
+ TextArea,
+ Label,
+} from '../../../atoms';
+import { LabelledField } from '../../../molecules';
import styles from './comment-form.module.scss';
export type CommentFormData = {
+ author: string;
comment: string;
email: string;
- name: string;
parentId?: number;
website?: string;
};
@@ -49,6 +52,7 @@ export type CommentFormProps = Pick<FormProps, 'className'> & {
};
export const CommentForm: FC<CommentFormProps> = ({
+ className = '',
Notice,
parentId,
saveComment,
@@ -57,21 +61,23 @@ export const CommentForm: FC<CommentFormProps> = ({
titleLevel = 2,
...props
}) => {
+ const formClass = `${styles.form} ${className}`;
const intl = useIntl();
- const [name, setName] = useState<string>('');
- const [email, setEmail] = useState<string>('');
- const [website, setWebsite] = useState<string>('');
- const [comment, setComment] = useState<string>('');
+ const emptyForm: CommentFormData = {
+ author: '',
+ comment: '',
+ email: '',
+ parentId,
+ website: '',
+ };
+ const [data, setData] = useState(emptyForm);
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
/**
* Reset all the form fields.
*/
const resetForm = () => {
- setName('');
- setEmail('');
- setWebsite('');
- setComment('');
+ setData(emptyForm);
setIsSubmitting(false);
};
@@ -109,14 +115,39 @@ export const CommentForm: FC<CommentFormProps> = ({
const formId = 'comment-form-title';
const formLabelledBy = title ? formId : undefined;
- /**
- * Handle form submit.
- */
- const submitHandler = () => {
+ const updateForm = (
+ e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
+ ) => {
+ switch (e.target.name) {
+ case 'author':
+ setData((prevData) => {
+ return { ...prevData, author: e.target.value };
+ });
+ break;
+ case 'comment':
+ setData((prevData) => {
+ return { ...prevData, comment: e.target.value };
+ });
+ break;
+ case 'email':
+ setData((prevData) => {
+ return { ...prevData, email: e.target.value };
+ });
+ break;
+ case 'website':
+ setData((prevData) => {
+ return { ...prevData, website: e.target.value };
+ });
+ break;
+ default:
+ break;
+ }
+ };
+
+ const submitHandler = (e: FormEvent) => {
+ e.preventDefault();
setIsSubmitting(true);
- saveComment({ comment, email, name, parentId, website }, resetForm).then(
- () => setIsSubmitting(false)
- );
+ saveComment(data, resetForm).then(() => setIsSubmitting(false));
};
return (
@@ -124,6 +155,7 @@ export const CommentForm: FC<CommentFormProps> = ({
{...props}
aria-label={formAriaLabel}
aria-labelledby={formLabelledBy}
+ className={formClass}
onSubmit={submitHandler}
>
{title && (
@@ -133,43 +165,69 @@ export const CommentForm: FC<CommentFormProps> = ({
)}
<LabelledField
className={styles.field}
- id="commenter-name"
- label={nameLabel}
- name="commenter-name"
- required={true}
- setValue={setName}
- type="text"
- value={name}
+ field={
+ <Input
+ id="commenter-name"
+ isRequired
+ name="author"
+ onChange={updateForm}
+ type="text"
+ value={data.author}
+ />
+ }
+ label={
+ <Label htmlFor="commenter-name" isRequired>
+ {nameLabel}
+ </Label>
+ }
/>
<LabelledField
className={styles.field}
- id="commenter-email"
- label={emailLabel}
- name="commenter-email"
- required={true}
- setValue={setEmail}
- type="email"
- value={email}
+ field={
+ <Input
+ id="commenter-email"
+ isRequired
+ name="email"
+ onChange={updateForm}
+ type="email"
+ value={data.email}
+ />
+ }
+ label={
+ <Label htmlFor="commenter-email" isRequired>
+ {emailLabel}
+ </Label>
+ }
/>
<LabelledField
className={styles.field}
- id="commenter-website"
- label={websiteLabel}
- name="commenter-website"
- required={false}
- setValue={setWebsite}
- type="text"
- value={website}
+ field={
+ <Input
+ id="commenter-website"
+ name="website"
+ onChange={updateForm}
+ type="url"
+ value={data.website}
+ />
+ }
+ label={<Label htmlFor="commenter-website">{websiteLabel}</Label>}
/>
<LabelledField
className={styles.field}
- id="commenter-comment"
- label={commentLabel}
- name="commenter-comment"
- required={true}
- setValue={setComment}
- type="textarea"
- value={comment}
+ field={
+ <TextArea
+ id="commenter-comment"
+ isRequired
+ name="comment"
+ onChange={updateForm}
+ value={data.comment}
+ />
+ }
+ label={
+ <Label htmlFor="commenter-comment" isRequired>
+ {commentLabel}
+ </Label>
+ }
/>
<Button type="submit" kind="primary" className={styles.button}>
{intl.formatMessage({
diff --git a/src/components/organisms/forms/comment-form/index.ts b/src/components/organisms/forms/comment-form/index.ts
new file mode 100644
index 0000000..9e22bd9
--- /dev/null
+++ b/src/components/organisms/forms/comment-form/index.ts
@@ -0,0 +1 @@
+export * from './comment-form';
diff --git a/src/components/organisms/forms/contact-form.module.scss b/src/components/organisms/forms/contact-form.module.scss
deleted file mode 100644
index f3f2646..0000000
--- a/src/components/organisms/forms/contact-form.module.scss
+++ /dev/null
@@ -1,8 +0,0 @@
-.field {
- width: 100%;
-}
-
-.button {
- display: block;
- margin: auto;
-}
diff --git a/src/components/organisms/forms/contact-form.tsx b/src/components/organisms/forms/contact-form.tsx
deleted file mode 100644
index ca84c25..0000000
--- a/src/components/organisms/forms/contact-form.tsx
+++ /dev/null
@@ -1,154 +0,0 @@
-import { FC, ReactNode, useState } from 'react';
-import { useIntl } from 'react-intl';
-import { Button, Form, Spinner } from '../../atoms';
-import { LabelledField } from '../../molecules';
-import styles from './contact-form.module.scss';
-
-export type ContactFormData = {
- email: string;
- message: string;
- name: string;
- subject: string;
-};
-
-export type ContactFormProps = {
- /**
- * Set additional classnames to the form wrapper.
- */
- className?: string;
- /**
- * Pass a component to print a success/error message.
- */
- Notice?: ReactNode;
- /**
- * A callback function to send mail.
- */
- sendMail: (data: ContactFormData, reset: () => void) => Promise<void>;
-};
-
-/**
- * ContactForm component
- *
- * Render a contact form.
- */
-export const ContactForm: FC<ContactFormProps> = ({
- className = '',
- Notice,
- sendMail,
-}) => {
- const intl = useIntl();
- const [name, setName] = useState<string>('');
- const [email, setEmail] = useState<string>('');
- const [object, setObject] = useState<string>('');
- const [message, setMessage] = useState<string>('');
- const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
-
- /**
- * Reset all the form fields.
- */
- const resetForm = () => {
- setName('');
- setEmail('');
- setObject('');
- setMessage('');
- setIsSubmitting(false);
- };
-
- const formName = intl.formatMessage({
- defaultMessage: 'Contact form',
- description: 'ContactForm: form accessible name',
- id: 'HFdzae',
- });
-
- const nameLabel = intl.formatMessage({
- defaultMessage: 'Name:',
- description: 'ContactForm: name label',
- id: '1dCuCx',
- });
-
- const emailLabel = intl.formatMessage({
- defaultMessage: 'Email:',
- description: 'ContactForm: email label',
- id: 'w4B5PA',
- });
-
- const objectLabel = intl.formatMessage({
- defaultMessage: 'Object:',
- description: 'ContactForm: object label',
- id: 's8/tyz',
- });
-
- const messageLabel = intl.formatMessage({
- defaultMessage: 'Message:',
- description: 'ContactForm: message label',
- id: 'yN5P+m',
- });
-
- const submitHandler = async () => {
- setIsSubmitting(true);
- sendMail({ email, message, name, subject: object }, resetForm).then(() =>
- setIsSubmitting(false)
- );
- };
-
- return (
- <Form aria-label={formName} className={className} onSubmit={submitHandler}>
- <LabelledField
- className={styles.field}
- id="contact-name"
- label={nameLabel}
- name="contact-name"
- required={true}
- setValue={setName}
- type="text"
- value={name}
- />
- <LabelledField
- className={styles.field}
- id="contact-email"
- label={emailLabel}
- name="contact-email"
- required={true}
- setValue={setEmail}
- type="email"
- value={email}
- />
- <LabelledField
- className={styles.field}
- id="contact-object"
- label={objectLabel}
- name="contact-object"
- setValue={setObject}
- type="text"
- value={object}
- />
- <LabelledField
- className={styles.field}
- id="contact-message"
- label={messageLabel}
- name="contact-message"
- required={true}
- setValue={setMessage}
- type="textarea"
- value={message}
- />
- <Button type="submit" kind="primary" className={styles.button}>
- {intl.formatMessage({
- defaultMessage: 'Send',
- description: 'ContactForm: send button',
- id: 'VkAnvv',
- })}
- </Button>
- {isSubmitting && (
- <Spinner
- message={intl.formatMessage({
- defaultMessage: 'Sending mail...',
- description: 'ContactForm: spinner message on submit',
- id: 'xaqaYQ',
- })}
- />
- )}
- {Notice}
- </Form>
- );
-};
diff --git a/src/components/organisms/forms/contact-form/contact-form.module.scss b/src/components/organisms/forms/contact-form/contact-form.module.scss
new file mode 100644
index 0000000..c106fb1
--- /dev/null
+++ b/src/components/organisms/forms/contact-form/contact-form.module.scss
@@ -0,0 +1,15 @@
+.form {
+ display: flex;
+ flex-flow: column wrap;
+ gap: var(--spacing-xs);
+ max-width: 45ch;
+}
+
+.field {
+ width: 100%;
+}
+
+.button {
+ display: block;
+ margin: var(--spacing-sm) auto 0;
+}
diff --git a/src/components/organisms/forms/contact-form.stories.tsx b/src/components/organisms/forms/contact-form/contact-form.stories.tsx
index 4df3db0..4df3db0 100644
--- a/src/components/organisms/forms/contact-form.stories.tsx
+++ b/src/components/organisms/forms/contact-form/contact-form.stories.tsx
diff --git a/src/components/organisms/forms/contact-form.test.tsx b/src/components/organisms/forms/contact-form/contact-form.test.tsx
index 8e27cd0..59d69fa 100644
--- a/src/components/organisms/forms/contact-form.test.tsx
+++ b/src/components/organisms/forms/contact-form/contact-form.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen } from '../../../../tests/utils';
+import { render, screen } from '../../../../../tests/utils';
import { ContactForm } from './contact-form';
const props = {
diff --git a/src/components/organisms/forms/contact-form/contact-form.tsx b/src/components/organisms/forms/contact-form/contact-form.tsx
new file mode 100644
index 0000000..6208b94
--- /dev/null
+++ b/src/components/organisms/forms/contact-form/contact-form.tsx
@@ -0,0 +1,210 @@
+import { ChangeEvent, FC, FormEvent, ReactNode, useState } from 'react';
+import { useIntl } from 'react-intl';
+import { Button, Form, Input, Label, Spinner, TextArea } from '../../../atoms';
+import { LabelledField } from '../../../molecules';
+import styles from './contact-form.module.scss';
+
+export type ContactFormData = {
+ email: string;
+ message: string;
+ name: string;
+ object: string;
+};
+
+export type ContactFormProps = {
+ /**
+ * Set additional classnames to the form wrapper.
+ */
+ className?: string;
+ /**
+ * Pass a component to print a success/error message.
+ */
+ Notice?: ReactNode;
+ /**
+ * A callback function to send mail.
+ */
+ sendMail: (data: ContactFormData, reset: () => void) => Promise<void>;
+};
+
+/**
+ * ContactForm component
+ *
+ * Render a contact form.
+ */
+export const ContactForm: FC<ContactFormProps> = ({
+ className = '',
+ Notice,
+ sendMail,
+}) => {
+ const formClass = `${styles.form} ${className}`;
+ const intl = useIntl();
+ const emptyForm: ContactFormData = {
+ email: '',
+ message: '',
+ name: '',
+ object: '',
+ };
+ const [data, setData] = useState(emptyForm);
+ const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
+
+ /**
+ * Reset all the form fields.
+ */
+ const resetForm = () => {
+ setData(emptyForm);
+ setIsSubmitting(false);
+ };
+
+ const updateForm = (
+ e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
+ ) => {
+ switch (e.target.name) {
+ case 'email':
+ setData((prevData) => {
+ return { ...prevData, email: e.target.value };
+ });
+ break;
+ case 'message':
+ setData((prevData) => {
+ return { ...prevData, message: e.target.value };
+ });
+ break;
+ case 'name':
+ setData((prevData) => {
+ return { ...prevData, name: e.target.value };
+ });
+ break;
+ case 'object':
+ setData((prevData) => {
+ return { ...prevData, object: e.target.value };
+ });
+ break;
+ default:
+ break;
+ }
+ };
+
+ const formName = intl.formatMessage({
+ defaultMessage: 'Contact form',
+ description: 'ContactForm: form accessible name',
+ id: 'HFdzae',
+ });
+
+ const nameLabel = intl.formatMessage({
+ defaultMessage: 'Name:',
+ description: 'ContactForm: name label',
+ id: '1dCuCx',
+ });
+
+ const emailLabel = intl.formatMessage({
+ defaultMessage: 'Email:',
+ description: 'ContactForm: email label',
+ id: 'w4B5PA',
+ });
+
+ const objectLabel = intl.formatMessage({
+ defaultMessage: 'Object:',
+ description: 'ContactForm: object label',
+ id: 's8/tyz',
+ });
+
+ const messageLabel = intl.formatMessage({
+ defaultMessage: 'Message:',
+ description: 'ContactForm: message label',
+ id: 'yN5P+m',
+ });
+
+ const submitHandler = async (e: FormEvent) => {
+ e.preventDefault();
+ setIsSubmitting(true);
+ sendMail(data, resetForm).then(() => setIsSubmitting(false));
+ };
+
+ return (
+ <Form aria-label={formName} className={formClass} onSubmit={submitHandler}>
+ <LabelledField
+ className={styles.field}
+ field={
+ <Input
+ id="contact-name"
+ isRequired
+ name="name"
+ onChange={updateForm}
+ type="text"
+ value={data.name}
+ />
+ }
+ label={
+ <Label htmlFor="contact-name" isRequired>
+ {nameLabel}
+ </Label>
+ }
+ />
+ <LabelledField
+ className={styles.field}
+ field={
+ <Input
+ id="contact-email"
+ isRequired
+ name="email"
+ onChange={updateForm}
+ type="email"
+ value={data.email}
+ />
+ }
+ label={
+ <Label htmlFor="contact-email" isRequired>
+ {emailLabel}
+ </Label>
+ }
+ />
+ <LabelledField
+ className={styles.field}
+ field={
+ <Input
+ id="contact-object"
+ name="object"
+ onChange={updateForm}
+ type="text"
+ value={data.object}
+ />
+ }
+ label={<Label htmlFor="contact-object">{objectLabel}</Label>}
+ />
+ <LabelledField
+ className={styles.field}
+ field={
+ <TextArea
+ id="contact-message"
+ isRequired
+ name="message"
+ onChange={updateForm}
+ value={data.message}
+ />
+ }
+ label={
+ <Label htmlFor="contact-message" isRequired>
+ {messageLabel}
+ </Label>
+ }
+ />
+ <Button type="submit" kind="primary" className={styles.button}>
+ {intl.formatMessage({
+ defaultMessage: 'Send',
+ description: 'ContactForm: send button',
+ id: 'VkAnvv',
+ })}
+ </Button>
+ {isSubmitting && (
+ <Spinner
+ message={intl.formatMessage({
+ defaultMessage: 'Sending mail...',
+ description: 'ContactForm: spinner message on submit',
+ id: 'xaqaYQ',
+ })}
+ />
+ )}
+ {Notice}
+ </Form>
+ );
+};
diff --git a/src/components/organisms/forms/contact-form/index.ts b/src/components/organisms/forms/contact-form/index.ts
new file mode 100644
index 0000000..c72af3d
--- /dev/null
+++ b/src/components/organisms/forms/contact-form/index.ts
@@ -0,0 +1 @@
+export * from './contact-form';
diff --git a/src/components/organisms/forms/index.ts b/src/components/organisms/forms/index.ts
index 10eaf20..e507895 100644
--- a/src/components/organisms/forms/index.ts
+++ b/src/components/organisms/forms/index.ts
@@ -1,3 +1,7 @@
+export * from './ackee-toggle';
export * from './comment-form';
export * from './contact-form';
+export * from './motion-toggle';
+export * from './prism-theme-toggle';
export * from './search-form';
+export * from './theme-toggle';
diff --git a/src/components/organisms/forms/motion-toggle/index.ts b/src/components/organisms/forms/motion-toggle/index.ts
new file mode 100644
index 0000000..0e35578
--- /dev/null
+++ b/src/components/organisms/forms/motion-toggle/index.ts
@@ -0,0 +1 @@
+export * from './motion-toggle';
diff --git a/src/components/molecules/forms/motion-toggle.fixture.tsx b/src/components/organisms/forms/motion-toggle/motion-toggle.fixture.tsx
index f13658a..f13658a 100644
--- a/src/components/molecules/forms/motion-toggle.fixture.tsx
+++ b/src/components/organisms/forms/motion-toggle/motion-toggle.fixture.tsx
diff --git a/src/components/molecules/forms/motion-toggle.stories.tsx b/src/components/organisms/forms/motion-toggle/motion-toggle.stories.tsx
index bb51e26..7e541db 100644
--- a/src/components/molecules/forms/motion-toggle.stories.tsx
+++ b/src/components/organisms/forms/motion-toggle/motion-toggle.stories.tsx
@@ -6,22 +6,9 @@ import { storageKey } from './motion-toggle.fixture';
* MotionToggle - Storybook Meta
*/
export default {
- title: 'Molecules/Forms/Toggle',
+ title: 'Organisms/Forms/Toggle',
component: MotionToggle,
argTypes: {
- bodyClassName: {
- control: {
- type: 'text',
- },
- description: 'Set additional classnames to the fieldset body wrapper.',
- table: {
- category: 'Styles',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
defaultValue: {
control: {
type: 'select',
@@ -33,32 +20,6 @@ export default {
required: true,
},
},
- groupClassName: {
- control: {
- type: 'text',
- },
- description: 'Set additional classnames to the radio group wrapper.',
- table: {
- category: 'Styles',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- legendClassName: {
- control: {
- type: 'text',
- },
- description: 'Set additional classnames to the legend.',
- table: {
- category: 'Styles',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
storageKey: {
control: {
type: 'text',
diff --git a/src/components/molecules/forms/motion-toggle.test.tsx b/src/components/organisms/forms/motion-toggle/motion-toggle.test.tsx
index 93df3a0..614c038 100644
--- a/src/components/molecules/forms/motion-toggle.test.tsx
+++ b/src/components/organisms/forms/motion-toggle/motion-toggle.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen } from '../../../../tests/utils';
+import { render, screen } from '../../../../../tests/utils';
import { MotionToggle } from './motion-toggle';
import { storageKey } from './motion-toggle.fixture';
diff --git a/src/components/molecules/forms/motion-toggle.tsx b/src/components/organisms/forms/motion-toggle/motion-toggle.tsx
index c1a55f0..a8ca7ce 100644
--- a/src/components/molecules/forms/motion-toggle.tsx
+++ b/src/components/organisms/forms/motion-toggle/motion-toggle.tsx
@@ -1,19 +1,14 @@
-import { FC } from 'react';
+import { ChangeEvent, FC } from 'react';
import { useIntl } from 'react-intl';
-import { useAttributes, useLocalStorage } from '../../../utils/hooks';
-import {
- RadioGroup,
- type RadioGroupCallback,
- type RadioGroupCallbackProps,
- type RadioGroupOption,
- type RadioGroupProps,
-} from './radio-group';
+import { useAttributes, useLocalStorage } from '../../../../utils/hooks';
+import { Legend } from '../../../atoms';
+import { Switch, SwitchOption, SwitchProps } from '../../../molecules';
export type MotionToggleValue = 'on' | 'off';
-export type MotionToggleProps = Pick<
- RadioGroupProps,
- 'bodyClassName' | 'groupClassName' | 'legendClassName' | 'legendPosition'
+export type MotionToggleProps = Omit<
+ SwitchProps,
+ 'isInline' | 'items' | 'name' | 'onSwitch' | 'value'
> & {
/**
* True if motion should be reduced by default.
@@ -38,7 +33,7 @@ export const MotionToggle: FC<MotionToggleProps> = ({
const intl = useIntl();
const { value: isReduced, setValue: setIsReduced } = useLocalStorage<boolean>(
storageKey,
- defaultValue === 'on' ? false : true
+ defaultValue !== 'on'
);
useAttributes({
element:
@@ -63,56 +58,32 @@ export const MotionToggle: FC<MotionToggleProps> = ({
id: 'pWKyyR',
});
- const options: RadioGroupOption[] = [
+ const options: [SwitchOption, SwitchOption] = [
{
id: 'reduced-motion-on',
label: onLabel,
- name: 'reduced-motion',
value: 'on',
},
{
id: 'reduced-motion-off',
label: offLabel,
- name: 'reduced-motion',
value: 'off',
},
];
- /**
- * Update the current setting.
- *
- * @param {string} newValue - A boolean as string.
- */
- const updateSetting = (newValue: MotionToggleValue) => {
- setIsReduced(newValue === 'on' ? false : true);
- };
-
- /**
- * Handle change events.
- *
- * @param {RadioGroupCallbackProps} props - An object with choices.
- */
- const handleChange: RadioGroupCallback = ({
- choices,
- updateChoice,
- }: RadioGroupCallbackProps) => {
- if (choices.new === choices.prev) {
- const newChoice = choices.new === 'on' ? 'off' : 'on';
- updateChoice(newChoice);
- updateSetting(newChoice);
- } else {
- updateSetting(choices.new as MotionToggleValue);
- }
+ const updateSetting = (e: ChangeEvent<HTMLInputElement>) => {
+ setIsReduced((prev) => !prev);
};
return (
- <RadioGroup
+ <Switch
{...props}
- initialChoice={defaultValue}
- kind="toggle"
- legend={reduceMotionLabel}
- onChange={handleChange}
- options={options}
+ isInline
+ items={options}
+ legend={<Legend>{reduceMotionLabel}</Legend>}
+ name="reduced-motion"
+ onSwitch={updateSetting}
+ value={isReduced ? 'off' : 'on'}
/>
);
};
diff --git a/src/components/organisms/forms/prism-theme-toggle/index.ts b/src/components/organisms/forms/prism-theme-toggle/index.ts
new file mode 100644
index 0000000..f4e490f
--- /dev/null
+++ b/src/components/organisms/forms/prism-theme-toggle/index.ts
@@ -0,0 +1 @@
+export * from './prism-theme-toggle';
diff --git a/src/components/organisms/forms/prism-theme-toggle/prism-theme-toggle.stories.tsx b/src/components/organisms/forms/prism-theme-toggle/prism-theme-toggle.stories.tsx
new file mode 100644
index 0000000..3c8eaba
--- /dev/null
+++ b/src/components/organisms/forms/prism-theme-toggle/prism-theme-toggle.stories.tsx
@@ -0,0 +1,20 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { PrismThemeToggle } from './prism-theme-toggle';
+
+/**
+ * PrismThemeToggle - Storybook Meta
+ */
+export default {
+ title: 'Organisms/Forms/Toggle',
+ component: PrismThemeToggle,
+ argTypes: {},
+} as ComponentMeta<typeof PrismThemeToggle>;
+
+const Template: ComponentStory<typeof PrismThemeToggle> = (args) => (
+ <PrismThemeToggle {...args} />
+);
+
+/**
+ * Toggle Stories - Prism theme
+ */
+export const PrismTheme = Template.bind({});
diff --git a/src/components/molecules/forms/prism-theme-toggle.test.tsx b/src/components/organisms/forms/prism-theme-toggle/prism-theme-toggle.test.tsx
index bd63762..f29418e 100644
--- a/src/components/molecules/forms/prism-theme-toggle.test.tsx
+++ b/src/components/organisms/forms/prism-theme-toggle/prism-theme-toggle.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen } from '../../../../tests/utils';
+import { render, screen } from '../../../../../tests/utils';
import { PrismThemeToggle } from './prism-theme-toggle';
describe('PrismThemeToggle', () => {
diff --git a/src/components/molecules/forms/prism-theme-toggle.tsx b/src/components/organisms/forms/prism-theme-toggle/prism-theme-toggle.tsx
index 5427fec..0e1649b 100644
--- a/src/components/molecules/forms/prism-theme-toggle.tsx
+++ b/src/components/organisms/forms/prism-theme-toggle/prism-theme-toggle.tsx
@@ -1,21 +1,12 @@
-import { FC } from 'react';
+import { ChangeEvent, FC } from 'react';
import { useIntl } from 'react-intl';
-import {
- type PrismTheme,
- usePrismTheme,
-} from '../../../utils/providers/prism-theme';
-import { Moon, Sun } from '../../atoms';
-import {
- RadioGroup,
- type RadioGroupCallback,
- type RadioGroupCallbackProps,
- type RadioGroupOption,
- type RadioGroupProps,
-} from './radio-group';
+import { type PrismTheme, usePrismTheme } from '../../../../utils/providers';
+import { Legend, Moon, Sun } from '../../../atoms';
+import { Switch, SwitchOption, SwitchProps } from '../../../molecules';
-export type PrismThemeToggleProps = Pick<
- RadioGroupProps,
- 'bodyClassName' | 'groupClassName' | 'legendClassName' | 'legendPosition'
+export type PrismThemeToggleProps = Omit<
+ SwitchProps,
+ 'isInline' | 'items' | 'name' | 'onSwitch' | 'value'
>;
/**
@@ -37,31 +28,8 @@ export const PrismThemeToggle: FC<PrismThemeToggleProps> = (props) => {
return prismTheme === 'dark';
};
- /**
- * Update the theme.
- *
- * @param {string} newTheme - A theme name.
- */
- const updateTheme = (newTheme: string) => {
- setTheme(newTheme === 'light' ? 'light' : 'dark');
- };
-
- /**
- * Handle change events.
- *
- * @param {RadioGroupCallbackProps} props - An object with choices.
- */
- const handleChange: RadioGroupCallback = ({
- choices,
- updateChoice,
- }: RadioGroupCallbackProps) => {
- if (choices.new === choices.prev) {
- const newTheme = choices.new === 'light' ? 'dark' : 'light';
- updateChoice(newTheme);
- updateTheme(newTheme);
- } else {
- updateTheme(choices.new);
- }
+ const updateTheme = (e: ChangeEvent<HTMLInputElement>) => {
+ setTheme(e.target.value === 'light' ? 'light' : 'dark');
};
const themeLabel = intl.formatMessage({
@@ -80,7 +48,7 @@ export const PrismThemeToggle: FC<PrismThemeToggleProps> = (props) => {
id: 'og/zWL',
});
- const options: RadioGroupOption[] = [
+ const options: [SwitchOption, SwitchOption] = [
{
id: 'code-blocks-light',
label: (
@@ -89,7 +57,6 @@ export const PrismThemeToggle: FC<PrismThemeToggleProps> = (props) => {
<Sun />
</>
),
- name: 'code-blocks',
value: 'light',
},
{
@@ -100,19 +67,19 @@ export const PrismThemeToggle: FC<PrismThemeToggleProps> = (props) => {
<Moon />
</>
),
- name: 'code-blocks',
value: 'dark',
},
];
return (
- <RadioGroup
+ <Switch
{...props}
- initialChoice={isDarkTheme(theme) ? 'dark' : 'light'}
- kind="toggle"
- legend={themeLabel}
- onChange={handleChange}
- options={options}
+ isInline
+ items={options}
+ legend={<Legend>{themeLabel}</Legend>}
+ name="code-blocks"
+ onSwitch={updateTheme}
+ value={isDarkTheme(theme) ? 'dark' : 'light'}
/>
);
};
diff --git a/src/components/organisms/forms/search-form/index.ts b/src/components/organisms/forms/search-form/index.ts
new file mode 100644
index 0000000..e7d3f3d
--- /dev/null
+++ b/src/components/organisms/forms/search-form/index.ts
@@ -0,0 +1 @@
+export * from './search-form';
diff --git a/src/components/organisms/forms/search-form.module.scss b/src/components/organisms/forms/search-form/search-form.module.scss
index 773a79f..e485380 100644
--- a/src/components/organisms/forms/search-form.module.scss
+++ b/src/components/organisms/forms/search-form/search-form.module.scss
@@ -1,5 +1,5 @@
-@use "../../../styles/abstracts/functions" as fun;
-@use "../../../styles/abstracts/mixins" as mix;
+@use "../../../../styles/abstracts/functions" as fun;
+@use "../../../../styles/abstracts/mixins" as mix;
.wrapper {
display: flex;
@@ -14,8 +14,12 @@
}
.btn {
- position: absolute;
- right: 0;
+ align-self: stretch;
+ background: var(--color-bg-tertiary);
+ border: fun.convert-px(2) solid var(--color-border);
+ border-left: none;
+ box-shadow: fun.convert-px(3) fun.convert-px(3) 0 0 var(--color-shadow);
+ transition: all 0.25s linear 0s;
&__icon {
transform: scale(0.85);
@@ -45,14 +49,19 @@
}
.field {
- width: 100%;
- padding-right: var(--spacing-lg);
-
- &:hover ~ .btn {
- transform: translate(fun.convert-px(-3), fun.convert-px(-3));
+ &:focus-within ~ .btn {
+ background: var(--color-bg);
+ border-color: var(--color-primary);
+ box-shadow: none;
+ transform: translate(fun.convert-px(3), fun.convert-px(3));
+ transition:
+ all 0.2s ease-in-out 0s,
+ transform 0.3s ease-out 0s;
}
- &:focus ~ .btn {
- transform: translate(fun.convert-px(3), fun.convert-px(3));
+ &:hover:not(:focus-within) ~ .btn {
+ 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));
}
}
diff --git a/src/components/organisms/forms/search-form.stories.tsx b/src/components/organisms/forms/search-form/search-form.stories.tsx
index 4a0a15c..c5fbeb9 100644
--- a/src/components/organisms/forms/search-form.stories.tsx
+++ b/src/components/organisms/forms/search-form/search-form.stories.tsx
@@ -8,7 +8,7 @@ export default {
title: 'Organisms/Forms',
component: SearchForm,
args: {
- hideLabel: false,
+ isLabelHidden: false,
searchPage: '#',
},
argTypes: {
@@ -25,7 +25,7 @@ export default {
required: false,
},
},
- hideLabel: {
+ isLabelHidden: {
control: {
type: 'boolean',
},
@@ -61,5 +61,5 @@ const Template: ComponentStory<typeof SearchForm> = (args) => (
*/
export const Search = Template.bind({});
Search.args = {
- hideLabel: true,
+ isLabelHidden: true,
};
diff --git a/src/components/organisms/forms/search-form.test.tsx b/src/components/organisms/forms/search-form/search-form.test.tsx
index bc9b7a0..b53b9cf 100644
--- a/src/components/organisms/forms/search-form.test.tsx
+++ b/src/components/organisms/forms/search-form/search-form.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen } from '../../../../tests/utils';
+import { render, screen } from '../../../../../tests/utils';
import { SearchForm } from './search-form';
describe('SearchForm', () => {
diff --git a/src/components/organisms/forms/search-form.tsx b/src/components/organisms/forms/search-form/search-form.tsx
index f80d295..826e6c8 100644
--- a/src/components/organisms/forms/search-form.tsx
+++ b/src/components/organisms/forms/search-form/search-form.tsx
@@ -1,11 +1,24 @@
import { useRouter } from 'next/router';
-import { forwardRef, ForwardRefRenderFunction, useId, useState } from 'react';
+import {
+ ChangeEvent,
+ FormEvent,
+ forwardRef,
+ ForwardRefRenderFunction,
+ useId,
+ useState,
+} from 'react';
import { useIntl } from 'react-intl';
-import { Button, Form, MagnifyingGlass } from '../../atoms';
-import { LabelledField, type LabelledFieldProps } from '../../molecules';
+import { Button, Form, Input, Label, MagnifyingGlass } from '../../../atoms';
+import { LabelledField } from '../../../molecules';
import styles from './search-form.module.scss';
-export type SearchFormProps = Pick<LabelledFieldProps, 'hideLabel'> & {
+export type SearchFormProps = {
+ /**
+ * Should the label be visually hidden?
+ *
+ * @default false
+ */
+ isLabelHidden?: boolean;
/**
* The search page url.
*/
@@ -15,7 +28,7 @@ export type SearchFormProps = Pick<LabelledFieldProps, 'hideLabel'> & {
const SearchFormWithRef: ForwardRefRenderFunction<
HTMLInputElement,
SearchFormProps
-> = ({ hideLabel, searchPage }, ref) => {
+> = ({ isLabelHidden = false, searchPage }, ref) => {
const intl = useIntl();
const fieldLabel = intl.formatMessage({
defaultMessage: 'Search for:',
@@ -31,25 +44,38 @@ const SearchFormWithRef: ForwardRefRenderFunction<
const router = useRouter();
const [value, setValue] = useState<string>('');
- const submitHandler = () => {
+ const submitHandler = (e: FormEvent) => {
+ e.preventDefault();
router.push({ pathname: searchPage, query: { s: value } });
setValue('');
};
+ const updateForm = (e: ChangeEvent<HTMLInputElement>) => {
+ setValue(e.target.value);
+ };
+
const id = useId();
return (
- <Form className={styles.wrapper} grouped={false} onSubmit={submitHandler}>
+ <Form className={styles.wrapper} onSubmit={submitHandler}>
<LabelledField
className={styles.field}
- hideLabel={hideLabel}
- id={`search-form-${id}`}
- label={fieldLabel}
- name="search-form"
- ref={ref}
- setValue={setValue}
- type="search"
- value={value}
+ field={
+ <Input
+ className={styles.field}
+ id={`search-form-${id}`}
+ name="search-form"
+ onChange={updateForm}
+ ref={ref}
+ type="search"
+ value={value}
+ />
+ }
+ label={
+ <Label htmlFor={`search-form-${id}`} isHidden={isLabelHidden}>
+ {fieldLabel}
+ </Label>
+ }
/>
<Button
aria-label={buttonLabel}
diff --git a/src/components/organisms/forms/theme-toggle/index.ts b/src/components/organisms/forms/theme-toggle/index.ts
new file mode 100644
index 0000000..0dbf668
--- /dev/null
+++ b/src/components/organisms/forms/theme-toggle/index.ts
@@ -0,0 +1 @@
+export * from './theme-toggle';
diff --git a/src/components/organisms/forms/theme-toggle/theme-toggle.stories.tsx b/src/components/organisms/forms/theme-toggle/theme-toggle.stories.tsx
new file mode 100644
index 0000000..ac228b4
--- /dev/null
+++ b/src/components/organisms/forms/theme-toggle/theme-toggle.stories.tsx
@@ -0,0 +1,20 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { ThemeToggle } from './theme-toggle';
+
+/**
+ * ThemeToggle - Storybook Meta
+ */
+export default {
+ title: 'Organisms/Forms/Toggle',
+ component: ThemeToggle,
+ argTypes: {},
+} as ComponentMeta<typeof ThemeToggle>;
+
+const Template: ComponentStory<typeof ThemeToggle> = (args) => (
+ <ThemeToggle {...args} />
+);
+
+/**
+ * Toggle Stories - Theme
+ */
+export const Theme = Template.bind({});
diff --git a/src/components/molecules/forms/theme-toggle.test.tsx b/src/components/organisms/forms/theme-toggle/theme-toggle.test.tsx
index aae627a..9f37a26 100644
--- a/src/components/molecules/forms/theme-toggle.test.tsx
+++ b/src/components/organisms/forms/theme-toggle/theme-toggle.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen } from '../../../../tests/utils';
+import { render, screen } from '../../../../../tests/utils';
import { ThemeToggle } from './theme-toggle';
describe('ThemeToggle', () => {
diff --git a/src/components/molecules/forms/theme-toggle.tsx b/src/components/organisms/forms/theme-toggle/theme-toggle.tsx
index 25e2a39..da303d3 100644
--- a/src/components/molecules/forms/theme-toggle.tsx
+++ b/src/components/organisms/forms/theme-toggle/theme-toggle.tsx
@@ -1,18 +1,12 @@
import { useTheme } from 'next-themes';
-import { FC } from 'react';
+import { ChangeEvent, FC } from 'react';
import { useIntl } from 'react-intl';
-import { Moon, Sun } from '../../atoms';
-import {
- RadioGroup,
- type RadioGroupCallback,
- type RadioGroupCallbackProps,
- type RadioGroupOption,
- type RadioGroupProps,
-} from './radio-group';
+import { Legend, Moon, Sun } from '../../../atoms';
+import { Switch, SwitchOption, SwitchProps } from '../../../molecules';
-export type ThemeToggleProps = Pick<
- RadioGroupProps,
- 'bodyClassName' | 'groupClassName' | 'legendClassName' | 'legendPosition'
+export type ThemeToggleProps = Omit<
+ SwitchProps,
+ 'isInline' | 'items' | 'name' | 'onSwitch' | 'value'
>;
/**
@@ -25,31 +19,8 @@ export const ThemeToggle: FC<ThemeToggleProps> = (props) => {
const { resolvedTheme, setTheme } = useTheme();
const isDarkTheme = resolvedTheme === 'dark';
- /**
- * Update the theme.
- *
- * @param {string} theme - A theme name.
- */
- const updateTheme = (theme: string) => {
- setTheme(theme === 'light' ? 'light' : 'dark');
- };
-
- /**
- * Handle change events.
- *
- * @param {RadioGroupCallbackProps} props - An object with choices.
- */
- const handleChange: RadioGroupCallback = ({
- choices,
- updateChoice,
- }: RadioGroupCallbackProps) => {
- if (choices.new === choices.prev) {
- const newTheme = choices.new === 'light' ? 'dark' : 'light';
- updateChoice(newTheme);
- updateTheme(newTheme);
- } else {
- updateTheme(choices.new);
- }
+ const updateTheme = (e: ChangeEvent<HTMLInputElement>) => {
+ setTheme(e.target.value === 'light' ? 'light' : 'dark');
};
const themeLabel = intl.formatMessage({
@@ -68,7 +39,7 @@ export const ThemeToggle: FC<ThemeToggleProps> = (props) => {
id: '2QwvtS',
});
- const options: RadioGroupOption[] = [
+ const options: [SwitchOption, SwitchOption] = [
{
id: 'theme-light',
label: (
@@ -77,7 +48,6 @@ export const ThemeToggle: FC<ThemeToggleProps> = (props) => {
<Sun />
</>
),
- name: 'theme',
value: 'light',
},
{
@@ -88,19 +58,19 @@ export const ThemeToggle: FC<ThemeToggleProps> = (props) => {
<Moon />
</>
),
- name: 'theme',
value: 'dark',
},
];
return (
- <RadioGroup
+ <Switch
{...props}
- initialChoice={isDarkTheme ? 'dark' : 'light'}
- kind="toggle"
- legend={themeLabel}
- onChange={handleChange}
- options={options}
+ isInline
+ items={options}
+ legend={<Legend>{themeLabel}</Legend>}
+ name="theme"
+ onSwitch={updateTheme}
+ value={isDarkTheme ? 'dark' : 'light'}
/>
);
};
diff --git a/src/components/organisms/layout/no-results.tsx b/src/components/organisms/layout/no-results.tsx
index 1e7afe1..b2acf12 100644
--- a/src/components/organisms/layout/no-results.tsx
+++ b/src/components/organisms/layout/no-results.tsx
@@ -28,7 +28,7 @@ export const NoResults: FC<NoResultsProps> = ({ searchPage }) => {
id: 'DVBwfu',
})}
</p>
- <SearchForm hideLabel={true} searchPage={searchPage} />
+ <SearchForm isLabelHidden searchPage={searchPage} />
</>
);
};
diff --git a/src/components/organisms/modals/search-modal.tsx b/src/components/organisms/modals/search-modal.tsx
index 7ba770f..7d772df 100644
--- a/src/components/organisms/modals/search-modal.tsx
+++ b/src/components/organisms/modals/search-modal.tsx
@@ -1,6 +1,6 @@
import { forwardRef, ForwardRefRenderFunction } from 'react';
import { useIntl } from 'react-intl';
-import { Modal, type ModalProps } from '../../molecules';
+import { Heading, Modal, type ModalProps } from '../../atoms';
import { SearchForm, type SearchFormProps } from '../forms';
import styles from './search-modal.module.scss';
@@ -23,8 +23,15 @@ const SearchModalWithRef: ForwardRefRenderFunction<
});
return (
- <Modal className={`${styles.wrapper} ${className}`} title={modalTitle}>
- <SearchForm hideLabel={true} ref={ref} searchPage={searchPage} />
+ <Modal
+ className={`${styles.wrapper} ${className}`}
+ heading={
+ <Heading isFake level={3}>
+ {modalTitle}
+ </Heading>
+ }
+ >
+ <SearchForm isLabelHidden ref={ref} searchPage={searchPage} />
</Modal>
);
};
diff --git a/src/components/organisms/modals/settings-modal.module.scss b/src/components/organisms/modals/settings-modal.module.scss
index e9b3b85..95626ab 100644
--- a/src/components/organisms/modals/settings-modal.module.scss
+++ b/src/components/organisms/modals/settings-modal.module.scss
@@ -20,27 +20,17 @@
column-gap: var(--spacing-lg);
}
-.items {
+.item {
+ width: 100%;
margin: 0 0 var(--spacing-2xs);
- max-width: unset;
-}
-.fieldset__body {
- margin-left: auto;
+ > *:last-child {
+ margin-left: auto;
+ }
}
-.tooltip {
- font-size: var(--font-size-sm);
- z-index: 2;
+.icon {
+ --icon-size: #{fun.convert-px(30)};
- @media screen and (max-height: #{var.get-breakpoint("2xs")}) {
- width: calc(100vw - var(--spacing-md));
- padding: var(--spacing-md) var(--spacing-2xs) var(--spacing-2xs)
- var(--spacing-2xs);
- right: 0;
- }
-
- @media screen and (min-width: #{var.get-breakpoint("sm")}) {
- width: 100%;
- }
+ margin-right: var(--spacing-2xs);
}
diff --git a/src/components/organisms/modals/settings-modal.stories.tsx b/src/components/organisms/modals/settings-modal.stories.tsx
index 093922d..7af0d60 100644
--- a/src/components/organisms/modals/settings-modal.stories.tsx
+++ b/src/components/organisms/modals/settings-modal.stories.tsx
@@ -1,6 +1,6 @@
import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { storageKey as ackeeStorageKey } from '../../molecules/forms/ackee-toggle.fixture';
-import { storageKey as motionStorageKey } from '../../molecules/forms/motion-toggle.fixture';
+import { storageKey as ackeeStorageKey } from '../../organisms/forms/ackee-toggle/ackee-toggle.fixture';
+import { storageKey as motionStorageKey } from '../../organisms/forms/motion-toggle/motion-toggle.fixture';
import { SettingsModal } from './settings-modal';
/**
diff --git a/src/components/organisms/modals/settings-modal.test.tsx b/src/components/organisms/modals/settings-modal.test.tsx
index ec14719..3cd64f6 100644
--- a/src/components/organisms/modals/settings-modal.test.tsx
+++ b/src/components/organisms/modals/settings-modal.test.tsx
@@ -1,6 +1,6 @@
import { render, screen } from '../../../../tests/utils';
-import { storageKey as ackeeStorageKey } from '../../molecules/forms/ackee-toggle.fixture';
-import { storageKey as motionStorageKey } from '../../molecules/forms/motion-toggle.fixture';
+import { storageKey as ackeeStorageKey } from '../../organisms/forms/ackee-toggle/ackee-toggle.fixture';
+import { storageKey as motionStorageKey } from '../../organisms/forms/motion-toggle/motion-toggle.fixture';
import { SettingsModal } from './settings-modal';
describe('SettingsModal', () => {
diff --git a/src/components/organisms/modals/settings-modal.tsx b/src/components/organisms/modals/settings-modal.tsx
index d4a3a49..bb3d886 100644
--- a/src/components/organisms/modals/settings-modal.tsx
+++ b/src/components/organisms/modals/settings-modal.tsx
@@ -1,29 +1,26 @@
import { FC } from 'react';
import { useIntl } from 'react-intl';
-import { Form } from '../../atoms';
+import { Cog, Form, Heading, Modal, type ModalProps } from '../../atoms';
import {
AckeeToggle,
type AckeeToggleProps,
- Modal,
- type ModalProps,
MotionToggle,
type MotionToggleProps,
PrismThemeToggle,
ThemeToggle,
-} from '../../molecules';
+} from '../../organisms';
import styles from './settings-modal.module.scss';
-export type SettingsModalProps = Pick<ModalProps, 'className'> &
- Pick<AckeeToggleProps, 'tooltipClassName'> & {
- /**
- * The local storage key for Ackee settings.
- */
- ackeeStorageKey: AckeeToggleProps['storageKey'];
- /**
- * The local storage key for Reduce motion settings.
- */
- motionStorageKey: MotionToggleProps['storageKey'];
- };
+export type SettingsModalProps = Pick<ModalProps, 'className'> & {
+ /**
+ * The local storage key for Ackee settings.
+ */
+ ackeeStorageKey: AckeeToggleProps['storageKey'];
+ /**
+ * The local storage key for Reduce motion settings.
+ */
+ motionStorageKey: MotionToggleProps['storageKey'];
+};
/**
* SettingsModal component
@@ -34,7 +31,6 @@ export const SettingsModal: FC<SettingsModalProps> = ({
className = '',
ackeeStorageKey,
motionStorageKey,
- tooltipClassName,
}) => {
const intl = useIntl();
const title = intl.formatMessage({
@@ -51,40 +47,30 @@ export const SettingsModal: FC<SettingsModalProps> = ({
return (
<Modal
className={`${styles.wrapper} ${className}`}
- icon="cogs"
- title={title}
+ heading={
+ <Heading isFake level={3}>
+ <Cog className={styles.icon} />
+ {title}
+ </Heading>
+ }
>
<Form
aria-label={ariaLabel}
className={styles.form}
- itemsClassName={styles.items}
onSubmit={() => null}
>
- <ThemeToggle
- bodyClassName={styles.fieldset__body}
- groupClassName={styles.group}
- legendClassName={styles.label}
- />
- <PrismThemeToggle
- bodyClassName={styles.fieldset__body}
- groupClassName={styles.group}
- legendClassName={styles.label}
- />
+ <ThemeToggle className={styles.item} />
+ <PrismThemeToggle className={styles.item} />
<MotionToggle
- bodyClassName={styles.fieldset__body}
+ className={styles.item}
defaultValue="on"
- groupClassName={styles.group}
- legendClassName={styles.label}
storageKey={motionStorageKey}
/>
<AckeeToggle
- bodyClassName={styles.fieldset__body}
- buttonClassName={styles.btn}
+ className={styles.item}
+ direction="upwards"
defaultValue="full"
- groupClassName={`${styles.group} ${styles['group--ackee']}`}
- legendClassName={`${styles.label} ${styles['label--ackee']}`}
storageKey={ackeeStorageKey}
- tooltipClassName={`${styles.tooltip} ${tooltipClassName}`}
/>
</Form>
</Modal>
diff --git a/src/components/organisms/toolbar/main-nav.stories.tsx b/src/components/organisms/toolbar/main-nav.stories.tsx
index 1ef10b5..57485d3 100644
--- a/src/components/organisms/toolbar/main-nav.stories.tsx
+++ b/src/components/organisms/toolbar/main-nav.stories.tsx
@@ -57,7 +57,7 @@ export default {
} as ComponentMeta<typeof MainNav>;
const Template: ComponentStory<typeof MainNav> = ({
- isActive,
+ isActive = false,
setIsActive: _setIsActive,
...args
}) => {
diff --git a/src/components/organisms/toolbar/main-nav.tsx b/src/components/organisms/toolbar/main-nav.tsx
index cf49bd4..4182b4c 100644
--- a/src/components/organisms/toolbar/main-nav.tsx
+++ b/src/components/organisms/toolbar/main-nav.tsx
@@ -18,7 +18,7 @@ export type MainNavProps = {
/**
* The button state.
*/
- isActive: BooleanFieldProps['checked'];
+ isActive: BooleanFieldProps['isChecked'];
/**
* The main nav items.
*/
@@ -30,7 +30,7 @@ export type MainNavProps = {
};
const MainNavWithRef: ForwardRefRenderFunction<HTMLDivElement, MainNavProps> = (
- { className = '', isActive, items, setIsActive },
+ { className = '', isActive = false, items, setIsActive },
ref
) => {
const intl = useIntl();
@@ -49,9 +49,9 @@ const MainNavWithRef: ForwardRefRenderFunction<HTMLDivElement, MainNavProps> = (
return (
<div className={`${sharedStyles.item} ${mainNavStyles.item}`} ref={ref}>
<BooleanField
- checked={isActive}
className={`${sharedStyles.checkbox} ${mainNavStyles.checkbox}`}
id="main-nav-button"
+ isChecked={isActive}
name="main-nav-button"
onChange={setIsActive}
type="checkbox"
diff --git a/src/components/organisms/toolbar/search.stories.tsx b/src/components/organisms/toolbar/search.stories.tsx
index 3b2a747..2c8dd10 100644
--- a/src/components/organisms/toolbar/search.stories.tsx
+++ b/src/components/organisms/toolbar/search.stories.tsx
@@ -62,7 +62,7 @@ export default {
} as ComponentMeta<typeof Search>;
const Template: ComponentStory<typeof Search> = ({
- isActive,
+ isActive = false,
setIsActive: _setIsActive,
...args
}) => {
diff --git a/src/components/organisms/toolbar/search.tsx b/src/components/organisms/toolbar/search.tsx
index 1b2643c..b20f0d5 100644
--- a/src/components/organisms/toolbar/search.tsx
+++ b/src/components/organisms/toolbar/search.tsx
@@ -19,7 +19,7 @@ export type SearchProps = {
/**
* The button state.
*/
- isActive: BooleanFieldProps['checked'];
+ isActive: BooleanFieldProps['isChecked'];
/**
* A callback function to execute search.
*/
@@ -31,7 +31,7 @@ export type SearchProps = {
};
const SearchWithRef: ForwardRefRenderFunction<HTMLDivElement, SearchProps> = (
- { className = '', isActive, searchPage, setIsActive },
+ { className = '', isActive = false, searchPage, setIsActive },
ref
) => {
const intl = useIntl();
@@ -57,9 +57,9 @@ const SearchWithRef: ForwardRefRenderFunction<HTMLDivElement, SearchProps> = (
return (
<div className={`${sharedStyles.item} ${searchStyles.item}`} ref={ref}>
<BooleanField
- checked={isActive}
className={`${sharedStyles.checkbox} ${searchStyles.checkbox}`}
id="search-button"
+ isChecked={isActive}
name="search-button"
onChange={setIsActive}
type="checkbox"
diff --git a/src/components/organisms/toolbar/settings.stories.tsx b/src/components/organisms/toolbar/settings.stories.tsx
index a83f8d3..bea0d9e 100644
--- a/src/components/organisms/toolbar/settings.stories.tsx
+++ b/src/components/organisms/toolbar/settings.stories.tsx
@@ -86,7 +86,7 @@ export default {
} as ComponentMeta<typeof Settings>;
const Template: ComponentStory<typeof Settings> = ({
- isActive,
+ isActive = false,
setIsActive: _setIsActive,
...args
}) => {
diff --git a/src/components/organisms/toolbar/settings.tsx b/src/components/organisms/toolbar/settings.tsx
index 8a4d4a9..3f328a5 100644
--- a/src/components/organisms/toolbar/settings.tsx
+++ b/src/components/organisms/toolbar/settings.tsx
@@ -3,14 +3,13 @@ import { useIntl } from 'react-intl';
import { BooleanField, type BooleanFieldProps, Cog } from '../../atoms';
import { FlippingLabel } from '../../molecules';
import { SettingsModal, type SettingsModalProps } from '../modals';
-import settingsStyles from './settings.module.scss';
-import sharedStyles from './toolbar-items.module.scss';
+import styles from './toolbar-items.module.scss';
export type SettingsProps = SettingsModalProps & {
/**
* The button state.
*/
- isActive: BooleanFieldProps['checked'];
+ isActive: BooleanFieldProps['isChecked'];
/**
* A callback function to handle button state.
*/
@@ -24,10 +23,9 @@ const SettingsWithRef: ForwardRefRenderFunction<
{
ackeeStorageKey,
className = '',
- isActive,
+ isActive = false,
motionStorageKey,
setIsActive,
- tooltipClassName = '',
},
ref
) => {
@@ -45,11 +43,11 @@ const SettingsWithRef: ForwardRefRenderFunction<
});
return (
- <div className={`${sharedStyles.item} ${settingsStyles.item}`} ref={ref}>
+ <div className={styles.item} ref={ref}>
<BooleanField
- checked={isActive}
- className={sharedStyles.checkbox}
+ className={styles.checkbox}
id="settings-button"
+ isChecked={isActive}
name="settings-button"
onChange={setIsActive}
type="checkbox"
@@ -57,7 +55,7 @@ const SettingsWithRef: ForwardRefRenderFunction<
/>
<FlippingLabel
aria-label={label}
- className={sharedStyles.label}
+ className={styles.label}
htmlFor="settings-button"
isActive={isActive}
>
@@ -65,9 +63,8 @@ const SettingsWithRef: ForwardRefRenderFunction<
</FlippingLabel>
<SettingsModal
ackeeStorageKey={ackeeStorageKey}
- className={`${sharedStyles.modal} ${className}`}
+ className={`${styles.modal} ${className}`}
motionStorageKey={motionStorageKey}
- tooltipClassName={`${settingsStyles.tooltip} ${tooltipClassName}`}
/>
</div>
);
diff --git a/src/components/organisms/toolbar/toolbar.tsx b/src/components/organisms/toolbar/toolbar.tsx
index 218b4fb..94c9d95 100644
--- a/src/components/organisms/toolbar/toolbar.tsx
+++ b/src/components/organisms/toolbar/toolbar.tsx
@@ -68,7 +68,6 @@ export const Toolbar: FC<ToolbarProps> = ({
motionStorageKey={motionStorageKey}
ref={settingsRef}
setIsActive={() => setIsSettingsOpened(!isSettingsOpened)}
- tooltipClassName={styles.tooltip}
/>
</div>
);
diff --git a/src/components/templates/page/page-layout.tsx b/src/components/templates/page/page-layout.tsx
index 3c6ff17..72bfd3f 100644
--- a/src/components/templates/page/page-layout.tsx
+++ b/src/components/templates/page/page-layout.tsx
@@ -126,11 +126,11 @@ export const PageLayout: FC<PageLayoutProps> = ({
const saveComment: CommentFormProps['saveComment'] = async (data, reset) => {
if (!id) throw new Error('Page id missing. Cannot save comment.');
- const { comment: commentBody, email, name, parentId, website } = data;
+ const { author, comment: commentBody, email, parentId, website } = data;
const commentData: SendCommentInput = {
- author: name,
+ author,
authorEmail: email,
- authorUrl: website || '',
+ authorUrl: website ?? '',
clientMutationId: 'contact',
commentOn: id,
content: commentBody,
@@ -248,13 +248,13 @@ export const PageLayout: FC<PageLayoutProps> = ({
comments={comments}
depth={2}
Notice={
- isReplyRef.current === true && (
+ isReplyRef.current === true && statusMessage ? (
<Notice
className={styles.notice}
kind={status}
message={statusMessage}
/>
- )
+ ) : null
}
saveComment={saveComment}
/>
@@ -275,13 +275,13 @@ export const PageLayout: FC<PageLayoutProps> = ({
title={commentFormTitle}
titleAlignment="center"
Notice={
- isReplyRef.current === false && (
+ isReplyRef.current === false && statusMessage ? (
<Notice
className={styles.notice}
kind={status}
message={statusMessage}
/>
- )
+ ) : null
}
/>
</section>
diff --git a/src/i18n/en.json b/src/i18n/en.json
index adffe39..02952b4 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -231,10 +231,6 @@
"defaultMessage": "Skip to content",
"description": "Layout: Skip to content link"
},
- "KUowUk": {
- "defaultMessage": "{name} CV",
- "description": "CVPage: CV image alternative text"
- },
"KVSWGP": {
"defaultMessage": "Other thematics",
"description": "ThematicPage: other thematics list widget title"
@@ -463,10 +459,6 @@
"defaultMessage": "Settings",
"description": "SettingsModal: title"
},
- "gX+YVy": {
- "defaultMessage": "Settings form",
- "description": "SettingsForm: an accessible form name"
- },
"hHVgW3": {
"defaultMessage": "Light Theme 🌞",
"description": "usePrism: toggle light theme button text"
@@ -623,6 +615,10 @@
"defaultMessage": "{website} logo",
"description": "Branding: logo title"
},
+ "xYNeKX": {
+ "defaultMessage": "Settings form",
+ "description": "SettingsModal: an accessible form name"
+ },
"xaqaYQ": {
"defaultMessage": "Sending mail...",
"description": "ContactForm: spinner message on submit"
diff --git a/src/i18n/fr.json b/src/i18n/fr.json
index 3fde110..2ec3657 100644
--- a/src/i18n/fr.json
+++ b/src/i18n/fr.json
@@ -231,10 +231,6 @@
"defaultMessage": "Aller au contenu",
"description": "Layout: Skip to content link"
},
- "KUowUk": {
- "defaultMessage": "CV d’{name}",
- "description": "CVPage: CV image alternative text"
- },
"KVSWGP": {
"defaultMessage": "Autres thématiques",
"description": "ThematicPage: other thematics list widget title"
@@ -463,10 +459,6 @@
"defaultMessage": "Réglages",
"description": "SettingsModal: title"
},
- "gX+YVy": {
- "defaultMessage": "Formulaire des réglages",
- "description": "SettingsForm: an accessible form name"
- },
"hHVgW3": {
"defaultMessage": "Thème clair 🌞",
"description": "usePrism: toggle light theme button text"
@@ -623,6 +615,10 @@
"defaultMessage": "Logo d’{website}",
"description": "Branding: logo title"
},
+ "xYNeKX": {
+ "defaultMessage": "Formulaire des réglages",
+ "description": "SettingsModal: an accessible form name"
+ },
"xaqaYQ": {
"defaultMessage": "Mail en cours d’envoi…",
"description": "ContactForm: spinner message on submit"
diff --git a/src/pages/404.tsx b/src/pages/404.tsx
index 5dff404..67daae1 100644
--- a/src/pages/404.tsx
+++ b/src/pages/404.tsx
@@ -123,7 +123,7 @@ const Error404Page: NextPageWithLayout<Error404PageProps> = ({
id: 'XKy7rx',
})}
</p>
- <SearchForm hideLabel={true} searchPage="/recherche/" />
+ <SearchForm isLabelHidden searchPage="/recherche/" />
</PageLayout>
</>
);
diff --git a/src/pages/contact.tsx b/src/pages/contact.tsx
index e3c8a2c..92c58cc 100644
--- a/src/pages/contact.tsx
+++ b/src/pages/contact.tsx
@@ -79,7 +79,7 @@ const ContactPage: NextPageWithLayout = () => {
const [statusMessage, setStatusMessage] = useState<string>('');
const submitMail: ContactFormProps['sendMail'] = async (data, reset) => {
- const { email, message, name, subject } = data;
+ const { email, message, name, object } = data;
const messageHTML = message.replace(/\r?\n/g, '<br />');
const body = `Message received from ${name} <${email}> on ${website.url}.<br /><br />${messageHTML}`;
const replyTo = `${name} <${email}>`;
@@ -87,7 +87,7 @@ const ContactPage: NextPageWithLayout = () => {
body,
clientMutationId: 'contact',
replyTo,
- subject,
+ subject: object,
};
const { message: mutationMessage, sent } = await sendMail(mailData);
@@ -139,11 +139,13 @@ const ContactPage: NextPageWithLayout = () => {
<ContactForm
sendMail={submitMail}
Notice={
- <Notice
- kind={status}
- message={statusMessage}
- className={styles.notice}
- />
+ statusMessage ? (
+ <Notice
+ kind={status}
+ message={statusMessage}
+ className={styles.notice}
+ />
+ ) : undefined
}
/>
</PageLayout>