diff options
Diffstat (limited to 'src/components/molecules/forms/switch')
| -rw-r--r-- | src/components/molecules/forms/switch/index.ts | 1 | ||||
| -rw-r--r-- | src/components/molecules/forms/switch/switch.module.scss | 105 | ||||
| -rw-r--r-- | src/components/molecules/forms/switch/switch.stories.tsx | 48 | ||||
| -rw-r--r-- | src/components/molecules/forms/switch/switch.test.tsx | 49 | ||||
| -rw-r--r-- | src/components/molecules/forms/switch/switch.tsx | 132 |
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> + ); +}; |
