Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
121 changes: 121 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,121 @@
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:
'dropdown 컴포넌트는 `select`와 `option` 컴포넌트를 포함하여 여러 항목 중 하나를 선택할 수 있는 UI 요소입니다. <br/>' +
'`onAddItem`과 `limit` props를 통해 사용자가 항목을 추가할 수 있는 기능도 제공합니다.',
},
},
},
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-[25rem]">
<Dropdown
{...args}
selectedValue={value}
onChange={(next: string | null) => {
setValue(next);
args.onChange?.(next);
}}
/>
</div>
);
}

export const Default: Story = {
name: '기본',
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,
};
87 changes: 87 additions & 0 deletions packages/design-system/src/components/dropdown/Dropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
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
Collaborator

Choose a reason for hiding this comment

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

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

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

Copy link
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
Collaborator

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

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-[24.8rem] ${className}`}>
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className={`body4-r flex h-[4.4rem] w-full 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}
rotate={isOpen ? 180 : undefined}
hasRotateAnimation={true}
/>
</button>

{isOpen && (
<div className="common-shadow ds-scrollbar absolute z-10 mt-[1.5rem] h-[20.4rem] w-full overflow-y-auto rounded-[0.4rem] bg-white p-[0.8rem]">
Copy link
Collaborator

Choose a reason for hiding this comment

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

ds-scrollbar로 커스텀 스크롤바 스타일이 적용될 수있군녀 알아갑니다!

Copy link
Collaborator

Choose a reason for hiding this comment

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

제가 만들어서 스타일 토큰해뒀습니당

<ul className="flex flex-col gap-[0.2rem]">
{options.map((option) => (
<li
key={option}
onClick={() => handleSelect(option)}
className={`body4-r h-[3.6rem] cursor-pointer p-[0.8rem] ${selectedValue === option ? 'text-main600' : 'text-font-gray-3'}`}
>
{option}
</li>
))}

{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>
)}
</ul>
</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
13 changes: 10 additions & 3 deletions packages/design-system/src/icons/components/icon.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { IconName } from '../iconNames';
import React from 'react';
import clsx from 'clsx';
import React from 'react';
import type { IconName } from '../iconNames';

type IconRotate = 90 | 180 | 270;

Expand Down Expand Up @@ -63,6 +63,7 @@ interface IconProps extends React.SVGProps<SVGSVGElement> {
color?: IconColor;
className?: string;
rotate?: IconRotate;
hasRotateAnimation?: boolean;
ariaHidden?: boolean;
}

Expand All @@ -74,6 +75,7 @@ export const Icon = ({
color,
className,
rotate,
hasRotateAnimation = false,
ariaHidden = true,
...rest
}: IconProps) => {
Expand All @@ -89,7 +91,12 @@ export const Icon = ({
? 'rotate-[270deg]'
: '';

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

return (
<svg
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