sh-ui-cli 0.113.0 → 0.115.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.
Files changed (23) hide show
  1. package/data/changelog/versions.json +27 -0
  2. package/data/registry/react/components/form/field.test.tsx +106 -1
  3. package/data/registry/react/components/form/field.tsx +179 -23
  4. package/data/registry/react/components/form/use-sh-ui-form.ts +14 -0
  5. package/data/registry/react/components/form-rhf/README.md +138 -8
  6. package/data/registry/react/components/form-rhf/index.tsx +75 -0
  7. package/data/registry/react/components/form-rhf/rhf.test.tsx +53 -1
  8. package/data/registry/react/components/label/index.tailwind.tsx +5 -1
  9. package/data/registry/react/components/label/styles.css +9 -5
  10. package/data/registry/react/components/label/styles.module.css +7 -5
  11. package/data/registry/react/components/rich-text-editor/index.module.tsx +523 -171
  12. package/data/registry/react/components/rich-text-editor/index.tailwind.tsx +596 -70
  13. package/data/registry/react/components/rich-text-editor/index.tsx +523 -171
  14. package/data/registry/react/components/rich-text-editor/styles.css +103 -5
  15. package/data/registry/react/components/rich-text-editor/styles.module.css +103 -5
  16. package/data/registry/react/components/sidebar/index.module.tsx +57 -0
  17. package/data/registry/react/components/sidebar/index.tailwind.tsx +68 -1
  18. package/data/registry/react/components/sidebar/index.tsx +57 -0
  19. package/data/registry/react/components/sidebar/styles.css +77 -0
  20. package/data/registry/react/components/sidebar/styles.module.css +77 -0
  21. package/data/registry/react/registry.json +319 -963
  22. package/data/registry/react/tokens-used.json +4 -1
  23. package/package.json +1 -1
@@ -2,6 +2,33 @@
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.115.0",
7
+ "date": "2026-06-01",
8
+ "title": "rich-text-editor — 밑줄·텍스트 컬러·인라인 링크 편집 + compact·focus 툴바 + i18n 라벨",
9
+ "type": "minor",
10
+ "highlights": [
11
+ "**밑줄 + 텍스트 컬러** — Underline 버튼(v3 StarterKit 내장)과 글자색 팔레트를 툴바에 추가. 색은 하드 hex 가 아니라 CSS 변수(`var(--sh-ui-rte-c-moss|red|orange|blue)`)로 저장돼 라이트/다크 테마를 추종한다. 변수 정의부는 `styles.css`/`styles.module.css`(plain·css-modules) 와 런타임 주입 스타일(tailwind) — 세 variant 모두 동일하게 동작. moss 는 accent 토큰과 맞춘 톤.",
12
+ "**인라인 링크 편집** — 기존 `window.prompt` 를 제거하고 툴바 아래로 펼쳐지는 인라인 입력 행으로 교체(URL 입력 + 적용/제거/취소 아이콘, Enter 적용·Esc 취소). 메일 클라이언트식 UX. 읽기 전용일 때만 링크가 클릭으로 열리고(`openOnClick`), 편집 중엔 이탈 방지.",
13
+ "**`compact` + `toolbarMode=\"focus\"`** — `compact` 는 핵심 버튼(굵게·기울임·밑줄·취소선·글자색·링크·목록)만 노출해 좁은 패널에 맞춘다. `toolbarMode=\"focus\"` 는 포커스 전 툴바를 숨겨 인라인 입력처럼 보이게 한다. 포커스 추적은 래퍼의 `focusin`/`focusout`(relatedTarget 가드)으로 처리 — 링크 입력·컬러 스와치로 포커스가 옮겨가도 패널이 닫히지 않는다.",
14
+ "**활성표시 반응성 + i18n** — 툴바를 `useEditorState` 로 트랜잭션마다 구독해 굵게/밑줄 등 활성 상태가 즉시 반영된다(v3 `useEditor` 는 기본적으로 트랜잭션마다 리렌더하지 않음). `labels` prop(`RichTextEditorLabels`)으로 모든 버튼 라벨/툴팁을 현지화 가능 — 누락 키는 영어 기본값으로 폴백.",
15
+ "**의존성** — `@tiptap/extension-text-style` 추가(`TextStyle` + `Color` 제공). 설치: `pnpm add @tiptap/extension-text-style`. 기존 RTE 사용처는 변경 없이 동작(신규 props 전부 optional)."
16
+ ],
17
+ "url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.115.0"
18
+ },
19
+ {
20
+ "version": "0.114.0",
21
+ "date": "2026-05-27",
22
+ "title": "form — render prop 통일 + useReactHookFormAdapter hook + onChange chain merge",
23
+ "type": "minor",
24
+ "highlights": [
25
+ "**`<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 흐름 그대로 유지.",
26
+ "**`useReactHookFormAdapter` hook 신규** (form-rhf) — 기존 `adaptReactHookForm` 함수가 매 렌더 새 store 인스턴스를 만들던 함정 회피. `useRef` 로 mount 시점에 한 번만 어댑터 생성. 큰 폼에서 register/unregister storm + 잠재 race 가 사라진다. 일반 사용은 hook 변종, 저레벨 함수는 그대로 export.",
27
+ "**`Form.Control` onChange/onBlur chain merge** — 이전엔 cloneElement 가 자식의 기존 `onChange`/`onBlur` 를 silent override. v0.114 부터 store sync 와 자식 핸들러 둘 다 호출. 단 신규 코드는 render prop 권장 — Form.Control 은 한 메이저 release 뒤 제거 예정 (deprecated 마킹).",
28
+ "**`useShUiForm` jsdoc 명시** — options 가 첫 마운트에만 캡처된다는 점, dynamic schema 가 필요하면 Form 자체를 key 로 리마운트하라는 안내. RHF 사용 시엔 `useReactHookFormAdapter` 가 더 적합하다는 cross-reference."
29
+ ],
30
+ "url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.114.0"
31
+ },
5
32
  {
6
33
  "version": "0.113.0",
7
34
  "date": "2026-05-26",
@@ -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
- children?: React.ReactNode;
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
- // eslint-disable-next-line react-hooks/exhaustive-deps
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: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => void;
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 ctrl: ControlProps = {
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) ctrl["aria-invalid"] = true;
223
- if (ctx.required) ctrl["aria-required"] = true;
355
+ if (field.hasError) baseCtrl["aria-invalid"] = true;
356
+ if (ctx.required) baseCtrl["aria-required"] = true;
224
357
 
225
358
  if (valueAs === "checked") {
226
- ctrl.checked = Boolean(field.value);
359
+ baseCtrl.checked = Boolean(field.value);
227
360
  } else {
228
- ctrl.value = field.value ?? "";
361
+ baseCtrl.value = field.value ?? "";
229
362
  }
230
363
 
231
- if (render) return render(ctrl);
364
+ if (render) return render(baseCtrl);
232
365
  if (!children) return null;
233
366
  const child = React.Children.only(children);
234
- return React.cloneElement(child, ctrl as unknown as Record<string, unknown>);
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 { adaptReactHookForm } from "@/components/ui/form-rhf";
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 rhf = useForm({ defaultValues, mode: "onBlur" });
20
- const form = adaptReactHookForm(rhf);
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
- <Form form={form}>
23
- <Form.Field name="email">...</Form.Field>
24
- </Form>
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
- 어댑터 모드에선 sh-ui `validateOn` / `schema` prop 무시된다. 검증 규칙은 RHF 쪽 `resolver`/`register` 옵션에 둔다.
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
+ }