aboutsummaryrefslogtreecommitdiffstats
path: root/src/components/molecules
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/molecules')
-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
-rw-r--r--src/components/molecules/modals/tooltip.fixture.tsx4
-rw-r--r--src/components/molecules/modals/tooltip.stories.tsx22
-rw-r--r--src/components/molecules/modals/tooltip.test.tsx6
-rw-r--r--src/components/molecules/modals/tooltip.tsx11
44 files changed, 1897 insertions, 997 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;
diff --git a/src/components/molecules/modals/tooltip.fixture.tsx b/src/components/molecules/modals/tooltip.fixture.tsx
new file mode 100644
index 0000000..5489f08
--- /dev/null
+++ b/src/components/molecules/modals/tooltip.fixture.tsx
@@ -0,0 +1,4 @@
+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.stories.tsx b/src/components/molecules/modals/tooltip.stories.tsx
index 06a4855..a3dfa9f 100644
--- a/src/components/molecules/modals/tooltip.stories.tsx
+++ b/src/components/molecules/modals/tooltip.stories.tsx
@@ -1,5 +1,6 @@
import { ComponentMeta, ComponentStory } from '@storybook/react';
import Tooltip from './tooltip';
+import { content, icon, title } from './tooltip.fixture';
/**
* Tooltip - Storybook Meta
@@ -21,6 +22,20 @@ export default {
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',
@@ -63,8 +78,7 @@ const Template: ComponentStory<typeof Tooltip> = (args) => (
*/
export const Help = Template.bind({});
Help.args = {
- content:
- 'Minima tempora fuga omnis ratione doloribus ut. Totam ea vitae consequatur. Fuga hic ipsum. In non debitis ex assumenda ut dicta. Sit ut maxime eligendi est.',
- icon: '?',
- title: 'Laborum enim vero',
+ content,
+ icon,
+ title,
};
diff --git a/src/components/molecules/modals/tooltip.test.tsx b/src/components/molecules/modals/tooltip.test.tsx
index 24f20d8..d00b4b1 100644
--- a/src/components/molecules/modals/tooltip.test.tsx
+++ b/src/components/molecules/modals/tooltip.test.tsx
@@ -1,10 +1,6 @@
import { render, screen } from '@test-utils';
import Tooltip from './tooltip';
-
-const title = 'Illum eum at';
-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.';
-const icon = '?';
+import { content, icon, title } from './tooltip.fixture';
describe('Tooltip', () => {
it('renders a title', () => {
diff --git a/src/components/molecules/modals/tooltip.tsx b/src/components/molecules/modals/tooltip.tsx
index efb3009..9801393 100644
--- a/src/components/molecules/modals/tooltip.tsx
+++ b/src/components/molecules/modals/tooltip.tsx
@@ -8,6 +8,10 @@ export type TooltipProps = {
*/
className?: string;
/**
+ * Set more additional classnames to the tooltip wrapper. Required when using React.cloneElement.
+ */
+ cloneClassName?: string;
+ /**
* The tooltip body.
*/
content: string | string[];
@@ -27,7 +31,7 @@ export type TooltipProps = {
* Render a tooltip modal.
*/
const Tooltip: ForwardRefRenderFunction<HTMLDivElement, TooltipProps> = (
- { className = '', content, icon, title },
+ { cloneClassName = '', className = '', content, icon, title },
ref
) => {
/**
@@ -43,7 +47,10 @@ const Tooltip: ForwardRefRenderFunction<HTMLDivElement, TooltipProps> = (
};
return (
- <div className={`${styles.wrapper} ${className}`} ref={ref}>
+ <div
+ className={`${styles.wrapper} ${cloneClassName} ${className}`}
+ ref={ref}
+ >
<div className={styles.title}>
<span className={styles.icon}>{icon}</span>
{title}