sh-ui-cli 0.45.3 → 0.47.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 (93) hide show
  1. package/data/changelog/versions.json +26 -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/button/index.module.tsx +45 -0
  11. package/data/registry/react/components/button/styles.module.css +92 -0
  12. package/data/registry/react/components/calendar/index.module.tsx +806 -0
  13. package/data/registry/react/components/calendar/styles.module.css +213 -0
  14. package/data/registry/react/components/card/index.module.tsx +63 -0
  15. package/data/registry/react/components/card/styles.module.css +73 -0
  16. package/data/registry/react/components/carousel/index.module.tsx +430 -0
  17. package/data/registry/react/components/carousel/styles.module.css +155 -0
  18. package/data/registry/react/components/checkbox/index.module.tsx +96 -0
  19. package/data/registry/react/components/checkbox/styles.module.css +75 -0
  20. package/data/registry/react/components/code-editor/index.module.tsx +230 -0
  21. package/data/registry/react/components/code-editor/styles.module.css +76 -0
  22. package/data/registry/react/components/code-panel/index.module.tsx +191 -0
  23. package/data/registry/react/components/code-panel/styles.module.css +124 -0
  24. package/data/registry/react/components/color-picker/index.module.tsx +467 -0
  25. package/data/registry/react/components/color-picker/styles.module.css +166 -0
  26. package/data/registry/react/components/combobox/index.module.tsx +165 -0
  27. package/data/registry/react/components/combobox/styles.module.css +151 -0
  28. package/data/registry/react/components/context-menu/index.module.tsx +251 -0
  29. package/data/registry/react/components/context-menu/styles.module.css +140 -0
  30. package/data/registry/react/components/date-picker/index.module.tsx +520 -0
  31. package/data/registry/react/components/date-picker/styles.module.css +103 -0
  32. package/data/registry/react/components/dialog/index.module.tsx +95 -0
  33. package/data/registry/react/components/dialog/styles.module.css +127 -0
  34. package/data/registry/react/components/dropdown-menu/index.module.tsx +255 -0
  35. package/data/registry/react/components/dropdown-menu/styles.module.css +150 -0
  36. package/data/registry/react/components/file-upload/index.module.tsx +487 -0
  37. package/data/registry/react/components/file-upload/styles.module.css +170 -0
  38. package/data/registry/react/components/form/index.module.tsx +61 -0
  39. package/data/registry/react/components/form/styles.module.css +47 -0
  40. package/data/registry/react/components/header/index.module.tsx +805 -0
  41. package/data/registry/react/components/header/styles.module.css +350 -0
  42. package/data/registry/react/components/input/index.module.tsx +486 -0
  43. package/data/registry/react/components/input/styles.module.css +200 -0
  44. package/data/registry/react/components/label/index.module.tsx +52 -0
  45. package/data/registry/react/components/label/styles.module.css +90 -0
  46. package/data/registry/react/components/markdown-editor/index.module.tsx +119 -0
  47. package/data/registry/react/components/markdown-editor/styles.module.css +160 -0
  48. package/data/registry/react/components/menubar/index.module.tsx +32 -0
  49. package/data/registry/react/components/menubar/styles.module.css +45 -0
  50. package/data/registry/react/components/numeric-input/index.module.tsx +148 -0
  51. package/data/registry/react/components/numeric-input/styles.module.css +56 -0
  52. package/data/registry/react/components/page-toc/index.module.tsx +174 -0
  53. package/data/registry/react/components/page-toc/styles.module.css +82 -0
  54. package/data/registry/react/components/pagination/index.module.tsx +269 -0
  55. package/data/registry/react/components/pagination/styles.module.css +105 -0
  56. package/data/registry/react/components/popover/index.module.tsx +113 -0
  57. package/data/registry/react/components/popover/styles.module.css +65 -0
  58. package/data/registry/react/components/progress/index.module.tsx +54 -0
  59. package/data/registry/react/components/progress/styles.module.css +41 -0
  60. package/data/registry/react/components/radio/index.module.tsx +65 -0
  61. package/data/registry/react/components/radio/styles.module.css +80 -0
  62. package/data/registry/react/components/rich-text-editor/index.module.tsx +348 -0
  63. package/data/registry/react/components/rich-text-editor/styles.module.css +196 -0
  64. package/data/registry/react/components/select/index.module.tsx +234 -0
  65. package/data/registry/react/components/select/styles.module.css +193 -0
  66. package/data/registry/react/components/separator/index.module.tsx +46 -0
  67. package/data/registry/react/components/separator/styles.module.css +15 -0
  68. package/data/registry/react/components/sidebar/index.module.tsx +1067 -0
  69. package/data/registry/react/components/sidebar/styles.module.css +502 -0
  70. package/data/registry/react/components/skeleton/index.module.tsx +22 -0
  71. package/data/registry/react/components/skeleton/styles.module.css +24 -0
  72. package/data/registry/react/components/slider/index.module.tsx +298 -0
  73. package/data/registry/react/components/slider/styles.module.css +64 -0
  74. package/data/registry/react/components/spinner/index.module.tsx +38 -0
  75. package/data/registry/react/components/spinner/styles.module.css +37 -0
  76. package/data/registry/react/components/switch/index.module.tsx +39 -0
  77. package/data/registry/react/components/switch/styles.module.css +83 -0
  78. package/data/registry/react/components/tabs/index.module.tsx +91 -0
  79. package/data/registry/react/components/tabs/styles.module.css +148 -0
  80. package/data/registry/react/components/textarea/index.module.tsx +23 -0
  81. package/data/registry/react/components/textarea/styles.module.css +54 -0
  82. package/data/registry/react/components/toast/index.module.tsx +258 -0
  83. package/data/registry/react/components/toast/styles.module.css +290 -0
  84. package/data/registry/react/components/toggle/index.module.tsx +131 -0
  85. package/data/registry/react/components/toggle/styles.module.css +85 -0
  86. package/data/registry/react/components/tooltip/index.module.tsx +83 -0
  87. package/data/registry/react/components/tooltip/styles.module.css +44 -0
  88. package/data/registry/react/registry.json +604 -1
  89. package/data/tokens/build.mjs +4 -0
  90. package/package.json +1 -1
  91. package/src/add.mjs +12 -12
  92. package/src/api.d.ts +4 -3
  93. package/src/constants.js +4 -3
@@ -0,0 +1,75 @@
1
+ /* ───────────── Checkbox ───────────── */
2
+
3
+ .checkbox {
4
+ display: inline-flex;
5
+ align-items: center;
6
+ justify-content: center;
7
+ width: 1.125rem;
8
+ height: 1.125rem;
9
+ border: 1px solid var(--border-strong);
10
+ border-radius: calc(var(--radius) - 2px);
11
+ background: var(--background);
12
+ color: var(--primary-foreground);
13
+ cursor: pointer;
14
+ flex-shrink: 0;
15
+ transition: background-color var(--duration-fast), border-color var(--duration-fast);
16
+ -webkit-tap-highlight-color: transparent;
17
+ }
18
+
19
+ .checkbox:hover:not([data-disabled]) {
20
+ border-color: var(--foreground);
21
+ }
22
+
23
+ .checkbox:focus-visible {
24
+ outline: var(--border-width-strong) solid var(--foreground);
25
+ outline-offset: 2px;
26
+ }
27
+
28
+ .checkbox[data-checked],
29
+ .checkbox[data-indeterminate] {
30
+ background: var(--primary);
31
+ border-color: var(--primary);
32
+ }
33
+
34
+ .checkbox[data-disabled] {
35
+ opacity: var(--opacity-disabled);
36
+ cursor: not-allowed;
37
+ }
38
+
39
+ /* 모바일/터치: 최소 탭 영역 */
40
+ @media (hover: none) and (pointer: coarse) {
41
+ .checkbox {
42
+ width: 1.25rem;
43
+ height: 1.25rem;
44
+ }
45
+ }
46
+
47
+ /* ───────────── Indicator ───────────── */
48
+
49
+ .checkbox__indicator {
50
+ display: inline-flex;
51
+ align-items: center;
52
+ justify-content: center;
53
+ }
54
+
55
+ /* ───────────── CheckboxGroup ───────────── */
56
+
57
+ .checkbox-group {
58
+ display: flex;
59
+ gap: 0.625rem;
60
+ }
61
+
62
+ .checkbox-group[data-orientation="vertical"] {
63
+ flex-direction: column;
64
+ }
65
+
66
+ .checkbox-group[data-orientation="horizontal"] {
67
+ flex-direction: row;
68
+ flex-wrap: wrap;
69
+ }
70
+
71
+ @media (prefers-reduced-motion: reduce) {
72
+ .checkbox {
73
+ transition: none;
74
+ }
75
+ }
@@ -0,0 +1,230 @@
1
+ "use client";
2
+
3
+ import { useEffect, useMemo, useRef } from "react";
4
+ import { Compartment, EditorState, type Extension } from "@codemirror/state";
5
+ import { EditorView, placeholder as placeholderExt } from "@codemirror/view";
6
+ import { basicSetup } from "codemirror";
7
+ import { javascript } from "@codemirror/lang-javascript";
8
+ import { json } from "@codemirror/lang-json";
9
+ import { css as cssLang } from "@codemirror/lang-css";
10
+ import { html } from "@codemirror/lang-html";
11
+ import { markdown } from "@codemirror/lang-markdown";
12
+ import styles from "./styles.module.css";
13
+
14
+ import { cn } from "@SH_UI_UTILS@";
15
+ export type CodeEditorLanguage =
16
+ | "text"
17
+ | "javascript"
18
+ | "typescript"
19
+ | "jsx"
20
+ | "tsx"
21
+ | "json"
22
+ | "css"
23
+ | "html"
24
+ | "markdown";
25
+
26
+ export interface CodeEditorProps {
27
+ /**
28
+ * Controlled — 현재 코드. 명시 시 value 가 진실원천이 되고 onChange 로 외부에서 갱신해야 한다.
29
+ * 미지정이면 uncontrolled — 에디터가 자체 내부 문서로 동작.
30
+ */
31
+ value?: string;
32
+ /**
33
+ * Uncontrolled 초기값. value 미지정 시에만 사용된다.
34
+ * @default ""
35
+ */
36
+ defaultValue?: string;
37
+ /** 코드가 바뀔 때마다 호출 (controlled · uncontrolled 모두). */
38
+ onChange?: (value: string) => void;
39
+ /**
40
+ * 신택스 하이라이팅 언어.
41
+ * @default "text"
42
+ */
43
+ language?: CodeEditorLanguage;
44
+ /** 비어 있을 때 표시할 placeholder. */
45
+ placeholder?: string;
46
+ /** 읽기 전용. 키 입력은 막지만 선택·복사는 가능. */
47
+ readOnly?: boolean;
48
+ /**
49
+ * 좌측 줄 번호 표시 여부.
50
+ * @default true
51
+ */
52
+ showLineNumbers?: boolean;
53
+ /** 에디터 최소 높이 (CSS 길이 단위). */
54
+ minHeight?: string;
55
+ /** 에디터 최대 높이 (CSS 길이 단위). 초과 시 내부 스크롤. */
56
+ maxHeight?: string;
57
+ className?: string;
58
+ id?: string;
59
+ "aria-label"?: string;
60
+ "aria-labelledby"?: string;
61
+ }
62
+
63
+
64
+ function languageExtension(language: CodeEditorLanguage): Extension {
65
+ switch (language) {
66
+ case "javascript":
67
+ return javascript();
68
+ case "typescript":
69
+ return javascript({ typescript: true });
70
+ case "jsx":
71
+ return javascript({ jsx: true });
72
+ case "tsx":
73
+ return javascript({ jsx: true, typescript: true });
74
+ case "json":
75
+ return json();
76
+ case "css":
77
+ return cssLang();
78
+ case "html":
79
+ return html();
80
+ case "markdown":
81
+ return markdown();
82
+ case "text":
83
+ default:
84
+ return [];
85
+ }
86
+ }
87
+
88
+ /**
89
+ * CodeMirror 6 기반 인라인 코드 에디터.
90
+ *
91
+ * Controlled (value/onChange) · Uncontrolled (defaultValue + 선택 onChange) 모두 지원.
92
+ * 라우터·외부 상태와 동기화할 게 없는 경우 defaultValue 한 줄로 끝 — useState 불필요.
93
+ *
94
+ * 신택스 하이라이팅·자동 들여쓰기·괄호 매칭 등은 CodeMirror `basicSetup` 을 그대로 사용,
95
+ * 컬러·여백은 sh-ui 토큰(`--background`, `--foreground`, `--border` 등)으로 매핑돼 테마에 자동 추종.
96
+ */
97
+ export function CodeEditor({
98
+ value: valueProp,
99
+ defaultValue,
100
+ onChange,
101
+ language = "text",
102
+ placeholder,
103
+ readOnly = false,
104
+ showLineNumbers = true,
105
+ minHeight,
106
+ maxHeight,
107
+ className,
108
+ id,
109
+ "aria-label": ariaLabel,
110
+ "aria-labelledby": ariaLabelledBy,
111
+ }: CodeEditorProps) {
112
+ const isControlled = valueProp !== undefined;
113
+ const hostRef = useRef<HTMLDivElement>(null);
114
+ const viewRef = useRef<EditorView | null>(null);
115
+ const onChangeRef = useRef(onChange);
116
+ onChangeRef.current = onChange;
117
+ const initialDocRef = useRef(valueProp ?? defaultValue ?? "");
118
+
119
+ const compartments = useMemo(
120
+ () => ({
121
+ language: new Compartment(),
122
+ readOnly: new Compartment(),
123
+ lineNumbers: new Compartment(),
124
+ placeholder: new Compartment(),
125
+ }),
126
+ [],
127
+ );
128
+
129
+ useEffect(() => {
130
+ if (!hostRef.current) return;
131
+
132
+ const extensions: Extension[] = [
133
+ basicSetup,
134
+ EditorView.lineWrapping,
135
+ EditorView.updateListener.of((update) => {
136
+ if (update.docChanged) {
137
+ onChangeRef.current?.(update.state.doc.toString());
138
+ }
139
+ }),
140
+ compartments.language.of(languageExtension(language)),
141
+ compartments.readOnly.of(EditorState.readOnly.of(readOnly)),
142
+ compartments.lineNumbers.of(
143
+ showLineNumbers
144
+ ? []
145
+ : EditorView.theme({ ".cm-gutters": { display: "none" } }),
146
+ ),
147
+ compartments.placeholder.of(placeholder ? placeholderExt(placeholder) : []),
148
+ ];
149
+
150
+ const view = new EditorView({
151
+ state: EditorState.create({ doc: initialDocRef.current, extensions }),
152
+ parent: hostRef.current,
153
+ });
154
+ viewRef.current = view;
155
+
156
+ return () => {
157
+ view.destroy();
158
+ viewRef.current = null;
159
+ };
160
+ // 초기 마운트 1회만 — 후속 동기화는 별도 이펙트가 처리
161
+ // eslint-disable-next-line react-hooks/exhaustive-deps
162
+ }, []);
163
+
164
+ // controlled 모드에서만 외부 value 를 에디터 doc 에 동기화. uncontrolled 면 에디터가 자체 source-of-truth.
165
+ useEffect(() => {
166
+ if (!isControlled) return;
167
+ const view = viewRef.current;
168
+ if (!view) return;
169
+ const current = view.state.doc.toString();
170
+ if (current === valueProp) return;
171
+ view.dispatch({
172
+ changes: { from: 0, to: current.length, insert: valueProp ?? "" },
173
+ });
174
+ }, [isControlled, valueProp]);
175
+
176
+ useEffect(() => {
177
+ viewRef.current?.dispatch({
178
+ effects: compartments.language.reconfigure(languageExtension(language)),
179
+ });
180
+ }, [language, compartments.language]);
181
+
182
+ useEffect(() => {
183
+ viewRef.current?.dispatch({
184
+ effects: compartments.readOnly.reconfigure(EditorState.readOnly.of(readOnly)),
185
+ });
186
+ }, [readOnly, compartments.readOnly]);
187
+
188
+ useEffect(() => {
189
+ viewRef.current?.dispatch({
190
+ effects: compartments.lineNumbers.reconfigure(
191
+ showLineNumbers
192
+ ? []
193
+ : EditorView.theme({ ".cm-gutters": { display: "none" } }),
194
+ ),
195
+ });
196
+ }, [showLineNumbers, compartments.lineNumbers]);
197
+
198
+ useEffect(() => {
199
+ viewRef.current?.dispatch({
200
+ effects: compartments.placeholder.reconfigure(
201
+ placeholder ? placeholderExt(placeholder) : [],
202
+ ),
203
+ });
204
+ }, [placeholder, compartments.placeholder]);
205
+
206
+ useEffect(() => {
207
+ const view = viewRef.current;
208
+ if (!view) return;
209
+ const node = view.contentDOM;
210
+ if (id) node.id = id;
211
+ if (ariaLabel) node.setAttribute("aria-label", ariaLabel);
212
+ else node.removeAttribute("aria-label");
213
+ if (ariaLabelledBy) node.setAttribute("aria-labelledby", ariaLabelledBy);
214
+ else node.removeAttribute("aria-labelledby");
215
+ }, [id, ariaLabel, ariaLabelledBy]);
216
+
217
+ return (
218
+ <div
219
+ ref={hostRef}
220
+ className={cn(styles["code-editor"], className)}
221
+ data-readonly={readOnly || undefined}
222
+ style={
223
+ {
224
+ "--sh-ui-code-editor-min-height": minHeight,
225
+ "--sh-ui-code-editor-max-height": maxHeight,
226
+ } as React.CSSProperties
227
+ }
228
+ />
229
+ );
230
+ }
@@ -0,0 +1,76 @@
1
+ .code-editor {
2
+ position: relative;
3
+ border: 1px solid var(--border);
4
+ border-radius: var(--radius);
5
+ background: var(--background);
6
+ font-size: 0.8125rem;
7
+ line-height: 1.6;
8
+ overflow: hidden;
9
+ transition: border-color var(--duration-fast);
10
+ }
11
+ .code-editor:focus-within {
12
+ border-color: var(--foreground);
13
+ outline: var(--border-width-strong) solid var(--foreground);
14
+ outline-offset: 2px;
15
+ }
16
+ .code-editor[data-readonly] {
17
+ background: var(--background-subtle);
18
+ }
19
+
20
+ .code-editor .cm-editor {
21
+ background: transparent;
22
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
23
+ }
24
+ .code-editor .cm-editor.cm-focused {
25
+ outline: none;
26
+ }
27
+ .code-editor .cm-scroller {
28
+ font-family: inherit;
29
+ min-height: var(--sh-ui-code-editor-min-height, 7.5rem);
30
+ max-height: var(--sh-ui-code-editor-max-height, 25rem);
31
+ }
32
+ .code-editor .cm-content {
33
+ caret-color: var(--foreground);
34
+ color: var(--foreground);
35
+ padding: var(--space-3) 0;
36
+ }
37
+ .code-editor .cm-line {
38
+ padding: 0 var(--space-3);
39
+ }
40
+ .code-editor .cm-gutters {
41
+ background: var(--background-subtle);
42
+ color: var(--foreground-muted);
43
+ border-right: 1px solid var(--border);
44
+ }
45
+ .code-editor .cm-activeLineGutter,
46
+ .code-editor .cm-activeLine {
47
+ background: var(--background-muted);
48
+ }
49
+ .code-editor .cm-cursor,
50
+ .code-editor .cm-dropCursor {
51
+ border-left-color: var(--foreground);
52
+ }
53
+ .code-editor .cm-selectionBackground,
54
+ .code-editor .cm-editor .cm-selectionBackground,
55
+ .code-editor .cm-editor.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground,
56
+ .code-editor ::selection {
57
+ background: var(--background-muted) !important;
58
+ }
59
+ .code-editor .cm-placeholder {
60
+ color: var(--foreground-muted);
61
+ }
62
+ .code-editor .cm-tooltip {
63
+ background: var(--background);
64
+ border: 1px solid var(--border);
65
+ color: var(--foreground);
66
+ border-radius: calc(var(--radius) - 2px);
67
+ }
68
+ .code-editor .cm-tooltip-autocomplete > ul > li[aria-selected] {
69
+ background: var(--background-muted);
70
+ color: var(--foreground);
71
+ }
72
+ .code-editor .cm-matchingBracket,
73
+ .code-editor .cm-nonmatchingBracket {
74
+ background: var(--background-muted);
75
+ color: var(--foreground);
76
+ }
@@ -0,0 +1,191 @@
1
+ import { codeToHtml } from "shiki";
2
+ import { CodePanelCopyButton } from "./copy";
3
+ import styles from "./styles.module.css";
4
+
5
+
6
+ import { cn } from "@SH_UI_UTILS@";
7
+ export interface CodePanelProps
8
+ extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
9
+ /** 하이라이팅할 코드 문자열. children을 제공하지 않을 때 필수. */
10
+ code?: string;
11
+ /**
12
+ * shiki가 지원하는 언어 ID (예: `"tsx"`, `"typescript"`, `"bash"`, `"json"`).
13
+ * 미지원 언어면 plain text로 폴백.
14
+ *
15
+ * @default "text"
16
+ */
17
+ language?: string;
18
+ /**
19
+ * 상단 헤더에 표시할 파일명. 지정하면 헤더가 그려지고 그 안에 복사 버튼이 들어가며,
20
+ * 미지정 시 우상단에 floating 복사 버튼만 표시된다.
21
+ */
22
+ filename?: string;
23
+ /**
24
+ * 좌측 줄 번호 표시 여부.
25
+ * @default true
26
+ */
27
+ showLineNumbers?: boolean;
28
+ /**
29
+ * 복사 버튼 숨기기. 코드 발췌가 클립보드 복사용이 아닐 때 사용.
30
+ * @default false
31
+ */
32
+ hideCopy?: boolean;
33
+ /**
34
+ * compound 모드. 직접 `CodePanelHeader`/`CodePanelFilename`/`CodePanelCopy`/`CodePanelBody`를
35
+ * 조합해 헤더 액션 추가나 복사 버튼 위치 변경 등을 한다. 지정 시 `code`/`language` 등은 무시.
36
+ */
37
+ children?: React.ReactNode;
38
+ }
39
+
40
+ /**
41
+ * 코드 블록 + 복사 버튼 패널. shiki로 SSR에서 하이라이팅.
42
+ *
43
+ * 기본 사용(자식 생략) — `code` prop만 넘기면 기본 레이아웃을 렌더한다.
44
+ * 커스텀 구성 — `CodePanelHeader`, `CodePanelFilename`, `CodePanelCopy`,
45
+ * `CodePanelBody`를 직접 조합하여 헤더 액션 추가·복사 버튼 위치 변경 등이 가능하다.
46
+ */
47
+ export async function CodePanel({
48
+ code,
49
+ language = "text",
50
+ filename,
51
+ showLineNumbers = true,
52
+ hideCopy,
53
+ className,
54
+ children,
55
+ ...rest
56
+ }: CodePanelProps) {
57
+ const classes = cn(styles.code, className);
58
+
59
+ if (children !== undefined) {
60
+ return (
61
+ <div className={classes} {...rest}>
62
+ {children}
63
+ </div>
64
+ );
65
+ }
66
+
67
+ if (code === undefined) {
68
+ throw new Error("CodePanel: `code` prop 또는 children 중 하나가 필요합니다.");
69
+ }
70
+
71
+ const trimmed = code.replace(/\n$/, "");
72
+
73
+ return (
74
+ <div className={classes} {...rest}>
75
+ {filename ? (
76
+ <CodePanelHeader>
77
+ <CodePanelFilename>{filename}</CodePanelFilename>
78
+ {!hideCopy && <CodePanelCopy code={trimmed} />}
79
+ </CodePanelHeader>
80
+ ) : (
81
+ !hideCopy && (
82
+ <div className={styles["code__copy-floating"]}>
83
+ <CodePanelCopy code={trimmed} />
84
+ </div>
85
+ )
86
+ )}
87
+ <CodePanelBody
88
+ code={trimmed}
89
+ language={language}
90
+ showLineNumbers={showLineNumbers}
91
+ />
92
+ </div>
93
+ );
94
+ }
95
+
96
+ /* ───────── CodePanelHeader ───────── */
97
+
98
+ /** 파일명·복사 버튼 등을 담는 코드 블록 상단 바. CodePanel 자식으로 사용. */
99
+ export function CodePanelHeader({
100
+ className,
101
+ children,
102
+ ...props
103
+ }: React.HTMLAttributes<HTMLDivElement>) {
104
+ return (
105
+ <div className={cn(styles.code__header, className)} {...props}>
106
+ {children}
107
+ </div>
108
+ );
109
+ }
110
+
111
+ /* ───────── CodePanelFilename ───────── */
112
+
113
+ /** CodePanelHeader 안에 표시되는 파일명 라벨. */
114
+ export function CodePanelFilename({
115
+ className,
116
+ children,
117
+ ...props
118
+ }: React.HTMLAttributes<HTMLSpanElement>) {
119
+ return (
120
+ <span className={cn(styles.code__filename, className)} {...props}>
121
+ {children}
122
+ </span>
123
+ );
124
+ }
125
+
126
+ /* ───────── CodePanelCopy ─────────
127
+ * 클립보드 복사 버튼. 내부적으로 client 컴포넌트를 사용한다.
128
+ */
129
+
130
+ export interface CodePanelCopyProps {
131
+ /** 클립보드에 복사할 코드 문자열. 부모(주로 CodePanel)가 명시적으로 전달한다. */
132
+ code: string;
133
+ }
134
+
135
+ /** 클립보드 복사 버튼. 부모가 복사할 코드 문자열을 명시적으로 전달한다. */
136
+ export function CodePanelCopy({ code }: CodePanelCopyProps) {
137
+ return <CodePanelCopyButton code={code} />;
138
+ }
139
+
140
+ /* ───────── CodePanelBody ─────────
141
+ * shiki로 코드를 하이라이팅하여 렌더하는 async 컴포넌트.
142
+ */
143
+
144
+ export interface CodePanelBodyProps
145
+ extends Omit<
146
+ React.HTMLAttributes<HTMLDivElement>,
147
+ "children" | "dangerouslySetInnerHTML"
148
+ > {
149
+ /** 하이라이팅할 코드 문자열. */
150
+ code: string;
151
+ /**
152
+ * shiki 언어 ID.
153
+ * @default "text"
154
+ */
155
+ language?: string;
156
+ /**
157
+ * 좌측 줄 번호 표시 여부.
158
+ * @default true
159
+ */
160
+ showLineNumbers?: boolean;
161
+ }
162
+
163
+ /**
164
+ * shiki로 코드를 SSR 하이라이팅하여 렌더하는 async 컴포넌트.
165
+ * 라이트/다크 테마는 `github-light`/`github-dark`를 사용하며, 부모 테마 클래스에 따라 자동 전환된다.
166
+ */
167
+ export async function CodePanelBody({
168
+ code,
169
+ language = "text",
170
+ showLineNumbers = true,
171
+ className,
172
+ ...rest
173
+ }: CodePanelBodyProps) {
174
+ const trimmed = code.replace(/\n$/, "");
175
+ const html = await codeToHtml(trimmed, {
176
+ lang: language,
177
+ themes: { light: "github-light", dark: "github-dark" },
178
+ defaultColor: false,
179
+ });
180
+
181
+ return (
182
+ <div
183
+ className={cn(styles.code__body, className)}
184
+ data-line-numbers={showLineNumbers || undefined}
185
+ dangerouslySetInnerHTML={{ __html: html }}
186
+ {...rest}
187
+ />
188
+ );
189
+ }
190
+
191
+ export { CodePanelCopyButton };
@@ -0,0 +1,124 @@
1
+ .code {
2
+ position: relative;
3
+ border: 1px solid var(--border);
4
+ border-radius: var(--radius);
5
+ background: var(--background-subtle);
6
+ overflow: hidden;
7
+ font-size: 0.8125rem;
8
+ line-height: 1.6;
9
+ margin: var(--space-4) 0;
10
+ }
11
+ @media (max-width: 640px) {
12
+ .code { font-size: var(--text-xs); }
13
+ }
14
+
15
+ /* 헤더 (filename 있을 때) */
16
+ .code__header {
17
+ display: flex;
18
+ align-items: center;
19
+ justify-content: space-between;
20
+ gap: var(--space-2);
21
+ padding: var(--space-2) var(--space-3) var(--space-2) var(--space-4);
22
+ border-bottom: 1px solid var(--border);
23
+ background: var(--background-muted);
24
+ font-size: var(--text-xs);
25
+ color: var(--foreground-muted);
26
+ }
27
+ .code__filename {
28
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
29
+ color: var(--foreground);
30
+ }
31
+
32
+ /* 헤더 없을 때 우상단에 떠있는 복사 버튼 */
33
+ .code__copy-floating {
34
+ position: absolute;
35
+ top: var(--space-2);
36
+ right: var(--space-2);
37
+ z-index: 1;
38
+ opacity: 0;
39
+ transition: opacity var(--duration-fast);
40
+ }
41
+ .code:hover .code__copy-floating,
42
+ .code:focus-within .code__copy-floating {
43
+ opacity: 1;
44
+ }
45
+
46
+ /* 복사 버튼 */
47
+ .code__copy {
48
+ display: inline-flex;
49
+ align-items: center;
50
+ gap: 0.375rem;
51
+ padding: var(--space-1) var(--space-2);
52
+ background: var(--background);
53
+ color: var(--foreground-muted);
54
+ border: 1px solid var(--border);
55
+ border-radius: calc(var(--radius) - 2px);
56
+ font-size: var(--text-xs);
57
+ line-height: 1;
58
+ cursor: pointer;
59
+ transition: color var(--duration-fast), border-color var(--duration-fast), background-color var(--duration-fast);
60
+ -webkit-tap-highlight-color: transparent;
61
+ }
62
+ .code__copy:hover {
63
+ color: var(--foreground);
64
+ border-color: var(--border-strong);
65
+ }
66
+ .code__copy:focus-visible {
67
+ outline: var(--border-width-strong) solid var(--foreground);
68
+ outline-offset: 2px;
69
+ }
70
+ .code__copy-label {
71
+ font-size: var(--text-xs);
72
+ }
73
+
74
+ /* 코드 본문 — shiki 출력(<pre class="shiki">...) */
75
+ .code__body {
76
+ overflow-x: auto;
77
+ }
78
+ .code__body pre {
79
+ margin: 0;
80
+ padding: var(--space-3) var(--space-4);
81
+ background: transparent !important;
82
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
83
+ font-size: inherit;
84
+ line-height: inherit;
85
+ border: none;
86
+ border-radius: 0;
87
+ }
88
+ .code__body code {
89
+ background: transparent;
90
+ padding: 0;
91
+ font-size: inherit;
92
+ display: block;
93
+ }
94
+
95
+ /* shiki dual theme — 기본은 light, .dark 스코프에선 dark.
96
+ span에는 color만 주고 background-color는 절대 강제하지 않는다.
97
+ (강제하면 --shiki-*-bg 변수가 상속돼 라인마다 띠가 생긴다.) */
98
+ .code__body .shiki,
99
+ .code__body .shiki span {
100
+ color: var(--shiki-light) !important;
101
+ background-color: transparent !important;
102
+ }
103
+ .dark .code__body .shiki,
104
+ .dark .code__body .shiki span {
105
+ color: var(--shiki-dark) !important;
106
+ background-color: transparent !important;
107
+ }
108
+
109
+ /* 라인 번호 */
110
+ .code__body[data-line-numbers] pre code {
111
+ counter-reset: step;
112
+ counter-increment: step 0;
113
+ }
114
+ .code__body[data-line-numbers] pre code .line::before {
115
+ content: counter(step);
116
+ counter-increment: step;
117
+ display: inline-block;
118
+ width: 1.75rem;
119
+ margin-right: var(--space-4);
120
+ text-align: right;
121
+ color: var(--foreground-muted);
122
+ opacity: 0.7;
123
+ user-select: none;
124
+ }