aboutsummaryrefslogtreecommitdiffstats
path: root/src/components/molecules/forms
diff options
context:
space:
mode:
authorArmand Philippot <git@armandphilippot.com>2022-06-01 22:37:56 +0200
committerGitHub <noreply@github.com>2022-06-01 22:37:56 +0200
commit0a33a4658d848fe056715c6da053763407845b2a (patch)
tree7c679e54ba4bbadaf0a59bbde780f5742e3b875d /src/components/molecules/forms
parent97031a86ca38890e60ecec79828498b7bb13cbfa (diff)
parent6be20422494e3806fba3d1c5ad5c3e98bd6e67e5 (diff)
chore(a11y): improve website settings accessibility (#17)
The previous switch buttons (using checkbox) was not a11y compliant. So I change my approach to use radio buttons and to clearly separate the two different states. I also convert the Ackee select setting to improve consistency between settings.
Diffstat (limited to 'src/components/molecules/forms')
-rw-r--r--src/components/molecules/forms/ackee-select.stories.tsx84
-rw-r--r--src/components/molecules/forms/ackee-select.test.tsx25
-rw-r--r--src/components/molecules/forms/ackee-select.tsx103
-rw-r--r--src/components/molecules/forms/ackee-toggle.fixture.tsx1
-rw-r--r--src/components/molecules/forms/ackee-toggle.module.scss (renamed from src/components/molecules/forms/ackee-select.module.scss)5
-rw-r--r--src/components/molecules/forms/ackee-toggle.stories.tsx112
-rw-r--r--src/components/molecules/forms/ackee-toggle.test.tsx15
-rw-r--r--src/components/molecules/forms/ackee-toggle.tsx143
-rw-r--r--src/components/molecules/forms/fieldset.fixture.tsx6
-rw-r--r--src/components/molecules/forms/fieldset.module.scss (renamed from src/components/molecules/forms/select-with-tooltip.module.scss)47
-rw-r--r--src/components/molecules/forms/fieldset.stories.tsx165
-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/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.tsx92
-rw-r--r--src/components/molecules/forms/motion-toggle.fixture.tsx1
-rw-r--r--src/components/molecules/forms/motion-toggle.stories.tsx40
-rw-r--r--src/components/molecules/forms/motion-toggle.test.tsx12
-rw-r--r--src/components/molecules/forms/motion-toggle.tsx82
-rw-r--r--src/components/molecules/forms/prism-theme-toggle.stories.tsx21
-rw-r--r--src/components/molecules/forms/prism-theme-toggle.test.tsx4
-rw-r--r--src/components/molecules/forms/prism-theme-toggle.tsx81
-rw-r--r--src/components/molecules/forms/radio-group.fixture.tsx47
-rw-r--r--src/components/molecules/forms/radio-group.module.scss112
-rw-r--r--src/components/molecules/forms/radio-group.stories.tsx272
-rw-r--r--src/components/molecules/forms/radio-group.test.tsx30
-rw-r--r--src/components/molecules/forms/radio-group.tsx148
-rw-r--r--src/components/molecules/forms/select-with-tooltip.stories.tsx223
-rw-r--r--src/components/molecules/forms/select-with-tooltip.test.tsx32
-rw-r--r--src/components/molecules/forms/select-with-tooltip.tsx78
-rw-r--r--src/components/molecules/forms/theme-toggle.stories.tsx21
-rw-r--r--src/components/molecules/forms/theme-toggle.test.tsx4
-rw-r--r--src/components/molecules/forms/theme-toggle.tsx73
-rw-r--r--src/components/molecules/forms/toggle.module.scss75
-rw-r--r--src/components/molecules/forms/toggle.stories.tsx134
-rw-r--r--src/components/molecules/forms/toggle.test.tsx29
-rw-r--r--src/components/molecules/forms/toggle.tsx87
40 files changed, 1865 insertions, 986 deletions
diff --git a/src/components/molecules/forms/ackee-select.stories.tsx b/src/components/molecules/forms/ackee-select.stories.tsx
deleted file mode 100644
index 81eb5df..0000000
--- a/src/components/molecules/forms/ackee-select.stories.tsx
+++ /dev/null
@@ -1,84 +0,0 @@
-import { ComponentMeta, ComponentStory } from '@storybook/react';
-import AckeeSelect from './ackee-select';
-
-/**
- * AckeeSelect - Storybook Meta
- */
-export default {
- title: 'Molecules/Forms/Select',
- component: AckeeSelect,
- argTypes: {
- className: {
- control: {
- type: 'text',
- },
- description: 'Set additional classnames to the select wrapper.',
- table: {
- category: 'Styles',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- initialValue: {
- control: {
- type: 'select',
- },
- description: 'Initial selected option.',
- options: ['full', 'partial'],
- type: {
- name: 'string',
- required: true,
- },
- },
- labelClassName: {
- control: {
- type: 'text',
- },
- description: 'Set additional classnames to the label wrapper.',
- table: {
- category: 'Styles',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- storageKey: {
- control: {
- type: 'text',
- },
- description: 'Set Ackee settings 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 AckeeSelect>;
-
-const Template: ComponentStory<typeof AckeeSelect> = (args) => (
- <AckeeSelect {...args} />
-);
-
-/**
- * Select Stories - Ackee select
- */
-export const Ackee = Template.bind({});
-Ackee.args = {
- initialValue: 'full',
-};
diff --git a/src/components/molecules/forms/ackee-select.test.tsx b/src/components/molecules/forms/ackee-select.test.tsx
deleted file mode 100644
index 0089c06..0000000
--- a/src/components/molecules/forms/ackee-select.test.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import user from '@testing-library/user-event';
-import { act, render, screen } from '@test-utils';
-import AckeeSelect from './ackee-select';
-
-describe('Select', () => {
- it('should correctly set default option', () => {
- render(<AckeeSelect storageKey="ackee-tracking" initialValue="full" />);
- expect(screen.getByRole('combobox')).toHaveValue('full');
- expect(screen.queryByRole('combobox')).not.toHaveValue('partial');
- });
-
- it('should correctly change value when user choose another option', async () => {
- render(<AckeeSelect storageKey="ackee-tracking" initialValue="full" />);
-
- await act(async () => {
- await user.selectOptions(
- screen.getByRole('combobox'),
- screen.getByRole('option', { name: 'Partial' })
- );
- });
-
- expect(screen.getByRole('combobox')).toHaveValue('partial');
- expect(screen.queryByRole('combobox')).not.toHaveValue('full');
- });
-});
diff --git a/src/components/molecules/forms/ackee-select.tsx b/src/components/molecules/forms/ackee-select.tsx
deleted file mode 100644
index f00ca74..0000000
--- a/src/components/molecules/forms/ackee-select.tsx
+++ /dev/null
@@ -1,103 +0,0 @@
-import { type SelectOptions } from '@components/atoms/forms/select';
-import useLocalStorage from '@utils/hooks/use-local-storage';
-import useUpdateAckeeOptions, {
- type AckeeOptions,
-} from '@utils/hooks/use-update-ackee-options';
-import { Dispatch, FC, SetStateAction } from 'react';
-import { useIntl } from 'react-intl';
-import SelectWithTooltip, {
- type SelectWithTooltipProps,
-} from './select-with-tooltip';
-
-export type AckeeSelectProps = Pick<
- SelectWithTooltipProps,
- 'className' | 'labelClassName' | 'tooltipClassName'
-> & {
- /**
- * A default value for Ackee settings.
- */
- initialValue: AckeeOptions;
- /**
- * The local storage key to save preference.
- */
- storageKey: string;
-};
-
-/**
- * AckeeSelect component
- *
- * Render a select to set Ackee settings.
- */
-const AckeeSelect: FC<AckeeSelectProps> = ({
- initialValue,
- storageKey,
- ...props
-}) => {
- const intl = useIntl();
- const { value, setValue } = useLocalStorage<AckeeOptions>(
- storageKey,
- initialValue
- );
- useUpdateAckeeOptions(value);
-
- const ackeeLabel = intl.formatMessage({
- defaultMessage: 'Tracking:',
- description: 'AckeeSelect: select label',
- id: '2pmylc',
- });
- const tooltipTitle = intl.formatMessage({
- defaultMessage: 'Ackee tracking (analytics)',
- description: 'AckeeSelect: tooltip title',
- id: 'F1EQX3',
- });
- const tooltipContent = [
- intl.formatMessage({
- defaultMessage: 'Partial includes only page url, views and duration.',
- description: 'AckeeSelect: tooltip message',
- id: 'skb4W5',
- }),
- intl.formatMessage({
- defaultMessage:
- 'Full includes all information from partial as well as information about referrer, operating system, device, browser, screen size and language.',
- description: 'AckeeSelect: tooltip message',
- id: 'Ogccx6',
- }),
- ];
- const options: SelectOptions[] = [
- {
- id: 'partial',
- name: intl.formatMessage({
- defaultMessage: 'Partial',
- description: 'AckeeSelect: partial option name',
- id: 'e/8Kyj',
- }),
- value: 'partial',
- },
- {
- id: 'full',
- name: intl.formatMessage({
- defaultMessage: 'Full',
- description: 'AckeeSelect: full option name',
- id: 'PzRpPw',
- }),
- value: 'full',
- },
- ];
-
- return (
- <SelectWithTooltip
- id="ackee-settings"
- name="ackee-settings"
- label={ackeeLabel}
- labelSize="medium"
- options={options}
- title={tooltipTitle}
- content={tooltipContent}
- value={value}
- setValue={setValue as Dispatch<SetStateAction<string>>}
- {...props}
- />
- );
-};
-
-export default AckeeSelect;
diff --git a/src/components/molecules/forms/ackee-toggle.fixture.tsx b/src/components/molecules/forms/ackee-toggle.fixture.tsx
new file mode 100644
index 0000000..04602f2
--- /dev/null
+++ b/src/components/molecules/forms/ackee-toggle.fixture.tsx
@@ -0,0 +1 @@
+export const storageKey = 'ackee';
diff --git a/src/components/molecules/forms/ackee-select.module.scss b/src/components/molecules/forms/ackee-toggle.module.scss
index 87cd9ee..f238bda 100644
--- a/src/components/molecules/forms/ackee-select.module.scss
+++ b/src/components/molecules/forms/ackee-toggle.module.scss
@@ -4,8 +4,3 @@
align-items: center;
position: relative;
}
-
-.tooltip {
- position: absolute;
- bottom: -100%;
-}
diff --git a/src/components/molecules/forms/ackee-toggle.stories.tsx b/src/components/molecules/forms/ackee-toggle.stories.tsx
new file mode 100644
index 0000000..bbc6fb4
--- /dev/null
+++ b/src/components/molecules/forms/ackee-toggle.stories.tsx
@@ -0,0 +1,112 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import AckeeToggleComponent from './ackee-toggle';
+import { storageKey } from './ackee-toggle.fixture';
+
+/**
+ * AckeeToggle - Storybook Meta
+ */
+export default {
+ title: 'Molecules/Forms/Toggle',
+ component: AckeeToggleComponent,
+ argTypes: {
+ bodyClassName: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the fieldset body wrapper.',
+ 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 AckeeToggleComponent>;
+
+const Template: ComponentStory<typeof AckeeToggleComponent> = (args) => (
+ <AckeeToggleComponent {...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
new file mode 100644
index 0000000..8a57ce7
--- /dev/null
+++ b/src/components/molecules/forms/ackee-toggle.test.tsx
@@ -0,0 +1,15 @@
+import { render, screen } from '@test-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
new file mode 100644
index 0000000..a666731
--- /dev/null
+++ b/src/components/molecules/forms/ackee-toggle.tsx
@@ -0,0 +1,143 @@
+import useLocalStorage from '@utils/hooks/use-local-storage';
+import useUpdateAckeeOptions, {
+ type AckeeOptions,
+} from '@utils/hooks/use-update-ackee-options';
+import { FC } from 'react';
+import { useIntl } from 'react-intl';
+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' | 'groupClassName' | 'legendClassName'
+> & {
+ /**
+ * 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.
+ */
+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
+ initialChoice={value}
+ kind="toggle"
+ legend={ackeeLabel}
+ onChange={handleChange}
+ options={options}
+ Tooltip={
+ <Tooltip
+ title={tooltipTitle}
+ content={tooltipContent}
+ icon="?"
+ className={tooltipClassName}
+ />
+ }
+ {...props}
+ />
+ );
+};
+
+export default AckeeToggle;
diff --git a/src/components/molecules/forms/fieldset.fixture.tsx b/src/components/molecules/forms/fieldset.fixture.tsx
new file mode 100644
index 0000000..b94f340
--- /dev/null
+++ b/src/components/molecules/forms/fieldset.fixture.tsx
@@ -0,0 +1,6 @@
+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/select-with-tooltip.module.scss b/src/components/molecules/forms/fieldset.module.scss
index bfadece..3102bf7 100644
--- a/src/components/molecules/forms/select-with-tooltip.module.scss
+++ b/src/components/molecules/forms/fieldset.module.scss
@@ -1,23 +1,16 @@
-@use "@styles/abstracts/functions" as fun;
-@use "@styles/abstracts/mixins" as mix;
-
-.wrapper {
- display: flex;
- flex-flow: row wrap;
- align-items: center;
- position: relative;
-}
-
-.select {
- width: auto;
-
- @include mix.pointer("fine") {
- padding: fun.convert-px(3) var(--spacing-xs);
+.legend {
+ float: left;
+ color: var(--color-primary-darker);
+ font-size: var(--font-size-md);
+ font-weight: 600;
+
+ &#{&}--has-tooltip {
+ padding: 0 var(--spacing-xs) 0 0;
}
}
.btn {
- margin-left: var(--spacing-xs);
+ margin: 0 var(--spacing-2xs) var(--spacing-2xs) 0;
&--activated {
background: var(--color-primary);
@@ -29,12 +22,12 @@
}
.tooltip {
- position: absolute;
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);
@@ -46,3 +39,23 @@
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
new file mode 100644
index 0000000..0778094
--- /dev/null
+++ b/src/components/molecules/forms/fieldset.stories.tsx
@@ -0,0 +1,165 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { TooltipProps } from '../modals/tooltip';
+import { Help } from '../modals/tooltip.stories';
+import FieldsetComponent from './fieldset';
+import { body, legend, Tooltip } from './fieldset.fixture';
+
+/**
+ * Fieldset - Storybook Meta
+ */
+export default {
+ title: 'Atoms/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,
+ },
+ },
+ 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
new file mode 100644
index 0000000..de89e31
--- /dev/null
+++ b/src/components/molecules/forms/fieldset.test.tsx
@@ -0,0 +1,22 @@
+import { render, screen } from '@test-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
new file mode 100644
index 0000000..9f46247
--- /dev/null
+++ b/src/components/molecules/forms/fieldset.tsx
@@ -0,0 +1,118 @@
+import useClickOutside from '@utils/hooks/use-click-outside';
+import {
+ cloneElement,
+ FC,
+ ReactComponentElement,
+ ReactNode,
+ useRef,
+ useState,
+} from 'react';
+import HelpButton from '../buttons/help-button';
+import Tooltip from '../modals/tooltip';
+import styles from './fieldset.module.scss';
+
+export type FieldsetProps = {
+ /**
+ * Set additional classnames to the body wrapper.
+ */
+ bodyClassName?: 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.
+ */
+const Fieldset: FC<FieldsetProps> = ({
+ bodyClassName = '',
+ children,
+ className = '',
+ legend,
+ legendClassName = '',
+ legendPosition = 'stacked',
+ Tooltip: TooltipComponent,
+ ...props
+}) => {
+ const [isTooltipOpened, setIsTooltipOpened] = useState<boolean>(false);
+ const buttonRef = useRef<HTMLButtonElement>(null);
+ const tooltipRef = useRef<HTMLDivElement>(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 event target is outside.
+ *
+ * @param {EventTarget} target - The event target.
+ */
+ const closeTooltip = (target: EventTarget) => {
+ if (buttonRef.current && !buttonRef.current.contains(target as Node))
+ setIsTooltipOpened(false);
+ };
+
+ useClickOutside(
+ tooltipRef,
+ (target) => isTooltipOpened && closeTooltip(target)
+ );
+
+ return (
+ <fieldset
+ className={`${styles.wrapper} ${styles[wrapperModifier]} ${className}`}
+ {...props}
+ >
+ <legend
+ className={`${styles.legend} ${styles[legendModifier]} ${legendClassName}`}
+ >
+ {legend}
+ </legend>
+ {TooltipComponent && (
+ <>
+ <HelpButton
+ className={`${styles.btn} ${buttonModifier}`}
+ onClick={() => setIsTooltipOpened(!isTooltipOpened)}
+ ref={buttonRef}
+ />
+ {cloneElement(TooltipComponent, {
+ cloneClassName: `${styles.tooltip} ${styles[tooltipModifier]}`,
+ ref: tooltipRef,
+ })}
+ </>
+ )}
+ <div className={`${styles.body} ${bodyClassName}`}>{children}</div>
+ </fieldset>
+ );
+};
+
+export default Fieldset;
diff --git a/src/components/molecules/forms/labelled-boolean-field.fixture.tsx b/src/components/molecules/forms/labelled-boolean-field.fixture.tsx
new file mode 100644
index 0000000..6b06887
--- /dev/null
+++ b/src/components/molecules/forms/labelled-boolean-field.fixture.tsx
@@ -0,0 +1 @@
+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
new file mode 100644
index 0000000..10a9eb2
--- /dev/null
+++ b/src/components/molecules/forms/labelled-boolean-field.module.scss
@@ -0,0 +1,15 @@
+.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
new file mode 100644
index 0000000..6098b51
--- /dev/null
+++ b/src/components/molecules/forms/labelled-boolean-field.stories.tsx
@@ -0,0 +1,254 @@
+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
new file mode 100644
index 0000000..55e04ea
--- /dev/null
+++ b/src/components/molecules/forms/labelled-boolean-field.test.tsx
@@ -0,0 +1,37 @@
+import { render, screen } from '@test-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
new file mode 100644
index 0000000..46eb080
--- /dev/null
+++ b/src/components/molecules/forms/labelled-boolean-field.tsx
@@ -0,0 +1,92 @@
+import BooleanField, {
+ type BooleanFieldProps,
+} from '@components/atoms/forms/boolean-field';
+import Label, { type LabelProps } from '@components/atoms/forms/label';
+import { FC } from 'react';
+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.
+ */
+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}`;
+
+ return labelPosition === 'left' ? (
+ <span className={`${styles.wrapper} ${className}`}>
+ <Label
+ className={`${styles[labelPositionModifier]} ${styles[labelHiddenModifier]} ${labelClassName}`}
+ htmlFor={id}
+ size={labelSize}
+ >
+ {label}
+ </Label>
+ <BooleanField
+ className={fieldClassName}
+ hidden={hidden}
+ id={id}
+ {...props}
+ />
+ </span>
+ ) : (
+ <span className={`${styles.wrapper} ${className}`}>
+ <BooleanField
+ className={fieldClassName}
+ hidden={hidden}
+ id={id}
+ {...props}
+ />
+ <Label
+ className={`${styles[labelPositionModifier]} ${styles[labelHiddenModifier]} ${labelClassName}`}
+ htmlFor={id}
+ size={labelSize}
+ >
+ {label}
+ </Label>
+ </span>
+ );
+};
+
+export default LabelledBooleanField;
diff --git a/src/components/molecules/forms/motion-toggle.fixture.tsx b/src/components/molecules/forms/motion-toggle.fixture.tsx
new file mode 100644
index 0000000..f13658a
--- /dev/null
+++ b/src/components/molecules/forms/motion-toggle.fixture.tsx
@@ -0,0 +1 @@
+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
index e9939bd..541ca8e 100644
--- a/src/components/molecules/forms/motion-toggle.stories.tsx
+++ b/src/components/molecules/forms/motion-toggle.stories.tsx
@@ -1,5 +1,6 @@
import { ComponentMeta, ComponentStory } from '@storybook/react';
import MotionToggleComponent from './motion-toggle';
+import { storageKey } from './motion-toggle.fixture';
/**
* MotionToggle - Storybook Meta
@@ -8,11 +9,11 @@ export default {
title: 'Molecules/Forms/Toggle',
component: MotionToggleComponent,
argTypes: {
- className: {
+ bodyClassName: {
control: {
type: 'text',
},
- description: 'Set additional classnames to the toggle wrapper.',
+ description: 'Set additional classnames to the fieldset body wrapper.',
table: {
category: 'Styles',
},
@@ -21,11 +22,22 @@ export default {
required: false,
},
},
- labelClassName: {
+ 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 label wrapper.',
+ description: 'Set additional classnames to the radio group wrapper.',
table: {
category: 'Styles',
},
@@ -34,23 +46,26 @@ export default {
required: false,
},
},
- storageKey: {
+ legendClassName: {
control: {
type: 'text',
},
- description: 'Set local storage key.',
+ description: 'Set additional classnames to the legend.',
+ table: {
+ category: 'Styles',
+ },
type: {
name: 'string',
- required: true,
+ required: false,
},
},
- value: {
+ storageKey: {
control: {
- type: null,
+ type: 'text',
},
- description: 'The reduce motion value.',
+ description: 'Set local storage key.',
type: {
- name: 'boolean',
+ name: 'string',
required: true,
},
},
@@ -66,5 +81,6 @@ const Template: ComponentStory<typeof MotionToggleComponent> = (args) => (
*/
export const Motion = Template.bind({});
Motion.args = {
- value: false,
+ defaultValue: 'on',
+ storageKey,
};
diff --git a/src/components/molecules/forms/motion-toggle.test.tsx b/src/components/molecules/forms/motion-toggle.test.tsx
index 4fd6b31..04c22a9 100644
--- a/src/components/molecules/forms/motion-toggle.test.tsx
+++ b/src/components/molecules/forms/motion-toggle.test.tsx
@@ -1,13 +1,15 @@
import { render, screen } from '@test-utils';
import MotionToggle from './motion-toggle';
+import { storageKey } from './motion-toggle.fixture';
describe('MotionToggle', () => {
- it('renders a checked toggle (deactivate animations choice)', () => {
- render(<MotionToggle storageKey="reduced-motion" value={true} />);
+ // toHaveValue received undefined. Maybe because of localStorage hook...
+ it('renders a toggle component', () => {
+ render(<MotionToggle storageKey={storageKey} defaultValue="on" />);
expect(
- screen.getByRole('checkbox', {
- name: `Animations: On Off`,
+ screen.getByRole('radiogroup', {
+ name: /Animations:/i,
})
- ).toBeChecked();
+ ).toBeInTheDocument();
});
});
diff --git a/src/components/molecules/forms/motion-toggle.tsx b/src/components/molecules/forms/motion-toggle.tsx
index 55ff150..ec2d950 100644
--- a/src/components/molecules/forms/motion-toggle.tsx
+++ b/src/components/molecules/forms/motion-toggle.tsx
@@ -1,17 +1,25 @@
-import Toggle, {
- type ToggleChoices,
- type ToggleProps,
-} from '@components/molecules/forms/toggle';
import useAttributes from '@utils/hooks/use-attributes';
import useLocalStorage from '@utils/hooks/use-local-storage';
import { FC } from 'react';
import { useIntl } from 'react-intl';
+import RadioGroup, {
+ type RadioGroupCallback,
+ type RadioGroupCallbackProps,
+ type RadioGroupOption,
+ type RadioGroupProps,
+} from './radio-group';
+
+export type MotionToggleValue = 'on' | 'off';
export type MotionToggleProps = Pick<
- ToggleProps,
- 'className' | 'labelClassName' | 'value'
+ RadioGroupProps,
+ 'bodyClassName' | 'groupClassName' | 'legendClassName'
> & {
/**
+ * True if motion should be reduced by default.
+ */
+ defaultValue: 'on' | 'off';
+ /**
* The local storage key to save preference.
*/
storageKey: string;
@@ -23,14 +31,14 @@ export type MotionToggleProps = Pick<
* Render a Toggle component to set reduce motion.
*/
const MotionToggle: FC<MotionToggleProps> = ({
+ defaultValue,
storageKey,
- value,
...props
}) => {
const intl = useIntl();
const { value: isReduced, setValue: setIsReduced } = useLocalStorage<boolean>(
storageKey,
- value
+ defaultValue === 'on' ? false : true
);
useAttributes({
element: document.documentElement || undefined,
@@ -53,20 +61,56 @@ const MotionToggle: FC<MotionToggleProps> = ({
description: 'MotionToggle: deactivate reduce motion label',
id: 'pWKyyR',
});
- const reduceMotionChoices: ToggleChoices = {
- left: onLabel,
- right: offLabel,
+
+ 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 (
- <Toggle
- id="reduce-motion-settings"
- name="reduce-motion-settings"
- label={reduceMotionLabel}
- labelSize="medium"
- choices={reduceMotionChoices}
- value={isReduced}
- setValue={setIsReduced}
+ <RadioGroup
+ initialChoice={defaultValue}
+ kind="toggle"
+ legend={reduceMotionLabel}
+ onChange={handleChange}
+ options={options}
{...props}
/>
);
diff --git a/src/components/molecules/forms/prism-theme-toggle.stories.tsx b/src/components/molecules/forms/prism-theme-toggle.stories.tsx
index 6a88e51..86f9773 100644
--- a/src/components/molecules/forms/prism-theme-toggle.stories.tsx
+++ b/src/components/molecules/forms/prism-theme-toggle.stories.tsx
@@ -8,11 +8,11 @@ export default {
title: 'Molecules/Forms/Toggle',
component: PrismThemeToggle,
argTypes: {
- className: {
+ bodyClassName: {
control: {
type: 'text',
},
- description: 'Set additional classnames to the toggle wrapper.',
+ description: 'Set additional classnames to the fieldset body wrapper.',
table: {
category: 'Styles',
},
@@ -21,11 +21,24 @@ export default {
required: false,
},
},
- labelClassName: {
+ groupClassName: {
control: {
type: 'text',
},
- description: 'Set additional classnames to the label wrapper.',
+ 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',
},
diff --git a/src/components/molecules/forms/prism-theme-toggle.test.tsx b/src/components/molecules/forms/prism-theme-toggle.test.tsx
index c9d7894..91e8e2e 100644
--- a/src/components/molecules/forms/prism-theme-toggle.test.tsx
+++ b/src/components/molecules/forms/prism-theme-toggle.test.tsx
@@ -5,8 +5,8 @@ describe('PrismThemeToggle', () => {
it('renders a toggle component', () => {
render(<PrismThemeToggle />);
expect(
- screen.getByRole('checkbox', {
- name: `Code blocks: Light theme Dark theme`,
+ 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
index e0b795f..7bf5b7c 100644
--- a/src/components/molecules/forms/prism-theme-toggle.tsx
+++ b/src/components/molecules/forms/prism-theme-toggle.tsx
@@ -1,16 +1,18 @@
import Moon from '@components/atoms/icons/moon';
import Sun from '@components/atoms/icons/sun';
-import Toggle, {
- type ToggleChoices,
- type ToggleProps,
-} from '@components/molecules/forms/toggle';
-import { usePrismTheme } from '@utils/providers/prism-theme';
+import { type PrismTheme, usePrismTheme } from '@utils/providers/prism-theme';
import { FC } from 'react';
import { useIntl } from 'react-intl';
+import RadioGroup, {
+ type RadioGroupCallback,
+ type RadioGroupCallbackProps,
+ type RadioGroupOption,
+ type RadioGroupProps,
+} from './radio-group';
export type PrismThemeToggleProps = Pick<
- ToggleProps,
- 'className' | 'labelClassName'
+ RadioGroupProps,
+ 'bodyClassName' | 'groupClassName' | 'legendClassName'
>;
/**
@@ -18,7 +20,7 @@ export type PrismThemeToggleProps = Pick<
*
* Render a Toggle component to set code blocks theme.
*/
-const PrismThemeToggle: FC<PrismThemeToggleProps> = ({ ...props }) => {
+const PrismThemeToggle: FC<PrismThemeToggleProps> = (props) => {
const intl = useIntl();
const { theme, setTheme, resolvedTheme } = usePrismTheme();
@@ -27,16 +29,36 @@ const PrismThemeToggle: FC<PrismThemeToggleProps> = ({ ...props }) => {
*
* @returns {boolean} True if it is dark theme.
*/
- const isDarkTheme = (): boolean => {
- if (theme === 'system') return resolvedTheme === 'dark';
- return theme === 'dark';
+ 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 updateTheme = () => {
- setTheme(isDarkTheme() ? 'light' : 'dark');
+ 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({
@@ -54,20 +76,29 @@ const PrismThemeToggle: FC<PrismThemeToggleProps> = ({ ...props }) => {
description: 'PrismThemeToggle: dark theme label',
id: 'og/zWL',
});
- const themeChoices: ToggleChoices = {
- left: <Sun title={lightThemeLabel} />,
- right: <Moon title={darkThemeLabel} />,
- };
+
+ const options: RadioGroupOption[] = [
+ {
+ id: 'code-blocks-light',
+ label: <Sun title={lightThemeLabel} />,
+ name: 'code-blocks',
+ value: 'light',
+ },
+ {
+ id: 'code-blocks-dark',
+ label: <Moon title={darkThemeLabel} />,
+ name: 'code-blocks',
+ value: 'dark',
+ },
+ ];
return (
- <Toggle
- id="prism-theme-settings"
- name="prism-theme-settings"
- label={themeLabel}
- labelSize="medium"
- choices={themeChoices}
- value={isDarkTheme()}
- setValue={updateTheme}
+ <RadioGroup
+ initialChoice={isDarkTheme(theme) ? 'dark' : 'light'}
+ kind="toggle"
+ legend={themeLabel}
+ onChange={handleChange}
+ options={options}
{...props}
/>
);
diff --git a/src/components/molecules/forms/radio-group.fixture.tsx b/src/components/molecules/forms/radio-group.fixture.tsx
new file mode 100644
index 0000000..686467c
--- /dev/null
+++ b/src/components/molecules/forms/radio-group.fixture.tsx
@@ -0,0 +1,47 @@
+import { RadioGroupOption } from './radio-group';
+
+export const getOptions = (name: string = 'group1') => {
+ const value1 = 'option1';
+ const value2 = 'option2';
+ const value3 = 'option3';
+ const value4 = 'option4';
+ const value5 = 'option5';
+
+ const options: RadioGroupOption[] = [
+ {
+ 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,
+ },
+ ];
+
+ return options;
+};
+
+export const initialChoice = 'option2';
+export const legend = 'Options:';
diff --git a/src/components/molecules/forms/radio-group.module.scss b/src/components/molecules/forms/radio-group.module.scss
new file mode 100644
index 0000000..0bd34b9
--- /dev/null
+++ b/src/components/molecules/forms/radio-group.module.scss
@@ -0,0 +1,112 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/mixins" as mix;
+
+.wrapper {
+ &--inline#{&}--regular {
+ .option:first-of-type {
+ margin-left: 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
new file mode 100644
index 0000000..ad1bd6d
--- /dev/null
+++ b/src/components/molecules/forms/radio-group.stories.tsx
@@ -0,0 +1,272 @@
+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,
+ },
+ },
+ 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
new file mode 100644
index 0000000..8171a49
--- /dev/null
+++ b/src/components/molecules/forms/radio-group.test.tsx
@@ -0,0 +1,30 @@
+import { render, screen } from '@test-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
new file mode 100644
index 0000000..64bdaa0
--- /dev/null
+++ b/src/components/molecules/forms/radio-group.tsx
@@ -0,0 +1,148 @@
+import Fieldset, {
+ type FieldsetProps,
+} from '@components/molecules/forms/fieldset';
+import useStateChange from '@utils/hooks/use-state-change';
+import { ChangeEvent, FC, MouseEvent, SetStateAction, useState } from 'react';
+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' | '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.
+ */
+const RadioGroup: FC<RadioGroupProps> = ({
+ className,
+ groupClassName = '',
+ initialChoice,
+ kind = 'regular',
+ labelPosition,
+ labelSize,
+ 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';
+
+ /**
+ * 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
+ 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"
+ {...option}
+ />
+ ));
+ };
+
+ return (
+ <Fieldset
+ className={`${styles.wrapper} ${styles[alignmentModifier]} ${styles[toggleModifier]} ${className}`}
+ legendPosition={legendPosition}
+ role="radiogroup"
+ {...props}
+ >
+ {isToggle ? (
+ <span className={`${styles.toggle} ${groupClassName}`}>
+ {getOptions()}
+ </span>
+ ) : (
+ getOptions()
+ )}
+ </Fieldset>
+ );
+};
+
+export default RadioGroup;
diff --git a/src/components/molecules/forms/select-with-tooltip.stories.tsx b/src/components/molecules/forms/select-with-tooltip.stories.tsx
deleted file mode 100644
index d6206a9..0000000
--- a/src/components/molecules/forms/select-with-tooltip.stories.tsx
+++ /dev/null
@@ -1,223 +0,0 @@
-import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { useState } from 'react';
-import SelectWithTooltip from './select-with-tooltip';
-
-/**
- * SelectWithTooltip - Storybook Meta
- */
-export default {
- title: 'Molecules/Forms/Select',
- component: SelectWithTooltip,
- argTypes: {
- className: {
- control: {
- type: 'text',
- },
- description: 'Set additional classnames to the select wrapper.',
- table: {
- category: 'Styles',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- content: {
- control: {
- type: 'text',
- },
- description: 'The tooltip body.',
- type: {
- name: 'string',
- required: true,
- },
- },
- 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: 'The select 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,
- },
- },
- 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,
- },
- },
- title: {
- control: {
- type: 'text',
- },
- description: 'The tooltip title',
- type: {
- name: 'string',
- required: true,
- },
- },
- tooltipClassName: {
- control: {
- type: 'text',
- },
- description: 'Set additional classnames to the tooltip.',
- table: {
- category: 'Styles',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- value: {
- control: {
- type: 'text',
- },
- description: 'Field value.',
- type: {
- name: 'string',
- required: true,
- },
- },
- },
-} as ComponentMeta<typeof SelectWithTooltip>;
-
-const selectOptions = [
- { id: 'option1', name: 'Option 1', value: 'option1' },
- { id: 'option2', name: 'Option 2', value: 'option2' },
- { id: 'option3', name: 'Option 3', value: 'option3' },
-];
-
-const Template: ComponentStory<typeof SelectWithTooltip> = ({
- value: _value,
- setValue: _setValue,
- ...args
-}) => {
- const [selected, setSelected] = useState<string>('option1');
- return (
- <SelectWithTooltip value={selected} setValue={setSelected} {...args} />
- );
-};
-
-/**
- * Select Stories - With tooltip
- */
-export const WithTooltip = Template.bind({});
-WithTooltip.args = {
- content: 'Illo voluptatibus quia minima placeat sit nostrum excepturi.',
- title: 'Possimus quidem dolor',
- id: 'storybook-select',
- label: 'Officiis:',
- name: 'storybook-select',
- options: selectOptions,
-};
diff --git a/src/components/molecules/forms/select-with-tooltip.test.tsx b/src/components/molecules/forms/select-with-tooltip.test.tsx
deleted file mode 100644
index 7a423f5..0000000
--- a/src/components/molecules/forms/select-with-tooltip.test.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import { render, screen } from '@test-utils';
-import SelectWithTooltip from './select-with-tooltip';
-
-const selectOptions = [
- { id: 'option1', name: 'Option 1', value: 'option1' },
- { id: 'option2', name: 'Option 2', value: 'option2' },
- { id: 'option3', name: 'Option 3', value: 'option3' },
-];
-const selectLabel = 'Jest select';
-const selectValue = selectOptions[0].value;
-const tooltipTitle = 'Jest tooltip';
-const tooltipContent = 'Nesciunt voluptatibus voluptatem omnis at quia libero.';
-
-describe('SelectWithTooltip', () => {
- it('renders a select', () => {
- render(
- <SelectWithTooltip
- id="jest-select"
- name="jest-select"
- label={selectLabel}
- options={selectOptions}
- value={selectValue}
- setValue={() => null}
- title={tooltipTitle}
- content={tooltipContent}
- />
- );
- expect(screen.getByRole('combobox', { name: selectLabel })).toHaveValue(
- selectValue
- );
- });
-});
diff --git a/src/components/molecules/forms/select-with-tooltip.tsx b/src/components/molecules/forms/select-with-tooltip.tsx
deleted file mode 100644
index 46075c2..0000000
--- a/src/components/molecules/forms/select-with-tooltip.tsx
+++ /dev/null
@@ -1,78 +0,0 @@
-import useClickOutside from '@utils/hooks/use-click-outside';
-import { FC, useRef, useState } from 'react';
-import HelpButton from '../buttons/help-button';
-import Tooltip, { type TooltipProps } from '../modals/tooltip';
-import LabelledSelect, { type LabelledSelectProps } from './labelled-select';
-import styles from './select-with-tooltip.module.scss';
-
-export type SelectWithTooltipProps = Omit<
- LabelledSelectProps,
- 'labelPosition'
-> &
- Pick<TooltipProps, 'title' | 'content'> & {
- /**
- * Set additional classnames to the select wrapper.
- */
- className?: string;
- /**
- * Set additional classnames to the tooltip wrapper.
- */
- tooltipClassName?: TooltipProps['className'];
- };
-
-/**
- * SelectWithTooltip component
- *
- * Render a select with a button to display a tooltip about options.
- */
-const SelectWithTooltip: FC<SelectWithTooltipProps> = ({
- className = '',
- content,
- id,
- title,
- tooltipClassName = '',
- ...props
-}) => {
- const [isTooltipOpened, setIsTooltipOpened] = useState<boolean>(false);
- const buttonRef = useRef<HTMLButtonElement>(null);
- const tooltipRef = useRef<HTMLDivElement>(null);
- const buttonModifier = isTooltipOpened ? styles['btn--activated'] : '';
- const tooltipModifier = isTooltipOpened
- ? styles['tooltip--visible']
- : styles['tooltip--hidden'];
-
- const closeTooltip = (target: EventTarget) => {
- if (buttonRef.current && !buttonRef.current.contains(target as Node))
- setIsTooltipOpened(false);
- };
-
- useClickOutside(
- tooltipRef,
- (target) => isTooltipOpened && closeTooltip(target)
- );
-
- return (
- <div className={`${styles.wrapper} ${className}`}>
- <LabelledSelect
- labelPosition="left"
- id={id}
- labelClassName={styles.label}
- {...props}
- />
- <HelpButton
- className={`${styles.btn} ${buttonModifier}`}
- onClick={() => setIsTooltipOpened(!isTooltipOpened)}
- ref={buttonRef}
- />
- <Tooltip
- title={title}
- content={content}
- icon="?"
- className={`${styles.tooltip} ${tooltipModifier} ${tooltipClassName}`}
- ref={tooltipRef}
- />
- </div>
- );
-};
-
-export default SelectWithTooltip;
diff --git a/src/components/molecules/forms/theme-toggle.stories.tsx b/src/components/molecules/forms/theme-toggle.stories.tsx
index a7bebb4..ff1034d 100644
--- a/src/components/molecules/forms/theme-toggle.stories.tsx
+++ b/src/components/molecules/forms/theme-toggle.stories.tsx
@@ -8,11 +8,11 @@ export default {
title: 'Molecules/Forms/Toggle',
component: ThemeToggle,
argTypes: {
- className: {
+ bodyClassName: {
control: {
type: 'text',
},
- description: 'Set additional classnames to the toggle wrapper.',
+ description: 'Set additional classnames to the fieldset body wrapper.',
table: {
category: 'Styles',
},
@@ -21,11 +21,24 @@ export default {
required: false,
},
},
- labelClassName: {
+ groupClassName: {
control: {
type: 'text',
},
- description: 'Set additional classnames to the label wrapper.',
+ 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',
},
diff --git a/src/components/molecules/forms/theme-toggle.test.tsx b/src/components/molecules/forms/theme-toggle.test.tsx
index 0600c5e..ed8b312 100644
--- a/src/components/molecules/forms/theme-toggle.test.tsx
+++ b/src/components/molecules/forms/theme-toggle.test.tsx
@@ -5,8 +5,8 @@ describe('ThemeToggle', () => {
it('renders a toggle component', () => {
render(<ThemeToggle />);
expect(
- screen.getByRole('checkbox', {
- name: `Theme: Light theme Dark theme`,
+ 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
index e9dd5e4..b796b27 100644
--- a/src/components/molecules/forms/theme-toggle.tsx
+++ b/src/components/molecules/forms/theme-toggle.tsx
@@ -1,16 +1,18 @@
import Moon from '@components/atoms/icons/moon';
import Sun from '@components/atoms/icons/sun';
-import Toggle, {
- type ToggleChoices,
- type ToggleProps,
-} from '@components/molecules/forms/toggle';
import { useTheme } from 'next-themes';
import { FC } from 'react';
import { useIntl } from 'react-intl';
+import RadioGroup, {
+ type RadioGroupCallback,
+ type RadioGroupCallbackProps,
+ type RadioGroupOption,
+ type RadioGroupProps,
+} from './radio-group';
export type ThemeToggleProps = Pick<
- ToggleProps,
- 'className' | 'labelClassName'
+ RadioGroupProps,
+ 'bodyClassName' | 'groupClassName' | 'legendClassName'
>;
/**
@@ -18,16 +20,36 @@ export type ThemeToggleProps = Pick<
*
* Render a Toggle component to set theme.
*/
-const ThemeToggle: FC<ThemeToggleProps> = ({ ...props }) => {
+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 = () => {
- setTheme(isDarkTheme ? 'light' : 'dark');
+ 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({
@@ -45,20 +67,29 @@ const ThemeToggle: FC<ThemeToggleProps> = ({ ...props }) => {
description: 'ThemeToggle: dark theme label',
id: '2QwvtS',
});
- const themeChoices: ToggleChoices = {
- left: <Sun title={lightThemeLabel} />,
- right: <Moon title={darkThemeLabel} />,
- };
+
+ const options: RadioGroupOption[] = [
+ {
+ id: 'theme-light',
+ label: <Sun title={lightThemeLabel} />,
+ name: 'theme',
+ value: 'light',
+ },
+ {
+ id: 'theme-dark',
+ label: <Moon title={darkThemeLabel} />,
+ name: 'theme',
+ value: 'dark',
+ },
+ ];
return (
- <Toggle
- id="theme-settings"
- name="theme-settings"
- label={themeLabel}
- labelSize="medium"
- choices={themeChoices}
- value={isDarkTheme}
- setValue={updateTheme}
+ <RadioGroup
+ initialChoice={isDarkTheme ? 'dark' : 'light'}
+ kind="toggle"
+ legend={themeLabel}
+ onChange={handleChange}
+ options={options}
{...props}
/>
);
diff --git a/src/components/molecules/forms/toggle.module.scss b/src/components/molecules/forms/toggle.module.scss
deleted file mode 100644
index 2e8a49f..0000000
--- a/src/components/molecules/forms/toggle.module.scss
+++ /dev/null
@@ -1,75 +0,0 @@
-@use "@styles/abstracts/functions" as fun;
-
-.label {
- --toggle-width: #{fun.convert-px(45)};
- --toggle-height: calc(var(--toggle-width) / 2);
-
- display: inline-flex;
- align-items: center;
- width: 100%;
-}
-
-.title {
- margin-right: var(--spacing-2xs);
-}
-
-.toggle {
- display: inline-flex;
- align-items: center;
- width: var(--toggle-width);
- height: var(--toggle-height);
- background: var(--color-shadow-light);
- border: fun.convert-px(1) solid var(--color-primary);
- border-radius: fun.convert-px(32);
- box-shadow: inset 0 0 fun.convert-px(3) 0 var(--color-shadow-dark);
- margin: 0 var(--spacing-2xs);
- position: relative;
-
- &::after {
- content: "";
- display: block;
- width: calc((var(--toggle-width) / 2) - 1px);
- height: calc((var(--toggle-width) / 2) - 1px);
- background: var(--color-primary-light);
- border: fun.convert-px(1) solid var(--color-primary);
- border-radius: 50%;
- box-shadow: inset 0 0 fun.convert-px(1) fun.convert-px(1)
- var(--color-shadow),
- 0 0 fun.convert-px(2) fun.convert-px(1) var(--color-shadow-light);
- position: absolute;
- left: fun.convert-px(-2);
- transition: all 0.3s ease-in-out 0s;
- }
-}
-
-.checkbox {
- position: absolute;
- opacity: 0;
- cursor: pointer;
-
- &:checked ~ .label {
- .toggle::after {
- position: absolute;
- left: calc(100% - (var(--toggle-width) / 2) + #{fun.convert-px(2)});
- }
- }
-
- &:hover,
- &:focus {
- ~ .label {
- .toggle::after {
- background: var(--color-primary-lighter);
- }
- }
- }
-
- &:focus ~ .label {
- .title {
- text-decoration: underline solid var(--color-primary) fun.convert-px(2);
- }
-
- .toggle {
- outline: var(--color-border) solid fun.convert-px(5);
- }
- }
-}
diff --git a/src/components/molecules/forms/toggle.stories.tsx b/src/components/molecules/forms/toggle.stories.tsx
deleted file mode 100644
index f1b8296..0000000
--- a/src/components/molecules/forms/toggle.stories.tsx
+++ /dev/null
@@ -1,134 +0,0 @@
-import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { useState } from 'react';
-import Toggle from './toggle';
-
-/**
- * ThemeToggle - Storybook Meta
- */
-export default {
- title: 'Molecules/Forms/Toggle',
- component: Toggle,
- argTypes: {
- choices: {
- description: 'The toggle choices.',
- type: {
- name: 'object',
- required: true,
- value: {},
- },
- },
- className: {
- control: {
- type: 'text',
- },
- description: 'Set additional classnames to the toggle wrapper.',
- table: {
- category: 'Styles',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- id: {
- control: {
- type: 'text',
- },
- description: 'The input id.',
- type: {
- name: 'string',
- required: true,
- },
- },
- label: {
- control: {
- type: 'text',
- },
- description: 'The toggle 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,
- },
- },
- 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 input name.',
- type: {
- name: 'string',
- required: true,
- },
- },
- setValue: {
- control: {
- type: null,
- },
- description: 'A callback function to update the toggle value.',
- type: {
- name: 'function',
- required: true,
- },
- },
- value: {
- control: {
- type: null,
- },
- description: 'The toggle value. True if checked.',
- type: {
- name: 'boolean',
- required: true,
- },
- },
- },
-} as ComponentMeta<typeof Toggle>;
-
-const Template: ComponentStory<typeof Toggle> = ({
- value: _value,
- setValue: _setValue,
- ...args
-}) => {
- const [isChecked, setIsChecked] = useState<boolean>(false);
- return <Toggle value={isChecked} setValue={setIsChecked} {...args} />;
-};
-
-/**
- * Toggle Stories - Default
- */
-export const Default = Template.bind({});
-Default.args = {
- choices: {
- left: 'On',
- right: 'Off',
- },
- id: 'toggle-example',
- label: 'Activate setting:',
- name: 'toggle-example',
-};
diff --git a/src/components/molecules/forms/toggle.test.tsx b/src/components/molecules/forms/toggle.test.tsx
deleted file mode 100644
index fb97adc..0000000
--- a/src/components/molecules/forms/toggle.test.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-import { render, screen } from '@test-utils';
-import Toggle from './toggle';
-
-const choices = {
- left: 'On',
- right: 'Off',
-};
-
-const label = 'Activate this setting:';
-
-describe('Toggle', () => {
- it('renders a checked toggle', () => {
- render(
- <Toggle
- id="toggle-example"
- name="toggle-example"
- choices={choices}
- label={label}
- value={true}
- setValue={(__value) => null}
- />
- );
- expect(
- screen.getByRole('checkbox', {
- name: `${label} ${choices.left} ${choices.right}`,
- })
- ).toBeChecked();
- });
-});
diff --git a/src/components/molecules/forms/toggle.tsx b/src/components/molecules/forms/toggle.tsx
deleted file mode 100644
index 0fac45c..0000000
--- a/src/components/molecules/forms/toggle.tsx
+++ /dev/null
@@ -1,87 +0,0 @@
-import Checkbox, { type CheckboxProps } from '@components/atoms/forms/checkbox';
-import Label, { type LabelProps } from '@components/atoms/forms/label';
-import { FC, ReactNode } from 'react';
-import styles from './toggle.module.scss';
-
-export type ToggleChoices = {
- /**
- * The left part of the toggle field (unchecked).
- */
- left: ReactNode;
- /**
- * The right part of the toggle field (checked).
- */
- right: ReactNode;
-};
-
-export type ToggleProps = Pick<CheckboxProps, 'id' | 'name'> & {
- /**
- * The toggle choices.
- */
- choices: ToggleChoices;
- /**
- * Set additional classnames to the toggle wrapper.
- */
- className?: string;
- /**
- * The toggle label.
- */
- label: string;
- /**
- * Set additional classnames to the label.
- */
- labelClassName?: LabelProps['className'];
- /**
- * The label size.
- */
- labelSize?: LabelProps['size'];
- /**
- * The toggle value. True if checked.
- */
- value: boolean;
- /**
- * A callback function to update the toggle value.
- */
- setValue: (value: boolean) => void;
-};
-
-/**
- * Toggle component
- *
- * Render a toggle with a label and two choices.
- */
-const Toggle: FC<ToggleProps> = ({
- choices,
- className = '',
- id,
- label,
- labelClassName = '',
- labelSize,
- name,
- setValue,
- value,
-}) => {
- return (
- <>
- <Checkbox
- name={name}
- id={id}
- value={value}
- setValue={() => setValue(!value)}
- className={styles.checkbox}
- />
- <Label
- size={labelSize}
- htmlFor={id}
- className={`${styles.label} ${className}`}
- >
- <span className={`${styles.title} ${labelClassName}`}>{label}</span>
- {choices.left}
- <span className={styles.toggle}></span>
- {choices.right}
- </Label>
- </>
- );
-};
-
-export default Toggle;