aboutsummaryrefslogtreecommitdiffstats
path: root/src/components/molecules
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
parent651ea4fc992e77d2f36b3c68f8e7a70644246067 (diff)
refactor(components): rewrite form components
Diffstat (limited to 'src/components/molecules')
-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
-rw-r--r--src/components/molecules/index.ts2
-rw-r--r--src/components/molecules/modals/modal.module.scss34
-rw-r--r--src/components/molecules/modals/modal.stories.tsx96
-rw-r--r--src/components/molecules/modals/modal.test.tsx18
-rw-r--r--src/components/molecules/modals/modal.tsx88
-rw-r--r--src/components/molecules/modals/tooltip.fixture.tsx4
-rw-r--r--src/components/molecules/modals/tooltip.module.scss46
-rw-r--r--src/components/molecules/modals/tooltip.stories.tsx84
-rw-r--r--src/components/molecules/modals/tooltip.test.tsx20
-rw-r--r--src/components/molecules/modals/tooltip.tsx67
-rw-r--r--src/components/molecules/tooltip/index.ts (renamed from src/components/molecules/modals/index.ts)1
-rw-r--r--src/components/molecules/tooltip/tooltip.module.scss72
-rw-r--r--src/components/molecules/tooltip/tooltip.stories.tsx42
-rw-r--r--src/components/molecules/tooltip/tooltip.test.tsx39
-rw-r--r--src/components/molecules/tooltip/tooltip.tsx92
74 files changed, 1092 insertions, 3428 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}
- />
- );
-};
diff --git a/src/components/molecules/index.ts b/src/components/molecules/index.ts
index 1a101e4..dae369b 100644
--- a/src/components/molecules/index.ts
+++ b/src/components/molecules/index.ts
@@ -2,5 +2,5 @@ export * from './buttons';
export * from './forms';
export * from './images';
export * from './layout';
-export * from './modals';
export * from './nav';
+export * from './tooltip';
diff --git a/src/components/molecules/modals/modal.module.scss b/src/components/molecules/modals/modal.module.scss
deleted file mode 100644
index 22ddb11..0000000
--- a/src/components/molecules/modals/modal.module.scss
+++ /dev/null
@@ -1,34 +0,0 @@
-@use "../../../styles/abstracts/functions" as fun;
-@use "../../../styles/abstracts/mixins" as mix;
-
-.wrapper {
- padding: var(--spacing-md);
- background: var(--color-bg-secondary);
- border: fun.convert-px(4) solid;
- border-image: radial-gradient(
- ellipse at top,
- var(--color-primary-lighter) 20%,
- var(--color-primary) 100%
- )
- 1;
- box-shadow: fun.convert-px(2) fun.convert-px(-2) fun.convert-px(3)
- fun.convert-px(-1) var(--color-shadow-dark);
-
- @include mix.media("screen") {
- @include mix.dimensions(null, "sm") {
- padding: var(--spacing-xs);
- border-left: none;
- border-right: none;
-
- .title {
- margin-bottom: var(--spacing-2xs);
- }
- }
- }
-}
-
-.icon {
- --icon-size: #{fun.convert-px(30)};
-
- margin-right: var(--spacing-2xs);
-}
diff --git a/src/components/molecules/modals/modal.stories.tsx b/src/components/molecules/modals/modal.stories.tsx
deleted file mode 100644
index 36e6bfc..0000000
--- a/src/components/molecules/modals/modal.stories.tsx
+++ /dev/null
@@ -1,96 +0,0 @@
-import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { Modal } from './modal';
-
-/**
- * Widget - Storybook Meta
- */
-export default {
- title: 'Molecules/Modals/Modal',
- component: Modal,
- argTypes: {
- children: {
- control: {
- type: 'text',
- },
- description: 'The modal body.',
- type: {
- name: 'string',
- required: true,
- },
- },
- className: {
- control: {
- type: 'text',
- },
- description: 'Set additional classnames to the modal.',
- table: {
- category: 'Styles',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- headingClassName: {
- control: {
- type: 'text',
- },
- description: 'Set additional classnames to the modal heading.',
- table: {
- category: 'Styles',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- icon: {
- control: {
- type: 'select',
- },
- description: 'The title icon.',
- options: ['', 'cogs', 'search'],
- table: {
- category: 'Options',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- title: {
- control: {
- type: 'text',
- },
- description: 'The modal title.',
- table: {
- category: 'Options',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- },
-} as ComponentMeta<typeof Modal>;
-
-const Template: ComponentStory<typeof Modal> = (args) => <Modal {...args} />;
-
-/**
- * Modal Stories - Default
- */
-export const Default = Template.bind({});
-Default.args = {
- children:
- 'Inventore natus dignissimos aut illum modi asperiores. Et voluptatibus delectus.',
-};
-
-/**
- * Modal Stories - With title
- */
-export const WithTitle = Template.bind({});
-WithTitle.args = {
- children:
- 'Inventore natus dignissimos aut illum modi asperiores. Et voluptatibus delectus.',
- title: 'Alias praesentium corporis',
-};
diff --git a/src/components/molecules/modals/modal.test.tsx b/src/components/molecules/modals/modal.test.tsx
deleted file mode 100644
index 5d55b3d..0000000
--- a/src/components/molecules/modals/modal.test.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import { render, screen } from '../../../../tests/utils';
-import { Modal } from './modal';
-
-const title = 'A custom title';
-const children =
- 'Labore ullam delectus sit modi quam dolores. Ratione id sint aliquid facilis ipsum. Unde necessitatibus provident minus.';
-
-describe('Modal', () => {
- it('renders a title', () => {
- render(<Modal title={title}>{children}</Modal>);
- expect(screen.getByText(title)).toBeInTheDocument();
- });
-
- it('renders the modal body', () => {
- render(<Modal title={title}>{children}</Modal>);
- expect(screen.getByText(children)).toBeInTheDocument();
- });
-});
diff --git a/src/components/molecules/modals/modal.tsx b/src/components/molecules/modals/modal.tsx
deleted file mode 100644
index 344d5b9..0000000
--- a/src/components/molecules/modals/modal.tsx
+++ /dev/null
@@ -1,88 +0,0 @@
-import dynamic from 'next/dynamic';
-import { FC, ReactNode } from 'react';
-import {
- type CogProps,
- Heading,
- type HeadingProps,
- type MagnifyingGlassProps,
-} from '../../atoms';
-import styles from './modal.module.scss';
-
-export type Icons = 'cogs' | 'search';
-
-export type ModalProps = {
- /**
- * The modal body.
- */
- children: ReactNode;
- /**
- * Set additional classnames.
- */
- className?: string;
- /**
- * Set additional classnames to the heading.
- */
- headingClassName?: HeadingProps['className'];
- /**
- * A icon to illustrate the modal.
- */
- icon?: Icons;
- /**
- * The modal title.
- */
- title?: string;
-};
-
-const CogIcon = dynamic<CogProps>(
- () => import('../../atoms/icons/cog').then((mod) => mod.Cog),
- {
- ssr: false,
- }
-);
-const SearchIcon = dynamic<MagnifyingGlassProps>(
- () =>
- import('../../atoms/icons/magnifying-glass').then(
- (mod) => mod.MagnifyingGlass
- ),
- { ssr: false }
-);
-
-/**
- * Modal component
- *
- * Render a modal component with an optional title and icon.
- */
-export const Modal: FC<ModalProps> = ({
- children,
- className = '',
- headingClassName = '',
- icon,
- title,
-}) => {
- const getIcon = (id: Icons) => {
- switch (id) {
- case 'cogs':
- return <CogIcon />;
- case 'search':
- return <SearchIcon />;
- default:
- return <></>;
- }
- };
-
- return (
- <div className={`${styles.wrapper} ${className}`}>
- {title && (
- <Heading
- isFake={true}
- level={3}
- className={`${styles.title} ${headingClassName}`}
- >
- {icon && <span className={styles.icon}>{getIcon(icon)}</span>}
- {title}
- </Heading>
- )}
- {children}
- </div>
- );
-};
diff --git a/src/components/molecules/modals/tooltip.fixture.tsx b/src/components/molecules/modals/tooltip.fixture.tsx
deleted file mode 100644
index 5489f08..0000000
--- a/src/components/molecules/modals/tooltip.fixture.tsx
+++ /dev/null
@@ -1,4 +0,0 @@
-export const title = 'Illum eum at';
-export const content =
- 'Non accusantium ad. Est et impedit iste animi voluptas cum accusamus accusantium. Repellat ut sint pariatur cumque cupiditate. Animi occaecati odio ut debitis ipsam similique. Repudiandae aut earum occaecati consequatur laborum ut nobis iusto. Adipisci laboriosam id.';
-export const icon = '?';
diff --git a/src/components/molecules/modals/tooltip.module.scss b/src/components/molecules/modals/tooltip.module.scss
deleted file mode 100644
index 0a177e5..0000000
--- a/src/components/molecules/modals/tooltip.module.scss
+++ /dev/null
@@ -1,46 +0,0 @@
-@use "../../../styles/abstracts/functions" as fun;
-
-.wrapper {
- --title-height: #{fun.convert-px(40)};
-
- margin-top: calc(var(--title-height) / 2);
- padding: calc((var(--title-height) / 2) + var(--spacing-sm)) var(--spacing-sm)
- var(--spacing-sm);
- position: relative;
- background: var(--color-bg);
- border: fun.convert-px(2) solid var(--color-primary-dark);
- border-radius: fun.convert-px(3);
- box-shadow: fun.convert-px(1) fun.convert-px(1) 0 0 var(--color-shadow),
- fun.convert-px(2) fun.convert-px(2) fun.convert-px(1) fun.convert-px(1)
- var(--color-shadow-light);
-}
-
-.title {
- display: flex;
- align-items: center;
- height: var(--title-height);
- padding-right: var(--spacing-xs);
- position: absolute;
- top: calc(var(--title-height) / -2);
- left: var(--spacing-xs);
- background: var(--color-bg);
- border: fun.convert-px(1) solid var(--color-primary-dark);
- box-shadow: fun.convert-px(1) fun.convert-px(1) 0 0 var(--color-shadow);
- color: var(--color-primary-darker);
- font-size: var(--font-size-sm);
- font-variant: small-caps;
- font-weight: 500;
-}
-
-.icon {
- display: flex;
- align-items: center;
- height: var(--title-height);
- margin-right: var(--spacing-xs);
- padding: 0 var(--spacing-2xs);
- background: var(--color-primary-dark);
- border: fun.convert-px(1) solid var(--color-primary-dark);
- box-shadow: fun.convert-px(1) fun.convert-px(1) 0 0 var(--color-shadow);
- color: var(--color-fg-inverted);
- font-weight: 600;
-}
diff --git a/src/components/molecules/modals/tooltip.stories.tsx b/src/components/molecules/modals/tooltip.stories.tsx
deleted file mode 100644
index abc3526..0000000
--- a/src/components/molecules/modals/tooltip.stories.tsx
+++ /dev/null
@@ -1,84 +0,0 @@
-import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { Tooltip } from './tooltip';
-import { content, icon, title } from './tooltip.fixture';
-
-/**
- * Tooltip - Storybook Meta
- */
-export default {
- title: 'Molecules/Modals/Tooltip',
- component: Tooltip,
- argTypes: {
- className: {
- control: {
- type: 'text',
- },
- description: 'Set additional classnames to the tooltip.',
- table: {
- category: 'Styles',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- cloneClassName: {
- control: {
- type: 'text',
- },
- description:
- 'Set additional classnames to the tooltip when using cloneElement.',
- table: {
- category: 'Styles',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- content: {
- control: {
- type: 'text',
- },
- description: 'The tooltip body.',
- type: {
- name: 'string',
- required: true,
- },
- },
- icon: {
- control: {
- type: 'text',
- },
- description: 'The tooltip icon.',
- type: {
- name: 'string',
- required: true,
- },
- },
- title: {
- control: {
- type: 'text',
- },
- description: 'The tooltip title',
- type: {
- name: 'string',
- required: true,
- },
- },
- },
-} as ComponentMeta<typeof Tooltip>;
-
-const Template: ComponentStory<typeof Tooltip> = (args) => (
- <Tooltip {...args} />
-);
-
-/**
- * Tooltip Stories - Help
- */
-export const Help = Template.bind({});
-Help.args = {
- content,
- icon,
- title,
-};
diff --git a/src/components/molecules/modals/tooltip.test.tsx b/src/components/molecules/modals/tooltip.test.tsx
deleted file mode 100644
index eb23334..0000000
--- a/src/components/molecules/modals/tooltip.test.tsx
+++ /dev/null
@@ -1,20 +0,0 @@
-import { render, screen } from '../../../../tests/utils';
-import { Tooltip } from './tooltip';
-import { content, icon, title } from './tooltip.fixture';
-
-describe('Tooltip', () => {
- it('renders a title', () => {
- render(<Tooltip title={title} content={content} icon={icon} />);
- expect(screen.getByText(title)).toBeInTheDocument();
- });
-
- it('renders an explanation', () => {
- render(<Tooltip title={title} content={content} icon={icon} />);
- expect(screen.getByText(content)).toBeInTheDocument();
- });
-
- it('renders an icon', () => {
- render(<Tooltip title={title} content={content} icon={icon} />);
- expect(screen.getByText(icon)).toBeInTheDocument();
- });
-});
diff --git a/src/components/molecules/modals/tooltip.tsx b/src/components/molecules/modals/tooltip.tsx
deleted file mode 100644
index 3c8a5df..0000000
--- a/src/components/molecules/modals/tooltip.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-import { forwardRef, ForwardRefRenderFunction, ReactNode } from 'react';
-import { List, type ListItem } from '../../atoms';
-import styles from './tooltip.module.scss';
-
-export type TooltipProps = {
- /**
- * Set additional classnames to the tooltip wrapper.
- */
- className?: string;
- /**
- * Set more additional classnames to the tooltip wrapper. Required when using React.cloneElement.
- */
- cloneClassName?: string;
- /**
- * The tooltip body.
- */
- content: string | string[];
- /**
- * An icon to illustrate tooltip content.
- */
- icon: ReactNode;
- /**
- * The tooltip title.
- */
- title: string;
-};
-
-const TooltipWithRef: ForwardRefRenderFunction<HTMLDivElement, TooltipProps> = (
- { cloneClassName = '', className = '', content, icon, title },
- ref
-) => {
- /**
- * Format an array of strings to an array of object with id and value.
- *
- * @param {string[]} array - An array of strings.
- * @returns {ListItem[]} The array formatted to be used as list items.
- */
- const getListItems = (array: string[]): ListItem[] => {
- return array.map((string, index) => {
- return { id: `item-${index}`, value: string };
- });
- };
-
- return (
- <div
- className={`${styles.wrapper} ${cloneClassName} ${className}`}
- ref={ref}
- >
- <div className={styles.title}>
- <span className={styles.icon}>{icon}</span>
- {title}
- </div>
- {Array.isArray(content) ? (
- <List items={getListItems(content)} />
- ) : (
- content
- )}
- </div>
- );
-};
-
-/**
- * Tooltip component
- *
- * Render a tooltip modal.
- */
-export const Tooltip = forwardRef(TooltipWithRef);
diff --git a/src/components/molecules/modals/index.ts b/src/components/molecules/tooltip/index.ts
index 595be13..ed8326d 100644
--- a/src/components/molecules/modals/index.ts
+++ b/src/components/molecules/tooltip/index.ts
@@ -1,2 +1 @@
-export * from './modal';
export * from './tooltip';
diff --git a/src/components/molecules/tooltip/tooltip.module.scss b/src/components/molecules/tooltip/tooltip.module.scss
new file mode 100644
index 0000000..029767f
--- /dev/null
+++ b/src/components/molecules/tooltip/tooltip.module.scss
@@ -0,0 +1,72 @@
+@use "../../../styles/abstracts/functions" as fun;
+@use "../../../styles/abstracts/mixins" as mix;
+@use "../../../styles/abstracts/variables" as var;
+
+.btn {
+ margin-right: var(--spacing-xs);
+}
+
+.tooltip {
+ position: absolute;
+ z-index: 10;
+ font-size: var(--font-size-sm);
+ transition: all 0.75s ease-in-out 0s;
+
+ @media screen and (max-height: #{var.get-breakpoint("2xs")}) {
+ width: calc(97.5vw - var(--spacing-md));
+ right: 0;
+ }
+
+ &--down {
+ top: calc(100% + var(--spacing-xs));
+ transform-origin: top;
+ }
+
+ &--up {
+ bottom: calc(100% + var(--spacing-2xs));
+ transform-origin: bottom;
+ }
+
+ &--hidden {
+ flex: 0 0 0;
+ opacity: 0;
+ visibility: hidden;
+ transform: scale(0);
+ }
+
+ &--visible {
+ opacity: 1;
+ visibility: visible;
+ transform: scale(1);
+
+ & ~ .btn {
+ background: var(--color-primary);
+
+ * {
+ color: var(--color-fg-inverted);
+ }
+ }
+ }
+}
+
+.heading {
+ display: flex;
+ flex-flow: row nowrap;
+ align-items: center;
+ height: 100%;
+ margin-left: calc(var(--spacing-xs) * -1.1);
+ font-size: var(--font-size-sm);
+}
+
+.icon {
+ align-self: stretch;
+ display: flex;
+ align-items: center;
+ margin-right: var(--spacing-xs);
+ padding: 0 var(--spacing-2xs);
+ background: var(--color-primary-dark);
+ border: fun.convert-px(1) solid var(--color-primary-dark);
+ box-shadow: fun.convert-px(1) fun.convert-px(1) 0 0 var(--color-shadow);
+ color: var(--color-fg-inverted);
+ font-weight: 600;
+}
diff --git a/src/components/molecules/tooltip/tooltip.stories.tsx b/src/components/molecules/tooltip/tooltip.stories.tsx
new file mode 100644
index 0000000..8a22a06
--- /dev/null
+++ b/src/components/molecules/tooltip/tooltip.stories.tsx
@@ -0,0 +1,42 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { Tooltip } from './tooltip';
+import { useState } from 'react';
+
+/**
+ * Switch - Storybook Meta
+ */
+export default {
+ title: 'Molecules/Tooltip',
+ component: Tooltip,
+ args: {},
+ argTypes: {},
+} as ComponentMeta<typeof Tooltip>;
+
+const Template: ComponentStory<typeof Tooltip> = ({
+ isOpen,
+ onToggle: _onToggle,
+ ...args
+}) => {
+ const [isOpened, setIsOpened] = useState(isOpen);
+
+ const toggle = () => {
+ setIsOpened((prev) => !prev);
+ };
+
+ return (
+ <div style={{ position: 'relative' }}>
+ <Tooltip {...args} isOpen={isOpened} onToggle={toggle} />
+ </div>
+ );
+};
+
+/**
+ * Tooltip Stories - Example
+ */
+export const Example = Template.bind({});
+Example.args = {
+ children:
+ 'Inventore natus dignissimos aut illum modi asperiores. Et voluptatibus delectus.',
+ heading: 'A title',
+ isOpen: false,
+};
diff --git a/src/components/molecules/tooltip/tooltip.test.tsx b/src/components/molecules/tooltip/tooltip.test.tsx
new file mode 100644
index 0000000..af2c7e4
--- /dev/null
+++ b/src/components/molecules/tooltip/tooltip.test.tsx
@@ -0,0 +1,39 @@
+import { render, screen } from '../../../../tests/utils';
+import { Tooltip } from './tooltip';
+
+const title = 'A custom title';
+const children =
+ 'Labore ullam delectus sit modi quam dolores. Ratione id sint aliquid facilis ipsum. Unde necessitatibus provident minus.';
+
+describe('Tooltip', () => {
+ it('renders a title and a body', () => {
+ render(<Tooltip heading={title}>{children}</Tooltip>);
+
+ expect(screen.getByText(title)).toBeInTheDocument();
+ expect(screen.getByText(children)).toBeInTheDocument();
+ });
+
+ it('can render a hidden modal', () => {
+ render(
+ <Tooltip heading={title} isOpen={false}>
+ {children}
+ </Tooltip>
+ );
+
+ // Neither toBeVisible or toHaveStyle are working.
+ //expect(screen.getByText(children)).not.toBeVisible();
+ //expect(screen.getByText(children)).toHaveStyle({ visibility: 'hidden' });
+ expect(screen.getByText(children)).toHaveClass('tooltip--hidden');
+ });
+
+ it('can render a visible modal', () => {
+ render(
+ <Tooltip heading={title} isOpen>
+ {children}
+ </Tooltip>
+ );
+
+ expect(screen.getByText(children)).toBeVisible();
+ expect(screen.getByText(children)).toHaveStyle({ visibility: 'visible' });
+ });
+});
diff --git a/src/components/molecules/tooltip/tooltip.tsx b/src/components/molecules/tooltip/tooltip.tsx
new file mode 100644
index 0000000..43ceced
--- /dev/null
+++ b/src/components/molecules/tooltip/tooltip.tsx
@@ -0,0 +1,92 @@
+import { FC, MouseEventHandler, useRef } from 'react';
+import { Heading, Modal, ModalProps } from '../../atoms';
+import { HelpButton } from '../buttons';
+import styles from './tooltip.module.scss';
+import { useOnClickOutside } from '../../../utils/hooks';
+
+export type TooltipProps = Omit<ModalProps, 'heading'> & {
+ /**
+ * The tooltip direction when opening.
+ *
+ * @default "downwards"
+ */
+ direction?: 'downwards' | 'upwards';
+ /**
+ * The tooltip heading.
+ */
+ heading: string;
+ /**
+ * Should the tooltip be opened?
+ *
+ * @default false
+ */
+ isOpen?: boolean;
+ /**
+ * A callback function to trigger when clicking outside the modal.
+ */
+ onClickOutside?: () => void;
+ /**
+ * An event handler when clicking on the help button.
+ */
+ onToggle?: MouseEventHandler<HTMLButtonElement>;
+};
+
+/**
+ * Tooltip component
+ *
+ * Render a button and a modal. Note: you should add a CSS rule
+ * `position: relative;` on the consumer.
+ */
+export const Tooltip: FC<TooltipProps> = ({
+ children,
+ className = '',
+ direction = 'downwards',
+ heading,
+ isOpen,
+ onClickOutside,
+ onToggle,
+ ...props
+}) => {
+ const directionModifier =
+ direction === 'upwards' ? 'tooltip--up' : 'tooltip--down';
+ const visibilityModifier = isOpen ? 'tooltip--visible' : 'tooltip--hidden';
+ const tooltipClass = `${styles.tooltip} ${styles[directionModifier]} ${styles[visibilityModifier]} ${className}`;
+ const btnRef = useRef<HTMLButtonElement>(null);
+
+ const closeModal = (target: Node) => {
+ if (!onClickOutside) return;
+
+ if (btnRef.current && !btnRef.current.contains(target)) {
+ onClickOutside();
+ }
+ };
+
+ const modalRef = useOnClickOutside<HTMLDivElement>(closeModal);
+
+ return (
+ <>
+ <Modal
+ {...props}
+ className={tooltipClass}
+ heading={
+ <Heading className={styles.heading} isFake level={6}>
+ <span aria-hidden className={styles.icon}>
+ ?
+ </span>
+ {heading}
+ </Heading>
+ }
+ kind="secondary"
+ ref={modalRef}
+ >
+ {children}
+ </Modal>
+ <HelpButton
+ aria-pressed={isOpen}
+ className={styles.btn}
+ onClick={onToggle}
+ ref={btnRef}
+ />
+ </>
+ );
+};