diff --git a/packages/jds/src/components/Radio/index.ts b/packages/jds/src/components/Radio/index.ts new file mode 100644 index 00000000..f8bfb960 --- /dev/null +++ b/packages/jds/src/components/Radio/index.ts @@ -0,0 +1 @@ +export * from './radioBasic/Radio'; diff --git a/packages/jds/src/components/Radio/radioBasic/Radio.stories.tsx b/packages/jds/src/components/Radio/radioBasic/Radio.stories.tsx new file mode 100644 index 00000000..43a8d865 --- /dev/null +++ b/packages/jds/src/components/Radio/radioBasic/Radio.stories.tsx @@ -0,0 +1,63 @@ +import React, { useState } from 'react'; +import { Meta, StoryObj } from '@storybook/react'; +import { Radio, RadioProps, RadioSize } from './Radio'; + +const meta: Meta = { + title: 'Components/Radio', + component: Radio, + parameters: { + layout: 'centered', + }, + argTypes: { + radioSize: { + control: { type: 'radio' }, + options: ['lg', 'md', 'sm', 'xs'], + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + radioSize: 'lg', + }, +}; + +export const Checked: Story = { + render: () => , +}; + +export const Disabled: Story = { + render: () => ( +
+ + +
+ ), +}; + +export const Sizes: Story = { + render: () => { + const [checkedSize, setCheckedSize] = useState('md'); + + const sizes: RadioProps['radioSize'][] = ['lg', 'md', 'sm', 'xs']; + + return ( +
+ {sizes.map(radioSize => ( + setCheckedSize(radioSize)} + /> + ))} +
+ ); + }, +}; diff --git a/packages/jds/src/components/Radio/radioBasic/Radio.style.ts b/packages/jds/src/components/Radio/radioBasic/Radio.style.ts new file mode 100644 index 00000000..82046c66 --- /dev/null +++ b/packages/jds/src/components/Radio/radioBasic/Radio.style.ts @@ -0,0 +1,80 @@ +import styled from '@emotion/styled'; +import { RadioSize } from './Radio'; +import { RADIO_SIZE } from './radio.variants'; +import { interaction, pxToRem } from 'utils'; + +interface RadioStyledProps { + radioSize: RadioSize; +} + +export const RadioLabel = styled.label(({ theme, radioSize }) => { + const borderSize = RADIO_SIZE[radioSize] + .border as keyof typeof theme.scheme.desktop.stroke.weight; + + return { + display: 'inline-flex', + position: 'relative', + + [`input[type="radio"]:not(:disabled):checked + .visual`]: { + backgroundColor: theme.color.surface.static.standard, + border: `${pxToRem(theme.scheme.desktop.stroke.weight[borderSize])} solid ${theme.color.accent.neutral}`, + [theme.breakPoint.tablet]: { + border: `${pxToRem(theme.scheme.tablet.stroke.weight[borderSize])} solid ${theme.color.accent.neutral}`, + }, + [theme.breakPoint.mobile]: { + border: `${pxToRem(theme.scheme.mobile.stroke.weight[borderSize])} solid ${theme.color.accent.neutral}`, + }, + }, + + [`input[type="radio"]:not(:checked):disabled + .visual`]: { + backgroundColor: theme.color.surface.standard, + borderColor: theme.color.stroke.alpha.subtler, + cursor: 'default', + ...interaction(theme, 'normal', 'normal', 'default', 'disabled'), + }, + + [`input[type="radio"]:checked:disabled + .visual`]: { + backgroundColor: theme.color.fill.subtle, + border: `${pxToRem(theme.scheme.desktop.stroke.weight[borderSize])}px solid ${theme.color.stroke.subtler}`, + cursor: 'default', + ...interaction(theme, 'normal', 'normal', 'default', 'disabled'), + + [theme.breakPoint.tablet]: { + border: `${pxToRem(theme.scheme.tablet.stroke.weight[borderSize])} solid ${theme.color.accent.neutral}`, + }, + [theme.breakPoint.mobile]: { + border: `${pxToRem(theme.scheme.mobile.stroke.weight[borderSize])} solid ${theme.color.accent.neutral}`, + }, + }, + + [`input[type="radio"]:focus-visible + .visual`]: { + boxShadow: `0 0 0 3px ${theme.color.interaction.focus}`, + }, + }; +}); + +export const RadioInput = styled.input({ + position: 'absolute', + width: 1, + height: 1, + padding: 0, + margin: -1, + border: 0, + overflow: 'hidden', + clipPath: 'inset(50%)', + whiteSpace: 'nowrap', +}); + +export const RadioSpan = styled.span(({ theme, radioSize }) => { + const sizeValue = pxToRem(RADIO_SIZE[radioSize].radioSize); + + return { + borderRadius: theme.scheme.desktop.radius.max, + width: sizeValue, + height: sizeValue, + border: `1px solid ${theme.color.stroke.alpha.assistive}`, + backgroundColor: theme.color.surface.shallow, + cursor: 'pointer', + ...interaction(theme, 'normal', 'normal', 'default'), + }; +}); diff --git a/packages/jds/src/components/Radio/radioBasic/Radio.tsx b/packages/jds/src/components/Radio/radioBasic/Radio.tsx new file mode 100644 index 00000000..e1be4e34 --- /dev/null +++ b/packages/jds/src/components/Radio/radioBasic/Radio.tsx @@ -0,0 +1,21 @@ +import { ComponentPropsWithoutRef, forwardRef } from 'react'; +import { RadioInput, RadioLabel, RadioSpan } from './Radio.style'; + +export type RadioSize = 'lg' | 'md' | 'sm' | 'xs'; + +export interface RadioProps extends ComponentPropsWithoutRef<'input'> { + radioSize?: RadioSize; +} + +export const Radio = forwardRef( + ({ radioSize = 'md', ...props }, ref) => { + return ( + + + + ); + }, +); + +Radio.displayName = 'Radio'; diff --git a/packages/jds/src/components/Radio/radioBasic/radio.variants.ts b/packages/jds/src/components/Radio/radioBasic/radio.variants.ts new file mode 100644 index 00000000..975d659a --- /dev/null +++ b/packages/jds/src/components/Radio/radioBasic/radio.variants.ts @@ -0,0 +1,26 @@ +import { RadioSize } from './Radio'; + +export const RADIO_SIZE: Record< + RadioSize, + { + radioSize: number; + border: string; + } +> = { + lg: { + radioSize: 20, + border: '6', + }, + md: { + radioSize: 18, + border: '5', + }, + sm: { + radioSize: 16, + border: '5', + }, + xs: { + radioSize: 14, + border: '4', + }, +}; diff --git a/packages/jds/src/components/Radio/radioContent/RadioContent.stories.tsx b/packages/jds/src/components/Radio/radioContent/RadioContent.stories.tsx new file mode 100644 index 00000000..a6b33a9f --- /dev/null +++ b/packages/jds/src/components/Radio/radioContent/RadioContent.stories.tsx @@ -0,0 +1,142 @@ +import { ChangeEvent, FormEvent, useState } from 'react'; +import { Meta, StoryObj } from '@storybook/react'; +import { RadioContent } from './RadioContent'; + +const meta: Meta = { + title: 'Components/RadioContent', + component: RadioContent, + parameters: { + layout: 'centered', + }, + argTypes: { + radioSize: { + control: { type: 'radio' }, + options: ['lg', 'md', 'sm', 'xs'], + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + radioSize: 'lg', + radioStyle: 'empty', + align: 'left', + disabled: false, + subLabelVisible: false, + subLabel: '레이블', + children: '레이블', + }, +}; + +export const OutlineRadio: Story = { + render: () => ( +
+ + 레이블 + + + 레이블 + + + 레이블 + + + 레이블 + +
+ ), +}; + +export const SubLabelWithHyperlink: Story = { + render: () => ( + + 하이퍼링크  + + (네이버 바로가기) + + + } + value='1' + > + 레이블 + + ), +}; + +export const controlledRadio: Story = { + render: () => { + const [checked, setChecked] = useState('korea'); + + const handleGenderChange = (e: ChangeEvent) => { + setChecked(e.target.value); + }; + + const items = ['korea', 'japan', 'us', 'uk']; + + return ( +
+ {items.map(item => ( + + {item} + + ))} +

결과: {checked}

+
+ ); + }, +}; + +export const uncontrolledRadio: Story = { + render: () => ( +
+
) => { + e.preventDefault(); + const form = e.target as HTMLFormElement; + alert(`${form.namedItem('groupName').value} 확인!`); + }} + > + + apple + + + banana + + + orange + + +
+
+ ), +}; diff --git a/packages/jds/src/components/Radio/radioContent/RadioContent.style.ts b/packages/jds/src/components/Radio/radioContent/RadioContent.style.ts new file mode 100644 index 00000000..e7e470f0 --- /dev/null +++ b/packages/jds/src/components/Radio/radioContent/RadioContent.style.ts @@ -0,0 +1,126 @@ +import styled from '@emotion/styled'; +import { interaction, pxToRem } from 'utils'; +import { RADIO_CONTAINER_SIZE, RadioSize } from './radioContent.variants'; + +interface RadioContainerProps { + radioSize: RadioSize; + isDisabled: boolean; + isAlignRight: boolean; + isStyleOutline: boolean; +} + +export const RadioContainerLabel = styled.label( + ({ theme, radioSize, isDisabled, isAlignRight, isStyleOutline }) => { + const rowGap = RADIO_CONTAINER_SIZE[radioSize].gap as keyof typeof theme.scheme.desktop.spacing; + const padding = RADIO_CONTAINER_SIZE[radioSize] + .padding as keyof typeof theme.scheme.desktop.spacing; + const borderRadius = RADIO_CONTAINER_SIZE[radioSize] + .borderRadius as keyof typeof theme.scheme.desktop.radius; + const interactionWidth = RADIO_CONTAINER_SIZE[radioSize].width; + const interactionHeight = RADIO_CONTAINER_SIZE[radioSize].height; + const borderColor = isDisabled + ? theme.color.stroke.alpha.subtler + : theme.color.stroke.alpha.subtle; + const checkedInteraction = interaction( + theme, + 'accent', + 'assistive', + 'default', + isDisabled ? 'readonly' : 'default', + ); + const nonCheckedInteraction = interaction( + theme, + 'normal', + 'normal', + 'default', + isDisabled ? 'readonly' : 'default', + ); + const addonInteraction = isStyleOutline + ? { + border: 'inherit', + } + : { + width: `calc(100% + ${interactionWidth}px)`, + height: `calc(100% + ${interactionHeight}px)`, + transform: `translate(-${Math.floor(interactionWidth / 2) + 1}px , -${Math.floor(interactionHeight / 2)}px)`, + }; + + return { + display: 'grid', + gridTemplateColumns: 'auto 1fr', + '& > :nth-child(1)': { + gridColumn: 1, + gridRow: 1, + justifyItems: 'center', + alignItems: 'center', + }, + '& > :nth-child(2)': { + gridColumn: 2, + gridRow: 1, + justifyItems: 'center', + alignItems: 'center', + }, + '& > :nth-child(3)': { + gridColumn: isAlignRight ? '1 / span 2' : 2, + gridRow: 2, + }, + + gap: `${pxToRem(theme.scheme.desktop.spacing[6])} ${pxToRem(theme.scheme.desktop.spacing[rowGap])} `, + border: isStyleOutline ? `1px solid ${borderColor}` : 'none', + borderRadius: pxToRem(theme.scheme.desktop.radius[borderRadius]), + padding: isStyleOutline ? pxToRem(theme.scheme.desktop.spacing[padding]) : '0', + ...nonCheckedInteraction, + + [theme.breakPoint.tablet]: { + gap: `${pxToRem(theme.scheme.tablet.spacing[6])} ${pxToRem(theme.scheme.tablet.spacing[rowGap])} `, + borderRadius: pxToRem(theme.scheme.tablet.radius[borderRadius]), + padding: isStyleOutline ? pxToRem(theme.scheme.tablet.spacing[padding]) : '0', + }, + + [theme.breakPoint.mobile]: { + gap: `${pxToRem(theme.scheme.mobile.spacing[6])} ${pxToRem(theme.scheme.mobile.spacing[rowGap])} `, + borderRadius: pxToRem(theme.scheme.mobile.radius[borderRadius]), + padding: isStyleOutline ? pxToRem(theme.scheme.mobile.spacing[padding]) : '0', + }, + + '::after': { + ...nonCheckedInteraction['::after'], + ...addonInteraction, + transition: `all ${theme.environment.duration[100]}ms ${theme.environment.motion.fluent}`, + }, + + '&:active::after': { + transition: 'none', + }, + + '&:has(input[type="radio"]:checked)': { + ...checkedInteraction, + '::after': { + ...checkedInteraction['::after'], + ...addonInteraction, + }, + }, + + '&:has(input[type="radio"]:focus-visible)::before': { + ...addonInteraction, + boxShadow: `0 0 0 3px ${theme.color.interaction.focus}`, + content: '""', + position: 'absolute', + inset: 0, + borderRadius: pxToRem(theme.scheme.desktop.radius[borderRadius]), + + [theme.breakPoint.tablet]: { + borderRadius: pxToRem(theme.scheme.tablet.radius[borderRadius]), + }, + + [theme.breakPoint.mobile]: { + borderRadius: pxToRem(theme.scheme.mobile.radius[borderRadius]), + }, + }, + + 'input[type="radio"]:focus-visible + .visual': { + boxShadow: 'none !important', + }, + }; + }, +); diff --git a/packages/jds/src/components/Radio/radioContent/RadioContent.tsx b/packages/jds/src/components/Radio/radioContent/RadioContent.tsx new file mode 100644 index 00000000..f6f2e45b --- /dev/null +++ b/packages/jds/src/components/Radio/radioContent/RadioContent.tsx @@ -0,0 +1,68 @@ +import { forwardRef, ReactNode } from 'react'; +import { Radio, RadioProps } from '../radioBasic/Radio'; +import { Label } from '@/components/Label'; +import { RadioContainerLabel } from './RadioContent.style'; +import { useTheme } from 'theme'; +import { SUB_LABEL_SIZE } from './radioContent.variants'; + +export interface RadioContentProps extends RadioProps { + radioStyle?: 'empty' | 'outline'; + align?: 'left' | 'right'; + disabled?: boolean; + subLabelVisible?: boolean; + subLabel?: ReactNode; + children: ReactNode; +} + +export const RadioContent = forwardRef( + ( + { + radioSize = 'md', + radioStyle = 'empty', + align = 'left', + disabled = false, + subLabelVisible = false, + subLabel = '', + children, + ...props + }, + ref, + ) => { + const theme = useTheme(); + const labelColor = disabled ? theme.color.object.subtle : theme.color.object.bold; + const subLabelColor = disabled ? theme.color.object.subtle : theme.color.object.assistive; + + return ( + + {align === 'right' && ( + + )} + + {align === 'left' && ( + + )} + {subLabelVisible && ( + + )} + + ); + }, +); + +RadioContent.displayName = 'RadioContent'; diff --git a/packages/jds/src/components/Radio/radioContent/radioContent.variants.ts b/packages/jds/src/components/Radio/radioContent/radioContent.variants.ts new file mode 100644 index 00000000..325b5cbf --- /dev/null +++ b/packages/jds/src/components/Radio/radioContent/radioContent.variants.ts @@ -0,0 +1,50 @@ +import { LabelSize } from '@/components/Label/Label.style'; + +export type RadioSize = 'lg' | 'md' | 'sm' | 'xs'; + +export const RADIO_CONTAINER_SIZE: Record< + RadioSize, + { + width: number; + height: number; + gap: string; + padding: string; + borderRadius: string; + } +> = { + lg: { + width: 13, + height: 8, + gap: '12', + padding: '12', + borderRadius: '6', + }, + md: { + width: 13, + height: 8, + gap: '10', + padding: '10', + borderRadius: '6', + }, + sm: { + width: 11, + height: 6, + gap: '8', + padding: '8', + borderRadius: '4', + }, + xs: { + width: 9, + height: 6, + gap: '8', + padding: '6', + borderRadius: '4', + }, +}; + +export const SUB_LABEL_SIZE: Record = { + lg: 'md', + md: 'sm', + sm: 'xs', + xs: 'xs', +}; diff --git a/packages/jds/src/utils/interaction.ts b/packages/jds/src/utils/interaction.ts index c88ae0a4..64f6c040 100644 --- a/packages/jds/src/utils/interaction.ts +++ b/packages/jds/src/utils/interaction.ts @@ -1,4 +1,4 @@ -import { Theme } from '@emotion/react'; +import { CSSObject, Theme } from '@emotion/react'; import { Density, FillColor, InteractionState, Variant } from 'types'; export function interaction( @@ -7,21 +7,23 @@ export function interaction( density: Density, fillColor: FillColor, state: InteractionState = 'default', -) { +): CSSObject { const createAfter = (backgroundColor: string) => { const baseStyle = { - position: 'relative', - outline: 'none', + position: 'relative' as const, + outline: 'none' as const, }; const afterBaseStyle = { content: '""', - position: 'absolute', + position: 'absolute' as const, top: 0, left: 0, width: '100%', height: '100%', backgroundColor: backgroundColor, + borderRadius: 'inherit' as const, + pointerEvents: 'none' as const, }; if (state === 'locked') {