aboutsummaryrefslogtreecommitdiffstats
path: root/src/components/molecules/forms/switch
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/molecules/forms/switch')
-rw-r--r--src/components/molecules/forms/switch/index.ts1
-rw-r--r--src/components/molecules/forms/switch/switch.module.scss105
-rw-r--r--src/components/molecules/forms/switch/switch.stories.tsx48
-rw-r--r--src/components/molecules/forms/switch/switch.test.tsx49
-rw-r--r--src/components/molecules/forms/switch/switch.tsx132
5 files changed, 335 insertions, 0 deletions
diff --git a/src/components/molecules/forms/switch/index.ts b/src/components/molecules/forms/switch/index.ts
new file mode 100644
index 0000000..4dd2256
--- /dev/null
+++ b/src/components/molecules/forms/switch/index.ts
@@ -0,0 +1 @@
+export * from './switch';
diff --git a/src/components/molecules/forms/switch/switch.module.scss b/src/components/molecules/forms/switch/switch.module.scss
new file mode 100644
index 0000000..44244e7
--- /dev/null
+++ b/src/components/molecules/forms/switch/switch.module.scss
@@ -0,0 +1,105 @@
+@use "../../../../styles/abstracts/functions" as fun;
+@use "../../../../styles/abstracts/mixins" as mix;
+
+.fieldset {
+ position: relative;
+}
+
+.switch {
+ display: inline-flex;
+ flex-flow: row wrap;
+ align-items: center;
+ width: fit-content;
+ background: var(--color-shadow-light);
+ border: fun.convert-px(2) solid var(--color-primary);
+ border-radius: fun.convert-px(32);
+ box-shadow:
+ inset 0 0 fun.convert-px(1) fun.convert-px(1) var(--color-shadow-dark),
+ inset 0 0 fun.convert-px(3) fun.convert-px(2) var(--color-shadow);
+
+ &:focus-within {
+ outline: fun.convert-px(2) solid var(--color-primary-light);
+ }
+}
+
+.label {
+ display: flex;
+ align-items: center;
+ min-height: 5ex;
+ padding: fun.convert-px(6) var(--spacing-2xs);
+ border-top: fun.convert-px(2) solid var(--color-border);
+ border-bottom: fun.convert-px(2) solid var(--color-border);
+ transition: all 0.15s linear 0s;
+
+ @include mix.pointer("fine") {
+ min-height: 3ex;
+ }
+}
+
+.item:first-of-type {
+ .label {
+ border-left: fun.convert-px(2) solid var(--color-border);
+ border-top-left-radius: fun.convert-px(32);
+ border-bottom-left-radius: fun.convert-px(32);
+ }
+}
+
+.item:last-of-type {
+ .label {
+ border-right: fun.convert-px(2) solid var(--color-border);
+ border-top-right-radius: fun.convert-px(32);
+ border-bottom-right-radius: fun.convert-px(32);
+ }
+}
+
+.radio {
+ &:checked + .label {
+ background: var(--color-primary);
+ box-shadow:
+ inset 0 0 fun.convert-px(1) fun.convert-px(2) var(--color-primary-dark),
+ inset 0 0 fun.convert-px(3) fun.convert-px(2) var(--color-primary-darker);
+ color: var(--color-fg-inverted);
+
+ svg {
+ fill: var(--color-fg-inverted);
+ stroke: var(--color-fg-inverted);
+ }
+ }
+
+ &:not(:checked) + .label {
+ svg {
+ fill: var(--color-primary-darker);
+ }
+ }
+
+ &[disabled] + .label {
+ opacity: 0.8;
+ }
+}
+
+.radio:not([disabled]) {
+ &:checked + .label:hover {
+ background: var(--color-primary-lighter);
+ box-shadow:
+ inset 0 0 fun.convert-px(1) fun.convert-px(2) var(--color-primary-light),
+ inset 0 0 fun.convert-px(3) fun.convert-px(2) var(--color-primary);
+ }
+
+ &:not(:checked) + .label:hover {
+ background: var(--color-shadow-light);
+ box-shadow:
+ inset 0 0 0 fun.convert-px(1) var(--color-shadow-dark),
+ inset 0 0 fun.convert-px(3) fun.convert-px(2) var(--color-shadow);
+ }
+
+ &:not(:checked):focus + .label {
+ background: var(--color-shadow-light);
+ }
+
+ &:checked:focus + .label {
+ background: var(--color-primary-lighter);
+ box-shadow:
+ inset 0 0 fun.convert-px(1) fun.convert-px(2) var(--color-primary-light),
+ inset 0 0 fun.convert-px(3) fun.convert-px(2) var(--color-primary);
+ }
+}
diff --git a/src/components/molecules/forms/switch/switch.stories.tsx b/src/components/molecules/forms/switch/switch.stories.tsx
new file mode 100644
index 0000000..eb169ad
--- /dev/null
+++ b/src/components/molecules/forms/switch/switch.stories.tsx
@@ -0,0 +1,48 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { Switch as SwitchComponent, SwitchOption } from './switch';
+import { ChangeEventHandler, useCallback, useState } from 'react';
+import { Legend } from '../../../atoms';
+
+/**
+ * Switch - Storybook Meta
+ */
+export default {
+ title: 'Molecules/Forms',
+ component: SwitchComponent,
+ args: {},
+ argTypes: {},
+} as ComponentMeta<typeof SwitchComponent>;
+
+const Template: ComponentStory<typeof SwitchComponent> = ({
+ value,
+ ...args
+}) => {
+ const [selection, setSelection] = useState(value);
+
+ const handleChange: ChangeEventHandler<HTMLInputElement> = useCallback(
+ (e) => {
+ setSelection(e.target.value);
+ },
+ []
+ );
+
+ return (
+ <SwitchComponent {...args} onSwitch={handleChange} value={selection} />
+ );
+};
+
+const items: [SwitchOption, SwitchOption] = [
+ { id: 'option-1', label: 'Choice 1', value: 'option-1' },
+ { id: 'option-2', label: 'Choice 2', value: 'option-2' },
+];
+
+/**
+ * Radio Group Story
+ */
+export const Switch = Template.bind({});
+Switch.args = {
+ items,
+ legend: <Legend>Choose the best option:</Legend>,
+ name: 'example',
+ value: items[0].value,
+};
diff --git a/src/components/molecules/forms/switch/switch.test.tsx b/src/components/molecules/forms/switch/switch.test.tsx
new file mode 100644
index 0000000..6ccd525
--- /dev/null
+++ b/src/components/molecules/forms/switch/switch.test.tsx
@@ -0,0 +1,49 @@
+import { render, screen } from '../../../../../tests/utils';
+import { Legend } from '../../../atoms';
+import { Switch, SwitchOption } from './switch';
+
+const doNothing = () => {
+ /* Do nothing. */
+};
+
+const items: [SwitchOption, SwitchOption] = [
+ { id: 'item-1', label: 'Option 1', value: 'option-1' },
+ { id: 'item-2', label: 'Option 2', value: 'option-2' },
+];
+
+describe('Switch', () => {
+ it('renders a radio group with two choices', () => {
+ const legend = 'Options:';
+
+ render(
+ <Switch
+ items={items}
+ legend={<Legend>{legend}</Legend>}
+ name="possimus"
+ onSwitch={doNothing}
+ value={items[0].value}
+ />
+ );
+
+ expect(
+ screen.getByRole('radiogroup', { name: legend })
+ ).toBeInTheDocument();
+ expect(screen.getAllByRole('radio')).toHaveLength(items.length);
+ });
+
+ it('can render a disabled switch', () => {
+ render(
+ <Switch
+ isDisabled
+ items={items}
+ name="architecto"
+ onSwitch={doNothing}
+ value={items[1].value}
+ />
+ );
+
+ const radios = screen.getAllByRole<HTMLInputElement>('radio');
+ expect(radios.every((radio) => radio.disabled)).toBe(true);
+ expect(screen.getByRole('radiogroup')).toBeDisabled();
+ });
+});
diff --git a/src/components/molecules/forms/switch/switch.tsx b/src/components/molecules/forms/switch/switch.tsx
new file mode 100644
index 0000000..d340a0c
--- /dev/null
+++ b/src/components/molecules/forms/switch/switch.tsx
@@ -0,0 +1,132 @@
+import type { FC, ChangeEventHandler, ReactNode, ReactElement } from 'react';
+import {
+ Fieldset,
+ type FieldsetProps,
+ LabelProps,
+ RadioProps,
+ Label,
+ Radio,
+} from '../../../atoms';
+import styles from './switch.module.scss';
+import { TooltipProps } from '../../tooltip';
+
+type SwitchItemProps = Omit<LabelProps, 'children' | 'htmlFor' | 'isRequired'> &
+ Pick<RadioProps, 'isDisabled' | 'name'> & {
+ /**
+ * The item id.
+ */
+ id: string;
+ /**
+ * Is the item selected?
+ */
+ isSelected?: boolean;
+ /**
+ * The label used to describe the switch item.
+ */
+ label: ReactNode;
+ /**
+ * The event handler on value change.
+ */
+ onSwitch: ChangeEventHandler<HTMLInputElement>;
+ /**
+ * The item value.
+ */
+ value: string;
+ };
+
+/**
+ * SwitchItem component.
+ */
+const SwitchItem: FC<SwitchItemProps> = ({
+ className = '',
+ id,
+ isDisabled = false,
+ isSelected = false,
+ label,
+ name,
+ onSwitch,
+ value,
+ ...props
+}) => {
+ const selectedItemClass = isSelected ? styles['item--selected'] : '';
+ const disabledItemClass = isDisabled ? styles['item--disabled'] : '';
+ const itemClass = `${styles.item} ${selectedItemClass} ${disabledItemClass} ${className}`;
+
+ return (
+ <Label {...props} className={itemClass} htmlFor={id}>
+ <Radio
+ className={styles.radio}
+ id={id}
+ isChecked={isSelected}
+ isDisabled={isDisabled}
+ isHidden
+ name={name}
+ onChange={onSwitch}
+ value={value}
+ />
+ <span className={styles.label}>{label}</span>
+ </Label>
+ );
+};
+
+export type SwitchOption = Pick<SwitchItemProps, 'id' | 'label' | 'value'>;
+
+export type SwitchProps = Omit<FieldsetProps, 'children'> & {
+ /**
+ * The switch items.
+ */
+ items: [SwitchOption, SwitchOption];
+ /**
+ * The switch group name.
+ */
+ name: string;
+ /**
+ * A function to handle selection change.
+ */
+ onSwitch: ChangeEventHandler<HTMLInputElement>;
+ /**
+ * A tooltip to display before switch options.
+ */
+ tooltip?: ReactElement<TooltipProps>;
+ /**
+ * The selected item value.
+ */
+ value: SwitchOption['value'];
+};
+
+/**
+ * Switch component.
+ */
+export const Switch: FC<SwitchProps> = ({
+ className = '',
+ isDisabled = false,
+ items,
+ name,
+ onSwitch,
+ tooltip,
+ value,
+ ...props
+}) => {
+ return (
+ <Fieldset
+ {...props}
+ className={`${styles.fieldset} ${className}`}
+ isDisabled={isDisabled}
+ role="radiogroup"
+ >
+ {tooltip}
+ <div className={styles.switch}>
+ {items.map((item) => (
+ <SwitchItem
+ {...item}
+ isDisabled={isDisabled}
+ isSelected={value === item.value}
+ key={item.id}
+ name={name}
+ onSwitch={onSwitch}
+ />
+ ))}
+ </div>
+ </Fieldset>
+ );
+};