sh-ui-cli 0.113.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.
@@ -2,6 +2,19 @@
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
+ },
5
18
  {
6
19
  "version": "0.113.0",
7
20
  "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
+ }
@@ -6,7 +6,7 @@ import { useForm } from "react-hook-form";
6
6
  import { Form } from "../form";
7
7
  import { Field } from "../form/field";
8
8
  import { FormControl, FormError } from "../form/field";
9
- import { adaptReactHookForm } from "./index";
9
+ import { adaptReactHookForm, useReactHookFormAdapter } from "./index";
10
10
 
11
11
  function TestForm() {
12
12
  const rhf = useForm({ defaultValues: { email: "" }, mode: "onBlur" });
@@ -40,3 +40,55 @@ describe("adaptReactHookForm", () => {
40
40
  await screen.findByText("bad");
41
41
  });
42
42
  });
43
+
44
+ // ─────────────────────────────────────────────
45
+ // useReactHookFormAdapter — v0.114+ (안정화 hook)
46
+ // ─────────────────────────────────────────────
47
+ describe("useReactHookFormAdapter", () => {
48
+ it("returns same store instance across re-renders", () => {
49
+ const stores: any[] = [];
50
+ function Probe() {
51
+ const rhf = useForm({ defaultValues: { x: "" } });
52
+ const form = useReactHookFormAdapter(rhf);
53
+ stores.push(form);
54
+ const [, force] = React.useReducer((s: number) => s + 1, 0);
55
+ return <button onClick={force as any} data-testid="rerender">rerender</button>;
56
+ }
57
+ const { getByTestId } = render(<Probe />);
58
+ // 강제 re-render 3회
59
+ for (let i = 0; i < 3; i++) {
60
+ getByTestId("rerender").click();
61
+ }
62
+ // 최초 + re-render 들 모두 같은 인스턴스
63
+ expect(stores.length).toBeGreaterThanOrEqual(2);
64
+ for (const s of stores) {
65
+ expect(s).toBe(stores[0]);
66
+ }
67
+ });
68
+
69
+ it("works with render prop and propagates value through RHF", async () => {
70
+ const user = userEvent.setup();
71
+ function HookForm() {
72
+ const rhf = useForm({ defaultValues: { email: "" }, mode: "onBlur" });
73
+ const form = useReactHookFormAdapter(rhf);
74
+ return (
75
+ <Form form={form}>
76
+ <Field name="email">
77
+ {(field) => (
78
+ <input
79
+ data-testid="i"
80
+ value={(field.value as string) ?? ""}
81
+ onChange={(e) => field.handleChange(e.target.value)}
82
+ onBlur={field.handleBlur}
83
+ />
84
+ )}
85
+ </Field>
86
+ </Form>
87
+ );
88
+ }
89
+ render(<HookForm />);
90
+ const input = screen.getByTestId("i") as HTMLInputElement;
91
+ await user.type(input, "kim@studio");
92
+ expect(input.value).toBe("kim@studio");
93
+ });
94
+ });
@@ -17,7 +17,11 @@ export const Label = React.forwardRef<HTMLLabelElement, LabelProps>(
17
17
  <label
18
18
  ref={ref}
19
19
  className={cn(
20
- "flex flex-col gap-0.5 text-[length:var(--text-sm)] font-medium leading-snug text-foreground cursor-pointer select-none not-has-[[data-sh-ui-label-part]]:block",
20
+ // 기본 typography + 인터랙션만. display label 의 native(inline) 유지.
21
+ // sub-part(LabelTitle/Subtitle/Description/Caption) 가 자식에 있을 때만
22
+ // 자동으로 flex-col stack — 사용자가 가로 정렬(inline-flex items-center)
23
+ // 을 명시할 수 있도록 기본 display 를 강제하지 않는다.
24
+ "text-[length:var(--text-sm)] font-medium leading-snug text-foreground cursor-pointer select-none has-[[data-sh-ui-label-part]]:flex has-[[data-sh-ui-label-part]]:flex-col has-[[data-sh-ui-label-part]]:gap-0.5",
21
25
  // 필수 표시 — title 이 있으면 title 뒤, 없으면 label 뒤에 * 부착
22
26
  isRequired &&
23
27
  "has-[[data-sh-ui-label-part='title']]:[&>[data-sh-ui-label-part='title']]:after:content-['_*'] has-[[data-sh-ui-label-part='title']]:[&>[data-sh-ui-label-part='title']]:after:text-danger has-[[data-sh-ui-label-part='title']]:[&>[data-sh-ui-label-part='title']]:after:font-semibold not-has-[[data-sh-ui-label-part='title']]:after:content-['_*'] not-has-[[data-sh-ui-label-part='title']]:after:text-danger not-has-[[data-sh-ui-label-part='title']]:after:font-semibold",
@@ -1,7 +1,6 @@
1
+ /* 기본 — typography + 인터랙션만. display 는 label 의 native (inline) 유지.
2
+ 사용자가 `inline-flex items-center gap-2` 같이 가로 정렬을 명시할 수 있다. */
1
3
  .sh-ui-label {
2
- display: flex;
3
- flex-direction: column;
4
- gap: 0.125rem;
5
4
  font-size: var(--text-sm);
6
5
  font-weight: var(--weight-medium);
7
6
  line-height: 1.4;
@@ -10,8 +9,13 @@
10
9
  user-select: none;
11
10
  }
12
11
 
13
- .sh-ui-label:not(:has(.sh-ui-label__title, .sh-ui-label__subtitle, .sh-ui-label__description, .sh-ui-label__caption)) {
14
- display: block;
12
+ /* sub-part (Title/Subtitle/Description/Caption) 자식에 있을 때만 세로 stack.
13
+ 사용자가 `<Label><LabelTitle .../><LabelDescription .../></Label>` 형태로
14
+ 쓸 때 자동으로 정렬. */
15
+ .sh-ui-label:has(.sh-ui-label__title, .sh-ui-label__subtitle, .sh-ui-label__description, .sh-ui-label__caption) {
16
+ display: flex;
17
+ flex-direction: column;
18
+ gap: 0.125rem;
15
19
  }
16
20
 
17
21
  /* ───── 텍스트 계층 ───── */
@@ -1,7 +1,6 @@
1
+ /* 기본 — typography + 인터랙션만. display 는 label 의 native (inline) 유지.
2
+ 사용자가 `inline-flex items-center gap-2` 같이 가로 정렬을 명시할 수 있다. */
1
3
  .label {
2
- display: flex;
3
- flex-direction: column;
4
- gap: 0.125rem;
5
4
  font-size: var(--text-sm);
6
5
  font-weight: var(--weight-medium);
7
6
  line-height: 1.4;
@@ -10,8 +9,11 @@
10
9
  user-select: none;
11
10
  }
12
11
 
13
- .label:not(:has(.label__title, .label__subtitle, .label__description, .label__caption)) {
14
- display: block;
12
+ /* sub-part (Title/Subtitle/Description/Caption) 자식에 있을 때만 세로 stack. */
13
+ .label:has(.label__title, .label__subtitle, .label__description, .label__caption) {
14
+ display: flex;
15
+ flex-direction: column;
16
+ gap: 0.125rem;
15
17
  }
16
18
 
17
19
  /* ───── 텍스트 계층 ───── */
@@ -743,6 +743,63 @@ export const SidebarMenuButton = React.forwardRef<HTMLButtonElement, SidebarMenu
743
743
  }
744
744
  );
745
745
 
746
+ /* ───────────── Menu trailing slots (badge / action) ─────────────
747
+ * SidebarMenuButton 의 `> span { flex: 1 }` 흐름 밖(absolute)에 위치해 라벨을
748
+ * 밀어내지 않는다. SidebarMenuItem 이 trailing 슬롯 존재를 :has() 로 감지해
749
+ * 내부 button/anchor 에 우측 패딩을 확보한다 (styles.module.css 참고). */
750
+
751
+ /**
752
+ * SidebarMenuItem 의 우측 trailing 배지 (안 읽음 수 등). SidebarMenuButton 의
753
+ * 형제로 둔다 — absolute 라 라벨을 밀어내지 않고, pointer-events:none 으로 클릭은
754
+ * 행 전체 button 으로 통과한다.
755
+ *
756
+ * <SidebarMenuItem>
757
+ * <SidebarMenuButton render={<Link href='/x'>채팅</Link>} />
758
+ * <SidebarMenuBadge>8</SidebarMenuBadge>
759
+ * </SidebarMenuItem>
760
+ */
761
+ export function SidebarMenuBadge({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
762
+ return (
763
+ <div
764
+ data-sidebar="menu-badge"
765
+ className={cn(styles["sidebar__menu-badge"], className)}
766
+ {...props}
767
+ />
768
+ );
769
+ }
770
+
771
+ export interface SidebarMenuActionProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
772
+ /** 행 hover/포커스 시에만 노출. 기본은 항상 표시. */
773
+ showOnHover?: boolean;
774
+ }
775
+
776
+ /**
777
+ * SidebarMenuItem 의 우측 trailing 액션 버튼 (… 메뉴, 추가 등). badge 와 달리
778
+ * 클릭 가능. `showOnHover` 면 행 hover/포커스 시에만 노출 (CSS 부모 hover 셀렉터).
779
+ *
780
+ * <SidebarMenuItem>
781
+ * <SidebarMenuButton render={<Link href='/x'>채널</Link>} />
782
+ * <SidebarMenuAction showOnHover aria-label='채널 설정'><MoreIcon /></SidebarMenuAction>
783
+ * </SidebarMenuItem>
784
+ */
785
+ export const SidebarMenuAction = React.forwardRef<HTMLButtonElement, SidebarMenuActionProps>(
786
+ function SidebarMenuAction({ className, showOnHover, ...props }, ref) {
787
+ return (
788
+ <button
789
+ ref={ref}
790
+ type="button"
791
+ data-sidebar="menu-action"
792
+ className={cn(
793
+ styles["sidebar__menu-action"],
794
+ showOnHover && styles["sidebar__menu-action--hover"],
795
+ className,
796
+ )}
797
+ {...props}
798
+ />
799
+ );
800
+ }
801
+ );
802
+
746
803
  /* ───────────── Sub menu ───────────── */
747
804
 
748
805
  /** 메뉴 항목 내부의 서브 메뉴 리스트. SidebarMenuItem 안에 둔다. */
@@ -445,7 +445,18 @@ export function SidebarMenu({ className, ...props }: React.HTMLAttributes<HTMLUL
445
445
  }
446
446
 
447
447
  export function SidebarMenuItem({ className, ...props }: React.HTMLAttributes<HTMLLIElement>) {
448
- return <li className={cn("relative m-0", className)} {...props} />;
448
+ return (
449
+ <li
450
+ className={cn(
451
+ // group/menu-item — SidebarMenuAction 의 showOnHover hover 타겟.
452
+ // trailing slot(badge/action)이 있으면 내부 button/anchor 에 우측 패딩을
453
+ // 확보해 라벨이 trailing 요소 밑으로 들어가지 않게 한다.
454
+ "group/menu-item relative m-0 has-[[data-sidebar=menu-badge]]:[&>a]:pr-8 has-[[data-sidebar=menu-badge]]:[&>button]:pr-8 has-[[data-sidebar=menu-action]]:[&>a]:pr-8 has-[[data-sidebar=menu-action]]:[&>button]:pr-8",
455
+ className,
456
+ )}
457
+ {...props}
458
+ />
459
+ );
449
460
  }
450
461
 
451
462
  export interface SidebarMenuButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
@@ -509,6 +520,62 @@ export const SidebarMenuButton = React.forwardRef<HTMLButtonElement, SidebarMenu
509
520
  }
510
521
  );
511
522
 
523
+ /**
524
+ * SidebarMenuItem 의 우측 trailing 배지 (안 읽음 수 등). SidebarMenuButton 의
525
+ * 형제로 둔다 — button 의 `[&>span]:flex-1` 흐름 밖(absolute)이라 라벨을 밀어내지
526
+ * 않는다. 클릭 통과(pointer-events-none) — 행 전체 클릭이 button 으로 간다.
527
+ *
528
+ * <SidebarMenuItem>
529
+ * <SidebarMenuButton render={<Link href='/x'>채팅</Link>} />
530
+ * <SidebarMenuBadge>8</SidebarMenuBadge>
531
+ * </SidebarMenuItem>
532
+ */
533
+ export function SidebarMenuBadge({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
534
+ return (
535
+ <div
536
+ data-sidebar="menu-badge"
537
+ className={cn(
538
+ "pointer-events-none absolute right-[var(--space-2)] top-1/2 flex h-5 min-w-5 -translate-y-1/2 select-none items-center justify-center px-1 text-[length:var(--text-xs)] font-medium tabular-nums text-[var(--sidebar-fg)] [[data-state=collapsed][data-collapsible=icon]_&]:hidden",
539
+ className,
540
+ )}
541
+ {...props}
542
+ />
543
+ );
544
+ }
545
+
546
+ /**
547
+ * SidebarMenuItem 의 우측 trailing 액션 버튼 (… 메뉴, 추가 등). badge 와 달리
548
+ * 클릭 가능. `showOnHover` 면 행 hover/포커스 시에만 노출.
549
+ *
550
+ * <SidebarMenuItem>
551
+ * <SidebarMenuButton render={<Link href='/x'>채널</Link>} />
552
+ * <SidebarMenuAction showOnHover aria-label='채널 설정'><MoreIcon /></SidebarMenuAction>
553
+ * </SidebarMenuItem>
554
+ */
555
+ export interface SidebarMenuActionProps
556
+ extends React.ButtonHTMLAttributes<HTMLButtonElement> {
557
+ showOnHover?: boolean;
558
+ }
559
+
560
+ export const SidebarMenuAction = React.forwardRef<HTMLButtonElement, SidebarMenuActionProps>(
561
+ function SidebarMenuAction({ className, showOnHover, ...props }, ref) {
562
+ return (
563
+ <button
564
+ ref={ref}
565
+ type="button"
566
+ data-sidebar="menu-action"
567
+ className={cn(
568
+ "absolute right-[var(--space-1)] top-1/2 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-[calc(var(--radius)-2px)] border-none bg-transparent text-[var(--foreground-muted)] cursor-pointer transition-[background-color,color] duration-[var(--duration-fast)] hover:bg-[var(--sidebar-accent)] hover:text-[var(--sidebar-accent-fg)] focus-visible:outline-[length:var(--border-width-strong)] focus-visible:outline-ring [&>svg]:h-4 [&>svg]:w-4 [&>svg]:shrink-0 [[data-state=collapsed][data-collapsible=icon]_&]:hidden motion-reduce:transition-none",
569
+ showOnHover &&
570
+ "opacity-0 focus-visible:opacity-100 group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[active]:opacity-100",
571
+ className,
572
+ )}
573
+ {...props}
574
+ />
575
+ );
576
+ }
577
+ );
578
+
512
579
  export function SidebarMenuSub({ className, ...props }: React.HTMLAttributes<HTMLUListElement>) {
513
580
  return (
514
581
  <ul
@@ -790,6 +790,63 @@ export const SidebarMenuButton = React.forwardRef<HTMLButtonElement, SidebarMenu
790
790
  }
791
791
  );
792
792
 
793
+ /* ───────────── Menu trailing slots (badge / action) ─────────────
794
+ * SidebarMenuButton 의 `> span { flex: 1 }` 흐름 밖(absolute)에 위치해 라벨을
795
+ * 밀어내지 않는다. SidebarMenuItem 이 trailing 슬롯 존재를 :has() 로 감지해
796
+ * 내부 button/anchor 에 우측 패딩을 확보한다 (styles.css 참고). */
797
+
798
+ /**
799
+ * SidebarMenuItem 의 우측 trailing 배지 (안 읽음 수 등). SidebarMenuButton 의
800
+ * 형제로 둔다 — absolute 라 라벨을 밀어내지 않고, pointer-events:none 으로 클릭은
801
+ * 행 전체 button 으로 통과한다.
802
+ *
803
+ * <SidebarMenuItem>
804
+ * <SidebarMenuButton render={<Link href='/x'>채팅</Link>} />
805
+ * <SidebarMenuBadge>8</SidebarMenuBadge>
806
+ * </SidebarMenuItem>
807
+ */
808
+ export function SidebarMenuBadge({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
809
+ return (
810
+ <div
811
+ data-sidebar="menu-badge"
812
+ className={cn("sh-ui-sidebar__menu-badge", className)}
813
+ {...props}
814
+ />
815
+ );
816
+ }
817
+
818
+ export interface SidebarMenuActionProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
819
+ /** 행 hover/포커스 시에만 노출. 기본은 항상 표시. */
820
+ showOnHover?: boolean;
821
+ }
822
+
823
+ /**
824
+ * SidebarMenuItem 의 우측 trailing 액션 버튼 (… 메뉴, 추가 등). badge 와 달리
825
+ * 클릭 가능. `showOnHover` 면 행 hover/포커스 시에만 노출 (CSS 부모 hover 셀렉터).
826
+ *
827
+ * <SidebarMenuItem>
828
+ * <SidebarMenuButton render={<Link href='/x'>채널</Link>} />
829
+ * <SidebarMenuAction showOnHover aria-label='채널 설정'><MoreIcon /></SidebarMenuAction>
830
+ * </SidebarMenuItem>
831
+ */
832
+ export const SidebarMenuAction = React.forwardRef<HTMLButtonElement, SidebarMenuActionProps>(
833
+ function SidebarMenuAction({ className, showOnHover, ...props }, ref) {
834
+ return (
835
+ <button
836
+ ref={ref}
837
+ type="button"
838
+ data-sidebar="menu-action"
839
+ className={cn(
840
+ "sh-ui-sidebar__menu-action",
841
+ showOnHover && "sh-ui-sidebar__menu-action--hover",
842
+ className,
843
+ )}
844
+ {...props}
845
+ />
846
+ );
847
+ }
848
+ );
849
+
793
850
  /* ───────────── Sub menu ───────────── */
794
851
 
795
852
  /** 메뉴 항목 내부의 서브 메뉴 리스트. SidebarMenuItem 안에 둔다. */
@@ -433,6 +433,15 @@
433
433
  margin: 0;
434
434
  }
435
435
 
436
+ /* trailing 슬롯(badge/action)이 있으면 내부 button/anchor 에 우측 패딩을 확보해
437
+ * 라벨이 trailing 요소 밑으로 들어가지 않게 한다. */
438
+ .sh-ui-sidebar__menu-item:has(.sh-ui-sidebar__menu-badge) > a,
439
+ .sh-ui-sidebar__menu-item:has(.sh-ui-sidebar__menu-badge) > button,
440
+ .sh-ui-sidebar__menu-item:has(.sh-ui-sidebar__menu-action) > a,
441
+ .sh-ui-sidebar__menu-item:has(.sh-ui-sidebar__menu-action) > button {
442
+ padding-inline-end: 2rem;
443
+ }
444
+
436
445
  .sh-ui-sidebar__menu-button {
437
446
  display: flex;
438
447
  width: 100%;
@@ -499,6 +508,73 @@
499
508
  font-size: 0.9375rem;
500
509
  }
501
510
 
511
+ /* ───────────── Menu trailing slots (badge / action) ───────────── */
512
+ .sh-ui-sidebar__menu-badge {
513
+ position: absolute;
514
+ right: var(--space-2);
515
+ top: 50%;
516
+ transform: translateY(-50%);
517
+ display: flex;
518
+ align-items: center;
519
+ justify-content: center;
520
+ height: 1.25rem;
521
+ min-width: 1.25rem;
522
+ padding: 0 0.25rem;
523
+ font-size: var(--text-xs);
524
+ font-weight: var(--weight-medium);
525
+ font-variant-numeric: tabular-nums;
526
+ color: var(--sidebar-fg);
527
+ pointer-events: none;
528
+ user-select: none;
529
+ }
530
+
531
+ .sh-ui-sidebar__menu-action {
532
+ position: absolute;
533
+ right: var(--space-1);
534
+ top: 50%;
535
+ transform: translateY(-50%);
536
+ display: flex;
537
+ align-items: center;
538
+ justify-content: center;
539
+ width: 1.5rem;
540
+ height: 1.5rem;
541
+ border: none;
542
+ border-radius: calc(var(--radius) - 2px);
543
+ background: transparent;
544
+ color: var(--foreground-muted);
545
+ cursor: pointer;
546
+ transition: background-color var(--duration-fast), color var(--duration-fast), opacity var(--duration-fast);
547
+ }
548
+ .sh-ui-sidebar__menu-action > svg {
549
+ width: 1rem;
550
+ height: 1rem;
551
+ flex-shrink: 0;
552
+ }
553
+ .sh-ui-sidebar__menu-action:hover {
554
+ background: var(--sidebar-accent);
555
+ color: var(--sidebar-accent-fg);
556
+ }
557
+ .sh-ui-sidebar__menu-action:focus-visible {
558
+ outline: var(--border-width-strong) solid var(--ring);
559
+ }
560
+
561
+ /* showOnHover — 행 hover/포커스 시에만 노출. 부모 menu-item hover/focus-within 셀렉터. */
562
+ .sh-ui-sidebar__menu-action--hover {
563
+ opacity: 0;
564
+ }
565
+ .sh-ui-sidebar__menu-item:hover .sh-ui-sidebar__menu-action--hover,
566
+ .sh-ui-sidebar__menu-item:focus-within .sh-ui-sidebar__menu-action--hover,
567
+ .sh-ui-sidebar__menu-action--hover:focus-visible,
568
+ .sh-ui-sidebar__menu-action--hover[data-active] {
569
+ opacity: 1;
570
+ }
571
+
572
+ /* collapsed=icon 모드에서는 trailing 슬롯 숨김 (라벨도 숨겨지므로) */
573
+ .sh-ui-sidebar[data-state="collapsed"][data-collapsible="icon"] .sh-ui-sidebar__menu-badge,
574
+ .sh-ui-sidebar[data-state="collapsed"][data-collapsible="icon"] .sh-ui-sidebar__menu-action {
575
+ display: none;
576
+ }
577
+
502
578
  /* ───────────── Sub menu ───────────── */
503
579
  .sh-ui-sidebar__menu-sub {
504
580
  list-style: none;
@@ -607,6 +683,7 @@
607
683
  .sh-ui-sidebar__trigger,
608
684
  .sh-ui-sidebar__menu-button,
609
685
  .sh-ui-sidebar__menu-sub-button,
686
+ .sh-ui-sidebar__menu-action,
610
687
  .sh-ui-sidebar__panel-close,
611
688
  .sh-ui-sidebar__chevron,
612
689
  .sh-ui-sidebar__collapsible-content {
@@ -412,6 +412,15 @@
412
412
  margin: 0;
413
413
  }
414
414
 
415
+ /* trailing 슬롯(badge/action)이 있으면 내부 button/anchor 에 우측 패딩을 확보해
416
+ * 라벨이 trailing 요소 밑으로 들어가지 않게 한다. */
417
+ .sidebar__menu-item:has(.sidebar__menu-badge) > a,
418
+ .sidebar__menu-item:has(.sidebar__menu-badge) > button,
419
+ .sidebar__menu-item:has(.sidebar__menu-action) > a,
420
+ .sidebar__menu-item:has(.sidebar__menu-action) > button {
421
+ padding-inline-end: 2rem;
422
+ }
423
+
415
424
  .sidebar__menu-button {
416
425
  display: flex;
417
426
  width: 100%;
@@ -478,6 +487,73 @@
478
487
  font-size: 0.9375rem;
479
488
  }
480
489
 
490
+ /* ───────────── Menu trailing slots (badge / action) ───────────── */
491
+ .sidebar__menu-badge {
492
+ position: absolute;
493
+ right: var(--space-2);
494
+ top: 50%;
495
+ transform: translateY(-50%);
496
+ display: flex;
497
+ align-items: center;
498
+ justify-content: center;
499
+ height: 1.25rem;
500
+ min-width: 1.25rem;
501
+ padding: 0 0.25rem;
502
+ font-size: var(--text-xs);
503
+ font-weight: var(--weight-medium);
504
+ font-variant-numeric: tabular-nums;
505
+ color: var(--sidebar-fg);
506
+ pointer-events: none;
507
+ user-select: none;
508
+ }
509
+
510
+ .sidebar__menu-action {
511
+ position: absolute;
512
+ right: var(--space-1);
513
+ top: 50%;
514
+ transform: translateY(-50%);
515
+ display: flex;
516
+ align-items: center;
517
+ justify-content: center;
518
+ width: 1.5rem;
519
+ height: 1.5rem;
520
+ border: none;
521
+ border-radius: calc(var(--radius) - 2px);
522
+ background: transparent;
523
+ color: var(--foreground-muted);
524
+ cursor: pointer;
525
+ transition: background-color var(--duration-fast), color var(--duration-fast), opacity var(--duration-fast);
526
+ }
527
+ .sidebar__menu-action > svg {
528
+ width: 1rem;
529
+ height: 1rem;
530
+ flex-shrink: 0;
531
+ }
532
+ .sidebar__menu-action:hover {
533
+ background: var(--sidebar-accent);
534
+ color: var(--sidebar-accent-fg);
535
+ }
536
+ .sidebar__menu-action:focus-visible {
537
+ outline: var(--border-width-strong) solid var(--ring);
538
+ }
539
+
540
+ /* showOnHover — 행 hover/포커스 시에만 노출. 부모 menu-item hover/focus-within 셀렉터. */
541
+ .sidebar__menu-action--hover {
542
+ opacity: 0;
543
+ }
544
+ .sidebar__menu-item:hover .sidebar__menu-action--hover,
545
+ .sidebar__menu-item:focus-within .sidebar__menu-action--hover,
546
+ .sidebar__menu-action--hover:focus-visible,
547
+ .sidebar__menu-action--hover[data-active] {
548
+ opacity: 1;
549
+ }
550
+
551
+ /* collapsed=icon 모드에서는 trailing 슬롯 숨김 (라벨도 숨겨지므로) */
552
+ .sidebar[data-state="collapsed"][data-collapsible="icon"] .sidebar__menu-badge,
553
+ .sidebar[data-state="collapsed"][data-collapsible="icon"] .sidebar__menu-action {
554
+ display: none;
555
+ }
556
+
481
557
  /* ───────────── Sub menu ───────────── */
482
558
  .sidebar__menu-sub {
483
559
  list-style: none;
@@ -575,6 +651,7 @@
575
651
  .sidebar__trigger,
576
652
  .sidebar__menu-button,
577
653
  .sidebar__menu-sub-button,
654
+ .sidebar__menu-action,
578
655
  .sidebar__panel-close,
579
656
  .sidebar__chevron {
580
657
  transition: none;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sh-ui-cli",
3
- "version": "0.113.0",
3
+ "version": "0.114.0",
4
4
  "description": "sh-ui CLI — 프로젝트 스캐폴드(create) + 컴포넌트 추가(add/list/remove) + IDE-내 AI용 MCP 서버",
5
5
  "license": "MIT",
6
6
  "repository": {