diff --git a/frontend/src/components/common/RadioLabelField/RadioLabelField.stories.tsx b/frontend/src/components/common/RadioLabelField/RadioLabelField.stories.tsx new file mode 100644 index 000000000..c37b8ca22 --- /dev/null +++ b/frontend/src/components/common/RadioLabelField/RadioLabelField.stories.tsx @@ -0,0 +1,132 @@ +import { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import RadioLabelField from './index'; + +const meta: Meta = { + title: 'Common/Radio/RadioLabelField', + component: RadioLabelField, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'RadioLabelField 컴포넌트는 라벨과 설명, 체크박스 옵션들을 포함한 필드입니다.', + }, + }, + }, + tags: ['autodocs'], + argTypes: { + label: { + description: '필드의 라벨입니다.', + control: { type: 'text' }, + table: { + type: { summary: 'string' }, + }, + }, + description: { + description: '필드의 설명입니다.', + control: { type: 'text' }, + table: { + type: { summary: 'string' }, + }, + }, + error: { + description: '에러 메시지입니다.', + control: { type: 'text' }, + table: { + type: { summary: 'string' }, + }, + }, + disabled: { + description: '필드를 비활성화합니다.', + control: { type: 'boolean' }, + table: { + type: { summary: 'boolean' }, + }, + }, + required: { + description: '필드를 필수로 표시합니다.', + control: { type: 'boolean' }, + table: { + type: { summary: 'boolean' }, + }, + }, + options: { + description: '체크박스 옵션 배열입니다.', + control: { type: 'object' }, + table: { + type: { summary: 'Option[]' }, + }, + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + label: '옵션 선택', + description: '원하는 옵션을 선택하세요.', + error: '', + disabled: false, + required: false, + }, + decorators: [ + (Child, context) => { + const [options, setOptions] = useState([ + { optionLabel: '옵션 1', isChecked: false }, + { optionLabel: '옵션 2', isChecked: true }, + ]); + + const toggleOption = (index: number) => { + const newOptions = [...options]; + newOptions[index].isChecked = !newOptions[index].isChecked; + setOptions(newOptions); + }; + + return ( + ({ ...value, onToggle: () => toggleOption(index) })), + }} + /> + ); + }, + ], +}; + +export const LongOptionLabel: Story = { + args: { + label: '옵션 선택', + description: '원하는 옵션을 선택하세요.', + error: '', + disabled: false, + required: false, + }, + decorators: [ + (Child, context) => { + const [options, setOptions] = useState([ + { optionLabel: '옵션, 옵션, 옵션, 옵션, 옵션, 옵션, 옵션, 옵션, 옵션1', isChecked: false }, + { optionLabel: '옵션 2', isChecked: true }, + ]); + + const toggleOption = (index: number) => { + const newOptions = [...options]; + newOptions[index].isChecked = !newOptions[index].isChecked; + setOptions(newOptions); + }; + + return ( +
+ ({ ...value, onToggle: () => toggleOption(index) })), + }} + /> +
+ ); + }, + ], +}; diff --git a/frontend/src/components/common/RadioLabelField/index.tsx b/frontend/src/components/common/RadioLabelField/index.tsx new file mode 100644 index 000000000..0761eea1c --- /dev/null +++ b/frontend/src/components/common/RadioLabelField/index.tsx @@ -0,0 +1,58 @@ +import Radio from '../Radio'; +import S from './style'; + +interface Option { + optionLabel: string; + isChecked: boolean; + onToggle: () => void; +} + +interface RadioLabelFieldProps { + label?: string; + description?: string; + disabled?: boolean; + error?: string; + required?: boolean; + options: Option[]; +} + +export default function RadioLabelField({ + label, + description, + disabled = false, + error, + required = false, + options, +}: RadioLabelFieldProps) { + return ( + + + + {label && ( + + {label} + {required && } + + )} + {description && {description}} + + + + + {options.map(({ optionLabel, isChecked, onToggle }, index) => ( + // eslint-disable-next-line react/no-array-index-key + + + {optionLabel} + + ))} + + + {error && {error}} + + ); +} diff --git a/frontend/src/components/common/RadioLabelField/style.ts b/frontend/src/components/common/RadioLabelField/style.ts new file mode 100644 index 000000000..8a83a4889 --- /dev/null +++ b/frontend/src/components/common/RadioLabelField/style.ts @@ -0,0 +1,81 @@ +import styled from '@emotion/styled'; + +const Wrapper = styled.fieldset` + display: flex; + flex-direction: column; + gap: 1.6rem; + + width: 100%; +`; + +const HeadWrapper = styled.div` + display: flex; + flex-direction: column; + gap: 0.8rem; +`; + +const LabelWrapper = styled.legend` + display: flex; + align-items: center; + gap: 0.4rem; +`; + +const DescriptionWrapper = styled.div` + ${({ theme }) => theme.typography.common.default}; + color: ${({ theme }) => theme.baseColors.grayscale[800]}; +`; + +const Label = styled.label<{ disabled: boolean }>` + ${({ theme }) => theme.typography.heading[500]}; + color: ${({ theme, disabled }) => (disabled ? theme.baseColors.grayscale[500] : theme.colors.text.default)}; +`; + +const Asterisk = styled.span` + display: block; + + &::before { + content: '*'; + } + color: ${({ theme }) => theme.colors.feedback.error}; + font-size: ${({ theme }) => theme.typography.heading[500]}; +`; + +const ErrorText = styled.p` + color: ${({ theme }) => theme.colors.feedback.error}; + ${({ theme }) => theme.typography.common.default}; +`; + +const OptionsWrapper = styled.div` + display: flex; + flex-direction: column; + gap: 0.8rem; +`; + +const Option = styled.div` + display: flex; + align-items: center; + gap: 0.8rem; +`; + +const OptionLabel = styled.div` + ${({ theme }) => theme.typography.common.default} + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + width: 90%; +`; + +const S = { + LabelWrapper, + DescriptionWrapper, + Label, + HeadWrapper, + Asterisk, + Wrapper, + OptionsWrapper, + Option, + OptionLabel, + ErrorText, +}; + +export default S;