diff --git a/babel.config.js b/babel.config.js index 23741e3..428f9fe 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,3 +1,5 @@ +const path = require("path"); + module.exports = (api) => { api.cache(true); return { @@ -5,5 +7,16 @@ module.exports = (api) => { ["babel-preset-expo", { jsxImportSource: "nativewind" }], "nativewind/babel", ], + plugins: [ + [ + "module-resolver", + { + root: [path.resolve(__dirname, "src")], + alias: { + "@": path.resolve(__dirname, "src"), + }, + }, + ], + ], }; }; diff --git a/package.json b/package.json index 62807d4..2d50cab 100644 --- a/package.json +++ b/package.json @@ -1,57 +1,60 @@ { - "name": "assu_fe_rn", - "main": "expo-router/entry", - "version": "1.0.0", - "scripts": { - "start": "expo start", - "android": "expo start --android", - "ios": "expo start --ios", - "web": "expo start --web", - "typecheck": "tsc --noEmit", - "biome:lint": "biome lint ./src", - "biome:fix": "biome check --write ./src", - "biome:format": "biome format ./src --write" - }, - "dependencies": { - "@expo/vector-icons": "^15.0.3", - "@react-navigation/bottom-tabs": "^7.4.0", - "@react-navigation/elements": "^2.6.3", - "@react-navigation/native": "^7.1.8", - "@tanstack/react-query": "^5.90.20", - "expo": "~54.0.33", - "expo-constants": "~18.0.13", - "expo-font": "~14.0.11", - "expo-haptics": "~15.0.8", - "expo-image": "~3.0.11", - "expo-linking": "~8.0.11", - "expo-router": "~6.0.23", - "expo-splash-screen": "~31.0.13", - "expo-status-bar": "~3.0.9", - "expo-symbols": "~1.0.8", - "expo-system-ui": "~6.0.9", - "expo-web-browser": "~15.0.10", - "nativewind": "^4.2.1", - "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", - "react-native-screens": "~4.16.0", - "react-native-web": "~0.21.0", - "react-native-worklets": "0.5.1", - "zustand": "^5.0.11" - }, - "devDependencies": { - "@biomejs/biome": "^2.3.14", - "@types/react": "~19.1.10", - "babel-plugin-module-resolver": "^5.0.2", - "eslint": "^9.25.0", - "eslint-config-expo": "~10.0.0", - "prettier-plugin-tailwindcss": "^0.7.2", - "tailwindcss": "3.4.17", - "typescript": "^5.9.3" - }, - "private": true + "name": "assu_fe_rn", + "main": "expo-router/entry", + "version": "1.0.0", + "scripts": { + "start": "expo start", + "android": "expo start --android", + "ios": "expo start --ios", + "web": "expo start --web", + "typecheck": "tsc --noEmit", + "biome:lint": "biome lint ./src", + "biome:fix": "biome check --write ./src", + "biome:format": "biome format ./src --write" + }, + "dependencies": { + "@expo/vector-icons": "^15.0.3", + "@hookform/resolvers": "^5.2.2", + "@react-navigation/bottom-tabs": "^7.4.0", + "@react-navigation/elements": "^2.6.3", + "@react-navigation/native": "^7.1.8", + "@tanstack/react-query": "^5.90.20", + "expo": "~54.0.33", + "expo-constants": "~18.0.13", + "expo-font": "~14.0.11", + "expo-haptics": "~15.0.8", + "expo-image": "~3.0.11", + "expo-linking": "~8.0.11", + "expo-router": "~6.0.23", + "expo-splash-screen": "~31.0.13", + "expo-status-bar": "~3.0.9", + "expo-symbols": "~1.0.8", + "expo-system-ui": "~6.0.9", + "expo-web-browser": "~15.0.10", + "nativewind": "^4.2.1", + "react": "19.1.0", + "react-dom": "19.1.0", + "react-hook-form": "^7.71.1", + "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", + "react-native-screens": "~4.16.0", + "react-native-web": "~0.21.0", + "react-native-worklets": "0.5.1", + "zod": "^4.3.6", + "zustand": "^5.0.11" + }, + "devDependencies": { + "@biomejs/biome": "^2.3.14", + "@types/react": "~19.1.10", + "babel-plugin-module-resolver": "^5.0.2", + "eslint": "^9.25.0", + "eslint-config-expo": "~10.0.0", + "prettier-plugin-tailwindcss": "^0.7.2", + "tailwindcss": "3.4.17", + "typescript": "^5.9.3" + }, + "private": true } diff --git a/src/shared/styles/tokens.ts b/src/shared/styles/tokens.ts index 734cfcb..0af87b3 100644 --- a/src/shared/styles/tokens.ts +++ b/src/shared/styles/tokens.ts @@ -31,4 +31,3 @@ export const colorTokens = { /** global.styles.css: --color-content-inverse (blue-50) */ contentInverse: "#F4F6FE", } as const; - diff --git a/src/shared/ui/FormField/FormField.tsx b/src/shared/ui/FormField/FormField.tsx new file mode 100644 index 0000000..cdcbd5a --- /dev/null +++ b/src/shared/ui/FormField/FormField.tsx @@ -0,0 +1,49 @@ +import { Controller, type FieldValues } from "react-hook-form"; +import { View } from "react-native"; +import { FormFieldHelper } from "./FormFieldHelper"; +import { FormFieldInput } from "./FormFieldInput"; +import { FormFieldLabel } from "./FormFieldLabel"; +import type { FormFieldProps } from "./types"; + +export function FormField({ + control, + name, + label, + appearance = "filled", + rightElement, + helperText, + labelColor, + ...inputProps +}: FormFieldProps) { + return ( + { + //todo: 일단 에러메세지 추출만 함 + const errorMessage = fieldState.error?.message; + const message = helperText; + + return ( + + {label && ( + {label} + )} + + + + {message && {message}} + + ); + }} + /> + ); +} diff --git a/src/shared/ui/FormField/FormFieldHelper.tsx b/src/shared/ui/FormField/FormFieldHelper.tsx new file mode 100644 index 0000000..9146ac9 --- /dev/null +++ b/src/shared/ui/FormField/FormFieldHelper.tsx @@ -0,0 +1,20 @@ +import type { ReactNode } from "react"; +import { Text } from "react-native"; +import { colorTokens } from "@/shared/styles/tokens"; + +type Props = { + children: ReactNode; +}; + +export function FormFieldHelper({ children }: Props) { + return ( + + {children} + + ); +} diff --git a/src/shared/ui/FormField/FormFieldInput.tsx b/src/shared/ui/FormField/FormFieldInput.tsx new file mode 100644 index 0000000..046cc55 --- /dev/null +++ b/src/shared/ui/FormField/FormFieldInput.tsx @@ -0,0 +1,92 @@ +import { useState } from "react"; +import { TextInput, View } from "react-native"; +import { colorTokens } from "@/shared/styles/tokens"; +import type { FormFieldAppearance, FormFieldInputProps } from "./types"; + +//formField가 크게 두가지 버전으로 존재함. +const APPEARANCE_STYLES: Record< + FormFieldAppearance, + { backgroundColor: string; borderColor: string } +> = { + filled: { + backgroundColor: colorTokens.neutral, + borderColor: colorTokens.neutral, + }, + outlined: { + backgroundColor: colorTokens.canvas, + borderColor: colorTokens.contentSecondary, + }, +}; + +export function FormFieldInput({ + appearance = "filled", + fontSize = 17, + inputBackgroundColor, + inputBorderColor, + inputTextColor, + inputStyle, + rightElement, + multiline, + hasError, + onFocus, + onBlur, + ...props +}: FormFieldInputProps) { + const [isFocused, setIsFocused] = useState(false); + + const baseStyle = APPEARANCE_STYLES[appearance]; + + const borderColor = hasError + ? colorTokens.danger + : isFocused + ? colorTokens.primary + : baseStyle.borderColor; + + const finalBackgroundColor = inputBackgroundColor + ? colorTokens[inputBackgroundColor] + : baseStyle.backgroundColor; + + const finalTextColor = inputTextColor + ? colorTokens[inputTextColor] + : colorTokens.contentPrimary; + + return ( + + { + setIsFocused(true); + onFocus?.(e); + }} + onBlur={(e) => { + setIsFocused(false); + onBlur?.(e); + }} + /> + + {rightElement && {rightElement}} + + ); +} diff --git a/src/shared/ui/FormField/FormFieldLabel.tsx b/src/shared/ui/FormField/FormFieldLabel.tsx new file mode 100644 index 0000000..c260f37 --- /dev/null +++ b/src/shared/ui/FormField/FormFieldLabel.tsx @@ -0,0 +1,21 @@ +import { Text } from "react-native"; +import { colorTokens } from "@/shared/styles/tokens"; +import type { ColorTokenKey } from "./types"; + +type Props = { + children: string; + color?: ColorTokenKey; +}; + +export function FormFieldLabel({ children, color }: Props) { + return ( + + {children} + + ); +} diff --git a/src/shared/ui/FormField/README.md b/src/shared/ui/FormField/README.md new file mode 100644 index 0000000..03796a0 --- /dev/null +++ b/src/shared/ui/FormField/README.md @@ -0,0 +1,188 @@ +# FormField + +`FormField`는 **React Native 환경에서 폼 입력을 쉽게 만들기 위한 공용 입력 컴포넌트**입니다. + +- 폼 상태 관리 자동 연결 +- validation 에러 자동 표시 +- 디자인 시스템 스타일 일관성 유지 +- label / helper / right element 지원 + +👉 **React Hook Form** 기반으로 동작합니다. + +--- + +## 언제 사용하나요? + +다음과 같은 입력 UI에 사용합니다 + +- 로그인 / 회원가입 폼 +- 인증번호 입력 +- 설정 화면 +- 문의 작성 폼 + +👉 **텍스트 기반 입력 필드 전용 컴포넌트입니다.** + +--- + +## 빠른 시작 + +### 1. 폼 스키마 정의 + +보통 **Zod**와 함께 사용합니다. + +```ts +const schema = z.object({ + phone: z.string().min(10, "전화번호를 입력해주세요"), +}); +``` + +--- + +### 2. useForm 설정 + +```ts +const { control } = useForm({ + resolver: zodResolver(schema), + defaultValues: { phone: "" }, +}); +``` + +--- + +### 3. FormField 사용 + +```tsx + +``` + +--- + +## 기본 사용 패턴 + +### 라벨 + helper 메시지 + +```tsx + +``` + +--- + +### 오른쪽 버튼 추가 + +```tsx + + 인증번호 받기 + + } +/> +``` + +👉 인증 버튼, 아이콘 등에 사용합니다. + +--- + +### 스타일 변경 + +```tsx + +``` + +지원 스타일: + +- `"filled"` (기본값) +- `"outlined"` + +--- + +## Props 설명 + +### 필수 props + +| 이름 | 설명 | +| --------- | ----------------------------- | +| `control` | useForm에서 받은 control 객체 | +| `name` | 폼 필드 이름 | + +--- + +### 선택 props + +| 이름 | 설명 | +| -------------- | ---------------------------- | +| `label` | 입력 필드 라벨 | +| `helperText` | 보조 설명 메시지 | +| `appearance` | `"filled"` 또는 `"outlined"` | +| `rightElement` | 입력창 오른쪽 커스텀 요소 | +| `labelColor` | 라벨 색상 토큰 | + +--- + +### TextInput props + +`placeholder`, `keyboardType` 등 React Native TextInput props를 그대로 사용할 수 있습니다. + +```tsx + +``` + +--- + +## Validation 동작 방식 + +validation 에러가 발생하면: + +- 입력창 테두리가 빨간색으로 변경됨 +- helper 영역에 에러 메시지 표시됨 + +예: + +```ts +z.string().min(10, "10자리 이상 입력해주세요"); +``` + +--- + +## 권장 사용 방식 + +### ✅ 권장 + +- Zod + react-hook-form 조합 +- 기본 디자인 토큰 사용 +- validation 메시지 명확하게 작성 + +--- + +### ❌ 권장하지 않음 + +- 숫자/객체 값을 직접 바인딩 +- 복잡한 커스텀 UI를 input 안에 삽입 + +👉 이 컴포넌트는 **텍스트 입력 전용**입니다. + +--- + +## 예시 + +```tsx + +``` diff --git a/src/shared/ui/FormField/index.ts b/src/shared/ui/FormField/index.ts new file mode 100644 index 0000000..6bb2e43 --- /dev/null +++ b/src/shared/ui/FormField/index.ts @@ -0,0 +1,5 @@ +export { FormField } from "./FormField"; +export { FormFieldHelper } from "./FormFieldHelper"; +export { FormFieldInput } from "./FormFieldInput"; +export { FormFieldLabel } from "./FormFieldLabel"; +export type { FormFieldProps } from "./types"; diff --git a/src/shared/ui/FormField/types.ts b/src/shared/ui/FormField/types.ts new file mode 100644 index 0000000..ede0eba --- /dev/null +++ b/src/shared/ui/FormField/types.ts @@ -0,0 +1,47 @@ +import type { ReactNode } from "react"; +import type { Control, FieldValues, Path } from "react-hook-form"; +import type { TextInputProps } from "react-native"; +import type { colorTokens } from "@/shared/styles/tokens"; + +export type ColorTokenKey = keyof typeof colorTokens; + +/** + * 텍스트필드 스타일 타입 (크게 두가지 있음) + */ +export type FormFieldAppearance = "filled" | "outlined"; + +export type FormFieldProps = TextInputProps & { + control: Control; + name: Path; + + label?: string; + helperText?: string; + + appearance?: FormFieldAppearance; + + labelColor?: ColorTokenKey; + + rightElement?: ReactNode; + + // 커스텀 스타일 오버라이드 + inputBackgroundColor?: ColorTokenKey; + inputBorderColor?: ColorTokenKey; + inputTextColor?: ColorTokenKey; +}; + +// Input Props +export type FormFieldInputProps = TextInputProps & { + appearance: FormFieldAppearance; + + fontSize?: number; + rightElement?: ReactNode; + + hasError?: boolean; + + // 커스텀 스타일 오버라이드 + inputBackgroundColor?: ColorTokenKey; + inputBorderColor?: ColorTokenKey; + inputTextColor?: ColorTokenKey; + + inputStyle?: TextInputProps["style"]; +}; diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index e4c0223..e1856bb 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -1,2 +1 @@ export * from "./select"; - diff --git a/src/shared/ui/select/Select.tsx b/src/shared/ui/select/Select.tsx index 5a3faf4..1cc0ae7 100644 --- a/src/shared/ui/select/Select.tsx +++ b/src/shared/ui/select/Select.tsx @@ -1,11 +1,10 @@ +import { Ionicons } from "@expo/vector-icons"; 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"; +import { colorTokens } from "@/shared/styles/tokens"; +import type { SelectItem, SelectProps } from "./types"; const SIZES = { sm: { @@ -118,9 +117,7 @@ export function Select({ const isSelected = item.value === value; return ( - + ); } - diff --git a/src/shared/ui/select/index.ts b/src/shared/ui/select/index.ts index 4ff96d8..87bf59c 100644 --- a/src/shared/ui/select/index.ts +++ b/src/shared/ui/select/index.ts @@ -1,3 +1,2 @@ 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 index 131e413..779a2c1 100644 --- a/src/shared/ui/select/types.ts +++ b/src/shared/ui/select/types.ts @@ -37,4 +37,3 @@ export type SelectProps = { /** 테스트용 id */ testID?: string; }; - diff --git a/src/shared/utils/formatDate.ts b/src/shared/utils/formatDate.ts index 3c7077f..1cbcbb0 100644 --- a/src/shared/utils/formatDate.ts +++ b/src/shared/utils/formatDate.ts @@ -11,7 +11,7 @@ export function formatDate( options?: { locale?: string; separator?: "." | "-" | "/"; - } + }, ): string { const date = toDate(input); if (!date) return ""; @@ -32,7 +32,7 @@ export function formatDateTime( input: DateInput, options?: { separator?: "." | "-" | "/"; - } + }, ): string { const date = toDate(input); if (!date) return ""; diff --git a/tailwind.config.js b/tailwind.config.js index e643e37..918baee 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -32,9 +32,7 @@ module.exports = { gutter: "var(--layout-gutter)", }, fontFamily: { - sans: [ - "Pretendard-Regular", - ], + sans: ["Pretendard-Regular"], }, }, }, diff --git a/tsconfig.json b/tsconfig.json index 3cd2d19..a36bf33 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,8 +2,9 @@ "extends": "expo/tsconfig.base", "compilerOptions": { "strict": true, + "baseUrl": ".", "paths": { - "@/*": ["./src/*"] + "@/*": ["src/*"] } }, "include": [ diff --git a/yarn.lock b/yarn.lock index b534dab..7df8ac2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1264,6 +1264,13 @@ chalk "^4.1.0" js-yaml "^4.1.0" +"@hookform/resolvers@^5.2.2": + version "5.2.2" + resolved "https://registry.yarnpkg.com/@hookform/resolvers/-/resolvers-5.2.2.tgz#5ac16cd89501ca31671e6e9f0f5c5d762a99aa12" + integrity sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA== + dependencies: + "@standard-schema/utils" "^0.3.0" + "@humanfs/core@^0.19.1": version "0.19.1" resolved "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz" @@ -1877,6 +1884,11 @@ dependencies: "@sinonjs/commons" "^3.0.0" +"@standard-schema/utils@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@standard-schema/utils/-/utils-0.3.0.tgz#3d5e608f16c2390c10528e98e59aef6bf73cae7b" + integrity sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g== + "@tanstack/query-core@5.90.20": version "5.90.20" resolved "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz" @@ -5938,6 +5950,11 @@ react-freeze@^1.0.0: resolved "https://registry.npmjs.org/react-freeze/-/react-freeze-1.0.4.tgz" integrity sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA== +react-hook-form@^7.71.1: + version "7.71.1" + resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.71.1.tgz#6a758958861682cf0eb22131eead684ba3618f66" + integrity sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w== + react-is@^16.13.1, react-is@^16.7.0: version "16.13.1" resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz" @@ -7400,6 +7417,11 @@ yocto-queue@^0.1.0: resolved "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== +zod@^4.3.6: + version "4.3.6" + resolved "https://registry.yarnpkg.com/zod/-/zod-4.3.6.tgz#89c56e0aa7d2b05107d894412227087885ab112a" + integrity sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg== + zustand@^5.0.11: version "5.0.11" resolved "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz"