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.
@@ -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
  /* ───── 텍스트 계층 ───── */
@@ -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
- * 시각적 구분선. 가로(height=1px) / 세로(width=1px).
20
- *
21
- * 의미 있는 구분에는 `decorative={false}`로 role=separator가 붙고, 그렇지 않으면
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
- { className, orientation = "horizontal", decorative = true, ...props },
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 : orientation}
34
- aria-hidden={decorative || undefined}
35
- data-orientation={orientation}
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 변종). 가로(height=1px) / 세로(width=1px).
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
- { className, orientation = "horizontal", decorative = true, ...props },
28
+ {
29
+ className,
30
+ orientation = "horizontal",
31
+ align = "center",
32
+ decorative = true,
33
+ children,
34
+ ...props
35
+ },
23
36
  ref,
24
37
  ) {
25
- const sizing =
26
- orientation === "horizontal" ? "w-full h-px" : "w-px h-full self-stretch";
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 : orientation}
32
- aria-hidden={decorative || undefined}
33
- data-orientation={orientation}
34
- className={cn("bg-border shrink-0", sizing, className)}
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
- * 시각적 구분선. 가로(height=1px) / 세로(width=1px).
25
+ * 시각적 구분선. 변종을 한 컴포넌트로:
26
+ * - children 없음: 가로/세로 1px 선 (orientation 으로 선택)
27
+ * - children 있음: 가운데에 라벨이 있는 "──── label ────" 형식
28
+ * (horizontal 강제, align 으로 라벨 위치 지정)
20
29
  *
21
- * 의미 있는 구분에는 `decorative={false}`로 role=separator가 붙고, 그렇지 않으면
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
- { className, orientation = "horizontal", decorative = true, ...props },
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 : orientation}
34
- aria-hidden={decorative || undefined}
35
- data-orientation={orientation}
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 <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 안에 둔다. */