From c78aec6a06915eaeecef01e3979a0953092af5e4 Mon Sep 17 00:00:00 2001 From: bae129 Date: Mon, 9 Nov 2026 11:13:19 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=EA=B3=B5=ED=86=B5=20TextInput=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?=EB=B0=8F=20warning=20=EC=83=89=EC=83=81=20=ED=86=A0=ED=81=B0?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/dev/components.tsx | 52 ++++++++++++++++++ src/components/TextInput.tsx | 101 +++++++++++++++++++++++++++++++++++ src/styles/colors.ts | 2 +- 3 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 src/app/dev/components.tsx create mode 100644 src/components/TextInput.tsx diff --git a/src/app/dev/components.tsx b/src/app/dev/components.tsx new file mode 100644 index 0000000..0fabfc3 --- /dev/null +++ b/src/app/dev/components.tsx @@ -0,0 +1,52 @@ +import { useState } from 'react'; +import { Redirect } from 'expo-router'; +import { ScrollView, StyleSheet, Text } from 'react-native'; +import { TextInput } from '@/components/TextInput'; +import { semantic } from '@/styles/colors'; +import { gap, grid } from '@/styles/measurement'; +import { typography } from '@/styles/typography'; + +// 개발 중에만 여는 공통 컴포넌트 확인 화면입니다. +export default function ComponentsPreview() { + const [empty, setEmpty] = useState(''); + const [filled, setFilled] = useState('값'); + const [error, setError] = useState('값'); + + if (!__DEV__) return ; + + return ( + + TextInput + + + + + ); +} + +const s = StyleSheet.create({ + page: { flex: 1, backgroundColor: semantic.surface.input }, + content: { + width: '100%', + maxWidth: 390, + alignSelf: 'center', + padding: grid.margin, + gap: gap[16], + }, + title: { ...typography.heading[1].bold, color: semantic.text.default.strong }, +}); diff --git a/src/components/TextInput.tsx b/src/components/TextInput.tsx new file mode 100644 index 0000000..2660bb2 --- /dev/null +++ b/src/components/TextInput.tsx @@ -0,0 +1,101 @@ +import { useState } from 'react'; +import type { ReactNode } from 'react'; +import { TextInput as RNTextInput, StyleSheet, Text, View } from 'react-native'; +import type { TextInputProps as RNTextInputProps } from 'react-native'; +import { semantic } from '@/styles/colors'; +import { gap, lineWeight, padding, radius } from '@/styles/measurement'; +import { fontSize, fontWeight, letterSpacing, lineHeight } from '@/styles/typography'; + +export type TextInputProps = Omit & { + /** 입력 아래에 표시하는 보조 문구 */ + helperText?: string; + /** 값이 있으면 오류 상태로 표시하고 helperText 대신 보여 줍니다. */ + errorMessage?: string; + /** 입력 오른쪽에 붙는 요소 (타이머, 지우기 버튼 등) */ + trailing?: ReactNode; +}; + +export function TextInput({ + helperText, + errorMessage, + trailing, + value, + onFocus, + onBlur, + ...props +}: TextInputProps) { + const [focused, setFocused] = useState(false); + const error = Boolean(errorMessage); + const filled = Boolean(value); + const message = errorMessage ?? helperText; + + return ( + + + { + setFocused(true); + onFocus?.(e); + }} + onBlur={(e) => { + setFocused(false); + onBlur?.(e); + }} + style={[s.input, error && s.inputError]} + /> + {trailing} + + {message ? {message} : null} + + ); +} + +const s = StyleSheet.create({ + root: { width: '100%', gap: gap[4] }, + box: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: padding[16], + paddingHorizontal: padding[12], + borderRadius: radius.small, + borderWidth: lineWeight.regular, + borderColor: semantic.border.default, + backgroundColor: semantic.surface.input, + }, + boxFilled: { backgroundColor: semantic.surface.subtle }, + boxFocused: { borderColor: semantic.border.focused }, + boxError: { borderColor: semantic.border.warning, backgroundColor: semantic.surface.input }, + input: { + flex: 1, + padding: 0, + fontSize: fontSize[13], + lineHeight: lineHeight[13], + fontWeight: fontWeight.medium, + letterSpacing: letterSpacing.none, + color: semantic.text.default.normal, + // 웹 기본 포커스 테두리를 숨기고 상자 테두리로 포커스를 표시합니다. + outlineWidth: 0, + outlineColor: 'transparent', + }, + inputError: { color: semantic.text.feedback.warning }, + message: { + fontSize: fontSize[12], + lineHeight: lineHeight[12], + fontWeight: fontWeight.regular, + letterSpacing: letterSpacing.none, + color: semantic.text.default.weak, + }, + messageError: { color: semantic.text.feedback.warning }, +}); diff --git a/src/styles/colors.ts b/src/styles/colors.ts index 1d3b5f1..9f54c8d 100644 --- a/src/styles/colors.ts +++ b/src/styles/colors.ts @@ -94,7 +94,7 @@ export const semantic = { }, feedback: { success: green[500], - warning: orange[500], + warning: orange[300], alert: red[350], }, }, From 11af09dbc2c33c4fad22d349d4f4c1f8b979b903 Mon Sep 17 00:00:00 2001 From: bae129 Date: Mon, 9 Nov 2026 11:25:07 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20=EA=B3=B5=ED=86=B5=20ProgressBar=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/dev/components.tsx | 7 ++++ src/components/ProgressBar.tsx | 62 ++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 src/components/ProgressBar.tsx diff --git a/src/app/dev/components.tsx b/src/app/dev/components.tsx index 0fabfc3..3fe8f5b 100644 --- a/src/app/dev/components.tsx +++ b/src/app/dev/components.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { Redirect } from 'expo-router'; import { ScrollView, StyleSheet, Text } from 'react-native'; +import { ProgressBar } from '@/components/ProgressBar'; import { TextInput } from '@/components/TextInput'; import { semantic } from '@/styles/colors'; import { gap, grid } from '@/styles/measurement'; @@ -35,6 +36,12 @@ export default function ComponentsPreview() { placeholder="텍스트를 입력해주세요" errorMessage="에러메세지" /> + + Progress Bar + + {[1, 2, 3, 4, 5].map((step) => ( + + ))} ); } diff --git a/src/components/ProgressBar.tsx b/src/components/ProgressBar.tsx new file mode 100644 index 0000000..ccf0b78 --- /dev/null +++ b/src/components/ProgressBar.tsx @@ -0,0 +1,62 @@ +import { StyleSheet, Text, View } from 'react-native'; +import { semantic } from '@/styles/colors'; +import { gap, radius } from '@/styles/measurement'; +import { fontSize, fontWeight, letterSpacing, lineHeight } from '@/styles/typography'; + +const SEGMENT_WIDTH = 54; +const SEGMENT_HEIGHT = 6; + +export type ProgressBarProps = { + /** 현재 단계 (1부터 시작) */ + step: number; + /** 전체 단계 수 */ + total: number; + /** 막대 아래 `step/total` 표시 여부 */ + showLabel?: boolean; +}; + +export function ProgressBar({ step, total, showLabel = true }: ProgressBarProps) { + const current = Math.min(Math.max(step, 0), total); + + return ( + + + {Array.from({ length: total }, (_, i) => ( + + ))} + + {showLabel ? ( + + {current}/{total} + + ) : null} + + ); +} + +const s = StyleSheet.create({ + root: { alignItems: 'center', gap: gap[12] }, + track: { flexDirection: 'row', gap: gap[2] }, + segment: { + width: SEGMENT_WIDTH, + height: SEGMENT_HEIGHT, + borderRadius: radius.small, + backgroundColor: semantic.surface.disabled, + }, + segmentFilled: { backgroundColor: semantic.icon.brand }, + label: { + fontSize: fontSize[12], + lineHeight: lineHeight[12], + fontWeight: fontWeight.medium, + letterSpacing: letterSpacing.none, + textAlign: 'center', + color: semantic.text.default.normal, + }, +}); From 0d3dfdcc1e450aee2886648cb1f3132f6ac1453c Mon Sep 17 00:00:00 2001 From: bae129 Date: Mon, 9 Nov 2026 11:47:14 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20=EA=B3=B5=ED=86=B5=20Loading=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/images/loading.svg | 22 +++++++++++++ src/app/dev/components.tsx | 17 +++++++++- src/components/Loading.tsx | 64 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 assets/images/loading.svg create mode 100644 src/components/Loading.tsx diff --git a/assets/images/loading.svg b/assets/images/loading.svg new file mode 100644 index 0000000..a0c4832 --- /dev/null +++ b/assets/images/loading.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/app/dev/components.tsx b/src/app/dev/components.tsx index 3fe8f5b..b1d40d1 100644 --- a/src/app/dev/components.tsx +++ b/src/app/dev/components.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { Redirect } from 'expo-router'; -import { ScrollView, StyleSheet, Text } from 'react-native'; +import { Pressable, ScrollView, StyleSheet, Text } from 'react-native'; +import { Loading } from '@/components/Loading'; import { ProgressBar } from '@/components/ProgressBar'; import { TextInput } from '@/components/TextInput'; import { semantic } from '@/styles/colors'; @@ -12,6 +13,7 @@ export default function ComponentsPreview() { const [empty, setEmpty] = useState(''); const [filled, setFilled] = useState('값'); const [error, setError] = useState('값'); + const [loading, setLoading] = useState(false); if (!__DEV__) return ; @@ -42,6 +44,18 @@ export default function ComponentsPreview() { {[1, 2, 3, 4, 5].map((step) => ( ))} + + Loading + { + setLoading(true); + setTimeout(() => setLoading(false), 3000); + }} + > + 3초 동안 로딩 보기 + + ); } @@ -56,4 +70,5 @@ const s = StyleSheet.create({ gap: gap[16], }, title: { ...typography.heading[1].bold, color: semantic.text.default.strong }, + link: { ...typography.body[2].medium, color: semantic.text.brand.normal }, }); diff --git a/src/components/Loading.tsx b/src/components/Loading.tsx new file mode 100644 index 0000000..04adccb --- /dev/null +++ b/src/components/Loading.tsx @@ -0,0 +1,64 @@ +import loadingImage from '../../assets/images/loading.svg'; +import { useEffect, useState } from 'react'; +import { Animated, Easing, Modal, Platform, StyleSheet, View } from 'react-native'; +import { Image } from 'expo-image'; +import { semantic } from '@/styles/colors'; +import { radius } from '@/styles/measurement'; + +const BOX_WIDTH = 120; +const BOX_HEIGHT = 130; +const SPINNER_SIZE = 80; +// SVG에 그림자 여백이 포함되어 있어 원(80)보다 이미지가 큽니다. (원 80 / 이미지 104) +const SPINNER_IMAGE_SIZE = (SPINNER_SIZE * 104) / 80; +const ROTATION_MS = 1000; + +export type LoadingProps = { + visible: boolean; +}; + +/** 화면 가운데에 로딩 상자를 띄우고, 로딩 중에는 뒤쪽 화면을 누를 수 없게 막습니다. */ +export function Loading({ visible }: LoadingProps) { + const [rotation] = useState(() => new Animated.Value(0)); + + useEffect(() => { + if (!visible) return; + rotation.setValue(0); + const loop = Animated.loop( + Animated.timing(rotation, { + toValue: 1, + duration: ROTATION_MS, + easing: Easing.linear, + useNativeDriver: Platform.OS !== 'web', + }), + ); + loop.start(); + return () => loop.stop(); + }, [visible, rotation]); + + const rotate = rotation.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '360deg'] }); + + return ( + {}}> + + + + + + + + + ); +} + +const s = StyleSheet.create({ + overlay: { flex: 1, alignItems: 'center', justifyContent: 'center' }, + box: { + width: BOX_WIDTH, + height: BOX_HEIGHT, + borderRadius: radius.medium, + backgroundColor: semantic.surface.brand, + alignItems: 'center', + justifyContent: 'center', + }, + spinner: { width: SPINNER_IMAGE_SIZE, height: SPINNER_IMAGE_SIZE }, +}); From f809d2eb60595d650940888e251a8f99d260f6a2 Mon Sep 17 00:00:00 2001 From: bae129 Date: Mon, 9 Nov 2026 11:57:28 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat:=20=EA=B3=B5=ED=86=B5=20ActionButton?= =?UTF-8?q?=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/images/loadingbtn.svg | 5 ++ src/app/dev/components.tsx | 16 +++++- src/components/ActionButton.tsx | 97 +++++++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 assets/images/loadingbtn.svg create mode 100644 src/components/ActionButton.tsx diff --git a/assets/images/loadingbtn.svg b/assets/images/loadingbtn.svg new file mode 100644 index 0000000..b23b9f3 --- /dev/null +++ b/assets/images/loadingbtn.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/app/dev/components.tsx b/src/app/dev/components.tsx index b1d40d1..f1f945d 100644 --- a/src/app/dev/components.tsx +++ b/src/app/dev/components.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { Redirect } from 'expo-router'; -import { Pressable, ScrollView, StyleSheet, Text } from 'react-native'; +import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { ActionButton } from '@/components/ActionButton'; import { Loading } from '@/components/Loading'; import { ProgressBar } from '@/components/ProgressBar'; import { TextInput } from '@/components/TextInput'; @@ -56,6 +57,18 @@ export default function ComponentsPreview() { 3초 동안 로딩 보기 + + ActionButton + {(['S', 'M'] as const).map((size) => ( + + + + + + ))} + + + ); } @@ -71,4 +84,5 @@ const s = StyleSheet.create({ }, title: { ...typography.heading[1].bold, color: semantic.text.default.strong }, link: { ...typography.body[2].medium, color: semantic.text.brand.normal }, + row: { flexDirection: 'row', gap: gap[8] }, }); diff --git a/src/components/ActionButton.tsx b/src/components/ActionButton.tsx new file mode 100644 index 0000000..ce6b234 --- /dev/null +++ b/src/components/ActionButton.tsx @@ -0,0 +1,97 @@ +import loadingDotsImage from '../../assets/images/loadingbtn.svg'; +import { Pressable, StyleSheet, Text } from 'react-native'; +import type { PressableProps } from 'react-native'; +import { Image } from 'expo-image'; +import { semantic } from '@/styles/colors'; +import { padding, radius } from '@/styles/measurement'; +import { fontSize, fontWeight, letterSpacing, lineHeight } from '@/styles/typography'; + +// TODO: 피그마에서 아직 확인하지 않은 값입니다. 확인 후 토큰으로 교체해야 합니다. +const unconfirmed = { + loadingBackground: semantic.action.pressed, +}; + +export type ActionButtonSize = 'L' | 'M' | 'S'; + +export type ActionButtonProps = Omit & { + label: string; + /** L: 가로 꽉 참, M: 96, S: 72 */ + size?: ActionButtonSize; + /** true면 글씨 대신 로딩 점을 보여 주고 누를 수 없게 합니다. */ + loading?: boolean; +}; + +export function ActionButton({ + label, + size = 'L', + disabled, + loading = false, + ...props +}: ActionButtonProps) { + return ( + [ + s.base, + sizes[size], + pressed && s.pressed, + loading && s.loading, + disabled && s.disabled, + ]} + > + {loading ? ( + + ) : ( + {label} + )} + + ); +} + +const s = StyleSheet.create({ + base: { + alignItems: 'center', + justifyContent: 'center', + borderRadius: radius.small, + backgroundColor: semantic.action.default, + }, + pressed: { backgroundColor: semantic.action.pressed }, + loading: { backgroundColor: unconfirmed.loadingBackground }, + disabled: { backgroundColor: semantic.action.disabled }, + label: { + fontWeight: fontWeight.semiBold, + textAlign: 'center', + color: semantic.text.default.inverse, + }, + labelDisabled: { color: semantic.text.default.weak }, + dots: { width: 23, height: 6 }, +}); + +const sizes = StyleSheet.create({ + L: { width: '100%', height: 56, paddingVertical: padding[16] }, + M: { width: 96, height: 42, padding: padding[12] }, + S: { width: 72, height: 34, paddingVertical: padding[8], paddingHorizontal: padding[12] }, +}); + +const labels = StyleSheet.create({ + L: { + fontSize: fontSize[18], + lineHeight: lineHeight[16], + letterSpacing: letterSpacing.normal, + }, + M: { + fontSize: fontSize[14], + lineHeight: lineHeight[13], + letterSpacing: letterSpacing.none, + }, + S: { + fontSize: fontSize[13], + lineHeight: lineHeight[13], + letterSpacing: letterSpacing.none, + }, +}); From 3ad1a70ebb22ab6e39f7c5875579626d1a3f6719 Mon Sep 17 00:00:00 2001 From: bae129 Date: Mon, 9 Nov 2026 13:04:14 +0900 Subject: [PATCH 5/6] =?UTF-8?q?feat:=20ActionButton=20=EC=97=B0=ED=95=9C?= =?UTF-8?q?=20=EB=B0=B0=EA=B2=BD=20=EB=B2=84=ED=8A=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/images/loadingbtn_blue.svg | 5 +++ src/app/dev/components.tsx | 10 +++++ src/components/ActionButton.tsx | 61 +++++++++++++++++++++---------- 3 files changed, 56 insertions(+), 20 deletions(-) create mode 100644 assets/images/loadingbtn_blue.svg diff --git a/assets/images/loadingbtn_blue.svg b/assets/images/loadingbtn_blue.svg new file mode 100644 index 0000000..d69d088 --- /dev/null +++ b/assets/images/loadingbtn_blue.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/app/dev/components.tsx b/src/app/dev/components.tsx index f1f945d..e3358e4 100644 --- a/src/app/dev/components.tsx +++ b/src/app/dev/components.tsx @@ -69,6 +69,16 @@ export default function ComponentsPreview() { + {(['S', 'M'] as const).map((size) => ( + + + + + + ))} + + + ); } diff --git a/src/components/ActionButton.tsx b/src/components/ActionButton.tsx index ce6b234..0d68ad9 100644 --- a/src/components/ActionButton.tsx +++ b/src/components/ActionButton.tsx @@ -1,4 +1,5 @@ import loadingDotsImage from '../../assets/images/loadingbtn.svg'; +import loadingDotsBlueImage from '../../assets/images/loadingbtn_blue.svg'; import { Pressable, StyleSheet, Text } from 'react-native'; import type { PressableProps } from 'react-native'; import { Image } from 'expo-image'; @@ -8,26 +9,59 @@ import { fontSize, fontWeight, letterSpacing, lineHeight } from '@/styles/typogr // TODO: 피그마에서 아직 확인하지 않은 값입니다. 확인 후 토큰으로 교체해야 합니다. const unconfirmed = { - loadingBackground: semantic.action.pressed, + primaryLoadingBackground: semantic.action.pressed, + secondaryLoadingBackground: semantic.surface.default, }; export type ActionButtonSize = 'L' | 'M' | 'S'; +/** primary: 파란 채움, secondary: 연한 배경 */ +export type ActionButtonVariant = 'primary' | 'secondary'; export type ActionButtonProps = Omit & { label: string; /** L: 가로 꽉 참, M: 96, S: 72 */ size?: ActionButtonSize; + variant?: ActionButtonVariant; /** true면 글씨 대신 로딩 점을 보여 주고 누를 수 없게 합니다. */ loading?: boolean; }; +const variants = { + primary: { + background: semantic.action.default, + pressedBackground: semantic.action.pressed, + loadingBackground: unconfirmed.primaryLoadingBackground, + disabledBackground: semantic.action.disabled, + text: semantic.text.default.inverse, + disabledText: semantic.text.default.weak, + dots: loadingDotsImage, + }, + secondary: { + background: semantic.surface.default, + pressedBackground: semantic.surface.default, + loadingBackground: unconfirmed.secondaryLoadingBackground, + disabledBackground: semantic.surface.disabled, + text: semantic.text.brand.assistive, + disabledText: semantic.text.default.weak, + dots: loadingDotsBlueImage, + }, +}; + export function ActionButton({ label, size = 'L', + variant = 'primary', disabled, loading = false, ...props }: ActionButtonProps) { + const v = variants[variant]; + const background = (pressed: boolean) => { + if (disabled) return v.disabledBackground; + if (loading) return v.loadingBackground; + return pressed ? v.pressedBackground : v.background; + }; + return ( [ - s.base, - sizes[size], - pressed && s.pressed, - loading && s.loading, - disabled && s.disabled, - ]} + style={({ pressed }) => [s.base, sizes[size], { backgroundColor: background(pressed) }]} > {loading ? ( - + ) : ( - {label} + + {label} + )} ); @@ -58,17 +88,8 @@ const s = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', borderRadius: radius.small, - backgroundColor: semantic.action.default, - }, - pressed: { backgroundColor: semantic.action.pressed }, - loading: { backgroundColor: unconfirmed.loadingBackground }, - disabled: { backgroundColor: semantic.action.disabled }, - label: { - fontWeight: fontWeight.semiBold, - textAlign: 'center', - color: semantic.text.default.inverse, }, - labelDisabled: { color: semantic.text.default.weak }, + label: { fontWeight: fontWeight.semiBold, textAlign: 'center' }, dots: { width: 23, height: 6 }, }); From 5f0f05e87ca37465fb3965c7d0aa2cd34290d905 Mon Sep 17 00:00:00 2001 From: bae129 Date: Wed, 11 Nov 2026 23:32:02 +0900 Subject: [PATCH 6/6] =?UTF-8?q?feat:=20SelectButton,=20Chip,=20SearchBar?= =?UTF-8?q?=20=EA=B3=B5=ED=86=B5=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/images/clear.svg | 11 ++++ assets/images/search.svg | 3 + src/app/dev/components.tsx | 25 +++++++ src/components/Chip.tsx | 62 ++++++++++++++++++ src/components/SearchBar.tsx | 112 ++++++++++++++++++++++++++++++++ src/components/SelectButton.tsx | 64 ++++++++++++++++++ 6 files changed, 277 insertions(+) create mode 100644 assets/images/clear.svg create mode 100644 assets/images/search.svg create mode 100644 src/components/Chip.tsx create mode 100644 src/components/SearchBar.tsx create mode 100644 src/components/SelectButton.tsx diff --git a/assets/images/clear.svg b/assets/images/clear.svg new file mode 100644 index 0000000..a69c37d --- /dev/null +++ b/assets/images/clear.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/assets/images/search.svg b/assets/images/search.svg new file mode 100644 index 0000000..b1a2bef --- /dev/null +++ b/assets/images/search.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/app/dev/components.tsx b/src/app/dev/components.tsx index e3358e4..c45e4ea 100644 --- a/src/app/dev/components.tsx +++ b/src/app/dev/components.tsx @@ -2,8 +2,11 @@ import { useState } from 'react'; import { Redirect } from 'expo-router'; import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { ActionButton } from '@/components/ActionButton'; +import { Chip } from '@/components/Chip'; import { Loading } from '@/components/Loading'; import { ProgressBar } from '@/components/ProgressBar'; +import { SearchBar } from '@/components/SearchBar'; +import { SelectButton } from '@/components/SelectButton'; import { TextInput } from '@/components/TextInput'; import { semantic } from '@/styles/colors'; import { gap, grid } from '@/styles/measurement'; @@ -15,6 +18,9 @@ export default function ComponentsPreview() { const [filled, setFilled] = useState('값'); const [error, setError] = useState('값'); const [loading, setLoading] = useState(false); + const [selected, setSelected] = useState(true); + const [chip, setChip] = useState(false); + const [search, setSearch] = useState('은행을 검색하세요'); if (!__DEV__) return ; @@ -79,6 +85,25 @@ export default function ComponentsPreview() { + + SelectButton + + setSelected((v) => !v)} + /> + + Chip + + + + setChip((v) => !v)} /> + + + SearchBar + + ); } diff --git a/src/components/Chip.tsx b/src/components/Chip.tsx new file mode 100644 index 0000000..f395787 --- /dev/null +++ b/src/components/Chip.tsx @@ -0,0 +1,62 @@ +import { Pressable, StyleSheet, Text } from 'react-native'; +import type { PressableProps } from 'react-native'; +import { semantic } from '@/styles/colors'; +import { lineWeight, padding, radius } from '@/styles/measurement'; +import { fontSize, fontWeight, letterSpacing, lineHeight } from '@/styles/typography'; + +const colors = { + background: semantic.surface.default, + border: semantic.border.default, + text: semantic.text.default.normal, + selectedBackground: semantic.action.default, + selectedBorder: semantic.border.focused, + selectedText: semantic.text.default.inverse, +}; + +export type ChipProps = Omit & { + label: string; + selected?: boolean; +}; + +/** 둥근 선택 칩. 너비는 글자 길이에 맞춰집니다. */ +export function Chip({ label, selected = false, ...props }: ChipProps) { + return ( + + + {label} + + + ); +} + +const s = StyleSheet.create({ + base: { + alignSelf: 'flex-start', + height: 34, + alignItems: 'center', + justifyContent: 'center', + paddingVertical: padding[8], + paddingHorizontal: padding[12], + borderRadius: radius.full, + borderWidth: lineWeight.regular, + }, + label: { + fontSize: fontSize[14], + lineHeight: lineHeight[13], + fontWeight: fontWeight.medium, + letterSpacing: letterSpacing.none, + textAlign: 'center', + }, +}); diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx new file mode 100644 index 0000000..9e823c0 --- /dev/null +++ b/src/components/SearchBar.tsx @@ -0,0 +1,112 @@ +import searchIcon from '../../assets/images/search.svg'; +import clearIcon from '../../assets/images/clear.svg'; +import { useState } from 'react'; +import { Pressable, StyleSheet, TextInput as RNTextInput, View } from 'react-native'; +import type { TextInputProps as RNTextInputProps } from 'react-native'; +import { Image } from 'expo-image'; +import { semantic } from '@/styles/colors'; +import { lineWeight, unit } from '@/styles/measurement'; +import { fontSize, fontWeight, letterSpacing, lineHeight } from '@/styles/typography'; + +const colors = { + border: semantic.border.default, + focusedBorder: semantic.border.focused, + text: semantic.text.default.normal, + placeholder: semantic.text.default.weak, +}; + +// TODO: 피그마에서 아직 확인하지 않은 값입니다. 확인 후 토큰으로 교체해야 합니다. +const unconfirmed = { + /** 아이콘을 담는 칸 크기. 이미지에서 아이콘 양옆 여백을 보고 맞춘 값입니다. */ + iconBox: 24, +}; + +export type SearchBarProps = Omit; + +/** + * 밑줄만 있는 검색 입력창. + * 비어 있으면 왼쪽에 돋보기, 글자가 있으면 오른쪽에 지우기 버튼을 보여 줍니다. + */ +export function SearchBar({ + value, + defaultValue, + onChangeText, + onFocus, + onBlur, + ...props +}: SearchBarProps) { + const [focused, setFocused] = useState(false); + const [innerValue, setInnerValue] = useState(defaultValue ?? ''); + const text = value ?? innerValue; + const hasText = text.length > 0; + + const changeText = (next: string) => { + if (value === undefined) setInnerValue(next); + onChangeText?.(next); + }; + + return ( + + {hasText ? null : ( + + + + )} + { + setFocused(true); + onFocus?.(e); + }} + onBlur={(e) => { + setFocused(false); + onBlur?.(e); + }} + style={s.input} + /> + {hasText ? ( + changeText('')} + style={s.iconBox} + > + + + ) : null} + + ); +} + +const s = StyleSheet.create({ + root: { + width: '100%', + height: 35, + flexDirection: 'row', + alignItems: 'center', + gap: unit[0], + borderBottomWidth: lineWeight.regular, + }, + iconBox: { + width: unconfirmed.iconBox, + height: unconfirmed.iconBox, + alignItems: 'center', + justifyContent: 'center', + }, + searchIcon: { width: 13, height: 13 }, + clearIcon: { width: 12, height: 12 }, + input: { + flex: 1, + fontSize: fontSize[14], + lineHeight: lineHeight[13], + fontWeight: fontWeight.medium, + letterSpacing: letterSpacing.none, + color: colors.text, + outlineWidth: 0, + outlineColor: 'transparent', + }, +}); diff --git a/src/components/SelectButton.tsx b/src/components/SelectButton.tsx new file mode 100644 index 0000000..46049d8 --- /dev/null +++ b/src/components/SelectButton.tsx @@ -0,0 +1,64 @@ +import { Pressable, StyleSheet, Text } from 'react-native'; +import type { PressableProps } from 'react-native'; +import { semantic } from '@/styles/colors'; +import { lineWeight, padding, radius } from '@/styles/measurement'; +import { fontSize, fontWeight, letterSpacing, lineHeight } from '@/styles/typography'; + +const colors = { + background: semantic.surface.input, + border: semantic.border.default, + text: semantic.text.default.weak, + selectedBackground: semantic.surface.focused, + selectedBorder: semantic.border.focused, + selectedText: semantic.text.brand.assistive, +}; + +export type SelectButtonProps = Omit & { + label: string; + selected?: boolean; +}; + +/** 눌러서 선택/해제하는 버튼 (예: 학생 당일 취소) */ +export function SelectButton({ label, selected = false, ...props }: SelectButtonProps) { + return ( + + + {label} + + + ); +} + +const s = StyleSheet.create({ + base: { + // 피그마 너비 135. 글자가 길면 줄바꿈 대신 버튼이 넓어집니다. + minWidth: 135, + alignSelf: 'flex-start', + height: 46, + alignItems: 'center', + justifyContent: 'center', + paddingVertical: padding[12], + paddingHorizontal: padding[24], + borderRadius: radius.medium, + borderWidth: lineWeight.regular, + }, + label: { + fontSize: fontSize[15], + lineHeight: lineHeight[14], + fontWeight: fontWeight.semiBold, + letterSpacing: letterSpacing.none, + textAlign: 'center', + }, +});