sh-ui-cli 0.112.0 → 0.114.0
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/data/changelog/versions.json +25 -0
- package/data/registry/react/components/form/field.test.tsx +106 -1
- package/data/registry/react/components/form/field.tsx +179 -23
- package/data/registry/react/components/form/use-sh-ui-form.ts +14 -0
- package/data/registry/react/components/form-rhf/README.md +138 -8
- package/data/registry/react/components/form-rhf/index.tsx +75 -0
- package/data/registry/react/components/form-rhf/rhf.test.tsx +53 -1
- package/data/registry/react/components/label/index.tailwind.tsx +5 -1
- package/data/registry/react/components/label/styles.css +9 -5
- package/data/registry/react/components/label/styles.module.css +7 -5
- package/data/registry/react/components/separator/index.module.tsx +47 -15
- package/data/registry/react/components/separator/index.tailwind.tsx +52 -10
- package/data/registry/react/components/separator/index.tsx +51 -13
- package/data/registry/react/components/separator/styles.css +21 -0
- package/data/registry/react/components/separator/styles.module.css +20 -0
- package/data/registry/react/components/sidebar/index.module.tsx +57 -0
- package/data/registry/react/components/sidebar/index.tailwind.tsx +68 -1
- package/data/registry/react/components/sidebar/index.tsx +57 -0
- package/data/registry/react/components/sidebar/styles.css +77 -0
- package/data/registry/react/components/sidebar/styles.module.css +77 -0
- package/data/registry/react/tokens-used.json +5 -3
- package/data/summaries/react.json +1 -1
- package/package.json +1 -1
|
@@ -2,6 +2,31 @@
|
|
|
2
2
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
3
|
"$description": "sh-ui 릴리즈 노트 단일 소스. docs(React)와 showcase(Flutter)가 함께 읽는다. 새 릴리즈마다 맨 앞에 추가.",
|
|
4
4
|
"versions": [
|
|
5
|
+
{
|
|
6
|
+
"version": "0.114.0",
|
|
7
|
+
"date": "2026-05-27",
|
|
8
|
+
"title": "form — render prop 통일 + useReactHookFormAdapter hook + onChange chain merge",
|
|
9
|
+
"type": "minor",
|
|
10
|
+
"highlights": [
|
|
11
|
+
"**`<Form.Field>` render prop 패턴** — children 을 함수로 받으면 `field` API 객체를 노출 (TanStack Form / RHF Controller 와 같은 idiom). `field.value` 읽고 `field.handleChange(next)` / `field.handleBlur()` 호출 — 이전 cloneElement 의 함정 (자식 onChange 무시, `Children.only` 제한, `valueAs` enum 한계) 가 사라진다. input/select/checkbox/custom 컴포넌트가 같은 패턴으로 결선되어 학습 비용 1. `field` 객체: `value`, `errors`, `error`, `hasError`, `touched`, `isValidating`, `handleChange(next)`, `handleBlur()`, `name`, `id`, `ariaInvalid`, `ariaDescribedBy`, `disabled`, `readOnly`, `required`. 함수가 아닌 children 경로는 기존 div wrap + Form.Control 흐름 그대로 유지.",
|
|
12
|
+
"**`useReactHookFormAdapter` hook 신규** (form-rhf) — 기존 `adaptReactHookForm` 함수가 매 렌더 새 store 인스턴스를 만들던 함정 회피. `useRef` 로 mount 시점에 한 번만 어댑터 생성. 큰 폼에서 register/unregister storm + 잠재 race 가 사라진다. 일반 사용은 hook 변종, 저레벨 함수는 그대로 export.",
|
|
13
|
+
"**`Form.Control` onChange/onBlur chain merge** — 이전엔 cloneElement 가 자식의 기존 `onChange`/`onBlur` 를 silent override. v0.114 부터 store sync 와 자식 핸들러 둘 다 호출. 단 신규 코드는 render prop 권장 — Form.Control 은 한 메이저 release 뒤 제거 예정 (deprecated 마킹).",
|
|
14
|
+
"**`useShUiForm` jsdoc 명시** — options 가 첫 마운트에만 캡처된다는 점, dynamic schema 가 필요하면 Form 자체를 key 로 리마운트하라는 안내. RHF 사용 시엔 `useReactHookFormAdapter` 가 더 적합하다는 cross-reference."
|
|
15
|
+
],
|
|
16
|
+
"url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.114.0"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"version": "0.113.0",
|
|
20
|
+
"date": "2026-05-26",
|
|
21
|
+
"title": "components — Separator label 슬롯 + align",
|
|
22
|
+
"type": "minor",
|
|
23
|
+
"highlights": [
|
|
24
|
+
"**`Separator` 라벨 슬롯 신규** — `<Separator />` 는 기존처럼 가로/세로 1px 선이지만, `<Separator>섹션 라벨</Separator>` 처럼 children 을 넘기면 가운데 라벨이 있는 `──── label ────` 형식으로 렌더. 라벨이 있을 땐 horizontal 로 강제되고 `align` prop(`start`/`center`/`end`) 으로 라벨 위치를 정한다. 기존 사용처는 그대로 — children 없는 호출은 동작 변화 없음.",
|
|
25
|
+
"**디자인 토큰 활용** — 선 색은 `--border`, 라벨은 `--foreground-subtle` + uppercase + tracking-wide 가 기본. 시안 빈도 높은 \"섹션 라벨 양옆 1px 선\" 패턴 (login OAuth 구분, 워크스페이스 스위처 invite 섹션 등) 을 1줄로 줄여준다. `className` 으로 라벨 컨테이너의 typography·spacing 도 override 가능.",
|
|
26
|
+
"**Tailwind / CSS Modules / plain CSS 세 변종 모두 갱신** — 라벨이 있을 때만 `flex items-center gap` 컨테이너로 전환되고 plain 변종은 컨테이너 자체에 background 를 두지 않아 visual side effect 없음."
|
|
27
|
+
],
|
|
28
|
+
"url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.113.0"
|
|
29
|
+
},
|
|
5
30
|
{
|
|
6
31
|
"version": "0.112.0",
|
|
7
32
|
"date": "2026-05-22",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
2
|
import { render, screen } from "@testing-library/react";
|
|
3
3
|
import userEvent from "@testing-library/user-event";
|
|
4
4
|
import * as React from "react";
|
|
@@ -228,3 +228,108 @@ describe("validateOn blur → change on error", () => {
|
|
|
228
228
|
expect(screen.queryByText("bad")).not.toBeInTheDocument();
|
|
229
229
|
});
|
|
230
230
|
});
|
|
231
|
+
|
|
232
|
+
// ─────────────────────────────────────────────
|
|
233
|
+
// Render prop (function-as-child) — v0.114+
|
|
234
|
+
// ─────────────────────────────────────────────
|
|
235
|
+
describe("Form.Field render prop", () => {
|
|
236
|
+
it("passes field API to function children + wires handleChange", async () => {
|
|
237
|
+
const user = userEvent.setup();
|
|
238
|
+
let receivedField: any = null;
|
|
239
|
+
|
|
240
|
+
render(
|
|
241
|
+
<Form>
|
|
242
|
+
<Field name="email">
|
|
243
|
+
{(field) => {
|
|
244
|
+
receivedField = field;
|
|
245
|
+
return (
|
|
246
|
+
<input
|
|
247
|
+
data-testid="i"
|
|
248
|
+
value={(field.value as string) ?? ""}
|
|
249
|
+
onChange={(e) => field.handleChange(e.target.value)}
|
|
250
|
+
onBlur={field.handleBlur}
|
|
251
|
+
/>
|
|
252
|
+
);
|
|
253
|
+
}}
|
|
254
|
+
</Field>
|
|
255
|
+
</Form>
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
expect(receivedField).toBeTruthy();
|
|
259
|
+
expect(receivedField.name).toBe("email");
|
|
260
|
+
expect(typeof receivedField.handleChange).toBe("function");
|
|
261
|
+
expect(typeof receivedField.handleBlur).toBe("function");
|
|
262
|
+
|
|
263
|
+
const input = screen.getByTestId("i") as HTMLInputElement;
|
|
264
|
+
await user.type(input, "kim@studio");
|
|
265
|
+
expect(input.value).toBe("kim@studio");
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("ariaInvalid + ariaDescribedBy reflect error state", async () => {
|
|
269
|
+
const user = userEvent.setup();
|
|
270
|
+
|
|
271
|
+
render(
|
|
272
|
+
<Form>
|
|
273
|
+
<Field name="email" validate={(v) => (String(v).includes("@") ? undefined : "bad")}>
|
|
274
|
+
{(field) => (
|
|
275
|
+
<>
|
|
276
|
+
<input
|
|
277
|
+
data-testid="i"
|
|
278
|
+
value={(field.value as string) ?? ""}
|
|
279
|
+
onChange={(e) => field.handleChange(e.target.value)}
|
|
280
|
+
onBlur={field.handleBlur}
|
|
281
|
+
aria-invalid={field.ariaInvalid}
|
|
282
|
+
aria-describedby={field.ariaDescribedBy}
|
|
283
|
+
/>
|
|
284
|
+
<FormError />
|
|
285
|
+
</>
|
|
286
|
+
)}
|
|
287
|
+
</Field>
|
|
288
|
+
</Form>
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
const input = screen.getByTestId("i") as HTMLInputElement;
|
|
292
|
+
await user.type(input, "abc");
|
|
293
|
+
input.blur();
|
|
294
|
+
await screen.findByText("bad");
|
|
295
|
+
expect(input.getAttribute("aria-invalid")).toBe("true");
|
|
296
|
+
expect(input.getAttribute("aria-describedby")).toContain("error");
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("does not wrap in sh-ui-form-field div when using render prop", () => {
|
|
300
|
+
const { container } = render(
|
|
301
|
+
<Form>
|
|
302
|
+
<Field name="email">
|
|
303
|
+
{() => <input data-testid="i" />}
|
|
304
|
+
</Field>
|
|
305
|
+
</Form>
|
|
306
|
+
);
|
|
307
|
+
expect(container.querySelector(".sh-ui-form-field")).toBeNull();
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// ─────────────────────────────────────────────
|
|
312
|
+
// Form.Control — chain merge (v0.114+: 자식 onChange 보존)
|
|
313
|
+
// ─────────────────────────────────────────────
|
|
314
|
+
describe("Form.Control chain merge", () => {
|
|
315
|
+
it("preserves child onChange while syncing store", async () => {
|
|
316
|
+
const user = userEvent.setup();
|
|
317
|
+
const childOnChange = vi.fn();
|
|
318
|
+
|
|
319
|
+
render(
|
|
320
|
+
<Form>
|
|
321
|
+
<Field name="email">
|
|
322
|
+
<FormControl>
|
|
323
|
+
<input data-testid="i" onChange={childOnChange} />
|
|
324
|
+
</FormControl>
|
|
325
|
+
</Field>
|
|
326
|
+
</Form>
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
const input = screen.getByTestId("i") as HTMLInputElement;
|
|
330
|
+
await user.type(input, "x");
|
|
331
|
+
expect(childOnChange).toHaveBeenCalled();
|
|
332
|
+
// store sync 도 작동 (입력값이 input 에 반영)
|
|
333
|
+
expect(input.value).toBe("x");
|
|
334
|
+
});
|
|
335
|
+
});
|
|
@@ -10,9 +10,51 @@ import {
|
|
|
10
10
|
DisabledContext,
|
|
11
11
|
useFormField,
|
|
12
12
|
} from "./context";
|
|
13
|
-
import type { FieldValidate, ValidateOn } from "./types";
|
|
13
|
+
import type { FieldError, FieldValidate, ValidateOn } from "./types";
|
|
14
14
|
import { scopedPath } from "./utils";
|
|
15
15
|
|
|
16
|
+
// ─────────────────────────────────────────────
|
|
17
|
+
// FieldRenderProps — render prop 으로 노출되는 필드 API
|
|
18
|
+
// ─────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* <Form.Field name="x">{(field) => ...}</Form.Field> 의 `field` 객체.
|
|
22
|
+
*
|
|
23
|
+
* 값/상태 + 액션 + a11y 메타. 사용자는 `field.value` 읽고
|
|
24
|
+
* `field.handleChange(next)` / `field.handleBlur()` 호출, 필요하면
|
|
25
|
+
* `field.id` / `field.name` / `field.ariaInvalid` / `field.ariaDescribedBy`
|
|
26
|
+
* 를 자체 input element 에 spread.
|
|
27
|
+
*
|
|
28
|
+
* `handleChange` 는 **next value 자체** 를 받는다 (event 객체 아님) — input,
|
|
29
|
+
* select, checkbox, custom (color picker · emoji picker 등) 모두 같은 시그니처.
|
|
30
|
+
* input 의 경우 사용자가 `onChange={(e) => field.handleChange(e.target.value)}`
|
|
31
|
+
* 로 wire.
|
|
32
|
+
*/
|
|
33
|
+
export interface FieldRenderProps {
|
|
34
|
+
// ── 값/상태 ──────────────────────────────
|
|
35
|
+
value: unknown;
|
|
36
|
+
errors: FieldError[];
|
|
37
|
+
/** 첫 에러 (편의). 여러 에러면 errors 배열을 직접 사용. */
|
|
38
|
+
error: FieldError | undefined;
|
|
39
|
+
hasError: boolean;
|
|
40
|
+
touched: boolean;
|
|
41
|
+
isValidating: boolean;
|
|
42
|
+
|
|
43
|
+
// ── 액션 ────────────────────────────────
|
|
44
|
+
/** next value 자체를 받는다. event 객체 아님. */
|
|
45
|
+
handleChange: (next: unknown) => void;
|
|
46
|
+
handleBlur: () => void;
|
|
47
|
+
|
|
48
|
+
// ── a11y / DOM 메타 ──────────────────────
|
|
49
|
+
name: string;
|
|
50
|
+
id: string;
|
|
51
|
+
ariaInvalid: true | undefined;
|
|
52
|
+
ariaDescribedBy: string | undefined;
|
|
53
|
+
disabled: boolean | undefined;
|
|
54
|
+
readOnly: boolean | undefined;
|
|
55
|
+
required: boolean | undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
16
58
|
// ─────────────────────────────────────────────
|
|
17
59
|
// Field
|
|
18
60
|
// ─────────────────────────────────────────────
|
|
@@ -25,7 +67,15 @@ export interface FieldProps
|
|
|
25
67
|
required?: boolean;
|
|
26
68
|
disabled?: boolean;
|
|
27
69
|
readOnly?: boolean;
|
|
28
|
-
|
|
70
|
+
/**
|
|
71
|
+
* children. 함수면 render prop 으로 호출되어 `field` API 노출 (TanStack /
|
|
72
|
+
* RHF Controller 와 같은 패턴). 함수가 아니면 일반 children 으로 렌더 +
|
|
73
|
+
* div wrap.
|
|
74
|
+
*
|
|
75
|
+
* 권장: render prop 통일 — 입력 종류 무관 동일 패턴.
|
|
76
|
+
* 단순 input 한 칸도 `{(field) => <Input value={field.value} ... />}` 로.
|
|
77
|
+
*/
|
|
78
|
+
children?: React.ReactNode | ((field: FieldRenderProps) => React.ReactNode);
|
|
29
79
|
}
|
|
30
80
|
|
|
31
81
|
export function Field({
|
|
@@ -59,23 +109,41 @@ export function Field({
|
|
|
59
109
|
sectionPath: section.path || undefined,
|
|
60
110
|
required,
|
|
61
111
|
});
|
|
62
|
-
|
|
112
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
63
113
|
}, [store, path]);
|
|
64
114
|
|
|
65
115
|
const effectiveDisabled = disabled || formDisabled;
|
|
66
116
|
|
|
117
|
+
const ctxValue = React.useMemo(
|
|
118
|
+
() => ({
|
|
119
|
+
path,
|
|
120
|
+
id,
|
|
121
|
+
descId,
|
|
122
|
+
errorId,
|
|
123
|
+
disabled: effectiveDisabled,
|
|
124
|
+
readOnly,
|
|
125
|
+
required,
|
|
126
|
+
}),
|
|
127
|
+
[path, id, descId, errorId, effectiveDisabled, readOnly, required]
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
// children 이 함수면 render prop 패턴 — FieldContext 만 제공하고 wrap 없음.
|
|
131
|
+
// 사용자가 JSX 모양 (label/input/error 배치, wrapper 등) 을 100% 결정.
|
|
132
|
+
if (typeof children === "function") {
|
|
133
|
+
return (
|
|
134
|
+
<FieldContext.Provider value={ctxValue}>
|
|
135
|
+
<FieldRenderBridge>
|
|
136
|
+
{(field) =>
|
|
137
|
+
(children as (f: FieldRenderProps) => React.ReactNode)(field)
|
|
138
|
+
}
|
|
139
|
+
</FieldRenderBridge>
|
|
140
|
+
</FieldContext.Provider>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 일반 children — 기존 div wrap (cloneElement 기반 Form.Control 경로용).
|
|
67
145
|
return (
|
|
68
|
-
<FieldContext.Provider
|
|
69
|
-
value={{
|
|
70
|
-
path,
|
|
71
|
-
id,
|
|
72
|
-
descId,
|
|
73
|
-
errorId,
|
|
74
|
-
disabled: effectiveDisabled,
|
|
75
|
-
readOnly,
|
|
76
|
-
required,
|
|
77
|
-
}}
|
|
78
|
-
>
|
|
146
|
+
<FieldContext.Provider value={ctxValue}>
|
|
79
147
|
<div
|
|
80
148
|
className={`sh-ui-form-field${className ? ` ${className}` : ""}`}
|
|
81
149
|
data-disabled={effectiveDisabled || undefined}
|
|
@@ -88,6 +156,51 @@ export function Field({
|
|
|
88
156
|
);
|
|
89
157
|
}
|
|
90
158
|
|
|
159
|
+
// Render prop bridge — FieldContext 안에서 store/field state 를 읽어 `field`
|
|
160
|
+
// 객체 구성. 별도 컴포넌트로 분리하는 이유: store subscribe 가 hook 이라
|
|
161
|
+
// Field 본체에서 conditional 호출 불가 (Rules of Hooks).
|
|
162
|
+
function FieldRenderBridge({
|
|
163
|
+
children,
|
|
164
|
+
}: {
|
|
165
|
+
children: (field: FieldRenderProps) => React.ReactNode;
|
|
166
|
+
}) {
|
|
167
|
+
const ctx = React.useContext(FieldContext);
|
|
168
|
+
if (!ctx) return null;
|
|
169
|
+
const store = React.useContext(FormContext)!;
|
|
170
|
+
const state = useFormField(ctx.path);
|
|
171
|
+
|
|
172
|
+
const describedBy =
|
|
173
|
+
cn(ctx.descId, state.hasError ? ctx.errorId : null) || undefined;
|
|
174
|
+
|
|
175
|
+
const field: FieldRenderProps = {
|
|
176
|
+
value: state.value,
|
|
177
|
+
errors: state.errors,
|
|
178
|
+
error: state.error,
|
|
179
|
+
hasError: state.hasError,
|
|
180
|
+
touched: state.touched,
|
|
181
|
+
isValidating: state.isValidating,
|
|
182
|
+
handleChange: (next) => {
|
|
183
|
+
store.setFieldValue(ctx.path, next);
|
|
184
|
+
if (store.getState().revalidateOnChange.has(ctx.path)) {
|
|
185
|
+
void store.validateField(ctx.path);
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
handleBlur: () => {
|
|
189
|
+
store.setFieldTouched(ctx.path, true);
|
|
190
|
+
void store.validateField(ctx.path);
|
|
191
|
+
},
|
|
192
|
+
name: ctx.path,
|
|
193
|
+
id: ctx.id,
|
|
194
|
+
ariaInvalid: state.hasError ? true : undefined,
|
|
195
|
+
ariaDescribedBy: describedBy,
|
|
196
|
+
disabled: ctx.disabled,
|
|
197
|
+
readOnly: ctx.readOnly,
|
|
198
|
+
required: ctx.required,
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
return <>{children(field)}</>;
|
|
202
|
+
}
|
|
203
|
+
|
|
91
204
|
// ─────────────────────────────────────────────
|
|
92
205
|
// Label
|
|
93
206
|
// ─────────────────────────────────────────────
|
|
@@ -161,7 +274,7 @@ export function FormError({
|
|
|
161
274
|
}
|
|
162
275
|
|
|
163
276
|
// ─────────────────────────────────────────────
|
|
164
|
-
// Control
|
|
277
|
+
// Control (DEPRECATED — render prop 사용 권장)
|
|
165
278
|
// ─────────────────────────────────────────────
|
|
166
279
|
|
|
167
280
|
export interface ControlProps {
|
|
@@ -169,7 +282,9 @@ export interface ControlProps {
|
|
|
169
282
|
name: string;
|
|
170
283
|
value?: unknown;
|
|
171
284
|
checked?: boolean;
|
|
172
|
-
onChange: (
|
|
285
|
+
onChange: (
|
|
286
|
+
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
|
|
287
|
+
) => void;
|
|
173
288
|
onBlur: () => void;
|
|
174
289
|
"aria-invalid"?: true;
|
|
175
290
|
"aria-describedby"?: string;
|
|
@@ -185,6 +300,24 @@ export interface FormControlProps {
|
|
|
185
300
|
render?: (ctrl: ControlProps) => React.ReactElement;
|
|
186
301
|
}
|
|
187
302
|
|
|
303
|
+
/**
|
|
304
|
+
* @deprecated v0.114+ 부터는 `<Form.Field>` 의 render prop 패턴을 권장한다.
|
|
305
|
+
*
|
|
306
|
+
* // 권장 (TanStack / RHF Controller 와 같은 idiom):
|
|
307
|
+
* <Form.Field name="email">
|
|
308
|
+
* {(field) => (
|
|
309
|
+
* <Input
|
|
310
|
+
* value={field.value as string}
|
|
311
|
+
* onChange={(e) => field.handleChange(e.target.value)}
|
|
312
|
+
* onBlur={field.handleBlur}
|
|
313
|
+
* />
|
|
314
|
+
* )}
|
|
315
|
+
* </Form.Field>
|
|
316
|
+
*
|
|
317
|
+
* 본 Form.Control 은 cloneElement 패턴 잔존성 호환 — 자식 1개 제한, custom
|
|
318
|
+
* value (event 객체 외) 미지원, 자식의 기존 onChange/onBlur 가 chain merge
|
|
319
|
+
* 됨 (이전엔 override). 한 메이저 release 뒤 제거 예정.
|
|
320
|
+
*/
|
|
188
321
|
export function FormControl({
|
|
189
322
|
children,
|
|
190
323
|
valueAs = "value",
|
|
@@ -198,7 +331,7 @@ export function FormControl({
|
|
|
198
331
|
const describedBy =
|
|
199
332
|
cn(ctx.descId, field.hasError ? ctx.errorId : null) || undefined;
|
|
200
333
|
|
|
201
|
-
const
|
|
334
|
+
const baseCtrl: ControlProps = {
|
|
202
335
|
id: ctx.id,
|
|
203
336
|
name: ctx.path,
|
|
204
337
|
onChange: (e) => {
|
|
@@ -219,17 +352,40 @@ export function FormControl({
|
|
|
219
352
|
required: ctx.required,
|
|
220
353
|
};
|
|
221
354
|
|
|
222
|
-
if (field.hasError)
|
|
223
|
-
if (ctx.required)
|
|
355
|
+
if (field.hasError) baseCtrl["aria-invalid"] = true;
|
|
356
|
+
if (ctx.required) baseCtrl["aria-required"] = true;
|
|
224
357
|
|
|
225
358
|
if (valueAs === "checked") {
|
|
226
|
-
|
|
359
|
+
baseCtrl.checked = Boolean(field.value);
|
|
227
360
|
} else {
|
|
228
|
-
|
|
361
|
+
baseCtrl.value = field.value ?? "";
|
|
229
362
|
}
|
|
230
363
|
|
|
231
|
-
if (render) return render(
|
|
364
|
+
if (render) return render(baseCtrl);
|
|
232
365
|
if (!children) return null;
|
|
233
366
|
const child = React.Children.only(children);
|
|
234
|
-
|
|
367
|
+
|
|
368
|
+
// chain merge — 자식의 기존 onChange/onBlur 가 있으면 store sync 와 함께
|
|
369
|
+
// 둘 다 호출 (이전 버전의 silent override 함정 fix).
|
|
370
|
+
const childProps = (child.props ?? {}) as Partial<ControlProps> & {
|
|
371
|
+
onChange?: ControlProps["onChange"];
|
|
372
|
+
onBlur?: ControlProps["onBlur"];
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
const mergedCtrl: ControlProps = {
|
|
376
|
+
...baseCtrl,
|
|
377
|
+
onChange: (e) => {
|
|
378
|
+
baseCtrl.onChange(e);
|
|
379
|
+
childProps.onChange?.(e);
|
|
380
|
+
},
|
|
381
|
+
onBlur: () => {
|
|
382
|
+
baseCtrl.onBlur();
|
|
383
|
+
childProps.onBlur?.();
|
|
384
|
+
},
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
return React.cloneElement(
|
|
388
|
+
child,
|
|
389
|
+
mergedCtrl as unknown as Record<string, unknown>
|
|
390
|
+
);
|
|
235
391
|
}
|
|
@@ -4,6 +4,20 @@ import * as React from "react";
|
|
|
4
4
|
import { createFormStore, type CreateFormStoreOptions } from "./store";
|
|
5
5
|
import type { FormStore } from "./types";
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* useShUiForm — sh-ui 자체 store 를 mount 시점에 한 번만 생성 (RHF 안 쓸 때).
|
|
9
|
+
*
|
|
10
|
+
* **중요**: `options` 는 **첫 마운트 시점에만 캡처** 된다. 매 렌더 다른
|
|
11
|
+
* `schema` / `defaultValues` / `onSubmit` 을 넘겨도 무시. 동적으로 schema 가
|
|
12
|
+
* 바뀌어야 하면:
|
|
13
|
+
* - i18n 메시지: schema 를 useMemo 로 메모 + locale 변경 시 Form 자체를
|
|
14
|
+
* `key` prop 으로 리마운트
|
|
15
|
+
* - 또는 `<Form schema={...}>` prop 으로 — Form 컴포넌트가 매 렌더 prop 으로
|
|
16
|
+
* 읽음 (단 이 경로도 첫 schema 만 사용한다는 점 동일)
|
|
17
|
+
*
|
|
18
|
+
* RHF 와 함께 쓸 때는 본 hook 대신 `useReactHookFormAdapter(rhf)` 를
|
|
19
|
+
* `form-rhf` 패키지에서 import.
|
|
20
|
+
*/
|
|
7
21
|
export function useShUiForm<T = unknown>(
|
|
8
22
|
options?: CreateFormStoreOptions<T>
|
|
9
23
|
): FormStore<T> {
|
|
@@ -9,19 +9,149 @@ npm i react-hook-form
|
|
|
9
9
|
sh-ui add form-rhf
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
-
## 사용
|
|
12
|
+
## 사용 — `useReactHookFormAdapter` (권장)
|
|
13
13
|
|
|
14
14
|
```tsx
|
|
15
15
|
import { useForm } from "react-hook-form";
|
|
16
|
-
import {
|
|
16
|
+
import { zodResolver } from "@hookform/resolvers/zod";
|
|
17
|
+
import { z } from "zod";
|
|
18
|
+
import { useReactHookFormAdapter } from "@/components/ui/form-rhf";
|
|
17
19
|
import { Form } from "@/components/ui/form";
|
|
20
|
+
import { Input } from "@/components/ui/input";
|
|
18
21
|
|
|
19
|
-
const
|
|
20
|
-
|
|
22
|
+
const schema = z.object({
|
|
23
|
+
email: z.string().email("이메일 형식이 아닙니다"),
|
|
24
|
+
name: z.string().min(1, "이름을 입력하세요"),
|
|
25
|
+
});
|
|
26
|
+
type Values = z.infer<typeof schema>;
|
|
21
27
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
28
|
+
function MyForm() {
|
|
29
|
+
const rhf = useForm<Values>({
|
|
30
|
+
resolver: zodResolver(schema),
|
|
31
|
+
defaultValues: { email: "", name: "" },
|
|
32
|
+
mode: "onBlur",
|
|
33
|
+
});
|
|
34
|
+
const form = useReactHookFormAdapter<Values>(rhf);
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<Form form={form} onSubmit={(values) => console.log(values)}>
|
|
38
|
+
<Form.Field name="email">
|
|
39
|
+
{(field) => (
|
|
40
|
+
<>
|
|
41
|
+
<Form.Label>이메일</Form.Label>
|
|
42
|
+
<Input
|
|
43
|
+
id={field.id}
|
|
44
|
+
name={field.name}
|
|
45
|
+
type="email"
|
|
46
|
+
value={field.value as string}
|
|
47
|
+
onChange={(e) => field.handleChange(e.target.value)}
|
|
48
|
+
onBlur={field.handleBlur}
|
|
49
|
+
aria-invalid={field.ariaInvalid}
|
|
50
|
+
aria-describedby={field.ariaDescribedBy}
|
|
51
|
+
/>
|
|
52
|
+
<Form.Error />
|
|
53
|
+
</>
|
|
54
|
+
)}
|
|
55
|
+
</Form.Field>
|
|
56
|
+
|
|
57
|
+
<Form.Field name="name">
|
|
58
|
+
{(field) => (
|
|
59
|
+
<>
|
|
60
|
+
<Form.Label>이름</Form.Label>
|
|
61
|
+
<Input
|
|
62
|
+
id={field.id}
|
|
63
|
+
name={field.name}
|
|
64
|
+
value={field.value as string}
|
|
65
|
+
onChange={(e) => field.handleChange(e.target.value)}
|
|
66
|
+
onBlur={field.handleBlur}
|
|
67
|
+
/>
|
|
68
|
+
<Form.Error />
|
|
69
|
+
</>
|
|
70
|
+
)}
|
|
71
|
+
</Form.Field>
|
|
72
|
+
|
|
73
|
+
<button type="submit">제출</button>
|
|
74
|
+
</Form>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## API
|
|
80
|
+
|
|
81
|
+
### `useReactHookFormAdapter<T>(rhf, config?)` (v0.114+)
|
|
82
|
+
|
|
83
|
+
Hook 변종. **`useRef` 로 어댑터 인스턴스를 mount 시점에 안정화** — 매 렌더 새
|
|
84
|
+
store 함정 회피.
|
|
85
|
+
|
|
86
|
+
### `adaptReactHookForm<T>(rhf, config?)` (저레벨)
|
|
87
|
+
|
|
88
|
+
순수 함수. 호출자가 `useMemo` 등으로 직접 안정화 책임. **일반 사용에선 hook
|
|
89
|
+
변종을 권장.**
|
|
90
|
+
|
|
91
|
+
## 패턴
|
|
92
|
+
|
|
93
|
+
### 권장 — render prop (`<Form.Field>{(field) => ...}</Form.Field>`)
|
|
94
|
+
|
|
95
|
+
`field` 객체:
|
|
96
|
+
|
|
97
|
+
| key | 설명 |
|
|
98
|
+
|---|---|
|
|
99
|
+
| `value` | 현재 값 |
|
|
100
|
+
| `errors` / `error` / `hasError` | 검증 에러 (배열 / 첫 에러 / boolean) |
|
|
101
|
+
| `touched` / `isValidating` | 상태 |
|
|
102
|
+
| `handleChange(next)` | **next value 자체** 받음 (event 아님) — input/select/checkbox/custom 통일 |
|
|
103
|
+
| `handleBlur()` | blur 처리 + 검증 트리거 |
|
|
104
|
+
| `name` / `id` | path / element id |
|
|
105
|
+
| `ariaInvalid` / `ariaDescribedBy` / `disabled` / `readOnly` / `required` | a11y · DOM 메타 |
|
|
106
|
+
|
|
107
|
+
### 다른 입력 종류
|
|
108
|
+
|
|
109
|
+
```tsx
|
|
110
|
+
{/* Checkbox */}
|
|
111
|
+
<Form.Field name="agreed">
|
|
112
|
+
{(field) => (
|
|
113
|
+
<Checkbox
|
|
114
|
+
checked={field.value as boolean}
|
|
115
|
+
onCheckedChange={(v) => field.handleChange(v === true)}
|
|
116
|
+
/>
|
|
117
|
+
)}
|
|
118
|
+
</Form.Field>
|
|
119
|
+
|
|
120
|
+
{/* Select */}
|
|
121
|
+
<Form.Field name="role">
|
|
122
|
+
{(field) => (
|
|
123
|
+
<Select
|
|
124
|
+
value={field.value as string}
|
|
125
|
+
onValueChange={(v) => field.handleChange(v ?? "")}
|
|
126
|
+
>
|
|
127
|
+
...
|
|
128
|
+
</Select>
|
|
129
|
+
)}
|
|
130
|
+
</Form.Field>
|
|
131
|
+
|
|
132
|
+
{/* Custom (직접 만든 입력) */}
|
|
133
|
+
<Form.Field name="emoji">
|
|
134
|
+
{(field) => (
|
|
135
|
+
<EmojiPicker
|
|
136
|
+
value={field.value as string}
|
|
137
|
+
onChange={field.handleChange}
|
|
138
|
+
/>
|
|
139
|
+
)}
|
|
140
|
+
</Form.Field>
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### Legacy — `Form.Control` (v0.114 deprecated)
|
|
144
|
+
|
|
145
|
+
cloneElement 기반의 단순 케이스 helper. 자식 1개 제한, custom value 미지원,
|
|
146
|
+
한 메이저 release 뒤 제거 예정. 신규 코드는 render prop 사용.
|
|
147
|
+
|
|
148
|
+
```tsx
|
|
149
|
+
<Form.Field name="email">
|
|
150
|
+
<Form.Label>이메일</Form.Label>
|
|
151
|
+
<Form.Control><Input /></Form.Control> {/* deprecated */}
|
|
152
|
+
<Form.Error />
|
|
153
|
+
</Form.Field>
|
|
25
154
|
```
|
|
26
155
|
|
|
27
|
-
|
|
156
|
+
검증 규칙은 RHF 쪽 `resolver` 에서 일괄 관리. sh-ui 의 `schema` / `validateOn`
|
|
157
|
+
prop 은 어댑터 모드에서 무시된다.
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
1
4
|
import type { UseFormReturn, FieldValues } from "react-hook-form";
|
|
2
5
|
import type {
|
|
3
6
|
FormStore,
|
|
@@ -287,3 +290,75 @@ export function adaptReactHookForm<T extends FieldValues>(
|
|
|
287
290
|
|
|
288
291
|
return store;
|
|
289
292
|
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* useReactHookFormAdapter — `adaptReactHookForm` 의 hook 변종.
|
|
296
|
+
*
|
|
297
|
+
* 어댑터는 호출마다 새 `FormStore` 인스턴스 (listeners Set, snapshot cache 등)
|
|
298
|
+
* 를 만들기 때문에, 컴포넌트 body 에서 직접 호출하면 매 렌더 새 store →
|
|
299
|
+
* `<Form form={...}>` 의 context value 가 매 렌더 바뀜 → 자식
|
|
300
|
+
* `<Form.Field>` 가 매번 register/unregister → perf hit + 잠재 race.
|
|
301
|
+
*
|
|
302
|
+
* 본 hook 은 `useRef` 로 첫 마운트에 한 번만 어댑터를 생성해 안정화한다.
|
|
303
|
+
* RHF 인스턴스 (`rhf`) 자체가 `useForm` 결과라 mount 후 안정 → ref 도 안전.
|
|
304
|
+
*
|
|
305
|
+
* const rhf = useForm<FormValues>({ resolver: zodResolver(schema) });
|
|
306
|
+
* const form = useReactHookFormAdapter<FormValues>(rhf);
|
|
307
|
+
*
|
|
308
|
+
* return <Form form={form}>...</Form>;
|
|
309
|
+
*
|
|
310
|
+
* `config` 의 onSubmit 은 초기 캡처 — 매 렌더 다른 함수 넘기면 무시. 동적
|
|
311
|
+
* 콜백이 필요하면 `<Form onSubmit={...}>` prop 으로 (Form 컴포넌트가 매
|
|
312
|
+
* 렌더 capture).
|
|
313
|
+
*/
|
|
314
|
+
export function useReactHookFormAdapter<T extends FieldValues>(
|
|
315
|
+
rhf: UseFormReturn<T>,
|
|
316
|
+
config: AdapterConfig<T> = {}
|
|
317
|
+
): FormStore<T> {
|
|
318
|
+
// 1) adapter 인스턴스 안정화 (mount 시 한 번 생성).
|
|
319
|
+
const ref = React.useRef<FormStore<T> | null>(null);
|
|
320
|
+
if (!ref.current) {
|
|
321
|
+
ref.current = adaptReactHookForm<T>(rhf, config);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// 2) RHF 의 form state 변경을 React render path 에 명시적으로 결선.
|
|
325
|
+
//
|
|
326
|
+
// 어댑터 내부의 `rhf.subscribe` 만으로는 React `useSyncExternalStore` 의
|
|
327
|
+
// listener 가 useFormField 에서 호출되긴 하지만, **자식 컴포넌트 (Field /
|
|
328
|
+
// FieldRenderBridge)** 가 부모로부터 props · context 변경 신호를 못 받아
|
|
329
|
+
// 재렌더 안 되는 케이스가 있다 (특히 어댑터를 useRef 로 안정화한 경우
|
|
330
|
+
// FormContext value 가 동일 reference 라 React 가 sub-tree 를 skip).
|
|
331
|
+
//
|
|
332
|
+
// 본 컴포넌트에서 useReducer 로 force update 를 발화해 트리 전체가 재평가
|
|
333
|
+
// 되게 한다 — `rhf.watch(...)` 같은 hook 을 외부에서 쓰는 부담을 hook 자체에
|
|
334
|
+
// 흡수.
|
|
335
|
+
const [, forceUpdate] = React.useReducer((x: number) => x + 1, 0);
|
|
336
|
+
React.useEffect(() => {
|
|
337
|
+
if (typeof (rhf as unknown as { subscribe?: unknown }).subscribe !== "function") {
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
let unsub: (() => void) | undefined;
|
|
341
|
+
try {
|
|
342
|
+
unsub = (rhf as unknown as {
|
|
343
|
+
subscribe: (opts: {
|
|
344
|
+
formState: Record<string, boolean>;
|
|
345
|
+
callback: () => void;
|
|
346
|
+
}) => () => void;
|
|
347
|
+
}).subscribe({
|
|
348
|
+
formState: { values: true, errors: true, isSubmitting: true, touchedFields: true },
|
|
349
|
+
callback: () => forceUpdate(),
|
|
350
|
+
});
|
|
351
|
+
} catch {
|
|
352
|
+
// 구버전 RHF (< 7.50) 의 subscribe 미지원 — fallback 없음.
|
|
353
|
+
}
|
|
354
|
+
return () => {
|
|
355
|
+
try {
|
|
356
|
+
unsub?.();
|
|
357
|
+
} catch {
|
|
358
|
+
// ignore
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
}, [rhf]);
|
|
362
|
+
|
|
363
|
+
return ref.current;
|
|
364
|
+
}
|