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,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
+ }
@@ -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
+ }