@reopt-ai/opt-ui 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/COMPONENT_CATALOG.md +3000 -0
- package/LICENSE +21 -0
- package/README.md +244 -0
- package/dist/chunk-3GWWZKX7.js +38 -0
- package/dist/chunk-AFF2HPE5.cjs +5008 -0
- package/dist/chunk-ELWICXYY.js +4745 -0
- package/dist/chunk-N4NDU5ET.cjs +4745 -0
- package/dist/chunk-ONE3C5RV.cjs +38 -0
- package/dist/chunk-QWBHD54V.js +3218 -0
- package/dist/chunk-RBM2RNC2.js +5008 -0
- package/dist/chunk-X5WCXJAF.cjs +3218 -0
- package/dist/core/index.cjs +324 -0
- package/dist/core/index.d.cts +5 -0
- package/dist/core/index.d.ts +5 -0
- package/dist/core/index.js +324 -0
- package/dist/docs/01-getting-started.md +129 -0
- package/dist/docs/02-components/01-core.md +1841 -0
- package/dist/docs/02-components/02-visuals.md +11 -0
- package/dist/docs/02-components/03-shells.md +1361 -0
- package/dist/docs/02-components/04-surfaces.md +11 -0
- package/dist/docs/02-components/index.md +106 -0
- package/dist/docs/03-recipes/01-forms.md +471 -0
- package/dist/docs/03-recipes/02-dashboards.md +397 -0
- package/dist/docs/03-recipes/03-layouts.md +424 -0
- package/dist/docs/04-theming.md +232 -0
- package/dist/docs/05-migration/01-breaking-changes.md +117 -0
- package/dist/docs/05-migration/02-formstore.md +336 -0
- package/dist/docs/06-troubleshooting.md +119 -0
- package/dist/docs/index.md +71 -0
- package/dist/id-registry.cjs +1875 -0
- package/dist/id-registry.d.cts +27 -0
- package/dist/id-registry.d.ts +27 -0
- package/dist/id-registry.js +1875 -0
- package/dist/id-registry.json +3799 -0
- package/dist/index-BZ_lBlO1.d.ts +474 -0
- package/dist/index-BuvxoWHf.d.cts +474 -0
- package/dist/index-DlAcuvQp.d.cts +1686 -0
- package/dist/index-Slu5hOj1.d.ts +1686 -0
- package/dist/index.cjs +9959 -0
- package/dist/index.d.cts +2351 -0
- package/dist/index.d.ts +2351 -0
- package/dist/index.js +9959 -0
- package/dist/meta.cjs +6898 -0
- package/dist/meta.d.cts +36 -0
- package/dist/meta.d.ts +36 -0
- package/dist/meta.js +6869 -0
- package/dist/shells/index.cjs +65 -0
- package/dist/shells/index.d.cts +5 -0
- package/dist/shells/index.d.ts +5 -0
- package/dist/shells/index.js +65 -0
- package/dist/tailwind.css +401 -0
- package/dist/theme/presets/default.css +355 -0
- package/dist/theme/presets/minimal.css +354 -0
- package/dist/theme/presets/mono-dark.css +354 -0
- package/dist/theme/presets/natural.css +181 -0
- package/dist/theme/presets/pro.css +354 -0
- package/dist/types-D4-0lwaE.d.cts +298 -0
- package/dist/types-D4-0lwaE.d.ts +298 -0
- package/dist/visuals/index.cjs +4 -0
- package/dist/visuals/index.d.cts +1 -0
- package/dist/visuals/index.d.ts +1 -0
- package/dist/visuals/index.js +4 -0
- package/package.json +165 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Surface Components"
|
|
3
|
+
description: "0 Surface layer components — props, examples, keyboard shortcuts."
|
|
4
|
+
version: "1.4.1"
|
|
5
|
+
generated: true
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Surface Components (0)
|
|
9
|
+
|
|
10
|
+
| Surface | Description | Key Props | Dependencies |
|
|
11
|
+
| ------- | ----------- | --------- | ------------ |
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Component Reference"
|
|
3
|
+
description: "Complete reference for 134 @reopt-ai/opt-ui components across 4 layers."
|
|
4
|
+
version: "1.4.1"
|
|
5
|
+
generated: true
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# @reopt-ai/opt-ui Component Reference
|
|
9
|
+
|
|
10
|
+
## Quick Stats
|
|
11
|
+
|
|
12
|
+
| Layer | Count |
|
|
13
|
+
| --------- | ------- |
|
|
14
|
+
| Core | 59 |
|
|
15
|
+
| Visuals | 0 |
|
|
16
|
+
| Shells | 75 |
|
|
17
|
+
| Surfaces | 0 |
|
|
18
|
+
| **Total** | **134** |
|
|
19
|
+
|
|
20
|
+
## Component Selection Guide
|
|
21
|
+
|
|
22
|
+
| 요구사항 | 추천 컴포넌트 | 레이어 |
|
|
23
|
+
| ------------------- | -------------------------------------------------- | ------------ |
|
|
24
|
+
| 텍스트 입력 | Input | Core |
|
|
25
|
+
| 멀티라인 텍스트 | Textarea | Core |
|
|
26
|
+
| 숫자 입력 | NumberInput | Core |
|
|
27
|
+
| 드롭다운 선택 | Select / StatusSelect / BranchSelect | Core / Shell |
|
|
28
|
+
| 검색 + 선택 | Combobox / SearchCombobox / AsyncCombobox | Core / Shell |
|
|
29
|
+
| 체크박스 | Checkbox / CheckboxGroup | Core |
|
|
30
|
+
| 라디오 버튼 | RadioGroup | Core |
|
|
31
|
+
| 토글 스위치 | Switch | Core |
|
|
32
|
+
| 날짜 범위 선택 | DateRangePicker | Core |
|
|
33
|
+
| 태그 입력 | TagInput | Core |
|
|
34
|
+
| 색상 선택 | ColorPicker | Core |
|
|
35
|
+
| 아이콘 선택 | IconPicker | Core |
|
|
36
|
+
| 데이터 테이블 | DataTable | Shell |
|
|
37
|
+
| 인라인 편집 테이블 | InlineEditTable | Shell |
|
|
38
|
+
| 트리/계층 테이블 | TreeTable | Shell |
|
|
39
|
+
| 칸반 보드 | KanbanBoard | Shell |
|
|
40
|
+
| 필터 바 | FilterBar | Shell |
|
|
41
|
+
| 필터 프리셋 저장 | SavedFilters | Shell |
|
|
42
|
+
| 일괄 작업 | BulkActions | Shell |
|
|
43
|
+
| 컬럼 표시 토글 | ColumnToggler | Shell |
|
|
44
|
+
| 시간 범위 선택 | TimeRangeSelector | Shell |
|
|
45
|
+
| A/B 비교 선택 | ComparisonSelector | Shell |
|
|
46
|
+
| 선 차트 / 영역 차트 | LineChart / AreaChart | Visual |
|
|
47
|
+
| 막대 차트 | BarChart | Visual |
|
|
48
|
+
| 파이/도넛 차트 | PieChart | Visual |
|
|
49
|
+
| 인라인 스파크라인 | Sparkline | Visual |
|
|
50
|
+
| 흐름 다이어그램 | SankeyChart | Visual |
|
|
51
|
+
| 퍼널 차트 | FunnelChart | Visual |
|
|
52
|
+
| 리텐션 히트맵 | RetentionHeatmap | Visual |
|
|
53
|
+
| 캘린더 히트맵 | CalendarHeatmap | Visual |
|
|
54
|
+
| 폭포 차트 | WaterfallChart | Visual |
|
|
55
|
+
| 버블 차트 | BubbleChart | Visual |
|
|
56
|
+
| 게이지/미터 | GaugeChart | Visual |
|
|
57
|
+
| KPI/통계 카드 | StatCard (Core) / MetricsCard / SummaryRow (Shell) | Core / Shell |
|
|
58
|
+
| 모달 대화상자 | Dialog | Core |
|
|
59
|
+
| 확인 대화상자 | ConfirmDialog | Shell |
|
|
60
|
+
| 서랍 패널 | Drawer | Core |
|
|
61
|
+
| 팝오버 | Popover | Core |
|
|
62
|
+
| 드롭다운 메뉴 | DropdownMenu | Core |
|
|
63
|
+
| 토스트 알림 | NotificationToast | Shell |
|
|
64
|
+
| 알림 센터 | NotificationCenter | Shell |
|
|
65
|
+
| 사이드바 내비게이션 | SidebarNav / Sidebar | Shell |
|
|
66
|
+
| 앱 레이아웃 | AppShell | Shell |
|
|
67
|
+
| 탭 내비게이션 | Tabs (Core) / ContentTabs / VerticalTabs (Shell) | Core / Shell |
|
|
68
|
+
| 메뉴 | Menu (Core) / AppMenubar / MegaMenu (Shell) | Core / Shell |
|
|
69
|
+
| 커맨드 팔레트 | CommandPalette | Shell |
|
|
70
|
+
| 아코디언 | Disclosure (Core) / FaqAccordion (Shell) | Core / Shell |
|
|
71
|
+
| 설정 폼 | SettingsForm | Shell |
|
|
72
|
+
| 로그인 폼 | AuthForm | Shell |
|
|
73
|
+
| 다단계 폼 | WizardForm | Shell |
|
|
74
|
+
| 동적 필드 폼 | DynamicFieldForm | Shell |
|
|
75
|
+
| 파일 업로드 폼 | FileUploadForm | Shell |
|
|
76
|
+
| SQL 에디터 | SqlEditor | Shell |
|
|
77
|
+
| 코드 표시 | CodeSnippetViewer | Shell |
|
|
78
|
+
| 코드 비교 | CodeDiffViewer | Shell |
|
|
79
|
+
| 리치 텍스트 에디터 | RichTextEditor | Shell |
|
|
80
|
+
| 단계 빌더 | StepBuilder | Core |
|
|
81
|
+
| 조건 빌더 | ConditionBuilder | Core |
|
|
82
|
+
| 경로 탐색 | Breadcrumb | Core |
|
|
83
|
+
| 빈 상태 표시 | EmptyState | Core |
|
|
84
|
+
| 로딩 스피너 | Spinner / LoadingOverlay | Core |
|
|
85
|
+
| 프로그레스 바 | Progress | Core |
|
|
86
|
+
| 아바타 | Avatar | Core |
|
|
87
|
+
| 페이지네이션 | Pagination | Core |
|
|
88
|
+
| 가상 목록 | VirtualList | Core |
|
|
89
|
+
| 리사이즈 패널 | Resizable | Core |
|
|
90
|
+
| 페이지 헤더 | PageHeader | Core |
|
|
91
|
+
| 테마 전환 | ThemeSwitcher | Shell |
|
|
92
|
+
| 데이터 내보내기 | ExportButton | Shell |
|
|
93
|
+
| 데이터 임포트 | DataImportWizard | Shell |
|
|
94
|
+
| 웹훅 설정 | WebhookConfig | Shell |
|
|
95
|
+
| 피처 플래그 | FeatureFlagPanel | Shell |
|
|
96
|
+
| 감사 로그 | AuditLog | Shell |
|
|
97
|
+
| 워크플로우 에디터 | WorkflowCanvas | Shell |
|
|
98
|
+
| 인사이트/이상탐지 | InsightsPanel | Shell |
|
|
99
|
+
| 프롬프트 템플릿 | PromptTemplate | Shell |
|
|
100
|
+
|
|
101
|
+
## Per-Layer Reference
|
|
102
|
+
|
|
103
|
+
- [Core Components](./01-core.md)
|
|
104
|
+
- [Visual Components](./02-visuals.md)
|
|
105
|
+
- [Shell Components](./03-shells.md)
|
|
106
|
+
- [Surface Components](./04-surfaces.md)
|
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Form Recipes"
|
|
3
|
+
description: "opt-ui FormStore 기반 폼 패턴 4종"
|
|
4
|
+
related:
|
|
5
|
+
links:
|
|
6
|
+
- ../02-components/01-core.md
|
|
7
|
+
- ../05-migration/02-formstore.md
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
<!-- AI agent hint: opt-ui 1.2.0부터 useValidate/useSubmit 훅이 제거됨. 반드시 useFormStore({ validate, onSubmit }) 옵션 패턴을 사용할 것. 이전 API는 05-migration/02-formstore.md 참조. -->
|
|
11
|
+
|
|
12
|
+
# Form Recipes
|
|
13
|
+
|
|
14
|
+
Copy-paste-ready form patterns using the FormStore API.
|
|
15
|
+
All imports from `@reopt-ai/opt-ui`.
|
|
16
|
+
|
|
17
|
+
## 1. Basic Settings Form
|
|
18
|
+
|
|
19
|
+
Standard settings page with text, select, switch, and textarea inputs.
|
|
20
|
+
|
|
21
|
+
```tsx
|
|
22
|
+
"use client";
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
SurfaceLayout,
|
|
26
|
+
PageHeader,
|
|
27
|
+
FormRoot,
|
|
28
|
+
FormField,
|
|
29
|
+
FormLabel,
|
|
30
|
+
FormControl,
|
|
31
|
+
FormSelect,
|
|
32
|
+
FormSelectPopover,
|
|
33
|
+
FormSelectItem,
|
|
34
|
+
FormSwitch,
|
|
35
|
+
FormTextarea,
|
|
36
|
+
FormSubmit,
|
|
37
|
+
FormDescription,
|
|
38
|
+
useFormStore,
|
|
39
|
+
} from "@reopt-ai/opt-ui";
|
|
40
|
+
|
|
41
|
+
interface ProfileValues {
|
|
42
|
+
displayName: string;
|
|
43
|
+
language: string;
|
|
44
|
+
bio: string;
|
|
45
|
+
emailNotifications: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function ProfileSettings() {
|
|
49
|
+
const form = useFormStore<ProfileValues>({
|
|
50
|
+
defaultValues: {
|
|
51
|
+
displayName: "",
|
|
52
|
+
language: "ko",
|
|
53
|
+
bio: "",
|
|
54
|
+
emailNotifications: true,
|
|
55
|
+
},
|
|
56
|
+
onSubmit: async (values) => {
|
|
57
|
+
await fetch("/api/profile", {
|
|
58
|
+
method: "PUT",
|
|
59
|
+
body: JSON.stringify(values),
|
|
60
|
+
});
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<SurfaceLayout>
|
|
66
|
+
<PageHeader
|
|
67
|
+
title="프로필 설정"
|
|
68
|
+
description="기본 프로필 정보를 수정합니다"
|
|
69
|
+
/>
|
|
70
|
+
|
|
71
|
+
<FormRoot store={form}>
|
|
72
|
+
<FormField name="displayName">
|
|
73
|
+
<FormLabel>이름</FormLabel>
|
|
74
|
+
<FormControl name="displayName" placeholder="홍길동" />
|
|
75
|
+
</FormField>
|
|
76
|
+
|
|
77
|
+
<FormField name="language">
|
|
78
|
+
<FormLabel>언어</FormLabel>
|
|
79
|
+
<FormSelect name="language" placeholder="언어 선택">
|
|
80
|
+
<FormSelectPopover>
|
|
81
|
+
<FormSelectItem value="ko">한국어</FormSelectItem>
|
|
82
|
+
<FormSelectItem value="en">English</FormSelectItem>
|
|
83
|
+
<FormSelectItem value="ja">日本語</FormSelectItem>
|
|
84
|
+
</FormSelectPopover>
|
|
85
|
+
</FormSelect>
|
|
86
|
+
</FormField>
|
|
87
|
+
|
|
88
|
+
<FormField name="bio">
|
|
89
|
+
<FormLabel>소개</FormLabel>
|
|
90
|
+
<FormTextarea name="bio" placeholder="간단한 자기소개" rows={3} />
|
|
91
|
+
</FormField>
|
|
92
|
+
|
|
93
|
+
<FormField name="emailNotifications">
|
|
94
|
+
<div className="gap-group flex items-center">
|
|
95
|
+
<FormSwitch name="emailNotifications" />
|
|
96
|
+
<div>
|
|
97
|
+
<FormLabel>이메일 알림</FormLabel>
|
|
98
|
+
<FormDescription>새 소식을 이메일로 받습니다</FormDescription>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
</FormField>
|
|
102
|
+
|
|
103
|
+
<FormSubmit>저장</FormSubmit>
|
|
104
|
+
</FormRoot>
|
|
105
|
+
</SurfaceLayout>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## 2. Form with Validation
|
|
111
|
+
|
|
112
|
+
Synchronous validation with `validate` option and error display.
|
|
113
|
+
|
|
114
|
+
```tsx
|
|
115
|
+
"use client";
|
|
116
|
+
|
|
117
|
+
import {
|
|
118
|
+
SurfaceLayout,
|
|
119
|
+
PageHeader,
|
|
120
|
+
FormRoot,
|
|
121
|
+
FormField,
|
|
122
|
+
FormLabel,
|
|
123
|
+
FormControl,
|
|
124
|
+
FormError,
|
|
125
|
+
FormSubmit,
|
|
126
|
+
FormReset,
|
|
127
|
+
useFormStore,
|
|
128
|
+
type FormErrors,
|
|
129
|
+
} from "@reopt-ai/opt-ui";
|
|
130
|
+
|
|
131
|
+
interface SignupValues {
|
|
132
|
+
email: string;
|
|
133
|
+
password: string;
|
|
134
|
+
passwordConfirm: string;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function validateSignup(values: SignupValues): FormErrors {
|
|
138
|
+
const errors: FormErrors = {};
|
|
139
|
+
|
|
140
|
+
if (!values.email) {
|
|
141
|
+
errors.email = "이메일을 입력하세요";
|
|
142
|
+
} else if (!/^[^@]+@[^@]+\.[^@]+$/.test(values.email)) {
|
|
143
|
+
errors.email = "올바른 이메일 형식이 아닙니다";
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (!values.password) {
|
|
147
|
+
errors.password = "비밀번호를 입력하세요";
|
|
148
|
+
} else if (values.password.length < 8) {
|
|
149
|
+
errors.password = "8자 이상 입력하세요";
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (values.password !== values.passwordConfirm) {
|
|
153
|
+
errors.passwordConfirm = "비밀번호가 일치하지 않습니다";
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return errors;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function SignupForm() {
|
|
160
|
+
const form = useFormStore<SignupValues>({
|
|
161
|
+
defaultValues: { email: "", password: "", passwordConfirm: "" },
|
|
162
|
+
validate: validateSignup,
|
|
163
|
+
validateOn: "blur",
|
|
164
|
+
onSubmit: async (values) => {
|
|
165
|
+
await fetch("/api/signup", {
|
|
166
|
+
method: "POST",
|
|
167
|
+
body: JSON.stringify(values),
|
|
168
|
+
});
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
return (
|
|
173
|
+
<SurfaceLayout>
|
|
174
|
+
<PageHeader title="회원가입" />
|
|
175
|
+
|
|
176
|
+
<FormRoot store={form}>
|
|
177
|
+
<FormField name="email">
|
|
178
|
+
<FormLabel>이메일</FormLabel>
|
|
179
|
+
<FormControl
|
|
180
|
+
name="email"
|
|
181
|
+
type="email"
|
|
182
|
+
placeholder="user@example.com"
|
|
183
|
+
/>
|
|
184
|
+
<FormError name="email" />
|
|
185
|
+
</FormField>
|
|
186
|
+
|
|
187
|
+
<FormField name="password">
|
|
188
|
+
<FormLabel>비밀번호</FormLabel>
|
|
189
|
+
<FormControl name="password" type="password" placeholder="8자 이상" />
|
|
190
|
+
<FormError name="password" />
|
|
191
|
+
</FormField>
|
|
192
|
+
|
|
193
|
+
<FormField name="passwordConfirm">
|
|
194
|
+
<FormLabel>비밀번호 확인</FormLabel>
|
|
195
|
+
<FormControl name="passwordConfirm" type="password" />
|
|
196
|
+
<FormError name="passwordConfirm" />
|
|
197
|
+
</FormField>
|
|
198
|
+
|
|
199
|
+
<div className="gap-element flex">
|
|
200
|
+
<FormSubmit>가입</FormSubmit>
|
|
201
|
+
<FormReset>초기화</FormReset>
|
|
202
|
+
</div>
|
|
203
|
+
</FormRoot>
|
|
204
|
+
</SurfaceLayout>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
## 3. Multi-Step Wizard
|
|
210
|
+
|
|
211
|
+
Step navigation with FormStore state and per-step validation.
|
|
212
|
+
|
|
213
|
+
```tsx
|
|
214
|
+
"use client";
|
|
215
|
+
|
|
216
|
+
import { useState } from "react";
|
|
217
|
+
import {
|
|
218
|
+
SurfaceLayout,
|
|
219
|
+
PageHeader,
|
|
220
|
+
FormRoot,
|
|
221
|
+
FormField,
|
|
222
|
+
FormLabel,
|
|
223
|
+
FormControl,
|
|
224
|
+
FormSelect,
|
|
225
|
+
FormSelectPopover,
|
|
226
|
+
FormSelectItem,
|
|
227
|
+
FormError,
|
|
228
|
+
FormSubmit,
|
|
229
|
+
Button,
|
|
230
|
+
Progress,
|
|
231
|
+
useFormStore,
|
|
232
|
+
type FormErrors,
|
|
233
|
+
} from "@reopt-ai/opt-ui";
|
|
234
|
+
|
|
235
|
+
interface WizardValues {
|
|
236
|
+
name: string;
|
|
237
|
+
email: string;
|
|
238
|
+
plan: string;
|
|
239
|
+
cardNumber: string;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const STEPS = ["기본 정보", "플랜 선택", "결제 정보"] as const;
|
|
243
|
+
|
|
244
|
+
function validateStep(values: WizardValues, step: number): FormErrors {
|
|
245
|
+
const errors: FormErrors = {};
|
|
246
|
+
if (step === 0) {
|
|
247
|
+
if (!values.name) errors.name = "이름을 입력하세요";
|
|
248
|
+
if (!values.email) errors.email = "이메일을 입력하세요";
|
|
249
|
+
} else if (step === 1) {
|
|
250
|
+
if (!values.plan) errors.plan = "플랜을 선택하세요";
|
|
251
|
+
} else if (step === 2) {
|
|
252
|
+
if (!values.cardNumber) errors.cardNumber = "카드 번호를 입력하세요";
|
|
253
|
+
}
|
|
254
|
+
return errors;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function OnboardingWizard() {
|
|
258
|
+
const [step, setStep] = useState(0);
|
|
259
|
+
|
|
260
|
+
const form = useFormStore<WizardValues>({
|
|
261
|
+
defaultValues: { name: "", email: "", plan: "", cardNumber: "" },
|
|
262
|
+
onSubmit: async (values) => {
|
|
263
|
+
await fetch("/api/onboarding", {
|
|
264
|
+
method: "POST",
|
|
265
|
+
body: JSON.stringify(values),
|
|
266
|
+
});
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
const handleNext = () => {
|
|
271
|
+
const values = form.getValues() as WizardValues;
|
|
272
|
+
const errors = validateStep(values, step);
|
|
273
|
+
Object.entries(errors).forEach(([k, v]) => {
|
|
274
|
+
if (v) form.setError(k, v);
|
|
275
|
+
});
|
|
276
|
+
if (Object.values(errors).some(Boolean)) return;
|
|
277
|
+
setStep((s) => Math.min(s + 1, STEPS.length - 1));
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const progress = ((step + 1) / STEPS.length) * 100;
|
|
281
|
+
|
|
282
|
+
return (
|
|
283
|
+
<SurfaceLayout>
|
|
284
|
+
<PageHeader title="온보딩" description={STEPS[step]} />
|
|
285
|
+
<Progress value={progress} aria-label="온보딩 진행률" />
|
|
286
|
+
|
|
287
|
+
<FormRoot store={form}>
|
|
288
|
+
{step === 0 && (
|
|
289
|
+
<div className="gap-group flex flex-col">
|
|
290
|
+
<FormField name="name">
|
|
291
|
+
<FormLabel>이름</FormLabel>
|
|
292
|
+
<FormControl name="name" placeholder="홍길동" />
|
|
293
|
+
<FormError name="name" />
|
|
294
|
+
</FormField>
|
|
295
|
+
<FormField name="email">
|
|
296
|
+
<FormLabel>이메일</FormLabel>
|
|
297
|
+
<FormControl name="email" type="email" />
|
|
298
|
+
<FormError name="email" />
|
|
299
|
+
</FormField>
|
|
300
|
+
</div>
|
|
301
|
+
)}
|
|
302
|
+
|
|
303
|
+
{step === 1 && (
|
|
304
|
+
<FormField name="plan">
|
|
305
|
+
<FormLabel>플랜</FormLabel>
|
|
306
|
+
<FormSelect name="plan" placeholder="플랜 선택">
|
|
307
|
+
<FormSelectPopover>
|
|
308
|
+
<FormSelectItem value="free">Free</FormSelectItem>
|
|
309
|
+
<FormSelectItem value="pro">Pro</FormSelectItem>
|
|
310
|
+
<FormSelectItem value="enterprise">Enterprise</FormSelectItem>
|
|
311
|
+
</FormSelectPopover>
|
|
312
|
+
</FormSelect>
|
|
313
|
+
<FormError name="plan" />
|
|
314
|
+
</FormField>
|
|
315
|
+
)}
|
|
316
|
+
|
|
317
|
+
{step === 2 && (
|
|
318
|
+
<FormField name="cardNumber">
|
|
319
|
+
<FormLabel>카드 번호</FormLabel>
|
|
320
|
+
<FormControl name="cardNumber" placeholder="1234-5678-9012-3456" />
|
|
321
|
+
<FormError name="cardNumber" />
|
|
322
|
+
</FormField>
|
|
323
|
+
)}
|
|
324
|
+
|
|
325
|
+
<div className="gap-element flex">
|
|
326
|
+
{step > 0 && (
|
|
327
|
+
<Button variant="secondary" onClick={() => setStep((s) => s - 1)}>
|
|
328
|
+
이전
|
|
329
|
+
</Button>
|
|
330
|
+
)}
|
|
331
|
+
{step < STEPS.length - 1 ? (
|
|
332
|
+
<Button onClick={handleNext}>다음</Button>
|
|
333
|
+
) : (
|
|
334
|
+
<FormSubmit>완료</FormSubmit>
|
|
335
|
+
)}
|
|
336
|
+
</div>
|
|
337
|
+
</FormRoot>
|
|
338
|
+
</SurfaceLayout>
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
## 4. Dynamic Fields
|
|
344
|
+
|
|
345
|
+
Add and remove array items with FormPush and FormRemove.
|
|
346
|
+
|
|
347
|
+
```tsx
|
|
348
|
+
"use client";
|
|
349
|
+
|
|
350
|
+
import {
|
|
351
|
+
SurfaceLayout,
|
|
352
|
+
PageHeader,
|
|
353
|
+
FormRoot,
|
|
354
|
+
FormField,
|
|
355
|
+
FormLabel,
|
|
356
|
+
FormControl,
|
|
357
|
+
FormPush,
|
|
358
|
+
FormRemove,
|
|
359
|
+
FormError,
|
|
360
|
+
FormSubmit,
|
|
361
|
+
useFormStore,
|
|
362
|
+
useFieldValue,
|
|
363
|
+
type FormErrors,
|
|
364
|
+
} from "@reopt-ai/opt-ui";
|
|
365
|
+
|
|
366
|
+
interface EnvItem {
|
|
367
|
+
key: string;
|
|
368
|
+
value: string;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
interface EnvFormValues {
|
|
372
|
+
projectName: string;
|
|
373
|
+
envVars: EnvItem[];
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function validateEnv(values: EnvFormValues): FormErrors {
|
|
377
|
+
const errors: FormErrors = {};
|
|
378
|
+
if (!values.projectName) errors.projectName = "프로젝트 이름 필수";
|
|
379
|
+
values.envVars.forEach((item, i) => {
|
|
380
|
+
if (!item.key) errors[`envVars.${i}.key`] = "키 필수";
|
|
381
|
+
if (!item.value) errors[`envVars.${i}.value`] = "값 필수";
|
|
382
|
+
});
|
|
383
|
+
return errors;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function EnvVarRows({ form }: { form: ReturnType<typeof useFormStore> }) {
|
|
387
|
+
const envVars = (useFieldValue(form, "envVars") as EnvItem[]) ?? [];
|
|
388
|
+
|
|
389
|
+
return (
|
|
390
|
+
<div className="gap-group flex flex-col">
|
|
391
|
+
{envVars.map((_, index) => (
|
|
392
|
+
<div key={index} className="gap-element flex items-end">
|
|
393
|
+
<FormField name={`envVars.${index}.key`} className="flex-1">
|
|
394
|
+
<FormLabel>Key</FormLabel>
|
|
395
|
+
<FormControl name={`envVars.${index}.key`} placeholder="API_KEY" />
|
|
396
|
+
<FormError name={`envVars.${index}.key`} />
|
|
397
|
+
</FormField>
|
|
398
|
+
|
|
399
|
+
<FormField name={`envVars.${index}.value`} className="flex-1">
|
|
400
|
+
<FormLabel>Value</FormLabel>
|
|
401
|
+
<FormControl name={`envVars.${index}.value`} placeholder="sk-..." />
|
|
402
|
+
<FormError name={`envVars.${index}.value`} />
|
|
403
|
+
</FormField>
|
|
404
|
+
|
|
405
|
+
<FormRemove name="envVars" index={index}>
|
|
406
|
+
삭제
|
|
407
|
+
</FormRemove>
|
|
408
|
+
</div>
|
|
409
|
+
))}
|
|
410
|
+
|
|
411
|
+
<FormPush name="envVars" value={{ key: "", value: "" }}>
|
|
412
|
+
+ 환경변수 추가
|
|
413
|
+
</FormPush>
|
|
414
|
+
</div>
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export function EnvVarEditor() {
|
|
419
|
+
const form = useFormStore<EnvFormValues>({
|
|
420
|
+
defaultValues: {
|
|
421
|
+
projectName: "",
|
|
422
|
+
envVars: [{ key: "", value: "" }],
|
|
423
|
+
},
|
|
424
|
+
validate: validateEnv,
|
|
425
|
+
onSubmit: async (values) => {
|
|
426
|
+
await fetch("/api/env", {
|
|
427
|
+
method: "POST",
|
|
428
|
+
body: JSON.stringify(values),
|
|
429
|
+
});
|
|
430
|
+
},
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
return (
|
|
434
|
+
<SurfaceLayout>
|
|
435
|
+
<PageHeader
|
|
436
|
+
title="환경변수 설정"
|
|
437
|
+
description="프로젝트 환경변수를 관리합니다"
|
|
438
|
+
/>
|
|
439
|
+
|
|
440
|
+
<FormRoot store={form}>
|
|
441
|
+
<FormField name="projectName">
|
|
442
|
+
<FormLabel>프로젝트 이름</FormLabel>
|
|
443
|
+
<FormControl name="projectName" placeholder="my-app" />
|
|
444
|
+
<FormError name="projectName" />
|
|
445
|
+
</FormField>
|
|
446
|
+
|
|
447
|
+
<EnvVarRows form={form} />
|
|
448
|
+
|
|
449
|
+
<FormSubmit>저장</FormSubmit>
|
|
450
|
+
</FormRoot>
|
|
451
|
+
</SurfaceLayout>
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
## Key API Notes
|
|
457
|
+
|
|
458
|
+
| API | Purpose |
|
|
459
|
+
| --------------------------------- | ---------------------------------------------------------------------- |
|
|
460
|
+
| `useFormStore<V>(options)` | Create a typed form store with `defaultValues`, `validate`, `onSubmit` |
|
|
461
|
+
| `validate` option | `(values: V) => FormErrors` — return `{}` for no errors |
|
|
462
|
+
| `validateOn` | `"change"` (default) \| `"blur"` \| `"submit"` |
|
|
463
|
+
| `FormRoot store={form}` | Binds store and wraps with FormProvider automatically |
|
|
464
|
+
| `FormField name="x"` | Field wrapper (adds `gap-element` spacing) |
|
|
465
|
+
| `FormControl name="x"` | Text input bound to store |
|
|
466
|
+
| `FormError name="x"` | Shows validation error for field |
|
|
467
|
+
| `FormPush name="arr" value={v}` | Append item to array field |
|
|
468
|
+
| `FormRemove name="arr" index={i}` | Remove item from array field |
|
|
469
|
+
| `useFieldValue(form, name)` | Subscribe to a single field value |
|
|
470
|
+
| `form.isDirty()` | Check if any field changed from defaults |
|
|
471
|
+
| `form.isSubmitting()` | True while async `onSubmit` is running |
|