Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FE] Checkbox component 변경 #960

Open
wants to merge 13 commits into
base: fe-dev-v3.1.4
Choose a base branch
from
104 changes: 73 additions & 31 deletions client/src/components/Design/components/Checkbox/Checkbox.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,57 +1,99 @@
/** @jsxImportSource @emotion/react */
import type {Meta, StoryObj} from '@storybook/react';

import {useEffect, useState} from 'react';
import {useState} from 'react';

import Text from '../Text/Text';

import Checkbox from './Checkbox';

const meta = {
title: 'Components/Checkbox',
component: Checkbox,
tags: ['autodocs'],
parameters: {
layout: 'centered',
docs: {
description: {
component: `
Checkbox 컴포넌트는 사용자가 여러 옵션 중에서 하나 이상을 선택할 수 있게 해주는 컴포넌트입니다.

### 주요 기능
- **체크 상태 관리**: checked prop으로 체크 상태를 제어할 수 있습니다.
- **우측 컨텐츠**: right prop으로 체크박스 우측에 텍스트나 컴포넌트를 추가할 수 있습니다.
- **접근성**: 키보드 탐색 및 스크린리더 지원
- **비활성화**: disabled prop으로 체크박스를 비활성화할 수 있습니다.

### 사용 예시
\`\`\`jsx
// 기본 사용
<Checkbox checked={checked} onChange={handleChange} />

// 우측 텍스트 추가
<Checkbox
checked={checked}
onChange={handleChange}
right={<Text size="bodyBold">체크박스 라벨</Text>}
/>

// 비활성화 상태
<Checkbox
checked={checked}
onChange={handleChange}
disabled={true}
/>
\`\`\`
`,
},
},
},
tags: ['autodocs'],
argTypes: {
labelText: {
description: '',
control: {type: 'text'},
checked: {
description: '체크박스의 체크 상태를 제어합니다.',
control: 'boolean',
defaultValue: false,
},
isChecked: {
description: '',
control: {type: 'boolean'},
right: {
description: '체크박스 우측에 표시될 element입니다.',
},
disabled: {
description: '체크박스의 비활성화 상태를 제어합니다.',
control: 'boolean',
defaultValue: false,
},
onChange: {
description: '',
control: {type: 'object'},
description: '체크박스 상태가 변경될 때 호출되는 콜백 함수입니다.',
},
},
} satisfies Meta<typeof Checkbox>;

export default meta;

type Story = StoryObj<typeof meta>;

export const Playground: Story = {
args: {
isChecked: false,
onChange: () => {},
labelText: '체크박스',
},
render: ({isChecked, onChange, labelText, ...args}) => {
const [isCheckedState, setIsCheckedState] = useState(isChecked);
const [labelTextState, setLabelTextState] = useState(labelText);

useEffect(() => {
setIsCheckedState(isChecked);
setLabelTextState(labelText);
}, [isChecked, labelText]);
const ControlledCheckbox = ({
label,
disabled,
defaultChecked,
}: {
label: string;
disabled?: boolean;
defaultChecked?: boolean;
}) => {
const [checked, setChecked] = useState(defaultChecked);
return (
<Checkbox
checked={checked}
onChange={e => setChecked(e.target.checked)}
right={<Text size="bodyBold">{label}</Text>}
disabled={disabled}
/>
);
};

const handleToggle = () => {
setIsCheckedState(!isCheckedState);
onChange();
};
export const Default: Story = {
render: args => <ControlledCheckbox label="기본 체크박스" />,
};

return <Checkbox {...args} isChecked={isCheckedState} onChange={handleToggle} labelText={labelTextState} />;
},
export const DisabledStates: Story = {
render: args => <ControlledCheckbox label="비활성화된 체크박스" disabled defaultChecked={true} />,
};
45 changes: 28 additions & 17 deletions client/src/components/Design/components/Checkbox/Checkbox.style.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import {css} from '@emotion/react';
import {WithTheme} from '@components/Design/type/withTheme';

interface CheckboxStyleProps {
isChecked: boolean;
checked: boolean;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isChecked보다 checked가 더 자연스럽다고 생각해서 is 키워드를 제거하신 걸까요!?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

애초에 기본적으로 input 태그의 prop으로 있는 이름이 checked 에요
기존에는 input props를 extends하지 않고, isChecked, onChange, labelText 세개의 prop만 받았는데, 이렇게 구현하면 나중에 접근성 태그나 disabled 등 input(Checkbox)를 커스터마이징 하는데 어렵다고 생각해서 input props를 extends 하면서 자연스럽게 isChecked를 input의 기본 prop인 checked로 바꿨습니다

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

그렇군요. 기본 태그를 활용해서 checked로 변경하는 것 동의합니다.

disabled?: boolean;
}

export const checkboxStyle = () =>
Expand All @@ -15,24 +16,34 @@ export const checkboxStyle = () =>
cursor: 'pointer',
});

export const inputGroupStyle = ({theme, isChecked}: WithTheme<CheckboxStyleProps>) =>
export const boxStyle = ({theme, checked, disabled}: WithTheme<CheckboxStyleProps>) =>
css({
position: 'relative',
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',

'.check-icon': {
position: 'absolute',
},
width: '1.375rem',
height: '1.375rem',
border: '1px solid',
borderRadius: '0.5rem',
borderColor: checked ? theme.colors.primary : theme.colors.tertiary,
backgroundColor: checked ? theme.colors.primary : theme.colors.white,

'.checkbox-input': {
width: '1.375rem',
height: '1.375rem',
border: '1px solid',
transition: 'all 0.2s',
transitionTimingFunction: 'cubic-bezier(0.7, 0, 0.3, 1)',
'&:focus-visible': {
outline: `2px solid ${theme.colors.primary}`,
outlineOffset: '2px',
Comment on lines +28 to +32
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

와우... 애니메이션의 신 토다리

borderRadius: '0.5rem',
borderColor: isChecked ? theme.colors.primary : theme.colors.tertiary,
backgroundColor: isChecked ? theme.colors.primary : theme.colors.white,
},
opacity: disabled ? 0.4 : 1,
});

export const invisibleInputStyle = () =>
css({
position: 'absolute',
width: '1px',
height: '1px',
padding: 0,
margin: '-1px',
overflow: 'hidden',
clip: 'rect(0,0,0,0)',
whiteSpace: 'nowrap',
border: 0,
});
81 changes: 60 additions & 21 deletions client/src/components/Design/components/Checkbox/Checkbox.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,67 @@
/** @jsxImportSource @emotion/react */
import {forwardRef, useState} from 'react';

import {useTheme} from '@components/Design/theme/HDesignProvider';
import {ariaProps, nonAriaProps} from '@components/Design/utils/attribute';

import Text from '../Text/Text';
import {IconCheck} from '../Icons/Icons/IconCheck';

import {checkboxStyle, inputGroupStyle} from './Checkbox.style';

interface Props {
labelText?: string;
isChecked: boolean;
onChange: () => void;
}

const Checkbox = ({labelText, isChecked = false, onChange}: Props) => {
const {theme} = useTheme();
return (
<label css={checkboxStyle}>
<div css={inputGroupStyle({theme, isChecked})}>
{isChecked ? <IconCheck size={20} color="onPrimary" className="check-icon" /> : null}
<input type="checkbox" checked={isChecked} onChange={onChange} className="checkbox-input" />
</div>
{labelText && <Text size="bodyBold">{labelText}</Text>}
</label>
);
};
import {boxStyle, checkboxStyle, invisibleInputStyle} from './Checkbox.style';
import {CheckboxProps} from './Checkbox.type';

const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
({right, checked: controlledChecked, onChange, defaultChecked = false, disabled, ...props}, ref) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오 ref를 사용할 때가 어떤 상황이 있나요?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

본문에서 작성했던 비제어 component로 사용할 수 있는 케이스를 열어두기 위함이었어요!
예를 들어서 state를 사용하지 않고, checkboxRef.current.checked = true 이런식으로 사용할 수 있는거죠

const {theme} = useTheme();
const [internalChecked, setInternalChecked] = useState(defaultChecked);

const isControlled = controlledChecked !== undefined;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

비제어 컴포넌트를 고려하신 이유가 궁금해요!

제가 생각하는 비제어 컴포넌트의 장점은 state의 prop drilling 없이 form submit 시 name으로 값을 가져올 수 있다는 것이에요. 혹시 이점을 고려해서 만든 것인지 아니면 토다리가 생각하는 다른 장점이 있어 비제어 컴포넌트 방식을 고려했는지 궁금합니다~

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

제어 컴포넌트와 비제어 컴포넌트 각각의 장점이 있겠지만, 대부분 상황에서 제어 컴포넌트를 사용할 것 같아요. 그렇지만, 비제어 컴포넌트의 가능성을 아에 닫아두는것 보단, 일단 열어두고 사용할 수 있다면 사용할 수 있는게 맞다고 판단했습니다

Copy link
Contributor

@jinhokim98 jinhokim98 Jan 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

추후에 비제어로 접근할 수도 있으니 열어둔다는 의미였군요. Checkbox는 공통 컴포넌트이니깐 사용성을 열어둔다.. 좋은 것 같아요!

const checked = isControlled ? controlledChecked : internalChecked;

const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!isControlled) {
setInternalChecked(e.target.checked);
}
Comment on lines +20 to +23
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

나중을 위해 비제어까지 열어서 구현하신게 정말..👍

onChange?.(e);
};

const handleKeyDown = (e: React.KeyboardEvent) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

접근성까지...! 굿굿스입니다 👍

if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
const input = e.currentTarget.querySelector('input');
if (input) {
input.click();
Comment on lines +29 to +32
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오 내부의 input을 찾아서 클릭 좋네요

}
}
};

return (
<label
css={checkboxStyle}
role="checkbox"
aria-checked={checked}
onKeyDown={handleKeyDown}
{...ariaProps(props)}
aria-label={props['aria-label'] ?? (right ? `${right} 체크박스` : '체크박스')}
>
<div css={boxStyle({theme, checked, disabled})}>
<div aria-hidden="true" role="presentation">
{checked && <IconCheck size={20} color="onPrimary" />}
</div>
<input
ref={ref}
type="checkbox"
checked={checked}
onChange={handleChange}
disabled={disabled}
css={invisibleInputStyle}
aria-hidden={true}
{...nonAriaProps(props)}
/>
</div>
{right}
</label>
);
},
);

export default Checkbox;
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import {InputHTMLAttributes, ReactNode} from 'react';

export interface CheckboxProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {
right?: ReactNode;
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export function CreatedEventItem({isEditMode, setEditMode, isChecked, onChange,

return (
<Flex>
{isEditMode && <Checkbox isChecked={isChecked} onChange={() => onChange(createdEvent)} />}
{isEditMode && <Checkbox checked={isChecked} onChange={() => onChange(createdEvent)} />}
<Flex
justifyContent="spaceBetween"
alignItems="center"
Expand Down
27 changes: 27 additions & 0 deletions client/src/components/Design/utils/attribute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,30 @@ export const attributeWithUnit = (attributes: Partial<Record<AttributeKey, strin
return stringValueWithUnit(value);
});
};

export const ariaProps = (props: React.HTMLAttributes<HTMLElement>) => {
const ariaAttributes = Object.entries(props).reduce(
(acc, [key, value]) => {
if (key.startsWith('aria-')) {
acc[key] = value;
}
return acc;
},
{} as Record<string, unknown>,
);

return ariaAttributes;
};

export const nonAriaProps = (props: React.HTMLAttributes<HTMLElement>) => {
const nonAriaAttributes = Object.entries(props).reduce(
(acc, [key, value]) => {
if (!key.startsWith('aria-')) {
acc[key] = value;
}
return acc;
},
{} as Record<string, unknown>,
);
return nonAriaAttributes;
};
15 changes: 8 additions & 7 deletions client/src/pages/mypage/withdraw/NotUseServiceStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {css} from '@emotion/react';

import {WithdrawStep} from '@hooks/useWithdrawFunnel';

import {Top, Checkbox, FixedButton, Flex} from '@components/Design';
import {Top, Checkbox, FixedButton, Flex, Text} from '@components/Design';

const NotUseServiceStep = ({handleMoveStep}: {handleMoveStep: (nextStep: WithdrawStep) => void}) => {
return (
Expand All @@ -22,14 +22,15 @@ const NotUseServiceStep = ({handleMoveStep}: {handleMoveStep: (nextStep: Withdra

<Flex flexDirection="column" gap="1rem">
{/* TODO: (@soha) 백엔드와 어떻게 관리할 지 논의 후에 기능(hook) 추가 예정 */}
<Checkbox isChecked={false} onChange={() => {}} labelText="예상했던 서비스가 아님" />
<Checkbox isChecked={false} onChange={() => {}} labelText="디자인이 별로임" />
<Checkbox isChecked={false} onChange={() => {}} labelText="사용하기 불편함" />
<Checkbox isChecked={false} onChange={() => {}} labelText="원하는 기능이 없음" />
<Checkbox isChecked={false} onChange={() => {}} labelText="기타" />
<Checkbox checked={false} onChange={() => {}} right={<Text size="bodyBold">예상했던 서비스가 아님</Text>} />
<Checkbox checked={false} onChange={() => {}} right={<Text size="bodyBold">디자인이 별로임</Text>} />
<Checkbox checked={false} onChange={() => {}} right={<Text size="bodyBold">사용하기 불편함</Text>} />
<Checkbox checked={false} onChange={() => {}} right={<Text size="bodyBold">원하는 기능이 없음</Text>} />
<Checkbox checked={false} onChange={() => {}} right={<Text size="bodyBold">기타</Text>} />
</Flex>
</div>
{/* TODO: (@soha) checkbox를 하나라도 해야 탈퇴하기 버튼 활성화 */}
{/* TODO: (@soha) checkbox를 하나라도 해야 탈퇴하기 버튼 활성화 */
/* TODO: (@todari) 현재 회원탈퇴 완료 페이지에서 뒤로가기 가능한 오류 있음!!**/}
<FixedButton
onClick={() => handleMoveStep('checkBeforeWithdrawing')}
onBackClick={() => handleMoveStep('withdrawReason')}
Expand Down
12 changes: 6 additions & 6 deletions client/src/pages/mypage/withdraw/UnableToUseDueToError.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {css} from '@emotion/react';

import {WithdrawStep} from '@hooks/useWithdrawFunnel';

import {Top, Checkbox, FixedButton, Flex} from '@components/Design';
import {Top, Checkbox, FixedButton, Flex, Text} from '@components/Design';

const UnableToUseDueToError = ({handleMoveStep}: {handleMoveStep: (nextStep: WithdrawStep) => void}) => {
return (
Expand All @@ -21,11 +21,11 @@ const UnableToUseDueToError = ({handleMoveStep}: {handleMoveStep: (nextStep: Wit

<Flex flexDirection="column" gap="1rem">
{/* TODO: (@soha) 백엔드와 어떻게 관리할 지 논의 후에 기능(hook) 추가 예정 */}
<Checkbox isChecked={false} onChange={() => {}} labelText="행사 생성" />
<Checkbox isChecked={false} onChange={() => {}} labelText="지출 내역 추가" />
<Checkbox isChecked={false} onChange={() => {}} labelText="정산 초대하기" />
<Checkbox isChecked={false} onChange={() => {}} labelText="관리자에게 송금" />
<Checkbox isChecked={false} onChange={() => {}} labelText="기타" />
<Checkbox checked={false} onChange={() => {}} right={<Text size="bodyBold">행사 생성</Text>} />
<Checkbox checked={false} onChange={() => {}} right={<Text size="bodyBold">지출 내역 추가</Text>} />
<Checkbox checked={false} onChange={() => {}} right={<Text size="bodyBold">정산 초대하기</Text>} />
<Checkbox checked={false} onChange={() => {}} right={<Text size="bodyBold">관리자에게 송금</Text>} />
<Checkbox checked={false} onChange={() => {}} right={<Text size="bodyBold">기타</Text>} />
</Flex>
</div>
{/* TODO: (@soha) checkbox를 하나라도 해야 탈퇴하기 버튼 활성화 */}
Expand Down