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/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/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/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/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
new file mode 100644
index 0000000..c45e4ea
--- /dev/null
+++ b/src/app/dev/components.tsx
@@ -0,0 +1,123 @@
+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';
+import { typography } from '@/styles/typography';
+
+// 개발 중에만 여는 공통 컴포넌트 확인 화면입니다.
+export default function ComponentsPreview() {
+ const [empty, setEmpty] = useState('');
+ 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 ;
+
+ return (
+
+ TextInput
+
+
+
+
+ Progress Bar
+
+ {[1, 2, 3, 4, 5].map((step) => (
+
+ ))}
+
+ Loading
+ {
+ setLoading(true);
+ setTimeout(() => setLoading(false), 3000);
+ }}
+ >
+ 3초 동안 로딩 보기
+
+
+
+ ActionButton
+ {(['S', 'M'] as const).map((size) => (
+
+
+
+
+
+ ))}
+
+
+
+ {(['S', 'M'] as const).map((size) => (
+
+
+
+
+
+ ))}
+
+
+
+
+ SelectButton
+
+ setSelected((v) => !v)}
+ />
+
+ Chip
+
+
+
+ setChip((v) => !v)} />
+
+
+ SearchBar
+
+
+
+ );
+}
+
+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 },
+ 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..0d68ad9
--- /dev/null
+++ b/src/components/ActionButton.tsx
@@ -0,0 +1,118 @@
+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';
+import { semantic } from '@/styles/colors';
+import { padding, radius } from '@/styles/measurement';
+import { fontSize, fontWeight, letterSpacing, lineHeight } from '@/styles/typography';
+
+// TODO: 피그마에서 아직 확인하지 않은 값입니다. 확인 후 토큰으로 교체해야 합니다.
+const unconfirmed = {
+ 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], { backgroundColor: background(pressed) }]}
+ >
+ {loading ? (
+
+ ) : (
+
+ {label}
+
+ )}
+
+ );
+}
+
+const s = StyleSheet.create({
+ base: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ borderRadius: radius.small,
+ },
+ label: { fontWeight: fontWeight.semiBold, textAlign: 'center' },
+ 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,
+ },
+});
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/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 },
+});
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,
+ },
+});
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',
+ },
+});
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],
},
},