sh-ui-cli 0.112.0 → 0.114.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/data/changelog/versions.json +25 -0
- package/data/registry/react/components/form/field.test.tsx +106 -1
- package/data/registry/react/components/form/field.tsx +179 -23
- package/data/registry/react/components/form/use-sh-ui-form.ts +14 -0
- package/data/registry/react/components/form-rhf/README.md +138 -8
- package/data/registry/react/components/form-rhf/index.tsx +75 -0
- package/data/registry/react/components/form-rhf/rhf.test.tsx +53 -1
- package/data/registry/react/components/label/index.tailwind.tsx +5 -1
- package/data/registry/react/components/label/styles.css +9 -5
- package/data/registry/react/components/label/styles.module.css +7 -5
- package/data/registry/react/components/separator/index.module.tsx +47 -15
- package/data/registry/react/components/separator/index.tailwind.tsx +52 -10
- package/data/registry/react/components/separator/index.tsx +51 -13
- package/data/registry/react/components/separator/styles.css +21 -0
- package/data/registry/react/components/separator/styles.module.css +20 -0
- package/data/registry/react/components/sidebar/index.module.tsx +57 -0
- package/data/registry/react/components/sidebar/index.tailwind.tsx +68 -1
- package/data/registry/react/components/sidebar/index.tsx +57 -0
- package/data/registry/react/components/sidebar/styles.css +77 -0
- package/data/registry/react/components/sidebar/styles.module.css +77 -0
- package/data/registry/react/tokens-used.json +5 -3
- package/data/summaries/react.json +1 -1
- package/package.json +1 -1
|
@@ -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
|
/* ───── 텍스트 계층 ───── */
|
|
@@ -4,42 +4,74 @@ import styles from "./styles.module.css";
|
|
|
4
4
|
|
|
5
5
|
import { cn } from "@SH_UI_UTILS@";
|
|
6
6
|
export type SeparatorOrientation = "horizontal" | "vertical";
|
|
7
|
+
export type SeparatorAlign = "start" | "center" | "end";
|
|
7
8
|
|
|
8
9
|
export interface SeparatorProps
|
|
9
10
|
extends Omit<React.HTMLAttributes<HTMLDivElement>, "role"> {
|
|
10
11
|
orientation?: SeparatorOrientation;
|
|
12
|
+
/**
|
|
13
|
+
* 라벨 정렬 — children 이 있을 때만 의미를 가진다. 기본 center.
|
|
14
|
+
*/
|
|
15
|
+
align?: SeparatorAlign;
|
|
11
16
|
/**
|
|
12
17
|
* 의미 없는 시각적 구분선인지 여부. 기본 true(aria-hidden).
|
|
13
|
-
* 스크린리더에도 섹션 구분을 알려야 하면 false.
|
|
14
18
|
*/
|
|
15
19
|
decorative?: boolean;
|
|
16
20
|
}
|
|
17
21
|
|
|
18
22
|
/**
|
|
19
|
-
* 시각적
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* aria-hidden 처리되어 보조 기술에 노출되지 않는다.
|
|
23
|
+
* 시각적 구분선 (CSS Modules 변종). 두 변종을 한 컴포넌트로:
|
|
24
|
+
* - children 없음: 가로/세로 1px
|
|
25
|
+
* - children 있음: 가운데 라벨이 있는 "──── label ────" 형식
|
|
23
26
|
*/
|
|
24
27
|
export const Separator = React.forwardRef<HTMLDivElement, SeparatorProps>(
|
|
25
28
|
function Separator(
|
|
26
|
-
{
|
|
29
|
+
{
|
|
30
|
+
className,
|
|
31
|
+
orientation = "horizontal",
|
|
32
|
+
align = "center",
|
|
33
|
+
decorative = true,
|
|
34
|
+
children,
|
|
35
|
+
...props
|
|
36
|
+
},
|
|
27
37
|
ref,
|
|
28
38
|
) {
|
|
39
|
+
const hasLabel = children != null && children !== false;
|
|
40
|
+
if (!hasLabel) {
|
|
41
|
+
return (
|
|
42
|
+
<div
|
|
43
|
+
ref={ref}
|
|
44
|
+
role={decorative ? undefined : "separator"}
|
|
45
|
+
aria-orientation={decorative ? undefined : orientation}
|
|
46
|
+
aria-hidden={decorative || undefined}
|
|
47
|
+
data-orientation={orientation}
|
|
48
|
+
className={cn(
|
|
49
|
+
styles.separator,
|
|
50
|
+
styles[`separator--${orientation}`],
|
|
51
|
+
className,
|
|
52
|
+
)}
|
|
53
|
+
{...props}
|
|
54
|
+
/>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
29
57
|
return (
|
|
30
58
|
<div
|
|
31
59
|
ref={ref}
|
|
32
60
|
role={decorative ? undefined : "separator"}
|
|
33
|
-
aria-orientation={decorative ? undefined :
|
|
34
|
-
|
|
35
|
-
data-
|
|
36
|
-
className={cn(
|
|
37
|
-
styles.separator,
|
|
38
|
-
styles[`separator--${orientation}`],
|
|
39
|
-
className,
|
|
40
|
-
)}
|
|
61
|
+
aria-orientation={decorative ? undefined : "horizontal"}
|
|
62
|
+
data-orientation="horizontal"
|
|
63
|
+
data-align={align}
|
|
64
|
+
className={cn(styles.separator__labeled, className)}
|
|
41
65
|
{...props}
|
|
42
|
-
|
|
66
|
+
>
|
|
67
|
+
{align !== "start" ? (
|
|
68
|
+
<span aria-hidden className={styles.separator__line} />
|
|
69
|
+
) : null}
|
|
70
|
+
<span className={styles.separator__label}>{children}</span>
|
|
71
|
+
{align !== "end" ? (
|
|
72
|
+
<span aria-hidden className={styles.separator__line} />
|
|
73
|
+
) : null}
|
|
74
|
+
</div>
|
|
43
75
|
);
|
|
44
76
|
},
|
|
45
77
|
);
|
|
@@ -3,37 +3,79 @@ import * as React from "react";
|
|
|
3
3
|
|
|
4
4
|
import { cn } from "@SH_UI_UTILS@";
|
|
5
5
|
export type SeparatorOrientation = "horizontal" | "vertical";
|
|
6
|
+
export type SeparatorAlign = "start" | "center" | "end";
|
|
6
7
|
|
|
7
8
|
export interface SeparatorProps
|
|
8
9
|
extends Omit<React.HTMLAttributes<HTMLDivElement>, "role"> {
|
|
9
10
|
orientation?: SeparatorOrientation;
|
|
11
|
+
/**
|
|
12
|
+
* 라벨 정렬 — children 이 있을 때만 의미를 가진다. 기본 center.
|
|
13
|
+
*/
|
|
14
|
+
align?: SeparatorAlign;
|
|
10
15
|
/**
|
|
11
16
|
* 의미 없는 시각적 구분선인지 여부. 기본 true(aria-hidden).
|
|
12
|
-
* 스크린리더에도 섹션 구분을 알려야 하면 false.
|
|
13
17
|
*/
|
|
14
18
|
decorative?: boolean;
|
|
15
19
|
}
|
|
16
20
|
|
|
17
21
|
/**
|
|
18
|
-
* 시각적 구분선 (Tailwind utility 변종).
|
|
22
|
+
* 시각적 구분선 (Tailwind utility 변종). 두 변종을 한 컴포넌트로:
|
|
23
|
+
* - children 없음: 가로(height=1px) / 세로(width=1px)
|
|
24
|
+
* - children 있음: 가운데에 라벨이 있는 "──── label ────" 형식
|
|
19
25
|
*/
|
|
20
26
|
export const Separator = React.forwardRef<HTMLDivElement, SeparatorProps>(
|
|
21
27
|
function Separator(
|
|
22
|
-
{
|
|
28
|
+
{
|
|
29
|
+
className,
|
|
30
|
+
orientation = "horizontal",
|
|
31
|
+
align = "center",
|
|
32
|
+
decorative = true,
|
|
33
|
+
children,
|
|
34
|
+
...props
|
|
35
|
+
},
|
|
23
36
|
ref,
|
|
24
37
|
) {
|
|
25
|
-
const
|
|
26
|
-
|
|
38
|
+
const hasLabel = children != null && children !== false;
|
|
39
|
+
if (!hasLabel) {
|
|
40
|
+
const sizing =
|
|
41
|
+
orientation === "horizontal"
|
|
42
|
+
? "w-full h-px"
|
|
43
|
+
: "w-px h-full self-stretch";
|
|
44
|
+
return (
|
|
45
|
+
<div
|
|
46
|
+
ref={ref}
|
|
47
|
+
role={decorative ? undefined : "separator"}
|
|
48
|
+
aria-orientation={decorative ? undefined : orientation}
|
|
49
|
+
aria-hidden={decorative || undefined}
|
|
50
|
+
data-orientation={orientation}
|
|
51
|
+
className={cn("bg-border shrink-0", sizing, className)}
|
|
52
|
+
{...props}
|
|
53
|
+
/>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
27
56
|
return (
|
|
28
57
|
<div
|
|
29
58
|
ref={ref}
|
|
30
59
|
role={decorative ? undefined : "separator"}
|
|
31
|
-
aria-orientation={decorative ? undefined :
|
|
32
|
-
|
|
33
|
-
data-
|
|
34
|
-
className={cn(
|
|
60
|
+
aria-orientation={decorative ? undefined : "horizontal"}
|
|
61
|
+
data-orientation="horizontal"
|
|
62
|
+
data-align={align}
|
|
63
|
+
className={cn(
|
|
64
|
+
"flex w-full shrink-0 items-center gap-[var(--space-3,0.75rem)]",
|
|
65
|
+
className,
|
|
66
|
+
)}
|
|
35
67
|
{...props}
|
|
36
|
-
|
|
68
|
+
>
|
|
69
|
+
{align !== "start" ? (
|
|
70
|
+
<span aria-hidden className="h-px flex-1 bg-border" />
|
|
71
|
+
) : null}
|
|
72
|
+
<span className="text-[length:var(--text-xs,0.75rem)] font-semibold uppercase tracking-[0.04em] text-foreground-subtle">
|
|
73
|
+
{children}
|
|
74
|
+
</span>
|
|
75
|
+
{align !== "end" ? (
|
|
76
|
+
<span aria-hidden className="h-px flex-1 bg-border" />
|
|
77
|
+
) : null}
|
|
78
|
+
</div>
|
|
37
79
|
);
|
|
38
80
|
},
|
|
39
81
|
);
|
|
@@ -4,10 +4,16 @@ import "./styles.css";
|
|
|
4
4
|
|
|
5
5
|
import { cn } from "@SH_UI_UTILS@";
|
|
6
6
|
export type SeparatorOrientation = "horizontal" | "vertical";
|
|
7
|
+
export type SeparatorAlign = "start" | "center" | "end";
|
|
7
8
|
|
|
8
9
|
export interface SeparatorProps
|
|
9
10
|
extends Omit<React.HTMLAttributes<HTMLDivElement>, "role"> {
|
|
10
11
|
orientation?: SeparatorOrientation;
|
|
12
|
+
/**
|
|
13
|
+
* 라벨 정렬 — children 이 있을 때만 의미를 가진다. 기본 center.
|
|
14
|
+
* `start` 면 라벨이 왼쪽에 붙고 오른쪽으로만 선이 뻗는다 (반대도 마찬가지).
|
|
15
|
+
*/
|
|
16
|
+
align?: SeparatorAlign;
|
|
11
17
|
/**
|
|
12
18
|
* 의미 없는 시각적 구분선인지 여부. 기본 true(aria-hidden).
|
|
13
19
|
* 스크린리더에도 섹션 구분을 알려야 하면 false.
|
|
@@ -16,30 +22,62 @@ export interface SeparatorProps
|
|
|
16
22
|
}
|
|
17
23
|
|
|
18
24
|
/**
|
|
19
|
-
* 시각적 구분선.
|
|
25
|
+
* 시각적 구분선. 두 변종을 한 컴포넌트로:
|
|
26
|
+
* - children 없음: 가로/세로 1px 선 (orientation 으로 선택)
|
|
27
|
+
* - children 있음: 가운데에 라벨이 있는 "──── label ────" 형식
|
|
28
|
+
* (horizontal 강제, align 으로 라벨 위치 지정)
|
|
20
29
|
*
|
|
21
|
-
* 의미 있는 구분에는 `decorative={false}
|
|
22
|
-
* aria-hidden 처리되어 보조 기술에 노출되지 않는다.
|
|
30
|
+
* 의미 있는 구분에는 `decorative={false}` 로 role=separator 가 붙고,
|
|
31
|
+
* 그렇지 않으면 aria-hidden 처리되어 보조 기술에 노출되지 않는다.
|
|
23
32
|
*/
|
|
24
33
|
export const Separator = React.forwardRef<HTMLDivElement, SeparatorProps>(
|
|
25
34
|
function Separator(
|
|
26
|
-
{
|
|
35
|
+
{
|
|
36
|
+
className,
|
|
37
|
+
orientation = "horizontal",
|
|
38
|
+
align = "center",
|
|
39
|
+
decorative = true,
|
|
40
|
+
children,
|
|
41
|
+
...props
|
|
42
|
+
},
|
|
27
43
|
ref,
|
|
28
44
|
) {
|
|
45
|
+
const hasLabel = children != null && children !== false;
|
|
46
|
+
if (!hasLabel) {
|
|
47
|
+
return (
|
|
48
|
+
<div
|
|
49
|
+
ref={ref}
|
|
50
|
+
role={decorative ? undefined : "separator"}
|
|
51
|
+
aria-orientation={decorative ? undefined : orientation}
|
|
52
|
+
aria-hidden={decorative || undefined}
|
|
53
|
+
data-orientation={orientation}
|
|
54
|
+
className={cn(
|
|
55
|
+
"sh-ui-separator",
|
|
56
|
+
`sh-ui-separator--${orientation}`,
|
|
57
|
+
className,
|
|
58
|
+
)}
|
|
59
|
+
{...props}
|
|
60
|
+
/>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
29
63
|
return (
|
|
30
64
|
<div
|
|
31
65
|
ref={ref}
|
|
32
66
|
role={decorative ? undefined : "separator"}
|
|
33
|
-
aria-orientation={decorative ? undefined :
|
|
34
|
-
|
|
35
|
-
data-
|
|
36
|
-
className={cn(
|
|
37
|
-
"sh-ui-separator",
|
|
38
|
-
`sh-ui-separator--${orientation}`,
|
|
39
|
-
className,
|
|
40
|
-
)}
|
|
67
|
+
aria-orientation={decorative ? undefined : "horizontal"}
|
|
68
|
+
data-orientation="horizontal"
|
|
69
|
+
data-align={align}
|
|
70
|
+
className={cn("sh-ui-separator--labeled", className)}
|
|
41
71
|
{...props}
|
|
42
|
-
|
|
72
|
+
>
|
|
73
|
+
{align !== "start" ? (
|
|
74
|
+
<span aria-hidden className="sh-ui-separator__line" />
|
|
75
|
+
) : null}
|
|
76
|
+
<span className="sh-ui-separator__label">{children}</span>
|
|
77
|
+
{align !== "end" ? (
|
|
78
|
+
<span aria-hidden className="sh-ui-separator__line" />
|
|
79
|
+
) : null}
|
|
80
|
+
</div>
|
|
43
81
|
);
|
|
44
82
|
},
|
|
45
83
|
);
|
|
@@ -13,3 +13,24 @@
|
|
|
13
13
|
height: 100%;
|
|
14
14
|
align-self: stretch;
|
|
15
15
|
}
|
|
16
|
+
|
|
17
|
+
/* labeled — children 있을 때 적용. 컨테이너 자체에는 배경 없음 (내부 .line 만 1px). */
|
|
18
|
+
.sh-ui-separator--labeled {
|
|
19
|
+
display: flex;
|
|
20
|
+
align-items: center;
|
|
21
|
+
gap: var(--space-3, 0.75rem);
|
|
22
|
+
width: 100%;
|
|
23
|
+
flex-shrink: 0;
|
|
24
|
+
}
|
|
25
|
+
.sh-ui-separator__line {
|
|
26
|
+
flex: 1;
|
|
27
|
+
height: 1px;
|
|
28
|
+
background: var(--border);
|
|
29
|
+
}
|
|
30
|
+
.sh-ui-separator__label {
|
|
31
|
+
font-size: var(--text-xs, 0.75rem);
|
|
32
|
+
font-weight: 600;
|
|
33
|
+
text-transform: uppercase;
|
|
34
|
+
letter-spacing: 0.04em;
|
|
35
|
+
color: var(--foreground-subtle);
|
|
36
|
+
}
|
|
@@ -13,3 +13,23 @@
|
|
|
13
13
|
height: 100%;
|
|
14
14
|
align-self: stretch;
|
|
15
15
|
}
|
|
16
|
+
|
|
17
|
+
.separator__labeled {
|
|
18
|
+
display: flex;
|
|
19
|
+
align-items: center;
|
|
20
|
+
gap: var(--space-3, 0.75rem);
|
|
21
|
+
width: 100%;
|
|
22
|
+
flex-shrink: 0;
|
|
23
|
+
}
|
|
24
|
+
.separator__line {
|
|
25
|
+
flex: 1;
|
|
26
|
+
height: 1px;
|
|
27
|
+
background: var(--border);
|
|
28
|
+
}
|
|
29
|
+
.separator__label {
|
|
30
|
+
font-size: var(--text-xs, 0.75rem);
|
|
31
|
+
font-weight: 600;
|
|
32
|
+
text-transform: uppercase;
|
|
33
|
+
letter-spacing: 0.04em;
|
|
34
|
+
color: var(--foreground-subtle);
|
|
35
|
+
}
|
|
@@ -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 안에 둔다. */
|