diff --git a/src/components/radio/__snapshots__/radio.test.tsx.snap b/src/components/radio/__snapshots__/radio.test.tsx.snap new file mode 100644 index 0000000..cfd4419 --- /dev/null +++ b/src/components/radio/__snapshots__/radio.test.tsx.snap @@ -0,0 +1,429 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`IonRadio should render a given option selected 1`] = ` +.c0 { + border: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 16px; +} + +.c1 { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 8px; +} + +.c1 label { + font-size: 1.4rem; + line-height: 2rem; + font-weight: 400; + color: #505566; + cursor: pointer; +} + +.c1 input[type='radio'] { + appearance: none; + width: 16px; + height: 16px; + margin: 4px; + border-radius: 50%; + border: 1px solid #aeb2bd; + background-color: #fcfcfd; + cursor: pointer; +} + +.c1 input[type='radio']:focus-visible { + outline: 2px solid #146ff5; + outline-offset: 2px; +} + +.c1 input[type='radio']::before { + content: ''; + display: none; + width: 4px; + height: 4px; + border-radius: 50%; + background-color: #b5d2fc; + position: relative; + top: 50%; + left: 50%; + transform: scale(1) translate(-50%, -50%); +} + +.c1 input[type='radio']:hover, +.c1 input[type='radio']:focus-visible { + border: 1px solid #84b4fa; + background-color: #ebf3fe; + box-shadow: 0 0 0 4px #ebf3fe; +} + +.c1 input[type='radio']:hover::before { + display: block; +} + +.c1 input[type='radio']:active { + border: 1px solid #146ff5; + background-color: #b5d2fc; + box-shadow: none; +} + +.c1 input[type='radio']:active::before { + display: block; + background-color: #ebf3fe; +} + +.c1 input[type='radio']:disabled { + border: 1px solid #ced2db; + background-color: #e4e6eb; + cursor: not-allowed; + box-shadow: none; +} + +.c1 input[type='radio']:disabled::before { + display: none; +} + +.c1 input[type='radio']:checked { + border: 4px solid #0858ce; + background-color: #ebf3fe; +} + +.c1 input[type='radio']:checked:hover, +.c1 input[type='radio']:checked:focus-visible { + border: 4px solid #146ff5; + box-shadow: 0 0 0 4px #ebf3fe; +} + +.c1 input[type='radio']:checked:hover::before, +.c1 input[type='radio']:checked:focus-visible::before { + display: none; +} + +.c1 input[type='radio']:checked:active { + border: 5px solid #06439d; + box-shadow: none; +} + +.c1 input[type='radio']:checked:disabled { + border: 4px solid #f2f3f5; + background-color: #ced2db; + box-shadow: none; +} + +
+
+
+ + +
+
+ + +
+
+
+`; + +exports[`IonRadio should render correctly when disabled 1`] = ` +.c0 { + border: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 16px; +} + +.c1 { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 8px; +} + +.c1 label { + font-size: 1.4rem; + line-height: 2rem; + font-weight: 400; + color: #505566; + cursor: pointer; +} + +.c1 input[type='radio'] { + appearance: none; + width: 16px; + height: 16px; + margin: 4px; + border-radius: 50%; + border: 1px solid #aeb2bd; + background-color: #fcfcfd; + cursor: pointer; +} + +.c1 input[type='radio']:focus-visible { + outline: 2px solid #146ff5; + outline-offset: 2px; +} + +.c1 input[type='radio']::before { + content: ''; + display: none; + width: 4px; + height: 4px; + border-radius: 50%; + background-color: #b5d2fc; + position: relative; + top: 50%; + left: 50%; + transform: scale(1) translate(-50%, -50%); +} + +.c1 input[type='radio']:hover, +.c1 input[type='radio']:focus-visible { + border: 1px solid #84b4fa; + background-color: #ebf3fe; + box-shadow: 0 0 0 4px #ebf3fe; +} + +.c1 input[type='radio']:hover::before { + display: block; +} + +.c1 input[type='radio']:active { + border: 1px solid #146ff5; + background-color: #b5d2fc; + box-shadow: none; +} + +.c1 input[type='radio']:active::before { + display: block; + background-color: #ebf3fe; +} + +.c1 input[type='radio']:disabled { + border: 1px solid #ced2db; + background-color: #e4e6eb; + cursor: not-allowed; + box-shadow: none; +} + +.c1 input[type='radio']:disabled::before { + display: none; +} + +.c1 input[type='radio']:checked { + border: 4px solid #0858ce; + background-color: #ebf3fe; +} + +.c1 input[type='radio']:checked:hover, +.c1 input[type='radio']:checked:focus-visible { + border: 4px solid #146ff5; + box-shadow: 0 0 0 4px #ebf3fe; +} + +.c1 input[type='radio']:checked:hover::before, +.c1 input[type='radio']:checked:focus-visible::before { + display: none; +} + +.c1 input[type='radio']:checked:active { + border: 5px solid #06439d; + box-shadow: none; +} + +.c1 input[type='radio']:checked:disabled { + border: 4px solid #f2f3f5; + background-color: #ced2db; + box-shadow: none; +} + +.c2 { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 8px; +} + +.c2 label { + font-size: 1.4rem; + line-height: 2rem; + font-weight: 400; + color: #505566; + cursor: pointer; + color: #aeb2bd; + cursor: not-allowed; +} + +.c2 input[type='radio'] { + appearance: none; + width: 16px; + height: 16px; + margin: 4px; + border-radius: 50%; + border: 1px solid #aeb2bd; + background-color: #fcfcfd; + cursor: pointer; +} + +.c2 input[type='radio']:focus-visible { + outline: 2px solid #146ff5; + outline-offset: 2px; +} + +.c2 input[type='radio']::before { + content: ''; + display: none; + width: 4px; + height: 4px; + border-radius: 50%; + background-color: #b5d2fc; + position: relative; + top: 50%; + left: 50%; + transform: scale(1) translate(-50%, -50%); +} + +.c2 input[type='radio']:hover, +.c2 input[type='radio']:focus-visible { + border: 1px solid #84b4fa; + background-color: #ebf3fe; + box-shadow: 0 0 0 4px #ebf3fe; +} + +.c2 input[type='radio']:hover::before { + display: block; +} + +.c2 input[type='radio']:active { + border: 1px solid #146ff5; + background-color: #b5d2fc; + box-shadow: none; +} + +.c2 input[type='radio']:active::before { + display: block; + background-color: #ebf3fe; +} + +.c2 input[type='radio']:disabled { + border: 1px solid #ced2db; + background-color: #e4e6eb; + cursor: not-allowed; + box-shadow: none; +} + +.c2 input[type='radio']:disabled::before { + display: none; +} + +.c2 input[type='radio']:checked { + border: 4px solid #0858ce; + background-color: #ebf3fe; +} + +.c2 input[type='radio']:checked:hover, +.c2 input[type='radio']:checked:focus-visible { + border: 4px solid #146ff5; + box-shadow: 0 0 0 4px #ebf3fe; +} + +.c2 input[type='radio']:checked:hover::before, +.c2 input[type='radio']:checked:focus-visible::before { + display: none; +} + +.c2 input[type='radio']:checked:active { + border: 5px solid #06439d; + box-shadow: none; +} + +.c2 input[type='radio']:checked:disabled { + border: 4px solid #f2f3f5; + background-color: #ced2db; + box-shadow: none; +} + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+`; diff --git a/src/components/radio/index.ts b/src/components/radio/index.ts new file mode 100644 index 0000000..1140e08 --- /dev/null +++ b/src/components/radio/index.ts @@ -0,0 +1 @@ +export * from './radio'; diff --git a/src/components/radio/radio.test.tsx b/src/components/radio/radio.test.tsx new file mode 100644 index 0000000..9242d26 --- /dev/null +++ b/src/components/radio/radio.test.tsx @@ -0,0 +1,56 @@ +import userEvent from '@testing-library/user-event'; +import { renderWithTheme } from '../utils/test-utils'; +import { IonRadio, IonRadioProps } from './radio'; + +const options = [ + { label: 'Option 1', value: 'option1' }, + { label: 'Option 2', value: 'option2' }, +]; + +const sut = (props: IonRadioProps) => { + return renderWithTheme(); +}; + +describe('IonRadio', () => { + it('should render a given option selected', () => { + const { container, getByRole } = sut({ + name: 'radio', + options, + value: 'option1', + onChange: jest.fn(), + }); + + expect(getByRole('radio', { name: 'Option 1' })).toBeChecked(); + expect(container).toMatchSnapshot(); + }); + + it('should render correctly when disabled', () => { + const { container, getByRole } = sut({ + name: 'radio', + options: [ + ...options, + { label: 'Option 3', value: 'option3', disabled: true }, + ], + value: 'option1', + onChange: jest.fn(), + }); + + expect(getByRole('radio', { name: 'Option 3' })).toBeDisabled(); + expect(container).toMatchSnapshot(); + }); + + it('should call onChange when an option is clicked', async () => { + const onChange = jest.fn(); + + const { getByLabelText } = sut({ + name: 'radio', + options, + value: 'option1', + onChange, + }); + + await userEvent.click(getByLabelText('Option 2')); + + expect(onChange).toHaveBeenCalledWith('option2'); + }); +}); diff --git a/src/components/radio/radio.tsx b/src/components/radio/radio.tsx new file mode 100644 index 0000000..ab5e21d --- /dev/null +++ b/src/components/radio/radio.tsx @@ -0,0 +1,39 @@ +import { Container, RadioGroup } from './styles'; + +interface Option { + label: string; + value: string; + disabled?: boolean; +} + +export interface IonRadioProps { + options: Option[]; + name: string; + onChange: (value: string) => void; + value?: Option['value']; +} + +export const IonRadio = ({ name, options, value, onChange }: IonRadioProps) => { + const handleChange = (value: string) => { + onChange(value); + }; + + return ( + + {options.map(({ label, value: optionValue, disabled }) => ( + + handleChange(optionValue)} + /> + + + ))} + + ); +}; diff --git a/src/components/radio/styles.ts b/src/components/radio/styles.ts new file mode 100644 index 0000000..827ebd9 --- /dev/null +++ b/src/components/radio/styles.ts @@ -0,0 +1,113 @@ +import { css, styled } from 'styled-components'; + +export const RadioGroup = styled.fieldset` + border: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 16px; +`; + +export const Container = styled.div<{ $disabled?: boolean }>` + ${({ theme, $disabled }) => css` + ${theme.utils.flex.start(8)} + + label { + ${theme.font.size[14]} + font-weight: 400; + color: ${theme.colors.neutral[7]}; + cursor: pointer; + + ${$disabled && + css` + color: ${theme.colors.neutral[5]}; + cursor: not-allowed; + `} + } + + input[type='radio'] { + appearance: none; + width: 16px; + height: 16px; + margin: 4px; + border-radius: 50%; + border: 1px solid ${theme.colors.neutral[5]}; + background-color: ${theme.colors.neutral[1]}; + ${theme.utils.focus} + cursor: pointer; + + &::before { + content: ''; + display: none; + width: 4px; + height: 4px; + border-radius: 50%; + background-color: ${theme.colors.primary[2]}; + position: relative; + top: 50%; + left: 50%; + transform: scale(1) translate(-50%, -50%); + } + + &:hover, + &:focus-visible { + border: 1px solid ${theme.colors.primary[3]}; + background-color: ${theme.colors.primary[1]}; + box-shadow: 0 0 0 4px ${theme.colors.primary[1]}; + } + + &:hover::before { + display: block; + } + + &:active { + border: 1px solid ${theme.colors.primary[5]}; + background-color: ${theme.colors.primary[2]}; + box-shadow: none; + + &::before { + display: block; + background-color: ${theme.colors.primary[1]}; + } + } + + &:disabled { + border: 1px solid ${theme.colors.neutral[4]}; + background-color: ${theme.colors.neutral[3]}; + cursor: not-allowed; + box-shadow: none; + + &::before { + display: none; + } + } + + &:checked { + border: 4px solid ${theme.colors.main.primary}; + background-color: ${theme.colors.primary[1]}; + + &:hover, + &:focus-visible { + border: 4px solid ${theme.colors.primary[5]}; + box-shadow: 0 0 0 4px ${theme.colors.primary[1]}; + + &::before { + display: none; + } + } + + &:active { + border: 5px solid ${theme.colors.primary[7]}; + box-shadow: none; + } + + &:disabled { + border: 4px solid ${theme.colors.neutral[2]}; + background-color: ${theme.colors.neutral[4]}; + box-shadow: none; + } + } + } + `} +`; diff --git a/src/stories/radio/radio.stories.tsx b/src/stories/radio/radio.stories.tsx new file mode 100644 index 0000000..15d153f --- /dev/null +++ b/src/stories/radio/radio.stories.tsx @@ -0,0 +1,29 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { useState } from 'react'; +import { IonRadio, IonRadioProps } from '../../components/radio'; + +export default { + title: 'Ion/Data Entry/Radio', + component: IonRadio, +} as ComponentMeta; + +const Template: ComponentStory = (args: IonRadioProps) => { + const [value, setValue] = useState(args.value); + + const handleChange = (value: string) => { + setValue(value); + args.onChange && args.onChange(value); + }; + + return ; +}; + +export const Default = Template.bind({}); +Default.args = { + name: 'radio', + options: [ + { label: 'Option 1', value: '1' }, + { label: 'Option 2', value: '2' }, + { label: 'Option 3', value: '3', disabled: true }, + ], +};