sh-ui-cli 0.15.0 → 0.21.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.
Files changed (163) hide show
  1. package/bin/sh-ui.mjs +6 -0
  2. package/data/changelog/versions.json +366 -0
  3. package/data/registry/flutter/foundation/sh_ui_tokens.dart +385 -0
  4. package/data/registry/flutter/registry.json +336 -0
  5. package/data/registry/flutter/widgets/sh_ui_accordion.dart +255 -0
  6. package/data/registry/flutter/widgets/sh_ui_app_shell.dart +267 -0
  7. package/data/registry/flutter/widgets/sh_ui_avatar.dart +95 -0
  8. package/data/registry/flutter/widgets/sh_ui_badge.dart +82 -0
  9. package/data/registry/flutter/widgets/sh_ui_breadcrumb.dart +107 -0
  10. package/data/registry/flutter/widgets/sh_ui_button.dart +201 -0
  11. package/data/registry/flutter/widgets/sh_ui_card.dart +159 -0
  12. package/data/registry/flutter/widgets/sh_ui_carousel.dart +204 -0
  13. package/data/registry/flutter/widgets/sh_ui_checkbox.dart +154 -0
  14. package/data/registry/flutter/widgets/sh_ui_color_picker.dart +264 -0
  15. package/data/registry/flutter/widgets/sh_ui_combobox.dart +614 -0
  16. package/data/registry/flutter/widgets/sh_ui_context_menu.dart +71 -0
  17. package/data/registry/flutter/widgets/sh_ui_date_picker.dart +648 -0
  18. package/data/registry/flutter/widgets/sh_ui_dialog.dart +567 -0
  19. package/data/registry/flutter/widgets/sh_ui_dropdown_menu.dart +251 -0
  20. package/data/registry/flutter/widgets/sh_ui_file_upload.dart +200 -0
  21. package/data/registry/flutter/widgets/sh_ui_header.dart +488 -0
  22. package/data/registry/flutter/widgets/sh_ui_input.dart +664 -0
  23. package/data/registry/flutter/widgets/sh_ui_label.dart +145 -0
  24. package/data/registry/flutter/widgets/sh_ui_menubar.dart +98 -0
  25. package/data/registry/flutter/widgets/sh_ui_pagination.dart +276 -0
  26. package/data/registry/flutter/widgets/sh_ui_popover.dart +248 -0
  27. package/data/registry/flutter/widgets/sh_ui_progress.dart +47 -0
  28. package/data/registry/flutter/widgets/sh_ui_radio.dart +108 -0
  29. package/data/registry/flutter/widgets/sh_ui_select.dart +904 -0
  30. package/data/registry/flutter/widgets/sh_ui_separator.dart +42 -0
  31. package/data/registry/flutter/widgets/sh_ui_sidebar.dart +1116 -0
  32. package/data/registry/flutter/widgets/sh_ui_skeleton.dart +129 -0
  33. package/data/registry/flutter/widgets/sh_ui_slider.dart +147 -0
  34. package/data/registry/flutter/widgets/sh_ui_spinner.dart +56 -0
  35. package/data/registry/flutter/widgets/sh_ui_switch.dart +109 -0
  36. package/data/registry/flutter/widgets/sh_ui_tabs.dart +329 -0
  37. package/data/registry/flutter/widgets/sh_ui_textarea.dart +126 -0
  38. package/data/registry/flutter/widgets/sh_ui_toast.dart +362 -0
  39. package/data/registry/flutter/widgets/sh_ui_toggle.dart +229 -0
  40. package/data/registry/flutter/widgets/sh_ui_tooltip.dart +62 -0
  41. package/data/registry/react/components/accordion/index.tsx +85 -0
  42. package/data/registry/react/components/accordion/styles.css +94 -0
  43. package/data/registry/react/components/animations/animations.css +51 -0
  44. package/data/registry/react/components/avatar/index.tsx +75 -0
  45. package/data/registry/react/components/avatar/styles.css +36 -0
  46. package/data/registry/react/components/badge/index.tsx +42 -0
  47. package/data/registry/react/components/badge/styles.css +57 -0
  48. package/data/registry/react/components/base/base.css +102 -0
  49. package/data/registry/react/components/breadcrumb/index.tsx +154 -0
  50. package/data/registry/react/components/breadcrumb/styles.css +82 -0
  51. package/data/registry/react/components/breakpoints/breakpoints.css +17 -0
  52. package/data/registry/react/components/button/index.tsx +47 -0
  53. package/data/registry/react/components/button/styles.css +93 -0
  54. package/data/registry/react/components/card/index.tsx +86 -0
  55. package/data/registry/react/components/card/styles.css +73 -0
  56. package/data/registry/react/components/carousel/index.tsx +432 -0
  57. package/data/registry/react/components/carousel/styles.css +155 -0
  58. package/data/registry/react/components/checkbox/index.tsx +98 -0
  59. package/data/registry/react/components/checkbox/styles.css +75 -0
  60. package/data/registry/react/components/code-panel/copy.tsx +56 -0
  61. package/data/registry/react/components/code-panel/index.tsx +193 -0
  62. package/data/registry/react/components/code-panel/styles.css +124 -0
  63. package/data/registry/react/components/color-picker/index.tsx +466 -0
  64. package/data/registry/react/components/color-picker/styles.css +166 -0
  65. package/data/registry/react/components/combobox/index.tsx +167 -0
  66. package/data/registry/react/components/combobox/styles.css +151 -0
  67. package/data/registry/react/components/context-menu/index.tsx +253 -0
  68. package/data/registry/react/components/context-menu/styles.css +140 -0
  69. package/data/registry/react/components/date-picker/index.tsx +757 -0
  70. package/data/registry/react/components/date-picker/styles.css +279 -0
  71. package/data/registry/react/components/dialog/index.tsx +97 -0
  72. package/data/registry/react/components/dialog/styles.css +127 -0
  73. package/data/registry/react/components/dropdown-menu/index.tsx +257 -0
  74. package/data/registry/react/components/dropdown-menu/styles.css +150 -0
  75. package/data/registry/react/components/file-upload/index.tsx +489 -0
  76. package/data/registry/react/components/file-upload/styles.css +170 -0
  77. package/data/registry/react/components/focus-ring/focus-ring.css +23 -0
  78. package/data/registry/react/components/form/context.ts +92 -0
  79. package/data/registry/react/components/form/field.test.tsx +230 -0
  80. package/data/registry/react/components/form/field.tsx +236 -0
  81. package/data/registry/react/components/form/focus-first-error.ts +54 -0
  82. package/data/registry/react/components/form/form.section.test.tsx +58 -0
  83. package/data/registry/react/components/form/form.test.tsx +146 -0
  84. package/data/registry/react/components/form/form.tsx +180 -0
  85. package/data/registry/react/components/form/index.tsx +61 -0
  86. package/data/registry/react/components/form/steps.test.tsx +106 -0
  87. package/data/registry/react/components/form/steps.tsx +193 -0
  88. package/data/registry/react/components/form/store.test.ts +206 -0
  89. package/data/registry/react/components/form/store.ts +318 -0
  90. package/data/registry/react/components/form/styles.css +47 -0
  91. package/data/registry/react/components/form/types.ts +104 -0
  92. package/data/registry/react/components/form/use-sh-ui-form.ts +15 -0
  93. package/data/registry/react/components/form/utils.test.ts +44 -0
  94. package/data/registry/react/components/form/utils.ts +49 -0
  95. package/data/registry/react/components/form/validation.test.ts +67 -0
  96. package/data/registry/react/components/form/validation.ts +64 -0
  97. package/data/registry/react/components/form-rhf/README.md +27 -0
  98. package/data/registry/react/components/form-rhf/index.tsx +289 -0
  99. package/data/registry/react/components/form-rhf/rhf.test.tsx +42 -0
  100. package/data/registry/react/components/form-tanstack/README.md +27 -0
  101. package/data/registry/react/components/form-tanstack/index.tsx +352 -0
  102. package/data/registry/react/components/form-tanstack/tanstack.test.tsx +45 -0
  103. package/data/registry/react/components/form-yup/README.md +22 -0
  104. package/data/registry/react/components/form-yup/index.tsx +50 -0
  105. package/data/registry/react/components/form-yup/yup.test.ts +27 -0
  106. package/data/registry/react/components/header/index.tsx +257 -0
  107. package/data/registry/react/components/header/styles.css +190 -0
  108. package/data/registry/react/components/input/index.tsx +517 -0
  109. package/data/registry/react/components/input/styles.css +203 -0
  110. package/data/registry/react/components/label/index.tsx +54 -0
  111. package/data/registry/react/components/label/styles.css +90 -0
  112. package/data/registry/react/components/menubar/index.tsx +34 -0
  113. package/data/registry/react/components/menubar/styles.css +45 -0
  114. package/data/registry/react/components/pagination/index.tsx +271 -0
  115. package/data/registry/react/components/pagination/styles.css +105 -0
  116. package/data/registry/react/components/popover/index.tsx +115 -0
  117. package/data/registry/react/components/popover/styles.css +65 -0
  118. package/data/registry/react/components/progress/index.tsx +56 -0
  119. package/data/registry/react/components/progress/styles.css +41 -0
  120. package/data/registry/react/components/radio/index.tsx +67 -0
  121. package/data/registry/react/components/radio/styles.css +80 -0
  122. package/data/registry/react/components/select/index.tsx +236 -0
  123. package/data/registry/react/components/select/styles.css +193 -0
  124. package/data/registry/react/components/separator/index.tsx +48 -0
  125. package/data/registry/react/components/separator/styles.css +15 -0
  126. package/data/registry/react/components/sidebar/index.tsx +1084 -0
  127. package/data/registry/react/components/sidebar/styles.css +502 -0
  128. package/data/registry/react/components/skeleton/index.tsx +24 -0
  129. package/data/registry/react/components/skeleton/styles.css +24 -0
  130. package/data/registry/react/components/slider/index.tsx +300 -0
  131. package/data/registry/react/components/slider/styles.css +64 -0
  132. package/data/registry/react/components/spinner/index.tsx +40 -0
  133. package/data/registry/react/components/spinner/styles.css +37 -0
  134. package/data/registry/react/components/switch/index.tsx +41 -0
  135. package/data/registry/react/components/switch/styles.css +83 -0
  136. package/data/registry/react/components/tabs/index.tsx +93 -0
  137. package/data/registry/react/components/tabs/styles.css +148 -0
  138. package/data/registry/react/components/textarea/index.tsx +25 -0
  139. package/data/registry/react/components/textarea/styles.css +54 -0
  140. package/data/registry/react/components/theme/index.tsx +91 -0
  141. package/data/registry/react/components/toast/index.tsx +257 -0
  142. package/data/registry/react/components/toast/styles.css +290 -0
  143. package/data/registry/react/components/toggle/index.tsx +133 -0
  144. package/data/registry/react/components/toggle/styles.css +85 -0
  145. package/data/registry/react/components/tooltip/index.tsx +85 -0
  146. package/data/registry/react/components/tooltip/styles.css +44 -0
  147. package/data/registry/react/components/z-index/z-index.css +16 -0
  148. package/data/registry/react/hooks/use-active-section.ts +104 -0
  149. package/data/registry/react/hooks/use-media-query.ts +27 -0
  150. package/data/registry/react/lib/cn.ts +39 -0
  151. package/data/registry/react/peer-versions.json +10 -0
  152. package/data/registry/react/registry.json +835 -0
  153. package/data/summaries/flutter.json +42 -0
  154. package/data/summaries/react.json +50 -0
  155. package/data/tokens/build.mjs +553 -0
  156. package/data/tokens/src/primitives.json +146 -0
  157. package/data/tokens/src/semantic.json +146 -0
  158. package/package.json +9 -2
  159. package/src/add.mjs +41 -15
  160. package/src/list.mjs +3 -11
  161. package/src/mcp.mjs +308 -0
  162. package/src/paths.mjs +59 -0
  163. package/src/remove.mjs +4 -11
@@ -0,0 +1,44 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { getByPath, setByPath, unflatten, flatten, scopedPath } from "./utils";
3
+
4
+ describe("getByPath", () => {
5
+ it("returns value at dot path", () => {
6
+ const obj = { a: { b: { c: 42 } } };
7
+ expect(getByPath(obj, "a.b.c")).toBe(42);
8
+ });
9
+ it("returns undefined for missing path", () => {
10
+ expect(getByPath({}, "a.b")).toBeUndefined();
11
+ });
12
+ it("returns root when path empty", () => {
13
+ const obj = { a: 1 };
14
+ expect(getByPath(obj, "")).toEqual(obj);
15
+ });
16
+ });
17
+
18
+ describe("setByPath", () => {
19
+ it("creates nested structure", () => {
20
+ expect(setByPath({}, "a.b.c", 1)).toEqual({ a: { b: { c: 1 } } });
21
+ });
22
+ it("preserves siblings", () => {
23
+ expect(setByPath({ a: { x: 1 } }, "a.b", 2)).toEqual({ a: { x: 1, b: 2 } });
24
+ });
25
+ });
26
+
27
+ describe("unflatten / flatten", () => {
28
+ it("roundtrips", () => {
29
+ const flat = { "a.b.c": 1, "a.d": 2, "e": 3 };
30
+ const nested = unflatten(flat);
31
+ expect(nested).toEqual({ a: { b: { c: 1 }, d: 2 }, e: 3 });
32
+ expect(flatten(nested)).toEqual(flat);
33
+ });
34
+ });
35
+
36
+ describe("scopedPath", () => {
37
+ it("joins non-empty parts with dot", () => {
38
+ expect(scopedPath("a", "b")).toBe("a.b");
39
+ });
40
+ it("skips empty parts", () => {
41
+ expect(scopedPath("", "b")).toBe("b");
42
+ expect(scopedPath("a", "")).toBe("a");
43
+ });
44
+ });
@@ -0,0 +1,49 @@
1
+ export function getByPath(obj: unknown, path: string): unknown {
2
+ if (path === "") return obj;
3
+ const parts = path.split(".");
4
+ let cur: any = obj;
5
+ for (const p of parts) {
6
+ if (cur == null) return undefined;
7
+ cur = cur[p];
8
+ }
9
+ return cur;
10
+ }
11
+
12
+ export function setByPath<T extends Record<string, any>>(obj: T, path: string, value: unknown): T {
13
+ if (path === "") return value as T;
14
+ const parts = path.split(".");
15
+ const out: any = Array.isArray(obj) ? [...obj] : { ...obj };
16
+ let cur = out;
17
+ for (let i = 0; i < parts.length - 1; i++) {
18
+ const p = parts[i];
19
+ cur[p] = cur[p] != null && typeof cur[p] === "object" ? { ...cur[p] } : {};
20
+ cur = cur[p];
21
+ }
22
+ cur[parts[parts.length - 1]] = value;
23
+ return out;
24
+ }
25
+
26
+ export function flatten(obj: Record<string, unknown>, prefix = ""): Record<string, unknown> {
27
+ const out: Record<string, unknown> = {};
28
+ for (const [k, v] of Object.entries(obj)) {
29
+ const path = prefix ? `${prefix}.${k}` : k;
30
+ if (v && typeof v === "object" && !Array.isArray(v)) {
31
+ Object.assign(out, flatten(v as Record<string, unknown>, path));
32
+ } else {
33
+ out[path] = v;
34
+ }
35
+ }
36
+ return out;
37
+ }
38
+
39
+ export function unflatten(flat: Record<string, unknown>): Record<string, unknown> {
40
+ let result: Record<string, unknown> = {};
41
+ for (const [path, value] of Object.entries(flat)) {
42
+ result = setByPath(result, path, value) as Record<string, unknown>;
43
+ }
44
+ return result;
45
+ }
46
+
47
+ export function scopedPath(...parts: (string | undefined)[]): string {
48
+ return parts.filter((p) => p && p.length > 0).join(".");
49
+ }
@@ -0,0 +1,67 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { runFieldValidate, runSchema, readHTML5Validity } from "./validation";
3
+
4
+ describe("runFieldValidate", () => {
5
+ it("returns undefined on pass", async () => {
6
+ const r = await runFieldValidate((v) => (v ? undefined : "required"), "ok", {});
7
+ expect(r).toBeUndefined();
8
+ });
9
+
10
+ it("returns error on fail", async () => {
11
+ const r = await runFieldValidate((v) => (v ? undefined : "required"), "", {});
12
+ expect(r).toEqual({ message: "required", source: "validate", type: "custom" });
13
+ });
14
+
15
+ it("supports object form", async () => {
16
+ const r = await runFieldValidate({ fn: (v) => (v === "x" ? undefined : "bad"), debounce: 100 }, "x", {});
17
+ expect(r).toBeUndefined();
18
+ });
19
+
20
+ it("supports async fn", async () => {
21
+ const r = await runFieldValidate(async (v) => (v === "ok" ? undefined : "no"), "ok", {});
22
+ expect(r).toBeUndefined();
23
+ });
24
+ });
25
+
26
+ describe("runSchema", () => {
27
+ it("maps Standard Schema issues to path-keyed errors", async () => {
28
+ const schema = {
29
+ "~standard": {
30
+ version: 1 as const,
31
+ vendor: "test",
32
+ validate: (v: any) => ({
33
+ issues: [{ path: ["profile", "name"], message: "required" }],
34
+ }),
35
+ },
36
+ };
37
+ const errors = await runSchema(schema, { profile: { name: "" } }, "schema");
38
+ expect(errors["profile.name"]).toEqual({ message: "required", source: "schema" });
39
+ });
40
+
41
+ it("returns empty object when schema passes", async () => {
42
+ const schema = {
43
+ "~standard": {
44
+ version: 1 as const,
45
+ vendor: "test",
46
+ validate: (v: any) => ({ value: v }),
47
+ },
48
+ };
49
+ const errors = await runSchema(schema, { x: 1 }, "schema");
50
+ expect(errors).toEqual({});
51
+ });
52
+ });
53
+
54
+ describe("readHTML5Validity", () => {
55
+ it("returns undefined for empty validity", () => {
56
+ const el = document.createElement("input");
57
+ expect(readHTML5Validity(el)).toBeUndefined();
58
+ });
59
+
60
+ it("returns first ValidityState flag as type", () => {
61
+ const el = document.createElement("input");
62
+ el.required = true;
63
+ const err = readHTML5Validity(el);
64
+ expect(err?.source).toBe("html5");
65
+ expect(err?.type).toBe("valueMissing");
66
+ });
67
+ });
@@ -0,0 +1,64 @@
1
+ import type { FieldError, FieldValidate, StandardSchemaV1, ErrorSource } from "./types";
2
+
3
+ export async function runFieldValidate(
4
+ validate: FieldValidate | undefined,
5
+ value: unknown,
6
+ allValues: unknown
7
+ ): Promise<FieldError | undefined> {
8
+ if (!validate) return undefined;
9
+ const fn = typeof validate === "function" ? validate : validate.fn;
10
+ const message = await fn(value, allValues);
11
+ if (!message) return undefined;
12
+ return { message, source: "validate", type: "custom" };
13
+ }
14
+
15
+ export async function runSchema(
16
+ schema: StandardSchemaV1,
17
+ values: unknown,
18
+ source: ErrorSource = "schema"
19
+ ): Promise<Record<string, FieldError>> {
20
+ const result = await schema["~standard"].validate(values);
21
+ if ("value" in result) return {};
22
+ const errors: Record<string, FieldError> = {};
23
+ for (const issue of result.issues) {
24
+ const path = (issue.path ?? [])
25
+ .map((seg) =>
26
+ typeof seg === "object" && seg !== null
27
+ ? String((seg as { key: PropertyKey }).key)
28
+ : String(seg)
29
+ )
30
+ .join(".");
31
+ if (!errors[path]) {
32
+ errors[path] = { message: issue.message, source };
33
+ }
34
+ }
35
+ return errors;
36
+ }
37
+
38
+ const HTML5_KEYS = [
39
+ "valueMissing",
40
+ "typeMismatch",
41
+ "patternMismatch",
42
+ "tooLong",
43
+ "tooShort",
44
+ "rangeUnderflow",
45
+ "rangeOverflow",
46
+ "stepMismatch",
47
+ "badInput",
48
+ ] as const;
49
+
50
+ export function readHTML5Validity(
51
+ el: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
52
+ ): FieldError | undefined {
53
+ if (!el.validity || el.validity.valid) return undefined;
54
+ for (const k of HTML5_KEYS) {
55
+ if ((el.validity as any)[k]) {
56
+ return {
57
+ message: el.validationMessage || k,
58
+ type: k,
59
+ source: "html5",
60
+ };
61
+ }
62
+ }
63
+ return undefined;
64
+ }
@@ -0,0 +1,27 @@
1
+ # form-rhf
2
+
3
+ React Hook Form 인스턴스를 sh-ui Form 에 연결하는 어댑터.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm i react-hook-form
9
+ sh-ui add form-rhf
10
+ ```
11
+
12
+ ## 사용
13
+
14
+ ```tsx
15
+ import { useForm } from "react-hook-form";
16
+ import { adaptReactHookForm } from "@/components/ui/form-rhf";
17
+ import { Form } from "@/components/ui/form";
18
+
19
+ const rhf = useForm({ defaultValues, mode: "onBlur" });
20
+ const form = adaptReactHookForm(rhf);
21
+
22
+ <Form form={form}>
23
+ <Form.Field name="email">...</Form.Field>
24
+ </Form>
25
+ ```
26
+
27
+ 어댑터 모드에선 sh-ui 의 `validateOn` / `schema` prop 은 무시된다. 검증 규칙은 RHF 쪽 `resolver`/`register` 옵션에 둔다.
@@ -0,0 +1,289 @@
1
+ import type { UseFormReturn, FieldValues } from "react-hook-form";
2
+ import type {
3
+ FormStore,
4
+ FormStoreState,
5
+ FieldError,
6
+ FieldConfig,
7
+ StandardSchemaV1,
8
+ FormConfig,
9
+ } from "../form/types";
10
+ import { flatten, getByPath } from "../form/utils";
11
+ import { runFieldValidate } from "../form/validation";
12
+
13
+ interface AdapterMeta {
14
+ fieldsByStep: Map<string, Set<string>>;
15
+ fieldsBySection: Map<string, Set<string>>;
16
+ fieldValidators: Map<string, FieldConfig>;
17
+ sectionSchemas: Map<string, StandardSchemaV1>;
18
+ activeStepId: string | null;
19
+ revalidateOnChange: Set<string>;
20
+ validatingFields: Set<string>;
21
+ }
22
+
23
+ /** 마지막으로 계산된 스냅샷 캐시. React useSyncExternalStore 가 Object.is 비교를 사용하므로 동일 상태면 같은 레퍼런스를 반환해야 한다. */
24
+ interface SnapshotCache {
25
+ snapshot: FormStoreState | null;
26
+ /** 캐시 무효화를 위한 간단한 version 카운터 */
27
+ version: number;
28
+ /** 마지막으로 스냅샷을 생성한 version */
29
+ snapshotVersion: number;
30
+ }
31
+
32
+ export interface AdapterConfig<T> {
33
+ onSubmit?: (
34
+ values: T,
35
+ helpers: { reset: () => void; setError: (path: string, message: string) => void }
36
+ ) => void | Promise<void>;
37
+ }
38
+
39
+ /**
40
+ * React Hook Form 인스턴스를 sh-ui FormStore 인터페이스로 감쌉니다.
41
+ *
42
+ * - RHF 가 state owner(values, errors, touched, isSubmitting)로 동작
43
+ * - sh-ui 메타데이터(fieldsByStep, sectionSchemas 등)는 어댑터 내부에서 관리
44
+ * - validateField: sh-ui runFieldValidate → rhf.setError / rhf.trigger 순으로 실행
45
+ */
46
+ export function adaptReactHookForm<T extends FieldValues>(
47
+ rhf: UseFormReturn<T>,
48
+ config: AdapterConfig<T> = {}
49
+ ): FormStore<T> {
50
+ const meta: AdapterMeta = {
51
+ fieldsByStep: new Map(),
52
+ fieldsBySection: new Map(),
53
+ fieldValidators: new Map(),
54
+ sectionSchemas: new Map(),
55
+ activeStepId: null,
56
+ revalidateOnChange: new Set(),
57
+ validatingFields: new Set(),
58
+ };
59
+
60
+ const cache: SnapshotCache = { snapshot: null, version: 0, snapshotVersion: -1 };
61
+
62
+ const listeners = new Set<() => void>();
63
+ const notify = () => {
64
+ cache.version++;
65
+ listeners.forEach((l) => l());
66
+ };
67
+
68
+ // RHF 7.50+ subscribe API
69
+ let unsubRhf: (() => void) | undefined;
70
+
71
+ const startRhfSubscription = () => {
72
+ if (typeof (rhf as any).subscribe === "function") {
73
+ try {
74
+ unsubRhf = (rhf as any).subscribe({
75
+ formState: { values: true, errors: true, isSubmitting: true, touchedFields: true },
76
+ callback: () => notify(),
77
+ });
78
+ } catch {
79
+ // 구버전 RHF: subscribe 미지원 — 무시
80
+ }
81
+ }
82
+ };
83
+
84
+ // 첫 subscriber 등록 시 RHF 구독 시작
85
+ let rhfSubscribed = false;
86
+
87
+ const store: FormStore<T> = {
88
+ subscribe(listener) {
89
+ listeners.add(listener);
90
+ if (!rhfSubscribed) {
91
+ rhfSubscribed = true;
92
+ startRhfSubscription();
93
+ }
94
+ return () => {
95
+ listeners.delete(listener);
96
+ if (listeners.size === 0) {
97
+ rhfSubscribed = false;
98
+ try {
99
+ unsubRhf?.();
100
+ } catch {}
101
+ unsubRhf = undefined;
102
+ }
103
+ };
104
+ },
105
+
106
+ getState(): FormStoreState {
107
+ // 동일 version이면 캐시 반환 — useSyncExternalStore Object.is 비교 통과
108
+ if (cache.snapshot !== null && cache.snapshotVersion === cache.version) {
109
+ return cache.snapshot;
110
+ }
111
+
112
+ const fs = rhf.formState;
113
+ const rawValues = rhf.getValues();
114
+ const values = flatten(rawValues as Record<string, unknown>);
115
+
116
+ const errors: Record<string, FieldError | undefined> = {};
117
+ const collectErrors = (errs: Record<string, any>, prefix = "") => {
118
+ for (const [key, e] of Object.entries(errs)) {
119
+ const path = prefix ? `${prefix}.${key}` : key;
120
+ if (e && typeof e === "object") {
121
+ if (typeof e.message === "string") {
122
+ errors[path] = { message: e.message, source: "validate" };
123
+ } else {
124
+ // nested errors
125
+ collectErrors(e, path);
126
+ }
127
+ }
128
+ }
129
+ };
130
+ collectErrors(fs.errors as Record<string, any>);
131
+
132
+ const snapshot: FormStoreState = {
133
+ values,
134
+ errors,
135
+ touched: { ...(fs.touchedFields as any) },
136
+ submitting: fs.isSubmitting,
137
+ submitCount: fs.submitCount,
138
+ activeStepId: meta.activeStepId,
139
+ fieldsByStep: meta.fieldsByStep,
140
+ fieldsBySection: meta.fieldsBySection,
141
+ fieldValidators: meta.fieldValidators,
142
+ sectionSchemas: meta.sectionSchemas,
143
+ validatingFields: meta.validatingFields,
144
+ revalidateOnChange: meta.revalidateOnChange,
145
+ };
146
+
147
+ cache.snapshot = snapshot;
148
+ cache.snapshotVersion = cache.version;
149
+ return snapshot;
150
+ },
151
+
152
+ getFieldState(path) {
153
+ const state = store.getState();
154
+ const err = state.errors[path];
155
+ return {
156
+ value: state.values[path],
157
+ error: err,
158
+ errors: err ? [err] : [],
159
+ touched: !!state.touched[path],
160
+ isValidating: rhf.formState.isValidating,
161
+ hasError: !!err,
162
+ };
163
+ },
164
+
165
+ setFieldValue(path, value) {
166
+ rhf.setValue(path as any, value as any, { shouldDirty: true });
167
+ },
168
+
169
+ setFieldTouched(path, touched) {
170
+ if (touched) {
171
+ void rhf.trigger(path as any);
172
+ }
173
+ },
174
+
175
+ registerField(path, cfg) {
176
+ meta.fieldValidators.set(path, cfg);
177
+ if (cfg.stepId) {
178
+ const s = meta.fieldsByStep.get(cfg.stepId) ?? new Set<string>();
179
+ s.add(path);
180
+ meta.fieldsByStep.set(cfg.stepId, s);
181
+ }
182
+ if (cfg.sectionPath) {
183
+ const s = meta.fieldsBySection.get(cfg.sectionPath) ?? new Set<string>();
184
+ s.add(path);
185
+ meta.fieldsBySection.set(cfg.sectionPath, s);
186
+ }
187
+ rhf.register(path as any);
188
+ notify();
189
+ return () => {
190
+ meta.fieldValidators.delete(path);
191
+ if (cfg.stepId) meta.fieldsByStep.get(cfg.stepId)?.delete(path);
192
+ if (cfg.sectionPath) meta.fieldsBySection.get(cfg.sectionPath)?.delete(path);
193
+ notify();
194
+ };
195
+ },
196
+
197
+ registerStep(stepId) {
198
+ if (!meta.fieldsByStep.has(stepId)) {
199
+ meta.fieldsByStep.set(stepId, new Set());
200
+ }
201
+ notify();
202
+ return () => {};
203
+ },
204
+
205
+ setActiveStep(stepId) {
206
+ meta.activeStepId = stepId;
207
+ notify();
208
+ },
209
+
210
+ registerSectionSchema(sectionPath, schema) {
211
+ meta.sectionSchemas.set(sectionPath, schema);
212
+ notify();
213
+ return () => {
214
+ meta.sectionSchemas.delete(sectionPath);
215
+ notify();
216
+ };
217
+ },
218
+
219
+ async validateField(path) {
220
+ const cfg = meta.fieldValidators.get(path);
221
+ const rawValues = rhf.getValues() as Record<string, unknown>;
222
+ const value = getByPath(rawValues, path);
223
+ const err = await runFieldValidate(cfg?.validate, value, rawValues);
224
+ if (err) {
225
+ rhf.setError(path as any, { type: "custom", message: err.message });
226
+ meta.revalidateOnChange.add(path);
227
+ notify();
228
+ return false;
229
+ }
230
+ rhf.clearErrors(path as any);
231
+ meta.revalidateOnChange.delete(path);
232
+ // RHF 자체 검증(resolver 등)도 함께 실행
233
+ const ok = await rhf.trigger(path as any);
234
+ notify();
235
+ return ok;
236
+ },
237
+
238
+ async validateStep(stepId) {
239
+ const fields = Array.from(meta.fieldsByStep.get(stepId) ?? []);
240
+ if (fields.length === 0) return true;
241
+ const results = await Promise.all(fields.map((f) => store.validateField(f)));
242
+ return results.every(Boolean);
243
+ },
244
+
245
+ async validateAll() {
246
+ const paths = Array.from(meta.fieldValidators.keys());
247
+ const results = await Promise.all(paths.map((p) => store.validateField(p)));
248
+ const rhfValid = await rhf.trigger();
249
+ return results.every(Boolean) && rhfValid;
250
+ },
251
+
252
+ getValues<S = T>(scope?: string): S {
253
+ const v = rhf.getValues() as unknown;
254
+ return (scope ? getByPath(v, scope) : v) as S;
255
+ },
256
+
257
+ async submit() {
258
+ let triggered = false;
259
+ await rhf.handleSubmit(async (values) => {
260
+ triggered = true;
261
+ await config?.onSubmit?.(values as T, {
262
+ reset: () => rhf.reset(),
263
+ setError: (p, m) => rhf.setError(p as any, { type: "server", message: m }),
264
+ });
265
+ })();
266
+ if (!triggered) {
267
+ notify();
268
+ }
269
+ },
270
+
271
+ reset(defaults) {
272
+ rhf.reset(defaults as any);
273
+ meta.revalidateOnChange.clear();
274
+ },
275
+
276
+ setError(path, message) {
277
+ rhf.setError(path as any, { type: "custom", message });
278
+ notify();
279
+ },
280
+
281
+ _config: {
282
+ validateOn: "blur",
283
+ scrollToFirstError: true,
284
+ focusFirstError: true,
285
+ } as FormConfig<T>,
286
+ };
287
+
288
+ return store;
289
+ }
@@ -0,0 +1,42 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import * as React from "react";
5
+ import { useForm } from "react-hook-form";
6
+ import { Form } from "../form";
7
+ import { Field } from "../form/field";
8
+ import { FormControl, FormError } from "../form/field";
9
+ import { adaptReactHookForm } from "./index";
10
+
11
+ function TestForm() {
12
+ const rhf = useForm({ defaultValues: { email: "" }, mode: "onBlur" });
13
+ const form = adaptReactHookForm(rhf);
14
+ return (
15
+ <Form form={form}>
16
+ <Field name="email" validate={(v) => (String(v).includes("@") ? undefined : "bad")}>
17
+ <FormControl><input data-testid="i" /></FormControl>
18
+ <FormError />
19
+ </Field>
20
+ <button type="submit">go</button>
21
+ </Form>
22
+ );
23
+ }
24
+
25
+ describe("adaptReactHookForm", () => {
26
+ it("value change via Form.Control updates RHF state", async () => {
27
+ const user = userEvent.setup();
28
+ render(<TestForm />);
29
+ const input = screen.getByTestId("i") as HTMLInputElement;
30
+ await user.type(input, "a@b.com");
31
+ expect(input.value).toBe("a@b.com");
32
+ });
33
+
34
+ it("validation error from sh-ui validate shows under field", async () => {
35
+ const user = userEvent.setup();
36
+ render(<TestForm />);
37
+ const input = screen.getByTestId("i") as HTMLInputElement;
38
+ await user.type(input, "nope");
39
+ input.blur();
40
+ await screen.findByText("bad");
41
+ });
42
+ });
@@ -0,0 +1,27 @@
1
+ # form-tanstack
2
+
3
+ TanStack Form 인스턴스를 sh-ui Form 에 연결하는 어댑터.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm i @tanstack/react-form
9
+ sh-ui add form-tanstack
10
+ ```
11
+
12
+ ## 사용
13
+
14
+ ```tsx
15
+ import { useForm } from "@tanstack/react-form";
16
+ import { adaptTanStackForm } from "@/components/ui/form-tanstack";
17
+ import { Form } from "@/components/ui/form";
18
+
19
+ const ts = useForm({ defaultValues, onSubmit: async () => {} });
20
+ const form = adaptTanStackForm(ts);
21
+
22
+ <Form form={form}>
23
+ <Form.Field name="email">...</Form.Field>
24
+ </Form>
25
+ ```
26
+
27
+ 어댑터 모드에선 sh-ui 의 schema / validateOn prop 은 무시된다. 검증은 TanStack 의 validators 에 둔다.