aboutsummaryrefslogtreecommitdiffstats
path: root/src/components/molecules/forms
diff options
context:
space:
mode:
authorArmand Philippot <git@armandphilippot.com>2023-09-22 19:34:01 +0200
committerArmand Philippot <git@armandphilippot.com>2023-10-24 12:23:48 +0200
commita6ff5eee45215effb3344cb5d631a27a7c0369aa (patch)
tree5051747acf72318b4fc5c18d603e3757fbefdfdb /src/components/molecules/forms
parent651ea4fc992e77d2f36b3c68f8e7a70644246067 (diff)
refactor(components): rewrite form components
Diffstat (limited to 'src/components/molecules/forms')
-rw-r--r--src/components/molecules/forms/ackee-toggle.fixture.tsx1
-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.test.tsx15
-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/motion-toggle.fixture.tsx1
-rw-r--r--src/components/molecules/forms/motion-toggle.stories.tsx86
-rw-r--r--src/components/molecules/forms/motion-toggle.test.tsx15
-rw-r--r--src/components/molecules/forms/motion-toggle.tsx118
-rw-r--r--src/components/molecules/forms/prism-theme-toggle.stories.tsx60
-rw-r--r--src/components/molecules/forms/prism-theme-toggle.test.tsx13
-rw-r--r--src/components/molecules/forms/prism-theme-toggle.tsx118
-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/forms/theme-toggle.test.tsx13
-rw-r--r--src/components/molecules/forms/theme-toggle.tsx106
59 files changed, 846 insertions, 2969 deletions
diff --git a/src/components/molecules/forms/ackee-toggle.fixture.tsx b/src/components/molecules/forms/ackee-toggle.fixture.tsx
deleted file mode 100644
index 04602f2..0000000
--- a/src/components/molecules/forms/ackee-toggle.fixture.tsx
+++ /dev/null
@@ -1 +0,0 @@
-export const storageKey = 'ackee';
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.test.tsx b/src/components/molecules/forms/ackee-toggle.test.tsx
deleted file mode 100644
index 97ebbe5..0000000
--- a/src/components/molecules/forms/ackee-toggle.test.tsx
+++ /dev/null
@@ -1,15 +0,0 @@
-import { render, screen } from '../../../../tests/utils';
-import { AckeeToggle } from './ackee-toggle';
-import { storageKey } from './ackee-toggle.fixture';
-
-describe('AckeeToggle', () => {
- // toHaveValue received undefined. Maybe because of localStorage hook...
- it('renders a toggle component', () => {
- render(<AckeeToggle storageKey={storageKey} defaultValue="full" />);
- expect(
- screen.getByRole('radiogroup', {
- name: /Tracking:/i,
- })
- ).toBeInTheDocument();
- });
-});
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/motion-toggle.fixture.tsx b/src/components/molecules/forms/motion-toggle.fixture.tsx
deleted file mode 100644
index f13658a..0000000
--- a/src/components/molecules/forms/motion-toggle.fixture.tsx
+++ /dev/null
@@ -1 +0,0 @@
-export const storageKey = 'reduced-motion';
diff --git a/src/components/molecules/forms/motion-toggle.stories.tsx b/src/components/molecules/forms/motion-toggle.stories.tsx
deleted file mode 100644
index bb51e26..0000000
--- a/src/components/molecules/forms/motion-toggle.stories.tsx
+++ /dev/null
@@ -1,86 +0,0 @@
-import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { MotionToggle } from './motion-toggle';
-import { storageKey } from './motion-toggle.fixture';
-
-/**
- * MotionToggle - Storybook Meta
- */
-export default {
- title: 'Molecules/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',
- },
- description: 'Set the default value.',
- options: ['on', 'off'],
- 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,
- },
- },
- },
-} as ComponentMeta<typeof MotionToggle>;
-
-const Template: ComponentStory<typeof MotionToggle> = (args) => (
- <MotionToggle {...args} />
-);
-
-/**
- * Toggle Stories - Motion
- */
-export const Motion = Template.bind({});
-Motion.args = {
- defaultValue: 'on',
- storageKey,
-};
diff --git a/src/components/molecules/forms/motion-toggle.test.tsx b/src/components/molecules/forms/motion-toggle.test.tsx
deleted file mode 100644
index 93df3a0..0000000
--- a/src/components/molecules/forms/motion-toggle.test.tsx
+++ /dev/null
@@ -1,15 +0,0 @@
-import { render, screen } from '../../../../tests/utils';
-import { MotionToggle } from './motion-toggle';
-import { storageKey } from './motion-toggle.fixture';
-
-describe('MotionToggle', () => {
- // toHaveValue received undefined. Maybe because of localStorage hook...
- it('renders a toggle component', () => {
- render(<MotionToggle storageKey={storageKey} defaultValue="on" />);
- expect(
- screen.getByRole('radiogroup', {
- name: /Animations:/i,
- })
- ).toBeInTheDocument();
- });
-});
diff --git a/src/components/molecules/forms/motion-toggle.tsx b/src/components/molecules/forms/motion-toggle.tsx
deleted file mode 100644
index c1a55f0..0000000
--- a/src/components/molecules/forms/motion-toggle.tsx
+++ /dev/null
@@ -1,118 +0,0 @@
-import { 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';
-
-export type MotionToggleValue = 'on' | 'off';
-
-export type MotionToggleProps = Pick<
- RadioGroupProps,
- 'bodyClassName' | 'groupClassName' | 'legendClassName' | 'legendPosition'
-> & {
- /**
- * True if motion should be reduced by default.
- */
- defaultValue: 'on' | 'off';
- /**
- * The local storage key to save preference.
- */
- storageKey: string;
-};
-
-/**
- * MotionToggle component
- *
- * Render a Toggle component to set reduce motion.
- */
-export const MotionToggle: FC<MotionToggleProps> = ({
- defaultValue,
- storageKey,
- ...props
-}) => {
- const intl = useIntl();
- const { value: isReduced, setValue: setIsReduced } = useLocalStorage<boolean>(
- storageKey,
- defaultValue === 'on' ? false : true
- );
- useAttributes({
- element:
- typeof window !== 'undefined' ? document.documentElement : undefined,
- attribute: 'reduced-motion',
- value: `${isReduced}`,
- });
-
- const reduceMotionLabel = intl.formatMessage({
- defaultMessage: 'Animations:',
- description: 'MotionToggle: reduce motion label',
- id: '/q5csZ',
- });
- const onLabel = intl.formatMessage({
- defaultMessage: 'On',
- description: 'MotionToggle: activate reduce motion label',
- id: 'va65iw',
- });
- const offLabel = intl.formatMessage({
- defaultMessage: 'Off',
- description: 'MotionToggle: deactivate reduce motion label',
- id: 'pWKyyR',
- });
-
- const options: RadioGroupOption[] = [
- {
- 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);
- }
- };
-
- return (
- <RadioGroup
- {...props}
- initialChoice={defaultValue}
- kind="toggle"
- legend={reduceMotionLabel}
- onChange={handleChange}
- options={options}
- />
- );
-};
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/prism-theme-toggle.test.tsx b/src/components/molecules/forms/prism-theme-toggle.test.tsx
deleted file mode 100644
index bd63762..0000000
--- a/src/components/molecules/forms/prism-theme-toggle.test.tsx
+++ /dev/null
@@ -1,13 +0,0 @@
-import { render, screen } from '../../../../tests/utils';
-import { PrismThemeToggle } from './prism-theme-toggle';
-
-describe('PrismThemeToggle', () => {
- it('renders a toggle component', () => {
- render(<PrismThemeToggle />);
- expect(
- screen.getByRole('radiogroup', {
- name: /Code blocks:/i,
- })
- ).toBeInTheDocument();
- });
-});
diff --git a/src/components/molecules/forms/prism-theme-toggle.tsx b/src/components/molecules/forms/prism-theme-toggle.tsx
deleted file mode 100644
index 5427fec..0000000
--- a/src/components/molecules/forms/prism-theme-toggle.tsx
+++ /dev/null
@@ -1,118 +0,0 @@
-import { 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';
-
-export type PrismThemeToggleProps = Pick<
- RadioGroupProps,
- 'bodyClassName' | 'groupClassName' | 'legendClassName' | 'legendPosition'
->;
-
-/**
- * PrismThemeToggle component
- *
- * Render a Toggle component to set code blocks theme.
- */
-export const PrismThemeToggle: FC<PrismThemeToggleProps> = (props) => {
- const intl = useIntl();
- const { theme, setTheme, resolvedTheme } = usePrismTheme();
-
- /**
- * Check if the resolved or chosen theme is dark theme.
- *
- * @returns {boolean} True if it is dark theme.
- */
- const isDarkTheme = (prismTheme?: PrismTheme): boolean => {
- if (prismTheme === 'system') return resolvedTheme === 'dark';
- 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 themeLabel = intl.formatMessage({
- defaultMessage: 'Code blocks:',
- description: 'PrismThemeToggle: theme label',
- id: 'ftXN+0',
- });
- const lightThemeLabel = intl.formatMessage({
- defaultMessage: 'Light theme',
- description: 'PrismThemeToggle: light theme label',
- id: 'tsWh8x',
- });
- const darkThemeLabel = intl.formatMessage({
- defaultMessage: 'Dark theme',
- description: 'PrismThemeToggle: dark theme label',
- id: 'og/zWL',
- });
-
- const options: RadioGroupOption[] = [
- {
- id: 'code-blocks-light',
- label: (
- <>
- <span className="screen-reader-text">{lightThemeLabel}</span>
- <Sun />
- </>
- ),
- name: 'code-blocks',
- value: 'light',
- },
- {
- id: 'code-blocks-dark',
- label: (
- <>
- <span className="screen-reader-text">{darkThemeLabel}</span>
- <Moon />
- </>
- ),
- name: 'code-blocks',
- value: 'dark',
- },
- ];
-
- return (
- <RadioGroup
- {...props}
- initialChoice={isDarkTheme(theme) ? 'dark' : 'light'}
- kind="toggle"
- legend={themeLabel}
- onChange={handleChange}
- options={options}
- />
- );
-};
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/forms/theme-toggle.test.tsx b/src/components/molecules/forms/theme-toggle.test.tsx
deleted file mode 100644
index aae627a..0000000
--- a/src/components/molecules/forms/theme-toggle.test.tsx
+++ /dev/null
@@ -1,13 +0,0 @@
-import { render, screen } from '../../../../tests/utils';
-import { ThemeToggle } from './theme-toggle';
-
-describe('ThemeToggle', () => {
- it('renders a toggle component', () => {
- render(<ThemeToggle />);
- expect(
- screen.getByRole('radiogroup', {
- name: /Theme:/i,
- })
- ).toBeInTheDocument();
- });
-});
diff --git a/src/components/molecules/forms/theme-toggle.tsx b/src/components/molecules/forms/theme-toggle.tsx
deleted file mode 100644
index 25e2a39..0000000
--- a/src/components/molecules/forms/theme-toggle.tsx
+++ /dev/null
@@ -1,106 +0,0 @@
-import { useTheme } from 'next-themes';
-import { 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';
-
-export type ThemeToggleProps = Pick<
- RadioGroupProps,
- 'bodyClassName' | 'groupClassName' | 'legendClassName' | 'legendPosition'
->;
-
-/**
- * ThemeToggle component
- *
- * Render a Toggle component to set theme.
- */
-export const ThemeToggle: FC<ThemeToggleProps> = (props) => {
- const intl = useIntl();
- 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 themeLabel = intl.formatMessage({
- defaultMessage: 'Theme:',
- description: 'ThemeToggle: theme label',
- id: 'suXOBu',
- });
- const lightThemeLabel = intl.formatMessage({
- defaultMessage: 'Light theme',
- description: 'ThemeToggle: light theme label',
- id: 'Ygea7s',
- });
- const darkThemeLabel = intl.formatMessage({
- defaultMessage: 'Dark theme',
- description: 'ThemeToggle: dark theme label',
- id: '2QwvtS',
- });
-
- const options: RadioGroupOption[] = [
- {
- id: 'theme-light',
- label: (
- <>
- <span className="screen-reader-text">{lightThemeLabel}</span>
- <Sun />
- </>
- ),
- name: 'theme',
- value: 'light',
- },
- {
- id: 'theme-dark',
- label: (
- <>
- <span className="screen-reader-text">{darkThemeLabel}</span>
- <Moon />
- </>
- ),
- name: 'theme',
- value: 'dark',
- },
- ];
-
- return (
- <RadioGroup
- {...props}
- initialChoice={isDarkTheme ? 'dark' : 'light'}
- kind="toggle"
- legend={themeLabel}
- onChange={handleChange}
- options={options}
- />
- );
-};