Skip to content
Open
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
1 change: 0 additions & 1 deletion src/shared/ui/index.ts

This file was deleted.

164 changes: 164 additions & 0 deletions src/shared/ui/select/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
# Select(드롭다운) 사용 가이드

이 문서는 `A:SSU` 프로젝트의 공용 `Select`(드롭다운) 컴포넌트 사용법을 정리합니다.

## 구현 세부사항

- **내부 구현**: `react-native-element-dropdown`라이브러리 기반 래퍼(Wrapper)
- **스타일링**:
- NativeWind `className` + RN `style` 혼합
- 색상은 `global.styles.css` 디자인 토큰과 **동일 값**을 갖는 TS 토큰(`src/shared/styles/tokens.ts`)을 통해 사용

## 설치 의존성

이 컴포넌트는 내부적으로 `react-native-element-dropdown`를 사용합니다.

```bash
yarn add react-native-element-dropdown
```

## import 방법
- `import { Select } from "@/shared/ui/select";`



## 빠른 사용 예시 (가장 기본)

`Select`는 **controlled 컴포넌트**입니다. 즉, `items/value/onChange`를 항상 함께 사용합니다.

```tsx
import { useMemo, useState } from "react";
import { View, Text } from "react-native";
import { Select } from "@/shared/ui/select";

export default function Example() {
const items = useMemo(
() => [
{ label: "총학생회", value: "university" },
{ label: "단과대학 학생회", value: "college" },
{ label: "학과/부 학생회", value: "department" },
],
[]
Comment on lines +35 to +41

Choose a reason for hiding this comment

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

medium

정적 배열 itemsuseMemo를 사용하는 것은 불필요합니다. useMemo는 복잡한 계산 결과나 참조 동일성을 유지해야 할 때 사용하며, 이 경우에는 성능 이점이 없고 코드만 복잡하게 만듭니다. useMemo를 제거하고 items 배열을 직접 선언하는 것이 좋습니다.

Suggested change
const items = useMemo(
() => [
{ label: "총학생회", value: "university" },
{ label: "단과대학 학생회", value: "college" },
{ label: "학과/부 학생회", value: "department" },
],
[]
const items = [
{ label: "총학생회", value: "university" },
{ label: "단과대학 학생회", value: "college" },
{ label: "학과/부 학생회", value: "department" },
];

Copy link
Contributor Author

Choose a reason for hiding this comment

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

readme.md파일은 예시일 뿐입니다! 데이터의 형태에 맞게 useMemo 사용여부를 결정하면 될 것 같습니다.

);

const [value, setValue] = useState<string | null>(null);

return (
<View>
<Select
label="학생회 유형"
items={items}
value={value}
onChange={setValue}
placeholder="선택하세요"
helperText="UI는 동일하고 items만 바꿔서 재사용합니다."
/>
</View>
);
}
```

## Props 설명

`SelectProps`는 `src/shared/ui/select/types.ts`에 정의되어 있습니다.

### 필수

- **`items`**
- 옵션 목록
- 타입: `Array<{ label: string; value: string; disabled?: boolean }>`
- `label`: 사용자에게 보이는 텍스트
- `value`: 상태/서버/스토어에 저장하는 “안정적인 식별자”
- **`value`**
- 현재 선택된 값
- 타입: `string | null`
- `null`이면 placeholder가 보입니다.
- **`onChange`**
- 선택 변경 콜백
- 타입: `(value: string | null) => void`

### 선택(옵션)

- **`placeholder`**: 선택 전 노출 문구 (기본값: `"선택"`)
- **`disabled`**: 비활성화(필드 전체)
- **`label`**: 필드 상단 라벨 텍스트
- **`helperText`**: 하단 도움말
- **`errorText`**: 하단 에러 문구 (값이 있으면 에러 상태로 렌더링)
- **`size`**: `"sm" | "md"` (패딩/폰트 크기 프리셋 크기 조절 가능)
- **`testID`**: 테스트 자동화/QA 용 식별자

## 상태/연결 패턴

### 1) 로컬 상태(useState)

가장 기본적인 사용법입니다.

```tsx
const [value, setValue] = useState<string | null>(null);
<Select items={items} value={value} onChange={setValue} />;
```

### 2) Zustand(전역 상태)

상태를 store에서 꺼내 props로 그대로 연결합니다.

```tsx
const value = useLoginStore((s) => s.councilType);
const setValue = useLoginStore((s) => s.setCouncilType);

<Select items={items} value={value} onChange={setValue} />;
```

## disabled 옵션 처리

`items`의 `disabled: true` 항목은:
- 리스트에서 opacity가 낮게 보이고
- 잠금 아이콘이 표시됩니다
- 선택이 무시됩니다(선택값이 바뀌지 않음)

> NOTE: `react-native-element-dropdown`는 item 단위 disabled를 공식 지원하지 않아,
> disabled 항목 탭 시 내부 선택 표시가 바뀔 수 있습니다. `Select`는 이 경우 표시값이 남지 않도록 내부적으로 강제 리마운트로 원복합니다.

```tsx
const items = [
{ label: "총학생회", value: "university" },
{ label: "단과대학 학생회", value: "college", disabled: true },
];
```

### 1) “필드 전체” disabled (`Select`의 `disabled` prop)

이건 **드롭다운 자체를 통째로 비활성화**하는 옵션입니다.

- **언제 쓰나**
- 선행 선택이 없어서 아직 고를 수 없을 때(의존 Select)
- API 로딩 중(옵션을 불러오는 중)
- 권한/상태상 선택 변경을 막아야 할 때

```tsx
<Select
label="단과대학"
items={collegeItems}
value={college}
onChange={setCollege}
disabled={!councilType || collegeLoading}
placeholder={!councilType ? "학생회 유형을 먼저 선택" : "선택하세요"}
/>
```

### 2) “특정 옵션만” disabled (`items[].disabled`)

이건 **목록은 보여주되, 일부 항목만 선택 불가**로 만드는 옵션입니다.

- **언제 쓰나**
- “준비중/마감/권한 없음” 같은 상태를 옵션으로 노출해야 할 때
- 리스트에서 존재는 알려야 하지만, 선택은 막아야 할 때

```tsx
const items = [
{ label: "총학생회", value: "university" },
{ label: "단과대학 학생회(준비중)", value: "college", disabled: true },
{ label: "학과/부 학생회", value: "department" },
];
```

53 changes: 33 additions & 20 deletions src/shared/ui/select/Select.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Ionicons } from "@expo/vector-icons";
import { useMemo, useState } from "react";
import { useState } from "react";
import { Text, View } from "react-native";
import { Dropdown } from "react-native-element-dropdown";
import { shadows } from "@/shared/styles/shadows";
Expand Down Expand Up @@ -33,23 +33,22 @@ export function Select({
}: SelectProps) {
const sizeToken = SIZES[size];
const [isOpen, setIsOpen] = useState(false);

const dropdownData = useMemo(() => {
// react-native-element-dropdown은 disabled key가 없어서
// item 렌더링/선택 로직에서 직접 처리한다.
return items;
}, [items]);
const [disabledTapNonce, setDisabledTapNonce] = useState(0);

return (
<View testID={testID}>
{!!label && (
<Text className="mb-2 font-regular text-content-primary color-content-secondary">
<Text className="mb-2 font-regular text-content-secondary">
{label}
</Text>
)}

<Dropdown
data={dropdownData}
// NOTE: react-native-element-dropdown은 item 단위 disabled를 지원하지 않습니다.
// disabled 항목을 탭하면 내부 selected 상태는 바뀔 수 있으므로,
// 이 key로 강제 리마운트해서 표시값을 즉시 원복합니다.
key={`${value ?? "null"}-${disabledTapNonce}`}
data={items}
labelField="label"
valueField="value"
disable={disabled}
Expand All @@ -59,7 +58,10 @@ export function Select({
onBlur={() => setIsOpen(false)}
onChange={(item: SelectItem) => {
// disabled 항목은 선택 무시
if (item?.disabled) return;
if (item?.disabled) {
setDisabledTapNonce((n) => n + 1);
return;
}
onChange(item?.value ?? null);
}}
// NOTE: Dropdown의 `style`은 내부에서 width를 측정하는 컨테이너(View)에 적용됩니다.
Expand Down Expand Up @@ -115,28 +117,39 @@ export function Select({
}}
renderItem={(item: SelectItem) => {
const isSelected = item.value === value;
const isDisabled = Boolean(item.disabled);

return (
<View className="flex-row items-center justify-between">
<View
className="flex-row items-center justify-between"
style={{ opacity: isDisabled ? 0.4 : 1 }}
>
<Text
style={{
fontFamily: "Pretendard-Regular",
fontSize: sizeToken.fontSize,
color: isSelected
? colorTokens.primary
: colorTokens.contentPrimary,
}}
className={`font-regular ${
isDisabled
? "text-content-secondary"
: isSelected
? "text-primary"
: "text-content-primary"
}`}
style={{ fontSize: sizeToken.fontSize }}
>
{item.label}
</Text>

{isSelected && (
{isDisabled ? (
<Ionicons
name="lock-closed"
size={18}
color={colorTokens.contentSecondary}
/>
) : isSelected ? (
<Ionicons
name="checkmark"
size={18}
color={colorTokens.primary}
/>
)}
) : null}
</View>
);
}}
Expand Down