diff --git a/package.json b/package.json index 8e97b55..62807d4 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "react": "19.1.0", "react-dom": "19.1.0", "react-native": "0.81.5", + "react-native-element-dropdown": "^2.12.4", "react-native-gesture-handler": "~2.28.0", "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0", diff --git a/src/shared/styles/tokens.ts b/src/shared/styles/tokens.ts new file mode 100644 index 0000000..734cfcb --- /dev/null +++ b/src/shared/styles/tokens.ts @@ -0,0 +1,34 @@ +/** + * RN style 객체에서 사용하기 위한 토큰 상수. + * + * NOTE: + * - React Native style은 `var(--token)`을 직접 해석하지 못하므로, + * `global.styles.css`의 토큰 값을 여기에도 "동일한 값"으로 유지합니다. + * - 값 변경 시 `global.styles.css`와 함께 수정하세요. + */ + +export const colorTokens = { + /** global.styles.css: --color-primary (blue-500) */ + primary: "#0068FE", + /** global.styles.css: --color-primary-tint (blue-100) */ + primaryTint: "#E5F6FE", + + /** global.styles.css: --color-neutral (gray-100) */ + neutral: "#F4F4F5", + /** global.styles.css: --color-neutral-variant (gray-300) */ + neutralVariant: "#DBDDE1", + + /** global.styles.css: --color-canvas (white) */ + canvas: "#FEFFFE", + + /** global.styles.css: --color-danger (red-500) */ + danger: "#FF6562", + + /** global.styles.css: --color-content-primary (black) */ + contentPrimary: "#040404", + /** global.styles.css: --color-content-secondary (gray-500) */ + contentSecondary: "#8E9398", + /** global.styles.css: --color-content-inverse (blue-50) */ + contentInverse: "#F4F6FE", +} as const; + diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts new file mode 100644 index 0000000..e4c0223 --- /dev/null +++ b/src/shared/ui/index.ts @@ -0,0 +1,2 @@ +export * from "./select"; + diff --git a/src/shared/ui/select/Select.tsx b/src/shared/ui/select/Select.tsx new file mode 100644 index 0000000..5a3faf4 --- /dev/null +++ b/src/shared/ui/select/Select.tsx @@ -0,0 +1,158 @@ +import { useMemo, useState } from "react"; +import { Text, View } from "react-native"; +import { Ionicons } from "@expo/vector-icons"; +import { Dropdown } from "react-native-element-dropdown"; + +import type { SelectItem, SelectProps } from "./types"; +import { colorTokens } from "@/shared/styles/tokens"; +import { shadows } from "@/shared/styles/shadows"; + +const SIZES = { + sm: { + fieldPx: 12, + fieldPy: 10, + fontSize: 14, + }, + md: { + fieldPx: 12, + fieldPy: 12, + fontSize: 16, + }, +} as const; + +export function Select({ + items, + value, + onChange, + placeholder = "선택", + disabled = false, + label, + helperText, + errorText, + size = "md", + testID, +}: SelectProps) { + const sizeToken = SIZES[size]; + const [isOpen, setIsOpen] = useState(false); + + const dropdownData = useMemo(() => { + // react-native-element-dropdown은 disabled key가 없어서 + // item 렌더링/선택 로직에서 직접 처리한다. + return items; + }, [items]); + + return ( + + {!!label && ( + + {label} + + )} + + setIsOpen(true)} + onBlur={() => setIsOpen(false)} + onChange={(item: SelectItem) => { + // disabled 항목은 선택 무시 + if (item?.disabled) return; + onChange(item?.value ?? null); + }} + // NOTE: Dropdown의 `style`은 내부에서 width를 측정하는 컨테이너(View)에 적용됩니다. + // 따라서 필드 UI(보더/패딩)를 여기로 옮기면, 옵션 리스트 컨테이너 폭도 필드와 동일하게 맞습니다. + style={{ + ...(isOpen + ? { + borderTopLeftRadius: 12, + borderTopRightRadius: 12, + borderBottomLeftRadius: 0, + borderBottomRightRadius: 0, + borderBottomWidth: 2, + borderBottomColor: colorTokens.neutralVariant, + } + : { + borderRadius: 12, + }), + backgroundColor: colorTokens.neutral, + paddingHorizontal: sizeToken.fieldPx, + paddingVertical: sizeToken.fieldPy, + opacity: disabled ? 0.3 : 1, + }} + containerStyle={{ + marginTop: -2, + borderBottomLeftRadius: 12, + borderBottomRightRadius: 12, + overflow: "hidden", + backgroundColor: colorTokens.neutral, + ...shadows.neutral, + }} + itemContainerStyle={{ + paddingHorizontal: 16, + paddingVertical: 14, + opacity: 1, + }} + // activeColor={colorTokens.primaryTint} + renderRightIcon={() => ( + + )} + placeholderStyle={{ + fontFamily: "Pretendard-Regular", + fontSize: sizeToken.fontSize, + color: colorTokens.contentSecondary, + }} + selectedTextStyle={{ + fontFamily: "Pretendard-Regular", + fontSize: sizeToken.fontSize, + color: colorTokens.contentPrimary, + }} + renderItem={(item: SelectItem) => { + const isSelected = item.value === value; + + return ( + + + {item.label} + + + {isSelected && ( + + )} + + ); + }} + /> + + {errorText ? ( + {errorText} + ) : helperText ? ( + + {helperText} + + ) : null} + + ); +} + diff --git a/src/shared/ui/select/index.ts b/src/shared/ui/select/index.ts new file mode 100644 index 0000000..4ff96d8 --- /dev/null +++ b/src/shared/ui/select/index.ts @@ -0,0 +1,3 @@ +export { Select } from "./Select"; +export type { SelectItem, SelectProps, SelectSize } from "./types"; + diff --git a/src/shared/ui/select/types.ts b/src/shared/ui/select/types.ts new file mode 100644 index 0000000..131e413 --- /dev/null +++ b/src/shared/ui/select/types.ts @@ -0,0 +1,40 @@ +export type SelectItem = { + label: string; + value: string; + disabled?: boolean; +}; + +export type SelectSize = "sm" | "md"; + +export type SelectProps = { + /** 옵션 목록 */ + items: SelectItem[]; + + /** 선택된 값 (없으면 null) */ + value: string | null; + + /** 값 변경 콜백 */ + onChange: (value: string | null) => void; + + /** placeholder 텍스트 */ + placeholder?: string; + + /** 비활성화 */ + disabled?: boolean; + + /** (옵션) 상단 라벨 */ + label?: string; + + /** (옵션) 도움말 */ + helperText?: string; + + /** (옵션) 에러 문구. 값이 있으면 에러 상태로 렌더링 */ + errorText?: string; + + /** 사이즈 */ + size?: SelectSize; + + /** 테스트용 id */ + testID?: string; +}; + diff --git a/yarn.lock b/yarn.lock index 6e70ff7..b534dab 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4966,6 +4966,11 @@ lodash.throttle@^4.1.1: resolved "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz" integrity sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ== +lodash@^4.17.21: + version "4.17.23" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.23.tgz#f113b0378386103be4f6893388c73d0bde7f2c5a" + integrity sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w== + log-symbols@^2.2.0: version "2.2.0" resolved "https://registry.npmjs.org/log-symbols/-/log-symbols-2.2.0.tgz" @@ -5960,6 +5965,13 @@ react-native-css-interop@0.2.1: lightningcss "~1.27.0" semver "^7.6.3" +react-native-element-dropdown@^2.12.4: + version "2.12.4" + resolved "https://registry.yarnpkg.com/react-native-element-dropdown/-/react-native-element-dropdown-2.12.4.tgz#259db84e673b9f4fc03090a54760efd4f4a1456c" + integrity sha512-abZc5SVji9FIt7fjojRYrbuvp03CoeZJrgvezQoDoSOrpiTqkX69ix5m+j06W2AVncA0VWvbT+vCMam8SoVadw== + dependencies: + lodash "^4.17.21" + react-native-gesture-handler@~2.28.0: version "2.28.0" resolved "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.28.0.tgz"