Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions packages/design-system/src/components/dropdown/Dropdown.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { useState } from 'react';
import Dropdown from './Dropdown';

const meta: Meta<typeof Dropdown> = {
title: 'Components/Dropdown',
component: Dropdown,
tags: ['autodocs'],
parameters: {
layout: 'centered',
docs: {
description: {
component:
'**선택형 입력 컴포넌트입니다.** `selectedValue`로 선택 상태를 제어하고 `onChange`로 변경 이벤트를 처리합니다. ' +
'`onAddItem`을 지정하면 목록 하단에 “추가” 버튼을 노출하며, `limit`으로 최대 개수를 제어합니다.',
},
},
},
argTypes: {
options: {
control: 'object',
description: '드롭다운 항목 문자열 배열입니다.',
},
selectedValue: {
control: 'text',
description: '현재 선택된 값(제어 컴포넌트에서 사용).',
},
placeholder: {
control: 'text',
description: '선택 전 표시되는 안내 문구.',
},
addItemLabel: {
control: 'text',
description: '추가 버튼 라벨 (`onAddItem`과 함께 사용).',
},
onChange: {
action: 'changed',
description: '선택이 변경될 때 호출됩니다.',
},
limit: {
control: 'number',
description: '옵션 개수 상한. 도달 시 “추가” 버튼 숨김.',
},
onAddItem: {
action: 'add item clicked',
description: '“추가” 버튼 클릭 시 호출됩니다.',
},
className: { table: { disable: true } },
},
args: {
options: ['사과', '바나나', '체리'],
selectedValue: null,
placeholder: '항목을 선택하세요',
addItemLabel: '항목 추가',
limit: 5,
},
};

export default meta;

type Story = StoryObj<typeof Dropdown>;

function ControlledRender(args: any) {

Check warning on line 63 in packages/design-system/src/components/dropdown/Dropdown.stories.tsx

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type
const [value, setValue] = useState<string | null>(args.selectedValue ?? null);

return (
<div className="h-[20rem]">
<Dropdown
{...args}
selectedValue={value}
onChange={(next: string | null) => {
setValue(next);
args.onChange?.(next);
}}
/>
</div>
);
}

export const Default: Story = {
name: '기본',
render: ControlledRender,
};

export const WithPreselected: Story = {
name: '선택값 초기화',
args: { selectedValue: '바나나' },
render: ControlledRender,
};

export const WithAddItemAlert: Story = {
name: '추가 버튼 클릭 시 (test Alert)',
args: {
options: ['사과', '바나나'],
limit: 5,
addItemLabel: '새 항목 추가',
onAddItem: () => {
alert('추가 버튼이 클릭되었습니다.');
},
},
render: ControlledRender,
};

export const LimitReached: Story = {
name: '추가 제한 도달 (limit)',
args: {
options: ['사과', '바나나', '체리'],
limit: 3,
},
parameters: {
docs: {
description: {
story:
'`options.length`가 `limit`에 도달하여 “추가” 버튼이 표시되지 않습니다.',
},
},
},
render: ControlledRender,
};

export const ManyOptions: Story = {
name: '옵션이 많은 경우',
args: {
options: Array.from({ length: 20 }, (_, i) => `옵션 ${i + 1}`),
},
render: ControlledRender,
};
88 changes: 88 additions & 0 deletions packages/design-system/src/components/dropdown/Dropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { Icon } from '@pinback/design-system/icons';
import { useState } from 'react';

interface DropdownProps {
options: string[];
selectedValue: string | null;
onChange: (selected: string | null) => void;
placeholder: string;
onAddItem?: () => void;
addItemLabel?: string;
limit?: number;
Comment on lines +10 to +11
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

리밋 속성을 통해서, 카테고리 추가 노출여부를 제어할 수 있어서! 확장성 측면에서 좋은 것 같네요!

++ 대신 디자인적으로 궁금한 점이, 카테고리 리스트개수가 적은 경우에도 그 박스의 height가 고정값일까요?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

++ 대신 디자인적으로 궁금한 점이, 카테고리 리스트개수가 적은 경우에도 그 박스의 height가 고정값일까요?

네네 일단 그렇게 구현이 되어있어요. 리스트가 많아지는 경우에 스크롤을 보여주려면 box에 height값을 줄 수 밖에 없어서..!
개수에 따라 height를 다르게 분기처리 할 수 있기는 하지만 현재도 괜찮다고 일단 생각이 드는데 어떻게 생각하시나요??
애매하다면 QA때 디자인 분들께 질문 드려도 좋을 것 같아요 👍

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

아하, 그러네요 스크롤 적용하려면 Height 지정을 해두긴 하네요,, 그렇다고 개수에 따라 height 분기하는 건 오히려 너무 불필요한 거 같아서! 저는 좋아요! QA때 디자이너 분들 피드백에 따라 추후에 수정해보는 걸로 해용!

className?: string;
}

const Dropdown = ({
options,
selectedValue,
onChange,
placeholder,
onAddItem,
addItemLabel,
limit,
className = '',
}: DropdownProps) => {
Comment on lines +15 to +24
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue

빈 라벨/플레이스홀더 노출 위험 수정: 기본 placeholder·addItemLabel 기본값을 부여하세요.
addItemLabel이 미지정이면 “빈 버튼”이 렌더링되고, placeholder 미지정 시 빈 텍스트가 나옵니다.

-  placeholder,
+  placeholder = '선택하세요',
   onAddItem,
-  addItemLabel,
+  addItemLabel = '추가',
@@
-        <span className={selectedValue ? 'text-black' : 'text-font-gray-3'}>
-          {selectedValue || placeholder}
+        <span className={selectedValue !== null ? 'text-black' : 'text-font-gray-3'}>
+          {selectedValue ?? placeholder}
         </span>
@@
-              {addItemLabel}
+              {addItemLabel}

Also applies to: 42-44, 69-81

🤖 Prompt for AI Agents
In packages/design-system/src/components/dropdown/Dropdown.tsx around lines
15-24 (and also address similar spots at 42-44 and 69-81), the component can
render an empty placeholder or an empty "add" button when placeholder or
addItemLabel are not provided; update the props destructuring to supply sensible
defaults (e.g., placeholder: 'Select...', addItemLabel: 'Add') or otherwise
ensure fallback strings are used wherever placeholder or addItemLabel are
rendered so the UI never shows an empty label/button; apply the same default or
fallback logic to the other affected blocks mentioned.

const [isOpen, setIsOpen] = useState(false);

const handleSelect = (option: string) => {
onChange(option);
setIsOpen(false);
};

const showAddItemButton =
onAddItem && (limit === undefined || options.length < limit);

return (
<div className={`relative w-full ${className}`}>
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className={`body4-r flex h-[4.4rem] w-[24.8rem] items-center justify-between rounded-[4px] border px-[0.8rem] py-[1.2rem] transition-colors duration-200 ${isOpen ? 'border-main500' : 'border-gray200'}`}
>
<span className={selectedValue ? 'text-black' : 'text-font-gray-3'}>
{selectedValue || placeholder}
</span>
<Icon
name="ic_arrow_down_disable"
width={16}
height={16}
// TODO: Icon 컴포넌트 내부에서 animation 관련 처리 고민하기
// rotate={isOpen ? 180 : undefined}
className={`transform transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

이부분도 고민해보았는데요!
isOpen에 따라서 회전 180이냐 0이냐 rotate 상태가 바뀌니까 이걸 transition 처리로 스무스(?)하게만 적용해주면 되는거라!

<Icon name="ic_arrow_down_disable" rotate={isOpen ? 180 : 0} animateRotate />
애니메이션 줄 지 의사를 boolean으로 전달후에

Icon 내에서 animateRotate 에 따른
combined 객체에 분기를 처리해두는 방향은 어떠한 지 제안합니다!

const combined = clsx( 'inline-block', rotateClass, animateRotate && 'transform transition-transform duration-200', className );

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

오 이렇게 처리해도 가능하겠네요! 이렇게 Icon파일에서 한 번에 적용하면 오히려 모든 icon의 rotate animation이 통일되니까 디자인 시스템의 의미도 갖을 것 같아요!
저는 좋은 생각 같은데 다른 분들의 생각은 어떠신가요? 괜찮으시다면 반영하겠습니다!

@jllee000 @jjangminii

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

(회전이나 애니메이션 들이 아이콘 단위로 많이 쓰이지는 않지만)
사이드바에도 비슷한 아이콘 회전이 있는 거 같아서! 이렇게 관리해도 좋을 듯해요!
animateRotate추가하고 combined분기만 하면 되니, 코드 수정에도 엄청 큰 공수가 들지는 않아서!!

/>
</button>
Comment thread
coderabbitai[bot] marked this conversation as resolved.

{isOpen && (
<div className="common-shadow absolute z-10 mt-[1.5rem] w-[24.8rem] rounded-[0.4rem] bg-white">
<ul className="flex flex-col gap-[0.2rem]">
{options.map((option) => (
<li
key={option}
onClick={() => handleSelect(option)}
className={`body4-r cursor-pointer p-[0.8rem] ${selectedValue === option ? 'text-main600' : 'text-font-gray-3'}`}
>
{option}
</li>
))}
</ul>

{showAddItemButton && (
<button
type="button"
onClick={() => {
onAddItem?.();
setIsOpen(false);
}}
className="text-main500 body4-r flex w-full cursor-pointer items-center gap-[0.8rem] p-[0.8rem]"
>
<Icon name="ic_plus" width={16} height={16} />
{addItemLabel}
</button>
)}
</div>
)}
</div>
);
};

export default Dropdown;
1 change: 1 addition & 0 deletions packages/design-system/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export { default as Badge } from './badge/Badge';
export { default as Button } from './button/Button';
export { default as Card } from './card/Card';
export { default as Chip } from './chip/Chip';
export { default as Dropdown } from './dropdown/Dropdown';
export { default as Input } from './input/Input';
export { default as Level } from './level/Level';
export { Progress } from './progress/Progress';
Expand Down
2 changes: 1 addition & 1 deletion packages/design-system/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"extends": "@pinback/typescript-config/react.json",
"compilerOptions": {
"typeRoots": ["node_modules/@types"],

"rootDir": ".",
"outDir": "dist",
"baseUrl": ".",
"paths": {
Expand Down
Loading