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.
- package/data/changelog/versions.json +13 -0
- package/data/registry/react/components/form/field.test.tsx +106 -1
- package/data/registry/react/components/form/field.tsx +179 -23
- package/data/registry/react/components/form/use-sh-ui-form.ts +14 -0
- package/data/registry/react/components/form-rhf/README.md +138 -8
- package/data/registry/react/components/form-rhf/index.tsx +75 -0
- package/data/registry/react/components/form-rhf/rhf.test.tsx +53 -1
- package/data/registry/react/components/label/index.tailwind.tsx +5 -1
- package/data/registry/react/components/label/styles.css +9 -5
- package/data/registry/react/components/label/styles.module.css +7 -5
- package/data/registry/react/components/sidebar/index.module.tsx +57 -0
- package/data/registry/react/components/sidebar/index.tailwind.tsx +68 -1
- package/data/registry/react/components/sidebar/index.tsx +57 -0
- package/data/registry/react/components/sidebar/styles.css +77 -0
- package/data/registry/react/components/sidebar/styles.module.css +77 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
70
|
+
/**
|
|
71
|
+
* children. 함수면 render prop 으로 호출되어 `field` API 노출 (TanStack /
|
|
72
|
+
* RHF Controller 와 같은 패턴). 함수가 아니면 일반 children 으로 렌더 +
|
|
73
|
+
* div wrap.
|
|
74
|
+
*
|
|
75
|
+
* 권장: render prop 통일 — 입력 종류 무관 동일 패턴.
|
|
76
|
+
* 단순 input 한 칸도 `{(field) => <Input value={field.value} ... />}` 로.
|
|
77
|
+
*/
|
|
78
|
+
children?: React.ReactNode | ((field: FieldRenderProps) => React.ReactNode);
|
|
29
79
|
}
|
|
30
80
|
|
|
31
81
|
export function Field({
|
|
@@ -59,23 +109,41 @@ export function Field({
|
|
|
59
109
|
sectionPath: section.path || undefined,
|
|
60
110
|
required,
|
|
61
111
|
});
|
|
62
|
-
|
|
112
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
63
113
|
}, [store, path]);
|
|
64
114
|
|
|
65
115
|
const effectiveDisabled = disabled || formDisabled;
|
|
66
116
|
|
|
117
|
+
const ctxValue = React.useMemo(
|
|
118
|
+
() => ({
|
|
119
|
+
path,
|
|
120
|
+
id,
|
|
121
|
+
descId,
|
|
122
|
+
errorId,
|
|
123
|
+
disabled: effectiveDisabled,
|
|
124
|
+
readOnly,
|
|
125
|
+
required,
|
|
126
|
+
}),
|
|
127
|
+
[path, id, descId, errorId, effectiveDisabled, readOnly, required]
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
// children 이 함수면 render prop 패턴 — FieldContext 만 제공하고 wrap 없음.
|
|
131
|
+
// 사용자가 JSX 모양 (label/input/error 배치, wrapper 등) 을 100% 결정.
|
|
132
|
+
if (typeof children === "function") {
|
|
133
|
+
return (
|
|
134
|
+
<FieldContext.Provider value={ctxValue}>
|
|
135
|
+
<FieldRenderBridge>
|
|
136
|
+
{(field) =>
|
|
137
|
+
(children as (f: FieldRenderProps) => React.ReactNode)(field)
|
|
138
|
+
}
|
|
139
|
+
</FieldRenderBridge>
|
|
140
|
+
</FieldContext.Provider>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 일반 children — 기존 div wrap (cloneElement 기반 Form.Control 경로용).
|
|
67
145
|
return (
|
|
68
|
-
<FieldContext.Provider
|
|
69
|
-
value={{
|
|
70
|
-
path,
|
|
71
|
-
id,
|
|
72
|
-
descId,
|
|
73
|
-
errorId,
|
|
74
|
-
disabled: effectiveDisabled,
|
|
75
|
-
readOnly,
|
|
76
|
-
required,
|
|
77
|
-
}}
|
|
78
|
-
>
|
|
146
|
+
<FieldContext.Provider value={ctxValue}>
|
|
79
147
|
<div
|
|
80
148
|
className={`sh-ui-form-field${className ? ` ${className}` : ""}`}
|
|
81
149
|
data-disabled={effectiveDisabled || undefined}
|
|
@@ -88,6 +156,51 @@ export function Field({
|
|
|
88
156
|
);
|
|
89
157
|
}
|
|
90
158
|
|
|
159
|
+
// Render prop bridge — FieldContext 안에서 store/field state 를 읽어 `field`
|
|
160
|
+
// 객체 구성. 별도 컴포넌트로 분리하는 이유: store subscribe 가 hook 이라
|
|
161
|
+
// Field 본체에서 conditional 호출 불가 (Rules of Hooks).
|
|
162
|
+
function FieldRenderBridge({
|
|
163
|
+
children,
|
|
164
|
+
}: {
|
|
165
|
+
children: (field: FieldRenderProps) => React.ReactNode;
|
|
166
|
+
}) {
|
|
167
|
+
const ctx = React.useContext(FieldContext);
|
|
168
|
+
if (!ctx) return null;
|
|
169
|
+
const store = React.useContext(FormContext)!;
|
|
170
|
+
const state = useFormField(ctx.path);
|
|
171
|
+
|
|
172
|
+
const describedBy =
|
|
173
|
+
cn(ctx.descId, state.hasError ? ctx.errorId : null) || undefined;
|
|
174
|
+
|
|
175
|
+
const field: FieldRenderProps = {
|
|
176
|
+
value: state.value,
|
|
177
|
+
errors: state.errors,
|
|
178
|
+
error: state.error,
|
|
179
|
+
hasError: state.hasError,
|
|
180
|
+
touched: state.touched,
|
|
181
|
+
isValidating: state.isValidating,
|
|
182
|
+
handleChange: (next) => {
|
|
183
|
+
store.setFieldValue(ctx.path, next);
|
|
184
|
+
if (store.getState().revalidateOnChange.has(ctx.path)) {
|
|
185
|
+
void store.validateField(ctx.path);
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
handleBlur: () => {
|
|
189
|
+
store.setFieldTouched(ctx.path, true);
|
|
190
|
+
void store.validateField(ctx.path);
|
|
191
|
+
},
|
|
192
|
+
name: ctx.path,
|
|
193
|
+
id: ctx.id,
|
|
194
|
+
ariaInvalid: state.hasError ? true : undefined,
|
|
195
|
+
ariaDescribedBy: describedBy,
|
|
196
|
+
disabled: ctx.disabled,
|
|
197
|
+
readOnly: ctx.readOnly,
|
|
198
|
+
required: ctx.required,
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
return <>{children(field)}</>;
|
|
202
|
+
}
|
|
203
|
+
|
|
91
204
|
// ─────────────────────────────────────────────
|
|
92
205
|
// Label
|
|
93
206
|
// ─────────────────────────────────────────────
|
|
@@ -161,7 +274,7 @@ export function FormError({
|
|
|
161
274
|
}
|
|
162
275
|
|
|
163
276
|
// ─────────────────────────────────────────────
|
|
164
|
-
// Control
|
|
277
|
+
// Control (DEPRECATED — render prop 사용 권장)
|
|
165
278
|
// ─────────────────────────────────────────────
|
|
166
279
|
|
|
167
280
|
export interface ControlProps {
|
|
@@ -169,7 +282,9 @@ export interface ControlProps {
|
|
|
169
282
|
name: string;
|
|
170
283
|
value?: unknown;
|
|
171
284
|
checked?: boolean;
|
|
172
|
-
onChange: (
|
|
285
|
+
onChange: (
|
|
286
|
+
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
|
|
287
|
+
) => void;
|
|
173
288
|
onBlur: () => void;
|
|
174
289
|
"aria-invalid"?: true;
|
|
175
290
|
"aria-describedby"?: string;
|
|
@@ -185,6 +300,24 @@ export interface FormControlProps {
|
|
|
185
300
|
render?: (ctrl: ControlProps) => React.ReactElement;
|
|
186
301
|
}
|
|
187
302
|
|
|
303
|
+
/**
|
|
304
|
+
* @deprecated v0.114+ 부터는 `<Form.Field>` 의 render prop 패턴을 권장한다.
|
|
305
|
+
*
|
|
306
|
+
* // 권장 (TanStack / RHF Controller 와 같은 idiom):
|
|
307
|
+
* <Form.Field name="email">
|
|
308
|
+
* {(field) => (
|
|
309
|
+
* <Input
|
|
310
|
+
* value={field.value as string}
|
|
311
|
+
* onChange={(e) => field.handleChange(e.target.value)}
|
|
312
|
+
* onBlur={field.handleBlur}
|
|
313
|
+
* />
|
|
314
|
+
* )}
|
|
315
|
+
* </Form.Field>
|
|
316
|
+
*
|
|
317
|
+
* 본 Form.Control 은 cloneElement 패턴 잔존성 호환 — 자식 1개 제한, custom
|
|
318
|
+
* value (event 객체 외) 미지원, 자식의 기존 onChange/onBlur 가 chain merge
|
|
319
|
+
* 됨 (이전엔 override). 한 메이저 release 뒤 제거 예정.
|
|
320
|
+
*/
|
|
188
321
|
export function FormControl({
|
|
189
322
|
children,
|
|
190
323
|
valueAs = "value",
|
|
@@ -198,7 +331,7 @@ export function FormControl({
|
|
|
198
331
|
const describedBy =
|
|
199
332
|
cn(ctx.descId, field.hasError ? ctx.errorId : null) || undefined;
|
|
200
333
|
|
|
201
|
-
const
|
|
334
|
+
const baseCtrl: ControlProps = {
|
|
202
335
|
id: ctx.id,
|
|
203
336
|
name: ctx.path,
|
|
204
337
|
onChange: (e) => {
|
|
@@ -219,17 +352,40 @@ export function FormControl({
|
|
|
219
352
|
required: ctx.required,
|
|
220
353
|
};
|
|
221
354
|
|
|
222
|
-
if (field.hasError)
|
|
223
|
-
if (ctx.required)
|
|
355
|
+
if (field.hasError) baseCtrl["aria-invalid"] = true;
|
|
356
|
+
if (ctx.required) baseCtrl["aria-required"] = true;
|
|
224
357
|
|
|
225
358
|
if (valueAs === "checked") {
|
|
226
|
-
|
|
359
|
+
baseCtrl.checked = Boolean(field.value);
|
|
227
360
|
} else {
|
|
228
|
-
|
|
361
|
+
baseCtrl.value = field.value ?? "";
|
|
229
362
|
}
|
|
230
363
|
|
|
231
|
-
if (render) return render(
|
|
364
|
+
if (render) return render(baseCtrl);
|
|
232
365
|
if (!children) return null;
|
|
233
366
|
const child = React.Children.only(children);
|
|
234
|
-
|
|
367
|
+
|
|
368
|
+
// chain merge — 자식의 기존 onChange/onBlur 가 있으면 store sync 와 함께
|
|
369
|
+
// 둘 다 호출 (이전 버전의 silent override 함정 fix).
|
|
370
|
+
const childProps = (child.props ?? {}) as Partial<ControlProps> & {
|
|
371
|
+
onChange?: ControlProps["onChange"];
|
|
372
|
+
onBlur?: ControlProps["onBlur"];
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
const mergedCtrl: ControlProps = {
|
|
376
|
+
...baseCtrl,
|
|
377
|
+
onChange: (e) => {
|
|
378
|
+
baseCtrl.onChange(e);
|
|
379
|
+
childProps.onChange?.(e);
|
|
380
|
+
},
|
|
381
|
+
onBlur: () => {
|
|
382
|
+
baseCtrl.onBlur();
|
|
383
|
+
childProps.onBlur?.();
|
|
384
|
+
},
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
return React.cloneElement(
|
|
388
|
+
child,
|
|
389
|
+
mergedCtrl as unknown as Record<string, unknown>
|
|
390
|
+
);
|
|
235
391
|
}
|
|
@@ -4,6 +4,20 @@ import * as React from "react";
|
|
|
4
4
|
import { createFormStore, type CreateFormStoreOptions } from "./store";
|
|
5
5
|
import type { FormStore } from "./types";
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* useShUiForm — sh-ui 자체 store 를 mount 시점에 한 번만 생성 (RHF 안 쓸 때).
|
|
9
|
+
*
|
|
10
|
+
* **중요**: `options` 는 **첫 마운트 시점에만 캡처** 된다. 매 렌더 다른
|
|
11
|
+
* `schema` / `defaultValues` / `onSubmit` 을 넘겨도 무시. 동적으로 schema 가
|
|
12
|
+
* 바뀌어야 하면:
|
|
13
|
+
* - i18n 메시지: schema 를 useMemo 로 메모 + locale 변경 시 Form 자체를
|
|
14
|
+
* `key` prop 으로 리마운트
|
|
15
|
+
* - 또는 `<Form schema={...}>` prop 으로 — Form 컴포넌트가 매 렌더 prop 으로
|
|
16
|
+
* 읽음 (단 이 경로도 첫 schema 만 사용한다는 점 동일)
|
|
17
|
+
*
|
|
18
|
+
* RHF 와 함께 쓸 때는 본 hook 대신 `useReactHookFormAdapter(rhf)` 를
|
|
19
|
+
* `form-rhf` 패키지에서 import.
|
|
20
|
+
*/
|
|
7
21
|
export function useShUiForm<T = unknown>(
|
|
8
22
|
options?: CreateFormStoreOptions<T>
|
|
9
23
|
): FormStore<T> {
|
|
@@ -9,19 +9,149 @@ npm i react-hook-form
|
|
|
9
9
|
sh-ui add form-rhf
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
-
## 사용
|
|
12
|
+
## 사용 — `useReactHookFormAdapter` (권장)
|
|
13
13
|
|
|
14
14
|
```tsx
|
|
15
15
|
import { useForm } from "react-hook-form";
|
|
16
|
-
import {
|
|
16
|
+
import { zodResolver } from "@hookform/resolvers/zod";
|
|
17
|
+
import { z } from "zod";
|
|
18
|
+
import { useReactHookFormAdapter } from "@/components/ui/form-rhf";
|
|
17
19
|
import { Form } from "@/components/ui/form";
|
|
20
|
+
import { Input } from "@/components/ui/input";
|
|
18
21
|
|
|
19
|
-
const
|
|
20
|
-
|
|
22
|
+
const schema = z.object({
|
|
23
|
+
email: z.string().email("이메일 형식이 아닙니다"),
|
|
24
|
+
name: z.string().min(1, "이름을 입력하세요"),
|
|
25
|
+
});
|
|
26
|
+
type Values = z.infer<typeof schema>;
|
|
21
27
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
28
|
+
function MyForm() {
|
|
29
|
+
const rhf = useForm<Values>({
|
|
30
|
+
resolver: zodResolver(schema),
|
|
31
|
+
defaultValues: { email: "", name: "" },
|
|
32
|
+
mode: "onBlur",
|
|
33
|
+
});
|
|
34
|
+
const form = useReactHookFormAdapter<Values>(rhf);
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<Form form={form} onSubmit={(values) => console.log(values)}>
|
|
38
|
+
<Form.Field name="email">
|
|
39
|
+
{(field) => (
|
|
40
|
+
<>
|
|
41
|
+
<Form.Label>이메일</Form.Label>
|
|
42
|
+
<Input
|
|
43
|
+
id={field.id}
|
|
44
|
+
name={field.name}
|
|
45
|
+
type="email"
|
|
46
|
+
value={field.value as string}
|
|
47
|
+
onChange={(e) => field.handleChange(e.target.value)}
|
|
48
|
+
onBlur={field.handleBlur}
|
|
49
|
+
aria-invalid={field.ariaInvalid}
|
|
50
|
+
aria-describedby={field.ariaDescribedBy}
|
|
51
|
+
/>
|
|
52
|
+
<Form.Error />
|
|
53
|
+
</>
|
|
54
|
+
)}
|
|
55
|
+
</Form.Field>
|
|
56
|
+
|
|
57
|
+
<Form.Field name="name">
|
|
58
|
+
{(field) => (
|
|
59
|
+
<>
|
|
60
|
+
<Form.Label>이름</Form.Label>
|
|
61
|
+
<Input
|
|
62
|
+
id={field.id}
|
|
63
|
+
name={field.name}
|
|
64
|
+
value={field.value as string}
|
|
65
|
+
onChange={(e) => field.handleChange(e.target.value)}
|
|
66
|
+
onBlur={field.handleBlur}
|
|
67
|
+
/>
|
|
68
|
+
<Form.Error />
|
|
69
|
+
</>
|
|
70
|
+
)}
|
|
71
|
+
</Form.Field>
|
|
72
|
+
|
|
73
|
+
<button type="submit">제출</button>
|
|
74
|
+
</Form>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## API
|
|
80
|
+
|
|
81
|
+
### `useReactHookFormAdapter<T>(rhf, config?)` (v0.114+)
|
|
82
|
+
|
|
83
|
+
Hook 변종. **`useRef` 로 어댑터 인스턴스를 mount 시점에 안정화** — 매 렌더 새
|
|
84
|
+
store 함정 회피.
|
|
85
|
+
|
|
86
|
+
### `adaptReactHookForm<T>(rhf, config?)` (저레벨)
|
|
87
|
+
|
|
88
|
+
순수 함수. 호출자가 `useMemo` 등으로 직접 안정화 책임. **일반 사용에선 hook
|
|
89
|
+
변종을 권장.**
|
|
90
|
+
|
|
91
|
+
## 패턴
|
|
92
|
+
|
|
93
|
+
### 권장 — render prop (`<Form.Field>{(field) => ...}</Form.Field>`)
|
|
94
|
+
|
|
95
|
+
`field` 객체:
|
|
96
|
+
|
|
97
|
+
| key | 설명 |
|
|
98
|
+
|---|---|
|
|
99
|
+
| `value` | 현재 값 |
|
|
100
|
+
| `errors` / `error` / `hasError` | 검증 에러 (배열 / 첫 에러 / boolean) |
|
|
101
|
+
| `touched` / `isValidating` | 상태 |
|
|
102
|
+
| `handleChange(next)` | **next value 자체** 받음 (event 아님) — input/select/checkbox/custom 통일 |
|
|
103
|
+
| `handleBlur()` | blur 처리 + 검증 트리거 |
|
|
104
|
+
| `name` / `id` | path / element id |
|
|
105
|
+
| `ariaInvalid` / `ariaDescribedBy` / `disabled` / `readOnly` / `required` | a11y · DOM 메타 |
|
|
106
|
+
|
|
107
|
+
### 다른 입력 종류
|
|
108
|
+
|
|
109
|
+
```tsx
|
|
110
|
+
{/* Checkbox */}
|
|
111
|
+
<Form.Field name="agreed">
|
|
112
|
+
{(field) => (
|
|
113
|
+
<Checkbox
|
|
114
|
+
checked={field.value as boolean}
|
|
115
|
+
onCheckedChange={(v) => field.handleChange(v === true)}
|
|
116
|
+
/>
|
|
117
|
+
)}
|
|
118
|
+
</Form.Field>
|
|
119
|
+
|
|
120
|
+
{/* Select */}
|
|
121
|
+
<Form.Field name="role">
|
|
122
|
+
{(field) => (
|
|
123
|
+
<Select
|
|
124
|
+
value={field.value as string}
|
|
125
|
+
onValueChange={(v) => field.handleChange(v ?? "")}
|
|
126
|
+
>
|
|
127
|
+
...
|
|
128
|
+
</Select>
|
|
129
|
+
)}
|
|
130
|
+
</Form.Field>
|
|
131
|
+
|
|
132
|
+
{/* Custom (직접 만든 입력) */}
|
|
133
|
+
<Form.Field name="emoji">
|
|
134
|
+
{(field) => (
|
|
135
|
+
<EmojiPicker
|
|
136
|
+
value={field.value as string}
|
|
137
|
+
onChange={field.handleChange}
|
|
138
|
+
/>
|
|
139
|
+
)}
|
|
140
|
+
</Form.Field>
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### Legacy — `Form.Control` (v0.114 deprecated)
|
|
144
|
+
|
|
145
|
+
cloneElement 기반의 단순 케이스 helper. 자식 1개 제한, custom value 미지원,
|
|
146
|
+
한 메이저 release 뒤 제거 예정. 신규 코드는 render prop 사용.
|
|
147
|
+
|
|
148
|
+
```tsx
|
|
149
|
+
<Form.Field name="email">
|
|
150
|
+
<Form.Label>이메일</Form.Label>
|
|
151
|
+
<Form.Control><Input /></Form.Control> {/* deprecated */}
|
|
152
|
+
<Form.Error />
|
|
153
|
+
</Form.Field>
|
|
25
154
|
```
|
|
26
155
|
|
|
27
|
-
|
|
156
|
+
검증 규칙은 RHF 쪽 `resolver` 에서 일괄 관리. sh-ui 의 `schema` / `validateOn`
|
|
157
|
+
prop 은 어댑터 모드에서 무시된다.
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
1
4
|
import type { UseFormReturn, FieldValues } from "react-hook-form";
|
|
2
5
|
import type {
|
|
3
6
|
FormStore,
|
|
@@ -287,3 +290,75 @@ export function adaptReactHookForm<T extends FieldValues>(
|
|
|
287
290
|
|
|
288
291
|
return store;
|
|
289
292
|
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* useReactHookFormAdapter — `adaptReactHookForm` 의 hook 변종.
|
|
296
|
+
*
|
|
297
|
+
* 어댑터는 호출마다 새 `FormStore` 인스턴스 (listeners Set, snapshot cache 등)
|
|
298
|
+
* 를 만들기 때문에, 컴포넌트 body 에서 직접 호출하면 매 렌더 새 store →
|
|
299
|
+
* `<Form form={...}>` 의 context value 가 매 렌더 바뀜 → 자식
|
|
300
|
+
* `<Form.Field>` 가 매번 register/unregister → perf hit + 잠재 race.
|
|
301
|
+
*
|
|
302
|
+
* 본 hook 은 `useRef` 로 첫 마운트에 한 번만 어댑터를 생성해 안정화한다.
|
|
303
|
+
* RHF 인스턴스 (`rhf`) 자체가 `useForm` 결과라 mount 후 안정 → ref 도 안전.
|
|
304
|
+
*
|
|
305
|
+
* const rhf = useForm<FormValues>({ resolver: zodResolver(schema) });
|
|
306
|
+
* const form = useReactHookFormAdapter<FormValues>(rhf);
|
|
307
|
+
*
|
|
308
|
+
* return <Form form={form}>...</Form>;
|
|
309
|
+
*
|
|
310
|
+
* `config` 의 onSubmit 은 초기 캡처 — 매 렌더 다른 함수 넘기면 무시. 동적
|
|
311
|
+
* 콜백이 필요하면 `<Form onSubmit={...}>` prop 으로 (Form 컴포넌트가 매
|
|
312
|
+
* 렌더 capture).
|
|
313
|
+
*/
|
|
314
|
+
export function useReactHookFormAdapter<T extends FieldValues>(
|
|
315
|
+
rhf: UseFormReturn<T>,
|
|
316
|
+
config: AdapterConfig<T> = {}
|
|
317
|
+
): FormStore<T> {
|
|
318
|
+
// 1) adapter 인스턴스 안정화 (mount 시 한 번 생성).
|
|
319
|
+
const ref = React.useRef<FormStore<T> | null>(null);
|
|
320
|
+
if (!ref.current) {
|
|
321
|
+
ref.current = adaptReactHookForm<T>(rhf, config);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// 2) RHF 의 form state 변경을 React render path 에 명시적으로 결선.
|
|
325
|
+
//
|
|
326
|
+
// 어댑터 내부의 `rhf.subscribe` 만으로는 React `useSyncExternalStore` 의
|
|
327
|
+
// listener 가 useFormField 에서 호출되긴 하지만, **자식 컴포넌트 (Field /
|
|
328
|
+
// FieldRenderBridge)** 가 부모로부터 props · context 변경 신호를 못 받아
|
|
329
|
+
// 재렌더 안 되는 케이스가 있다 (특히 어댑터를 useRef 로 안정화한 경우
|
|
330
|
+
// FormContext value 가 동일 reference 라 React 가 sub-tree 를 skip).
|
|
331
|
+
//
|
|
332
|
+
// 본 컴포넌트에서 useReducer 로 force update 를 발화해 트리 전체가 재평가
|
|
333
|
+
// 되게 한다 — `rhf.watch(...)` 같은 hook 을 외부에서 쓰는 부담을 hook 자체에
|
|
334
|
+
// 흡수.
|
|
335
|
+
const [, forceUpdate] = React.useReducer((x: number) => x + 1, 0);
|
|
336
|
+
React.useEffect(() => {
|
|
337
|
+
if (typeof (rhf as unknown as { subscribe?: unknown }).subscribe !== "function") {
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
let unsub: (() => void) | undefined;
|
|
341
|
+
try {
|
|
342
|
+
unsub = (rhf as unknown as {
|
|
343
|
+
subscribe: (opts: {
|
|
344
|
+
formState: Record<string, boolean>;
|
|
345
|
+
callback: () => void;
|
|
346
|
+
}) => () => void;
|
|
347
|
+
}).subscribe({
|
|
348
|
+
formState: { values: true, errors: true, isSubmitting: true, touchedFields: true },
|
|
349
|
+
callback: () => forceUpdate(),
|
|
350
|
+
});
|
|
351
|
+
} catch {
|
|
352
|
+
// 구버전 RHF (< 7.50) 의 subscribe 미지원 — fallback 없음.
|
|
353
|
+
}
|
|
354
|
+
return () => {
|
|
355
|
+
try {
|
|
356
|
+
unsub?.();
|
|
357
|
+
} catch {
|
|
358
|
+
// ignore
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
}, [rhf]);
|
|
362
|
+
|
|
363
|
+
return ref.current;
|
|
364
|
+
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
14
|
-
|
|
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
|
|
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;
|