Skip to content

Commit

Permalink
Merge branch 'fe-dev' into feature/#974
Browse files Browse the repository at this point in the history
  • Loading branch information
jinhokim98 authored Feb 5, 2025
2 parents ad5e171 + 9f7a47e commit 7f17a85
Show file tree
Hide file tree
Showing 15 changed files with 242 additions and 92 deletions.
5 changes: 3 additions & 2 deletions client/src/apis/request/user.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {User, UserInfo} from 'types/serviceType';
import {WithErrorHandlingStrategy} from '@errors/RequestGetError';

import {BASE_URL} from '@apis/baseUrl';
import {USER_API_PREFIX} from '@apis/endpointPrefix';
Expand All @@ -12,11 +13,11 @@ export const requestDeleteUser = async () => {
});
};

export const requestGetUserInfo = async () => {
export const requestGetUserInfo = async ({...props}: WithErrorHandlingStrategy | null = {}) => {
return await requestGet<UserInfo>({
baseUrl: BASE_URL.HD,
endpoint: `${USER_API_PREFIX}/mine`,
errorHandlingStrategy: 'unsubscribe',
...props,
});
};

Expand Down
2 changes: 2 additions & 0 deletions client/src/components/AppErrorBoundary/ErrorCatcher.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {useEffect} from 'react';

import toast from '@hooks/useToast/toast';
import {RequestGetError} from '@errors/RequestGetError';

import {useAppErrorStore} from '@store/appErrorStore';

Expand All @@ -23,6 +24,7 @@ const ErrorCatcher = ({children}: React.PropsWithChildren) => {

captureError(error);

// 전역 에러 바운더리로 처리
if (!isRequestError(error) || !isPredictableError(error)) throw error;

toast.error(SERVER_ERROR_MESSAGES[error.errorCode], {
Expand Down
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;
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',
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) => {
const {theme} = useTheme();
const [internalChecked, setInternalChecked] = useState(defaultChecked);

const isControlled = controlledChecked !== undefined;
const checked = isControlled ? controlledChecked : internalChecked;

const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!isControlled) {
setInternalChecked(e.target.checked);
}
onChange?.(e);
};

const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
const input = e.currentTarget.querySelector('input');
if (input) {
input.click();
}
}
};

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 @@ -24,7 +24,7 @@ const ContentItem = ({labels, onEditClick, children}: ContentItemProps) => {
{children}
{onEditClick && (
<button onClick={onEditClick} css={iconStyle}>
<IconEdit />
<IconEdit size={16} />
</button>
)}
</VStack>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export function CreatedEventItem({isEditMode, setEditMode, isChecked, onChange,

return (
<Flex width="100%">
{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;
};
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,8 @@ const QueryClientBoundary = ({children}: React.PropsWithChildren) => {
},
queryCache: new QueryCache({
onError: (error: Error) => {
// errorBoundary로 처리해야하는 에러인 경우 updateAppError를 하지 못하도록 얼리리턴
if (error instanceof RequestGetError && error.errorHandlingStrategy === 'errorBoundary') return;
if (error instanceof RequestGetError && error.errorHandlingStrategy === 'unsubscribe') return;
if (error instanceof RequestGetError && error.errorHandlingStrategy === 'ignore') return;

updateAppError(error);
},
Expand Down
2 changes: 1 addition & 1 deletion client/src/errors/RequestGetError.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import RequestError from './RequestError';
import {RequestErrorType} from './requestErrorType';

type ErrorHandlingStrategy = 'toast' | 'errorBoundary' | 'unsubscribe';
type ErrorHandlingStrategy = 'toast' | 'errorBoundary' | 'ignore';

export type WithErrorHandlingStrategy<P = unknown> = P & {
errorHandlingStrategy?: ErrorHandlingStrategy;
Expand Down
Loading

0 comments on commit 7f17a85

Please sign in to comment.