sh-ui-cli 0.46.0 → 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 (85) hide show
  1. package/data/changelog/versions.json +13 -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,350 @@
1
+ /* ───── Root ───── */
2
+ .header {
3
+ position: relative;
4
+ display: flex;
5
+ align-items: center;
6
+ gap: var(--space-4);
7
+ height: var(--control-md);
8
+ padding: 0 var(--space-3);
9
+ background: var(--background);
10
+ border-bottom: 1px solid var(--border);
11
+ transition: transform var(--duration-base) var(--ease-standard),
12
+ background-color var(--duration-base) var(--ease-standard);
13
+ /* hover/active 배경 — transparent/blur variant 가 currentColor 기반으로 재정의 */
14
+ --sh-ui-header-hover-bg: var(--background-muted);
15
+ /* blur variant 튜닝용 — instance 별 style 로 override 가능 */
16
+ --sh-ui-header-blur-opacity: 85%;
17
+ --sh-ui-header-blur-radius: 16px;
18
+ }
19
+
20
+ /* prefers-reduced-motion — stickyHide 슬라이드 애니메이션을 즉시 toggle 로 대체.
21
+ variant 전환의 background 트랜지션도 비활성화. */
22
+ @media (prefers-reduced-motion: reduce) {
23
+ .header {
24
+ transition: none;
25
+ }
26
+ }
27
+
28
+ /* variant */
29
+ .header--solid {
30
+ background: var(--background);
31
+ }
32
+ .header--transparent {
33
+ background: transparent;
34
+ border-bottom-color: transparent;
35
+ /* 컬러풀 배경 위에서도 자연스러운 hover — 텍스트 색의 14% 오버레이 */
36
+ --sh-ui-header-hover-bg: color-mix(in srgb, currentColor 14%, transparent);
37
+ }
38
+ .header--blur {
39
+ background: color-mix(in srgb, var(--background) var(--sh-ui-header-blur-opacity), transparent);
40
+ backdrop-filter: saturate(180%) blur(var(--sh-ui-header-blur-radius));
41
+ -webkit-backdrop-filter: saturate(180%) blur(var(--sh-ui-header-blur-radius));
42
+ --sh-ui-header-hover-bg: color-mix(in srgb, currentColor 14%, transparent);
43
+ }
44
+ /* backdrop-filter 미지원 브라우저 폴백 — 더 불투명한 배경으로 가독성 확보 */
45
+ @supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
46
+ .header--blur {
47
+ background: var(--background);
48
+ }
49
+ }
50
+
51
+ /* sticky-hide — sticky 컨텍스트는 사용자가 직접 (예: position:sticky; top:0) 적용. */
52
+ .header[data-sticky-hide][data-hidden] {
53
+ transform: translateY(-100%);
54
+ }
55
+
56
+ /* ───── Brand ───── */
57
+ .header__brand {
58
+ display: inline-flex;
59
+ align-items: center;
60
+ gap: var(--space-2);
61
+ flex-shrink: 0;
62
+ }
63
+ .header__logo {
64
+ display: inline-flex;
65
+ align-items: center;
66
+ color: var(--foreground);
67
+ }
68
+ .header__title {
69
+ font-size: var(--text-base);
70
+ font-weight: var(--weight-bold);
71
+ color: var(--foreground);
72
+ letter-spacing: -0.3px;
73
+ }
74
+
75
+ /* ───── Trigger (햄버거) ───── */
76
+ .header__trigger {
77
+ display: none;
78
+ align-items: center;
79
+ justify-content: center;
80
+ width: 2.25rem;
81
+ height: 2.25rem;
82
+ padding: 0;
83
+ background: transparent;
84
+ border: 0;
85
+ color: var(--foreground);
86
+ border-radius: calc(var(--radius) - 2px);
87
+ cursor: pointer;
88
+ transition: background-color var(--duration-fast);
89
+ }
90
+ .header__trigger:hover {
91
+ background: var(--sh-ui-header-hover-bg);
92
+ }
93
+ .header__trigger:focus-visible {
94
+ outline: var(--border-width-strong) solid var(--foreground);
95
+ outline-offset: 2px;
96
+ }
97
+
98
+ /* ───── Inline Nav ───── */
99
+ .header__nav {
100
+ display: flex;
101
+ align-items: center;
102
+ gap: var(--space-1);
103
+ flex: 1;
104
+ min-width: 0;
105
+ overflow-x: auto;
106
+ scrollbar-width: none;
107
+ }
108
+ .header__nav::-webkit-scrollbar {
109
+ display: none;
110
+ }
111
+
112
+ /* ───── Item ───── */
113
+ .header__item {
114
+ display: inline-flex;
115
+ align-items: center;
116
+ gap: var(--space-1);
117
+ padding: var(--space-2) var(--space-3);
118
+ font-size: var(--text-sm);
119
+ font-weight: var(--weight-medium);
120
+ color: var(--foreground-muted);
121
+ text-decoration: none;
122
+ background: transparent;
123
+ border: 0;
124
+ border-radius: calc(var(--radius) - 2px);
125
+ cursor: pointer;
126
+ white-space: nowrap;
127
+ transition: color var(--duration-fast), background-color var(--duration-fast);
128
+ }
129
+ .header__item:hover {
130
+ color: var(--foreground);
131
+ background: var(--sh-ui-header-hover-bg);
132
+ }
133
+ .header__item[data-active] {
134
+ color: var(--foreground);
135
+ font-weight: var(--weight-semibold);
136
+ }
137
+ .header__item:focus-visible {
138
+ outline: var(--border-width-strong) solid var(--foreground);
139
+ outline-offset: 2px;
140
+ }
141
+
142
+ /* ───── Actions ───── */
143
+ .header__actions {
144
+ display: inline-flex;
145
+ align-items: center;
146
+ gap: var(--space-2);
147
+ margin-left: auto;
148
+ flex-shrink: 0;
149
+ }
150
+
151
+ /* ───── 반응형 가시성 유틸 (HeaderDesktopOnly / HeaderMobileOnly) ─────
152
+ * display: contents 로 wrapper 가 레이아웃에 잡히지 않게 함 — 부모(HeaderActions 등)의 flex 흐름 유지.
153
+ */
154
+ .header__desktop-only {
155
+ display: contents;
156
+ }
157
+ .header__mobile-only {
158
+ display: none;
159
+ }
160
+
161
+ /* ───── NavGroup ─────
162
+ * inline 모드에서는 자식만 펼친 것처럼 평면 렌더(라벨 숨김).
163
+ * drawer 모드에서는 라벨 + 들여쓴 항목.
164
+ */
165
+ .header__group--inline {
166
+ display: contents;
167
+ }
168
+ .header__group--drawer {
169
+ display: flex;
170
+ flex-direction: column;
171
+ margin-top: var(--space-3);
172
+ }
173
+ .header__group--drawer:first-child {
174
+ margin-top: 0;
175
+ }
176
+ .header__group-label {
177
+ display: flex;
178
+ align-items: center;
179
+ height: 2rem;
180
+ padding: 0 var(--space-2);
181
+ font-size: var(--text-xs);
182
+ font-weight: var(--weight-medium);
183
+ color: var(--foreground-muted);
184
+ }
185
+ .header__group-items {
186
+ display: flex;
187
+ flex-direction: column;
188
+ gap: 1px;
189
+ }
190
+
191
+ /* ───── Menu (서브메뉴) — inline 모드 = dropdown ───── */
192
+ .header__menu {
193
+ position: relative;
194
+ }
195
+ .header__menu--inline {
196
+ display: inline-block;
197
+ }
198
+ .header__menu-trigger {
199
+ display: inline-flex;
200
+ align-items: center;
201
+ gap: var(--space-1);
202
+ padding: var(--space-2) var(--space-3);
203
+ font-size: var(--text-sm);
204
+ font-weight: var(--weight-medium);
205
+ color: var(--foreground-muted);
206
+ background: transparent;
207
+ border: 0;
208
+ border-radius: calc(var(--radius) - 2px);
209
+ cursor: pointer;
210
+ white-space: nowrap;
211
+ transition: color var(--duration-fast), background-color var(--duration-fast);
212
+ }
213
+ .header__menu-trigger:hover,
214
+ .header__menu-trigger[data-open] {
215
+ color: var(--foreground);
216
+ background: var(--sh-ui-header-hover-bg);
217
+ }
218
+ .header__menu-trigger:focus-visible {
219
+ outline: var(--border-width-strong) solid var(--foreground);
220
+ outline-offset: 2px;
221
+ }
222
+ .header__chevron {
223
+ transition: transform var(--duration-fast) var(--ease-standard);
224
+ }
225
+ .header__menu-trigger[data-open] .header__chevron {
226
+ transform: rotate(180deg);
227
+ }
228
+
229
+ /* inline 모드 dropdown 은 portal 로 document.body 에 렌더된다 — 부모 overflow 영향 받지 않음. */
230
+ .header__menu-content--portal {
231
+ z-index: var(--z-dropdown, 50);
232
+ padding: var(--space-1);
233
+ background: var(--background);
234
+ border: 1px solid var(--border);
235
+ border-radius: var(--radius);
236
+ box-shadow: 0 8px 24px -8px rgba(0, 0, 0, 0.18);
237
+ display: flex;
238
+ flex-direction: column;
239
+ gap: 1px;
240
+ color: var(--foreground);
241
+ }
242
+ .header__menu-content--portal .header__item {
243
+ padding: var(--space-2) var(--space-3);
244
+ font-size: var(--text-sm);
245
+ }
246
+
247
+ /* ───── Drawer (backdrop + panel) — 기본 숨김 ───── */
248
+ .header__backdrop,
249
+ .header__drawer {
250
+ display: none;
251
+ }
252
+
253
+ /* ───── Mobile (< breakpoint.md) ───── */
254
+ @media (max-width: 767px) {
255
+ .header__trigger {
256
+ display: inline-flex;
257
+ order: -1;
258
+ }
259
+ .header__nav {
260
+ display: none;
261
+ }
262
+ .header {
263
+ gap: var(--space-2);
264
+ }
265
+ /* 가시성 유틸 토글 */
266
+ .header__desktop-only {
267
+ display: none;
268
+ }
269
+ .header__mobile-only {
270
+ display: contents;
271
+ }
272
+
273
+ /* backdrop */
274
+ .header__backdrop {
275
+ display: block;
276
+ position: fixed;
277
+ inset: 0;
278
+ background: rgba(0, 0, 0, 0.25);
279
+ backdrop-filter: blur(8px);
280
+ z-index: var(--z-overlay);
281
+ opacity: 0;
282
+ pointer-events: none;
283
+ transition: opacity var(--duration-base) var(--ease-standard);
284
+ }
285
+ .header__backdrop[data-open] {
286
+ opacity: 1;
287
+ pointer-events: auto;
288
+ }
289
+
290
+ /* drawer panel */
291
+ .header__drawer {
292
+ display: flex;
293
+ position: fixed;
294
+ left: 0;
295
+ top: 0;
296
+ bottom: 0;
297
+ width: min(17.5rem, 85vw);
298
+ background: var(--background-subtle);
299
+ border-right: 1px solid var(--border);
300
+ z-index: var(--z-modal);
301
+ transform: translateX(-100%);
302
+ transition: transform var(--duration-base) var(--ease-standard);
303
+ flex-direction: column;
304
+ overflow-y: auto;
305
+ }
306
+ .header__drawer[data-open] {
307
+ transform: translateX(0);
308
+ }
309
+
310
+ .header__drawer-head {
311
+ display: flex;
312
+ align-items: center;
313
+ justify-content: flex-end;
314
+ padding: var(--space-2) var(--space-2);
315
+ border-bottom: 1px solid var(--border);
316
+ }
317
+
318
+ .header__drawer-nav {
319
+ display: flex;
320
+ flex-direction: column;
321
+ padding: var(--space-2);
322
+ gap: 1px;
323
+ }
324
+
325
+ .header__drawer .header__item {
326
+ padding: var(--space-3) var(--space-3);
327
+ font-size: var(--text-sm);
328
+ border-radius: calc(var(--radius) - 2px);
329
+ }
330
+
331
+ /* Menu in drawer = collapsible */
332
+ .header__menu--drawer {
333
+ display: flex;
334
+ flex-direction: column;
335
+ }
336
+ .header__menu--drawer > .header__menu-trigger {
337
+ justify-content: space-between;
338
+ width: 100%;
339
+ padding: var(--space-3) var(--space-3);
340
+ }
341
+ .header__menu--drawer > .header__menu-content {
342
+ display: flex;
343
+ flex-direction: column;
344
+ padding: var(--space-1) 0 var(--space-1) var(--space-4);
345
+ gap: 1px;
346
+ }
347
+ .header__menu--drawer > .header__menu-content[hidden] {
348
+ display: none;
349
+ }
350
+ }
@@ -0,0 +1,52 @@
1
+ import * as React from "react";
2
+ import styles from "./styles.module.css";
3
+
4
+ import { cn } from "@SH_UI_UTILS@";
5
+ export interface LabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {
6
+ /**
7
+ * 필수 필드 표시. `true`면 `::after`로 `*` 표시가 붙는다.
8
+ * Input에 `required` 속성이 있으면 CSS `:has()`로 자동 감지되므로 보통 명시 불필요.
9
+ *
10
+ * @default false
11
+ */
12
+ isRequired?: boolean;
13
+ }
14
+
15
+
16
+ /**
17
+ * 폼 컨트롤과 1:1로 연결되는 레이블. `htmlFor`로 컨트롤의 `id`와 매칭하거나
18
+ * Label 안에 컨트롤을 감싸 묵시적으로 연결한다. 클릭 시 자동으로 컨트롤에 포커스가 간다.
19
+ */
20
+ export const Label = React.forwardRef<HTMLLabelElement, LabelProps>(
21
+ ({ className, children, isRequired, ...props }, ref) => (
22
+ <label
23
+ ref={ref}
24
+ className={cn(styles.label, className)}
25
+ data-required={isRequired || undefined}
26
+ {...props}
27
+ >
28
+ {children}
29
+ </label>
30
+ ),
31
+ );
32
+ Label.displayName = "Label";
33
+
34
+ /** Label 안의 주 라벨 텍스트. 구조적 그룹핑이 필요할 때 Label과 함께 사용. */
35
+ export function LabelTitle({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) {
36
+ return <span className={cn(styles.label__title, className)} {...props} />;
37
+ }
38
+
39
+ /** 라벨 옆에 약하게 표시되는 보조 텍스트(예: "선택 사항"). */
40
+ export function LabelSubtitle({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) {
41
+ return <span className={cn(styles.label__subtitle, className)} {...props} />;
42
+ }
43
+
44
+ /** 라벨 아래에 붙는 안내 문구. 컨트롤과 `aria-describedby`로 연결할 것. */
45
+ export function LabelDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
46
+ return <p className={cn(styles.label__description, className)} {...props} />;
47
+ }
48
+
49
+ /** 라벨 아래의 보조 캡션(예: 입력 형식 예시, 글자 수 제한). */
50
+ export function LabelCaption({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
51
+ return <p className={cn(styles.label__caption, className)} {...props} />;
52
+ }
@@ -0,0 +1,90 @@
1
+ .label {
2
+ display: flex;
3
+ flex-direction: column;
4
+ gap: 0.125rem;
5
+ font-size: var(--text-sm);
6
+ font-weight: var(--weight-medium);
7
+ line-height: 1.4;
8
+ color: var(--foreground);
9
+ cursor: pointer;
10
+ user-select: none;
11
+ }
12
+
13
+ .label:not(:has(.label__title, .label__subtitle, .label__description, .label__caption)) {
14
+ display: block;
15
+ }
16
+
17
+ /* ───── 텍스트 계층 ───── */
18
+
19
+ /* Title — 주 라벨 텍스트 (기본). Label에 서브컴포넌트 없이 텍스트만 넣으면 이 스타일. */
20
+ .label__title {
21
+ font-weight: var(--weight-semibold);
22
+ font-size: var(--text-sm);
23
+ color: var(--foreground);
24
+ }
25
+
26
+ /* Subtitle — 부제. 타이틀 옆이나 아래에 보조 강조. */
27
+ .label__subtitle {
28
+ font-weight: var(--weight-regular);
29
+ font-size: 0.8125rem;
30
+ color: var(--foreground);
31
+ }
32
+
33
+ /* Description — 설명. 입력 필드 가이드. */
34
+ .label__description {
35
+ margin: 0;
36
+ font-weight: var(--weight-regular);
37
+ font-size: 0.8125rem;
38
+ line-height: 1.4;
39
+ color: var(--foreground-muted);
40
+ }
41
+
42
+ /* Caption — 흐린 설명. 가장 덜 중요한 보조 텍스트. */
43
+ .label__caption {
44
+ margin: 0;
45
+ font-weight: var(--weight-regular);
46
+ font-size: var(--text-xs);
47
+ line-height: 1.3;
48
+ color: var(--foreground-subtle, var(--foreground-muted));
49
+ opacity: 0.75;
50
+ }
51
+
52
+ /* ───── 필수 표시 (* 마크) ─────
53
+ * 1) isRequired prop (data-required) — 명시적 수동 지정
54
+ * 2) 인접 폼 컴포넌트에 required 속성 — CSS :has()로 자동 감지
55
+ * ::after는 Label의 첫 번째 인라인 자식(타이틀) 뒤에 표시. */
56
+
57
+ /* LabelTitle sub-component가 있는 경우 */
58
+ .label[data-required] > .label__title::after,
59
+ .label:has(+ .input:required) > .label__title::after,
60
+ .label:has(+ .input-wrap .input:required) > .label__title::after,
61
+ .label:has(+ .textarea:required) > .label__title::after,
62
+ .label:has(+ .combobox__input:required) > .label__title::after {
63
+ content: " *";
64
+ color: var(--danger);
65
+ font-weight: var(--weight-semibold);
66
+ }
67
+
68
+ /* Title 없이 Label에 직접 텍스트를 넣은 경우 */
69
+ .label[data-required]:not(:has(.label__title))::after,
70
+ .label:not(:has(.label__title)):has(+ .input:required)::after,
71
+ .label:not(:has(.label__title)):has(+ .input-wrap .input:required)::after,
72
+ .label:not(:has(.label__title)):has(+ .textarea:required)::after,
73
+ .label:not(:has(.label__title)):has(+ .combobox__input:required)::after {
74
+ content: " *";
75
+ color: var(--danger);
76
+ font-weight: var(--weight-semibold);
77
+ }
78
+
79
+ /* ───── disabled 자동 감지 ─────
80
+ * 인접 폼 컴포넌트가 disabled이면 Label도 흐리게. */
81
+ .label:has(+ .input:disabled),
82
+ .label:has(+ .input-wrap .input:disabled),
83
+ .label:has(+ .textarea:disabled),
84
+ .label:has(+ .select__trigger:disabled),
85
+ .label:has(+ .combobox__input:disabled),
86
+ .label:has(+ .date-picker__trigger:disabled),
87
+ .label:has(+ .file-upload .file-upload__dropzone--disabled) {
88
+ opacity: var(--opacity-disabled);
89
+ cursor: not-allowed;
90
+ }
@@ -0,0 +1,119 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import ReactMarkdown from "react-markdown";
5
+ import remarkGfm from "remark-gfm";
6
+ import { CodeEditor } from "../code-editor";
7
+ import styles from "./styles.module.css";
8
+
9
+ import { cn } from "@SH_UI_UTILS@";
10
+ export interface MarkdownEditorProps {
11
+ /**
12
+ * Controlled — 현재 마크다운. 명시 시 외부 상태가 진실원천.
13
+ * 미지정이면 uncontrolled — 컴포넌트가 자체 내부 상태로 동작.
14
+ */
15
+ value?: string;
16
+ /**
17
+ * Uncontrolled 초기값. value 미지정 시에만 사용된다.
18
+ * @default ""
19
+ */
20
+ defaultValue?: string;
21
+ /** 마크다운이 바뀔 때마다 호출 (controlled · uncontrolled 모두). */
22
+ onChange?: (value: string) => void;
23
+ /** 비어 있을 때 표시할 placeholder. */
24
+ placeholder?: string;
25
+ /** 읽기 전용. 키 입력 차단, 미리보기는 그대로 렌더. */
26
+ readOnly?: boolean;
27
+ /**
28
+ * 미리보기 패널 표시 여부.
29
+ * @default true
30
+ */
31
+ preview?: boolean;
32
+ /**
33
+ * 미리보기 위치. 좁은 화면(<768px)에서는 항상 아래로 쌓임.
34
+ * @default "right"
35
+ */
36
+ previewPosition?: "right" | "bottom";
37
+ /** 에디터·미리보기 영역의 최소 높이 (CSS 길이 단위). */
38
+ minHeight?: string;
39
+ /** 에디터·미리보기 영역의 최대 높이. 초과 시 내부 스크롤. */
40
+ maxHeight?: string;
41
+ className?: string;
42
+ /** 에디터 영역에 부여할 aria-label. */
43
+ "aria-label"?: string;
44
+ }
45
+
46
+
47
+ /**
48
+ * 마크다운 에디터 — CodeEditor(소스) + react-markdown(라이브 프리뷰)의 합성.
49
+ *
50
+ * Controlled (value/onChange) · Uncontrolled (defaultValue) 모두 지원. 미리보기 패널이
51
+ * 현재 마크다운을 필요로 하므로 uncontrolled 모드에서도 내부 상태로 트래킹.
52
+ *
53
+ * 미리보기는 GFM(테이블·체크박스·strikethrough)을 지원하고, raw HTML은 기본적으로
54
+ * 차단(react-markdown 기본 동작)되어 사용자 입력으로부터의 XSS가 자동 방어된다.
55
+ */
56
+ export function MarkdownEditor({
57
+ value: valueProp,
58
+ defaultValue,
59
+ onChange,
60
+ placeholder,
61
+ readOnly,
62
+ preview = true,
63
+ previewPosition = "right",
64
+ minHeight,
65
+ maxHeight,
66
+ className,
67
+ "aria-label": ariaLabel = "Markdown editor",
68
+ }: MarkdownEditorProps) {
69
+ const isControlled = valueProp !== undefined;
70
+ const [internalValue, setInternalValue] = useState(valueProp ?? defaultValue ?? "");
71
+ const value = isControlled ? valueProp : internalValue;
72
+
73
+ const handleChange = (next: string) => {
74
+ if (!isControlled) setInternalValue(next);
75
+ onChange?.(next);
76
+ };
77
+
78
+ return (
79
+ <div
80
+ className={cn(
81
+ styles["md-editor"],
82
+ preview && styles[`md-editor--${previewPosition}`],
83
+ !preview && styles["md-editor--no-preview"],
84
+ className,
85
+ )}
86
+ data-readonly={readOnly || undefined}
87
+ >
88
+ <div className={styles["md-editor__source"]}>
89
+ <CodeEditor
90
+ value={value}
91
+ onChange={handleChange}
92
+ language="markdown"
93
+ placeholder={placeholder}
94
+ readOnly={readOnly}
95
+ minHeight={minHeight}
96
+ maxHeight={maxHeight}
97
+ aria-label={ariaLabel}
98
+ />
99
+ </div>
100
+ {preview && (
101
+ <div
102
+ className={styles["md-editor__preview"]}
103
+ role="region"
104
+ aria-label="Preview"
105
+ style={
106
+ {
107
+ "--sh-ui-md-editor-min-height": minHeight,
108
+ "--sh-ui-md-editor-max-height": maxHeight,
109
+ } as React.CSSProperties
110
+ }
111
+ >
112
+ <div className={styles["md-editor__preview-inner"]}>
113
+ <ReactMarkdown remarkPlugins={[remarkGfm]}>{value}</ReactMarkdown>
114
+ </div>
115
+ </div>
116
+ )}
117
+ </div>
118
+ );
119
+ }