sh-ui-cli 0.46.0 → 0.48.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.
Files changed (85) hide show
  1. package/data/changelog/versions.json +25 -0
  2. package/data/registry/react/components/accordion/index.module.tsx +97 -0
  3. package/data/registry/react/components/accordion/styles.module.css +111 -0
  4. package/data/registry/react/components/avatar/index.module.tsx +73 -0
  5. package/data/registry/react/components/avatar/styles.module.css +36 -0
  6. package/data/registry/react/components/badge/index.module.tsx +40 -0
  7. package/data/registry/react/components/badge/styles.module.css +57 -0
  8. package/data/registry/react/components/breadcrumb/index.module.tsx +152 -0
  9. package/data/registry/react/components/breadcrumb/styles.module.css +82 -0
  10. package/data/registry/react/components/calendar/index.module.tsx +806 -0
  11. package/data/registry/react/components/calendar/styles.module.css +213 -0
  12. package/data/registry/react/components/carousel/index.module.tsx +430 -0
  13. package/data/registry/react/components/carousel/styles.module.css +155 -0
  14. package/data/registry/react/components/checkbox/index.module.tsx +96 -0
  15. package/data/registry/react/components/checkbox/styles.module.css +75 -0
  16. package/data/registry/react/components/code-editor/index.module.tsx +230 -0
  17. package/data/registry/react/components/code-editor/styles.module.css +76 -0
  18. package/data/registry/react/components/code-panel/index.module.tsx +191 -0
  19. package/data/registry/react/components/code-panel/styles.module.css +124 -0
  20. package/data/registry/react/components/color-picker/index.module.tsx +467 -0
  21. package/data/registry/react/components/color-picker/styles.module.css +166 -0
  22. package/data/registry/react/components/combobox/index.module.tsx +165 -0
  23. package/data/registry/react/components/combobox/styles.module.css +151 -0
  24. package/data/registry/react/components/context-menu/index.module.tsx +251 -0
  25. package/data/registry/react/components/context-menu/styles.module.css +140 -0
  26. package/data/registry/react/components/date-picker/index.module.tsx +520 -0
  27. package/data/registry/react/components/date-picker/styles.module.css +103 -0
  28. package/data/registry/react/components/dialog/index.module.tsx +95 -0
  29. package/data/registry/react/components/dialog/styles.module.css +127 -0
  30. package/data/registry/react/components/dropdown-menu/index.module.tsx +255 -0
  31. package/data/registry/react/components/dropdown-menu/styles.module.css +150 -0
  32. package/data/registry/react/components/file-upload/index.module.tsx +487 -0
  33. package/data/registry/react/components/file-upload/styles.module.css +170 -0
  34. package/data/registry/react/components/form/index.module.tsx +61 -0
  35. package/data/registry/react/components/form/styles.module.css +47 -0
  36. package/data/registry/react/components/header/index.module.tsx +805 -0
  37. package/data/registry/react/components/header/styles.module.css +350 -0
  38. package/data/registry/react/components/label/index.module.tsx +52 -0
  39. package/data/registry/react/components/label/styles.module.css +90 -0
  40. package/data/registry/react/components/markdown-editor/index.module.tsx +119 -0
  41. package/data/registry/react/components/markdown-editor/styles.module.css +160 -0
  42. package/data/registry/react/components/menubar/index.module.tsx +32 -0
  43. package/data/registry/react/components/menubar/styles.module.css +45 -0
  44. package/data/registry/react/components/numeric-input/index.module.tsx +148 -0
  45. package/data/registry/react/components/numeric-input/styles.module.css +56 -0
  46. package/data/registry/react/components/page-toc/index.module.tsx +174 -0
  47. package/data/registry/react/components/page-toc/styles.module.css +82 -0
  48. package/data/registry/react/components/pagination/index.module.tsx +269 -0
  49. package/data/registry/react/components/pagination/styles.module.css +105 -0
  50. package/data/registry/react/components/popover/index.module.tsx +113 -0
  51. package/data/registry/react/components/popover/styles.module.css +65 -0
  52. package/data/registry/react/components/progress/index.module.tsx +54 -0
  53. package/data/registry/react/components/progress/styles.module.css +41 -0
  54. package/data/registry/react/components/radio/index.module.tsx +65 -0
  55. package/data/registry/react/components/radio/styles.module.css +80 -0
  56. package/data/registry/react/components/rich-text-editor/index.module.tsx +348 -0
  57. package/data/registry/react/components/rich-text-editor/styles.module.css +196 -0
  58. package/data/registry/react/components/select/index.module.tsx +234 -0
  59. package/data/registry/react/components/select/styles.module.css +193 -0
  60. package/data/registry/react/components/separator/index.module.tsx +46 -0
  61. package/data/registry/react/components/separator/styles.module.css +15 -0
  62. package/data/registry/react/components/sidebar/index.module.tsx +1067 -0
  63. package/data/registry/react/components/sidebar/styles.module.css +502 -0
  64. package/data/registry/react/components/skeleton/index.module.tsx +22 -0
  65. package/data/registry/react/components/skeleton/styles.module.css +24 -0
  66. package/data/registry/react/components/slider/index.module.tsx +298 -0
  67. package/data/registry/react/components/slider/styles.module.css +64 -0
  68. package/data/registry/react/components/spinner/index.module.tsx +38 -0
  69. package/data/registry/react/components/spinner/styles.module.css +37 -0
  70. package/data/registry/react/components/switch/index.module.tsx +39 -0
  71. package/data/registry/react/components/switch/styles.module.css +83 -0
  72. package/data/registry/react/components/tabs/index.module.tsx +91 -0
  73. package/data/registry/react/components/tabs/styles.module.css +148 -0
  74. package/data/registry/react/components/textarea/index.module.tsx +23 -0
  75. package/data/registry/react/components/textarea/styles.module.css +54 -0
  76. package/data/registry/react/components/toast/index.module.tsx +258 -0
  77. package/data/registry/react/components/toast/styles.module.css +290 -0
  78. package/data/registry/react/components/toggle/index.module.tsx +131 -0
  79. package/data/registry/react/components/toggle/styles.module.css +85 -0
  80. package/data/registry/react/components/tooltip/index.module.tsx +83 -0
  81. package/data/registry/react/components/tooltip/styles.module.css +44 -0
  82. package/data/registry/react/registry.json +560 -0
  83. package/package.json +1 -1
  84. package/src/api.d.ts +4 -3
  85. package/src/constants.js +4 -3
@@ -0,0 +1,160 @@
1
+ .md-editor {
2
+ display: grid;
3
+ gap: var(--space-3);
4
+ }
5
+ .md-editor--right {
6
+ grid-template-columns: 1fr 1fr;
7
+ }
8
+ .md-editor--bottom,
9
+ .md-editor--no-preview {
10
+ grid-template-columns: 1fr;
11
+ }
12
+
13
+ @media (max-width: 768px) {
14
+ .md-editor--right {
15
+ grid-template-columns: 1fr;
16
+ }
17
+ }
18
+
19
+ .md-editor__source {
20
+ min-width: 0;
21
+ }
22
+
23
+ .md-editor__preview {
24
+ min-width: 0;
25
+ border: 1px solid var(--border);
26
+ border-radius: var(--radius);
27
+ background: var(--background);
28
+ overflow: hidden;
29
+ }
30
+
31
+ .md-editor__preview-inner {
32
+ padding: var(--space-3) var(--space-4);
33
+ min-height: var(--sh-ui-md-editor-min-height, 7.5rem);
34
+ max-height: var(--sh-ui-md-editor-max-height, 25rem);
35
+ overflow-y: auto;
36
+ font-size: 0.875rem;
37
+ line-height: 1.65;
38
+ color: var(--foreground);
39
+ }
40
+
41
+ /* 마크다운 본문 타이포그래피 — 토큰 기반, 페이지 기본 스타일과 충돌 없도록 자기 스코프 안에서만 */
42
+ .md-editor__preview-inner > :first-child {
43
+ margin-top: 0;
44
+ }
45
+ .md-editor__preview-inner > :last-child {
46
+ margin-bottom: 0;
47
+ }
48
+ .md-editor__preview-inner h1,
49
+ .md-editor__preview-inner h2,
50
+ .md-editor__preview-inner h3,
51
+ .md-editor__preview-inner h4,
52
+ .md-editor__preview-inner h5,
53
+ .md-editor__preview-inner h6 {
54
+ margin-top: var(--space-4);
55
+ margin-bottom: var(--space-2);
56
+ font-weight: 600;
57
+ line-height: 1.3;
58
+ color: var(--foreground);
59
+ }
60
+ .md-editor__preview-inner h1 { font-size: 1.5rem; }
61
+ .md-editor__preview-inner h2 { font-size: 1.25rem; }
62
+ .md-editor__preview-inner h3 { font-size: 1.125rem; }
63
+ .md-editor__preview-inner h4,
64
+ .md-editor__preview-inner h5,
65
+ .md-editor__preview-inner h6 { font-size: 1rem; }
66
+
67
+ .md-editor__preview-inner p,
68
+ .md-editor__preview-inner ul,
69
+ .md-editor__preview-inner ol,
70
+ .md-editor__preview-inner blockquote,
71
+ .md-editor__preview-inner pre,
72
+ .md-editor__preview-inner table {
73
+ margin-top: 0;
74
+ margin-bottom: var(--space-3);
75
+ }
76
+
77
+ .md-editor__preview-inner ul,
78
+ .md-editor__preview-inner ol {
79
+ padding-left: var(--space-5);
80
+ }
81
+ .md-editor__preview-inner li {
82
+ margin-bottom: var(--space-1);
83
+ }
84
+ .md-editor__preview-inner li > input[type="checkbox"] {
85
+ margin-right: var(--space-2);
86
+ }
87
+
88
+ .md-editor__preview-inner a {
89
+ color: var(--primary);
90
+ text-decoration: underline;
91
+ text-underline-offset: 2px;
92
+ }
93
+ .md-editor__preview-inner a:hover {
94
+ text-decoration-thickness: 2px;
95
+ }
96
+
97
+ .md-editor__preview-inner blockquote {
98
+ padding: var(--space-2) var(--space-3);
99
+ border-left: 3px solid var(--border-strong);
100
+ background: var(--background-subtle);
101
+ color: var(--foreground-muted);
102
+ border-radius: 0 calc(var(--radius) - 2px) calc(var(--radius) - 2px) 0;
103
+ }
104
+ .md-editor__preview-inner blockquote > :last-child {
105
+ margin-bottom: 0;
106
+ }
107
+
108
+ .md-editor__preview-inner code {
109
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
110
+ font-size: 0.875em;
111
+ padding: 0.125rem 0.375rem;
112
+ border-radius: calc(var(--radius) - 4px);
113
+ background: var(--background-muted);
114
+ color: var(--foreground);
115
+ }
116
+ .md-editor__preview-inner pre {
117
+ padding: var(--space-3);
118
+ border: 1px solid var(--border);
119
+ border-radius: var(--radius);
120
+ background: var(--background-subtle);
121
+ overflow-x: auto;
122
+ font-size: 0.8125rem;
123
+ line-height: 1.6;
124
+ }
125
+ .md-editor__preview-inner pre > code {
126
+ padding: 0;
127
+ background: transparent;
128
+ font-size: inherit;
129
+ }
130
+
131
+ .md-editor__preview-inner hr {
132
+ border: 0;
133
+ border-top: 1px solid var(--border);
134
+ margin: var(--space-4) 0;
135
+ }
136
+
137
+ .md-editor__preview-inner table {
138
+ width: 100%;
139
+ border-collapse: collapse;
140
+ font-size: 0.875rem;
141
+ }
142
+ .md-editor__preview-inner th,
143
+ .md-editor__preview-inner td {
144
+ padding: var(--space-2) var(--space-3);
145
+ border: 1px solid var(--border);
146
+ text-align: left;
147
+ }
148
+ .md-editor__preview-inner thead {
149
+ background: var(--background-subtle);
150
+ }
151
+
152
+ .md-editor__preview-inner img {
153
+ max-width: 100%;
154
+ height: auto;
155
+ border-radius: calc(var(--radius) - 2px);
156
+ }
157
+
158
+ .md-editor__preview-inner del {
159
+ color: var(--foreground-muted);
160
+ }
@@ -0,0 +1,32 @@
1
+ import * as React from "react";
2
+ import { Menubar as BaseMenubar } from "@base-ui/react/menubar";
3
+ import styles from "./styles.module.css";
4
+
5
+ import { cn } from "@SH_UI_UTILS@";
6
+ type WithStringClassName<T> = Omit<T, "className"> & { className?: string };
7
+
8
+
9
+ /**
10
+ * 상단 앱 메뉴바(파일/편집/보기 등). 내부에 DropdownMenu를 나란히 배치하여
11
+ * 좌우 화살표로 메뉴 간 이동이 가능해진다.
12
+ *
13
+ * <Menubar>
14
+ * <DropdownMenu>
15
+ * <DropdownMenuTrigger>파일</DropdownMenuTrigger>
16
+ * <DropdownMenuContent>...</DropdownMenuContent>
17
+ * </DropdownMenu>
18
+ * <DropdownMenu>...</DropdownMenu>
19
+ * </Menubar>
20
+ */
21
+ export const Menubar = React.forwardRef<
22
+ HTMLDivElement,
23
+ WithStringClassName<React.ComponentPropsWithoutRef<typeof BaseMenubar>>
24
+ >(function Menubar({ className, ...props }, ref) {
25
+ return (
26
+ <BaseMenubar
27
+ ref={ref}
28
+ className={cn(styles.menubar, className)}
29
+ {...props}
30
+ />
31
+ );
32
+ });
@@ -0,0 +1,45 @@
1
+ .menubar {
2
+ display: inline-flex;
3
+ align-items: center;
4
+ gap: var(--space-1);
5
+ padding: var(--space-1);
6
+ background: var(--background);
7
+ border: 1px solid var(--border);
8
+ border-radius: var(--radius);
9
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
10
+ }
11
+
12
+ /* Menubar 안의 DropdownMenuTrigger는 메뉴바 항목 스타일로 재지정.
13
+ dropdown-menu가 함께 설치되어 있어야 한다(registryDependencies).
14
+ 사용자가 render로 자체 엘리먼트를 넘겨 덮어쓸 수 있다. */
15
+ .menubar .dm__trigger {
16
+ display: inline-flex;
17
+ align-items: center;
18
+ gap: var(--space-1);
19
+ padding: var(--space-1) var(--space-3);
20
+ height: var(--control-md);
21
+ border: 0;
22
+ border-radius: calc(var(--radius) - 2px);
23
+ background: transparent;
24
+ color: var(--foreground);
25
+ font-size: var(--text-sm);
26
+ line-height: 1;
27
+ cursor: pointer;
28
+ transition: background-color var(--duration-fast), color var(--duration-fast);
29
+ }
30
+
31
+ .menubar .dm__trigger:hover,
32
+ .menubar .dm__trigger[data-popup-open] {
33
+ background: var(--background-muted);
34
+ }
35
+
36
+ .menubar .dm__trigger:focus-visible {
37
+ outline: var(--border-width-strong) solid var(--foreground);
38
+ outline-offset: -1px;
39
+ }
40
+
41
+ @media (prefers-reduced-motion: reduce) {
42
+ .menubar .dm__trigger {
43
+ transition: none;
44
+ }
45
+ }
@@ -0,0 +1,148 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import styles from "./styles.module.css";
5
+
6
+
7
+ import { cn } from "@SH_UI_UTILS@";
8
+ export interface NumericInputProps
9
+ extends Omit<
10
+ React.InputHTMLAttributes<HTMLInputElement>,
11
+ "value" | "defaultValue" | "onChange" | "type" | "min" | "max" | "step"
12
+ > {
13
+ /** 제어 모드 값. */
14
+ value?: number;
15
+ /** 비제어 모드 초기값. */
16
+ defaultValue?: number;
17
+ /** 값 변경 콜백. min/max 범위로 자동 clamp 된 값이 전달된다. */
18
+ onValueChange?: (value: number) => void;
19
+ /** 허용 최솟값. 입력값이 이보다 작으면 자동 clamp. */
20
+ min?: number;
21
+ /** 허용 최댓값. 입력값이 이보다 크면 자동 clamp. */
22
+ max?: number;
23
+ /** 화살표 키 step 폭. 디폴트 1. */
24
+ step?: number;
25
+ /** 값 우측에 부착할 단위 표시 (px / ms / % / ° 등). */
26
+ unit?: React.ReactNode;
27
+ }
28
+
29
+ /**
30
+ * 슬라이더 동반·토큰 편집 등 컴팩트 컨텍스트에 적합한 숫자 입력.
31
+ *
32
+ * 구현 특이점:
33
+ * - `type="text"` + `inputMode="decimal"` — type=number 가 Chrome 에서 select() 와
34
+ * selectionStart/End 를 지원하지 않아 "0 위에 2 타이핑 → 02" 회귀가 발생함.
35
+ * text 로 바꾸고 우리가 직접 숫자 검증/클램프.
36
+ * - 내부 buffer state — "-", "1.", "" 같이 입력 중간 transient 상태 허용. 유효한
37
+ * 숫자가 되는 순간 onValueChange 즉시 호출. 포커스 잃을 때 정규화.
38
+ * - focus 시 setTimeout(0) → select() — mouseup 의 커서 재배치 이후에 selection
39
+ * 적용되도록.
40
+ * - ArrowUp/Down 으로 step 조정, Enter 로 blur(commit).
41
+ *
42
+ * 일반 폼 입력에는 `Input` / `NumberInput` 사용 권장.
43
+ */
44
+ export const NumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
45
+ (
46
+ {
47
+ value,
48
+ defaultValue,
49
+ onValueChange,
50
+ min,
51
+ max,
52
+ step = 1,
53
+ unit,
54
+ className,
55
+ onFocus,
56
+ onBlur,
57
+ onKeyDown,
58
+ ...props
59
+ },
60
+ ref,
61
+ ) => {
62
+ const isControlled = value !== undefined;
63
+ const [internal, setInternal] = React.useState<number>(defaultValue ?? 0);
64
+ const current = isControlled ? value! : internal;
65
+
66
+ const [buffer, setBuffer] = React.useState<string>(() => String(current));
67
+ const focusedRef = React.useRef(false);
68
+
69
+ // 포커스 잡지 않은 동안 외부 value 변경되면 buffer 동기화.
70
+ React.useEffect(() => {
71
+ if (!focusedRef.current) setBuffer(String(current));
72
+ }, [current]);
73
+
74
+ const clamp = (n: number) => {
75
+ let v = n;
76
+ if (min !== undefined && v < min) v = min;
77
+ if (max !== undefined && v > max) v = max;
78
+ return v;
79
+ };
80
+
81
+ const commit = (n: number): number => {
82
+ const c = clamp(n);
83
+ if (!isControlled) setInternal(c);
84
+ onValueChange?.(c);
85
+ return c;
86
+ };
87
+
88
+ return (
89
+ <span className={styles["numeric-input"]}>
90
+ <input
91
+ ref={ref}
92
+ type="text"
93
+ inputMode="decimal"
94
+ className={cn(styles["numeric-input__input"], className)}
95
+ value={buffer}
96
+ onChange={(e) => {
97
+ const raw = e.target.value;
98
+ setBuffer(raw);
99
+ // 입력 중간 상태("", "-", ".", "-.") 는 commit 안 함 — 사용자 타이핑 흐름 유지.
100
+ if (raw === "" || raw === "-" || raw === "." || raw === "-.") return;
101
+ const n = Number(raw);
102
+ if (Number.isFinite(n)) commit(n);
103
+ }}
104
+ onFocus={(e) => {
105
+ focusedRef.current = true;
106
+ const t = e.currentTarget;
107
+ // setTimeout 0 로 미뤄야 mouseup 의 커서 재배치 이후에 select 가 적용됨.
108
+ setTimeout(() => t.select(), 0);
109
+ onFocus?.(e);
110
+ }}
111
+ onBlur={(e) => {
112
+ focusedRef.current = false;
113
+ const n = Number(buffer);
114
+ if (buffer !== "" && Number.isFinite(n)) {
115
+ const c = commit(n);
116
+ setBuffer(String(c));
117
+ } else {
118
+ // 비어있거나 NaN — 마지막 유효 값으로 복원
119
+ setBuffer(String(current));
120
+ }
121
+ onBlur?.(e);
122
+ }}
123
+ onKeyDown={(e) => {
124
+ if (e.key === "ArrowUp") {
125
+ e.preventDefault();
126
+ const next = commit(current + step);
127
+ setBuffer(String(next));
128
+ } else if (e.key === "ArrowDown") {
129
+ e.preventDefault();
130
+ const next = commit(current - step);
131
+ setBuffer(String(next));
132
+ } else if (e.key === "Enter") {
133
+ e.currentTarget.blur();
134
+ }
135
+ onKeyDown?.(e);
136
+ }}
137
+ {...props}
138
+ />
139
+ {unit !== undefined && unit !== "" && (
140
+ <span className={styles["numeric-input__unit"]} aria-hidden>
141
+ {unit}
142
+ </span>
143
+ )}
144
+ </span>
145
+ );
146
+ },
147
+ );
148
+ NumericInput.displayName = "NumericInput";
@@ -0,0 +1,56 @@
1
+ /* NumericInput — 컴팩트 monospace 숫자 입력. 슬라이더 동반·토큰 편집기 같은
2
+ 좁은 영역용. 일반 폼 입력은 Input / NumberInput 사용. */
3
+
4
+ .numeric-input {
5
+ display: inline-flex;
6
+ align-items: baseline;
7
+ gap: 2px;
8
+ min-width: 3rem;
9
+ justify-content: flex-end;
10
+ }
11
+
12
+ .numeric-input__input {
13
+ width: 2.5rem;
14
+ padding: 2px 4px;
15
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
16
+ font-size: var(--text-xs);
17
+ line-height: 1.2;
18
+ text-align: right;
19
+ border: 1px solid transparent;
20
+ border-radius: calc(var(--radius) - 4px);
21
+ background: transparent;
22
+ color: var(--foreground);
23
+ appearance: textfield;
24
+ -moz-appearance: textfield;
25
+ transition: border-color var(--duration-fast) var(--ease-standard),
26
+ background-color var(--duration-fast) var(--ease-standard);
27
+ }
28
+
29
+ /* WebKit 의 스피너 버튼 숨김 — 컴팩트 영역에 노이즈. 키보드 step 은 유지됨. */
30
+ .numeric-input__input::-webkit-inner-spin-button,
31
+ .numeric-input__input::-webkit-outer-spin-button {
32
+ -webkit-appearance: none;
33
+ margin: 0;
34
+ }
35
+
36
+ .numeric-input__input:hover:not(:disabled):not(:focus) {
37
+ border-color: var(--border);
38
+ }
39
+
40
+ .numeric-input__input:focus,
41
+ .numeric-input__input:focus-visible {
42
+ outline: none;
43
+ border-color: var(--foreground);
44
+ background: var(--background);
45
+ }
46
+
47
+ .numeric-input__input:disabled {
48
+ cursor: not-allowed;
49
+ opacity: var(--opacity-disabled);
50
+ }
51
+
52
+ .numeric-input__unit {
53
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
54
+ font-size: var(--text-xs);
55
+ color: var(--foreground-muted);
56
+ }
@@ -0,0 +1,174 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { cn } from "@SH_UI_UTILS@";
5
+ import styles from "./styles.module.css";
6
+
7
+ export type HeadingLevel = "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
8
+
9
+ export interface PageTOCProps {
10
+ /**
11
+ * 스캔할 컨테이너 selector. 기본 `"main"`.
12
+ */
13
+ containerSelector?: string;
14
+ /**
15
+ * 외부 신호로 TOC 재스캔. Next.js 사용 시 `usePathname()` 결과를 그대로 전달하면
16
+ * 라우트 변경 때마다 자동 갱신. 같은 페이지 안에서 헤딩이 동적으로 바뀌면 이 값을 갱신.
17
+ */
18
+ routeKey?: string;
19
+ /**
20
+ * sticky 헤더 아래로 헤딩이 가려지지 않도록 띄울 거리(rem). `scroll-margin-top` 으로 적용.
21
+ * @default 5
22
+ */
23
+ headerOffsetRem?: number;
24
+ /**
25
+ * 라벨 텍스트.
26
+ * @default "On this page"
27
+ */
28
+ label?: React.ReactNode;
29
+ /**
30
+ * 수집할 헤딩 레벨.
31
+ * @default ["h2", "h3"]
32
+ */
33
+ levels?: HeadingLevel[];
34
+ /**
35
+ * 제외할 헤딩 selector. 컨테이너 안에서 이 selector 의 자손인 헤딩은 무시.
36
+ * 데모 미리보기·중첩 위젯 등을 TOC 에서 빼고 싶을 때 사용.
37
+ */
38
+ excludeSelector?: string;
39
+ /** 추가 클래스. */
40
+ className?: string;
41
+ }
42
+
43
+ const slugify = (text: string): string =>
44
+ text
45
+ .trim()
46
+ .toLowerCase()
47
+ .replace(/[^\w\s가-힣-]/g, "")
48
+ .replace(/\s+/g, "-");
49
+
50
+ interface TocItem {
51
+ id: string;
52
+ text: string;
53
+ level: HeadingLevel;
54
+ }
55
+
56
+
57
+ /**
58
+ * 페이지 내 자동 목차 (On this page).
59
+ *
60
+ * 컨테이너 안의 지정한 헤딩 레벨을 스캔해 자동 slugify · id 부여 · `IntersectionObserver` 로
61
+ * 현재 보이는 섹션을 active 표시 · 클릭 시 smooth scroll. 라우터 비종속 — `routeKey` 를
62
+ * 외부에서 갱신하면 재스캔된다.
63
+ */
64
+ export function PageTOC({
65
+ containerSelector = "main",
66
+ routeKey,
67
+ headerOffsetRem = 5,
68
+ label = "On this page",
69
+ levels = ["h2", "h3"],
70
+ excludeSelector,
71
+ className,
72
+ }: PageTOCProps) {
73
+ const [items, setItems] = React.useState<TocItem[]>([]);
74
+ const [activeId, setActiveId] = React.useState<string | null>(null);
75
+
76
+ // levels 가 inline 배열(["h2", "h3"]) 로 전달되면 매 렌더마다 새 참조라 useEffect 가
77
+ // 무한 루프에 빠짐. 내용 기반 안정 키로 비교하고, 효과 안에서는 ref 로 최신 값 사용.
78
+ const levelsKey = levels.join(",");
79
+ const levelsRef = React.useRef(levels);
80
+ levelsRef.current = levels;
81
+
82
+ React.useEffect(() => {
83
+ const container = document.querySelector(containerSelector);
84
+ if (!container) {
85
+ setItems([]);
86
+ return;
87
+ }
88
+
89
+ const headingSelector = levelsRef.current.join(", ");
90
+ let headings = Array.from(
91
+ container.querySelectorAll<HTMLHeadingElement>(headingSelector),
92
+ );
93
+ if (excludeSelector) {
94
+ headings = headings.filter((h) => !h.closest(excludeSelector));
95
+ }
96
+
97
+ const usedIds = new Set<string>();
98
+ const collected: TocItem[] = headings.map((h) => {
99
+ const text = h.textContent?.trim() ?? "";
100
+ let id = h.id || slugify(text);
101
+ let suffix = 2;
102
+ const base = id;
103
+ while (!id || usedIds.has(id)) {
104
+ id = `${base}-${suffix++}`;
105
+ }
106
+ usedIds.add(id);
107
+ if (!h.id) h.id = id;
108
+ h.style.scrollMarginTop = `${headerOffsetRem}rem`;
109
+ const level = h.tagName.toLowerCase() as HeadingLevel;
110
+ return { id, text, level };
111
+ });
112
+
113
+ setItems(collected);
114
+
115
+ if (collected.length === 0) return;
116
+
117
+ const remInPx =
118
+ parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
119
+ const topOffsetPx = Math.round(headerOffsetRem * remInPx);
120
+
121
+ const observer = new IntersectionObserver(
122
+ (entries) => {
123
+ const visible = entries
124
+ .filter((e) => e.isIntersecting)
125
+ .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
126
+ if (visible.length > 0) {
127
+ setActiveId(visible[0].target.id);
128
+ }
129
+ },
130
+ {
131
+ rootMargin: `-${topOffsetPx}px 0px -70% 0px`,
132
+ threshold: 0,
133
+ },
134
+ );
135
+
136
+ headings.forEach((h) => observer.observe(h));
137
+ return () => observer.disconnect();
138
+ }, [containerSelector, headerOffsetRem, levelsKey, excludeSelector, routeKey]);
139
+
140
+ const handleClick = (event: React.MouseEvent<HTMLAnchorElement>, id: string) => {
141
+ event.preventDefault();
142
+ const el = document.getElementById(id);
143
+ if (!el) return;
144
+ el.scrollIntoView({ behavior: "smooth", block: "start" });
145
+ history.replaceState(null, "", `#${id}`);
146
+ setActiveId(id);
147
+ };
148
+
149
+ if (items.length === 0) return null;
150
+
151
+ return (
152
+ <nav
153
+ className={cn(styles["page-toc"], className)}
154
+ aria-label={typeof label === "string" ? label : "목차"}
155
+ >
156
+ <div className={styles["page-toc__label"]}>{label}</div>
157
+ <ul className={styles["page-toc__list"]}>
158
+ {items.map((item) => (
159
+ <li key={item.id} data-level={item.level.replace("h", "")}>
160
+ <a
161
+ href={`#${item.id}`}
162
+ onClick={(e) => handleClick(e, item.id)}
163
+ className={styles["page-toc__link"]}
164
+ data-active={activeId === item.id ? "true" : undefined}
165
+ aria-current={activeId === item.id ? "true" : undefined}
166
+ >
167
+ {item.text}
168
+ </a>
169
+ </li>
170
+ ))}
171
+ </ul>
172
+ </nav>
173
+ );
174
+ }
@@ -0,0 +1,82 @@
1
+ /* 페이지 내 목차 (On this page).
2
+ 기본은 우측 고정 레일 — 좁은 뷰포트(<80rem)에서는 자동 숨김. 다른 배치(예: 본문 상단 inline)
3
+ 가 필요하면 className override 또는 자체 wrapper 로 위치 재정의. */
4
+ .page-toc {
5
+ position: fixed;
6
+ top: 5rem;
7
+ right: 1.5rem;
8
+ width: 14rem;
9
+ max-height: calc(100vh - 7rem);
10
+ overflow-y: auto;
11
+ padding: 0.75rem 0.5rem 0.75rem 1rem;
12
+ border-left: 1px solid var(--border);
13
+ font-size: 0.8125rem;
14
+ z-index: 5;
15
+ }
16
+
17
+ .page-toc__label {
18
+ font-weight: 600;
19
+ font-size: 0.75rem;
20
+ color: var(--foreground-muted);
21
+ text-transform: uppercase;
22
+ letter-spacing: 0.04em;
23
+ margin-bottom: 0.5rem;
24
+ }
25
+
26
+ .page-toc__list {
27
+ list-style: none;
28
+ margin: 0;
29
+ padding: 0;
30
+ display: flex;
31
+ flex-direction: column;
32
+ gap: 0.125rem;
33
+ }
34
+
35
+ .page-toc__list > li[data-level="3"] .page-toc__link,
36
+ .page-toc__list > li[data-level="4"] .page-toc__link {
37
+ padding-left: 1.25rem;
38
+ font-size: 0.8125em;
39
+ color: var(--foreground-subtle, var(--foreground-muted));
40
+ }
41
+ .page-toc__list > li[data-level="5"] .page-toc__link,
42
+ .page-toc__list > li[data-level="6"] .page-toc__link {
43
+ padding-left: 2rem;
44
+ font-size: 0.75em;
45
+ color: var(--foreground-subtle, var(--foreground-muted));
46
+ }
47
+
48
+ .page-toc__link {
49
+ display: block;
50
+ padding: 0.25rem 0.5rem;
51
+ border-radius: calc(var(--radius) - 4px);
52
+ color: var(--foreground-muted);
53
+ text-decoration: none;
54
+ line-height: 1.4;
55
+ transition: color var(--duration-fast), background-color var(--duration-fast);
56
+ }
57
+ .page-toc__link:hover {
58
+ color: var(--foreground);
59
+ background: var(--background-subtle);
60
+ }
61
+ .page-toc__link:focus-visible {
62
+ outline: var(--border-width-strong) solid var(--foreground);
63
+ outline-offset: 2px;
64
+ }
65
+ .page-toc__link[data-active="true"] {
66
+ color: var(--foreground);
67
+ font-weight: 600;
68
+ background: var(--background-subtle);
69
+ }
70
+
71
+ /* 좁은 뷰포트에선 우측 레일 숨김 — 본문 너비를 침범하지 않도록 */
72
+ @media (max-width: 80rem) {
73
+ .page-toc {
74
+ display: none;
75
+ }
76
+ }
77
+
78
+ @media (prefers-reduced-motion: reduce) {
79
+ .page-toc__link {
80
+ transition: none;
81
+ }
82
+ }