sh-ui-cli 0.49.0 → 0.50.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 +14 -0
  2. package/data/registry/react/components/accordion/index.vanilla-extract.tsx +97 -0
  3. package/data/registry/react/components/accordion/styles.css.ts +131 -0
  4. package/data/registry/react/components/avatar/index.vanilla-extract.tsx +73 -0
  5. package/data/registry/react/components/avatar/styles.css.ts +68 -0
  6. package/data/registry/react/components/badge/index.vanilla-extract.tsx +40 -0
  7. package/data/registry/react/components/badge/styles.css.ts +71 -0
  8. package/data/registry/react/components/breadcrumb/index.vanilla-extract.tsx +152 -0
  9. package/data/registry/react/components/breadcrumb/styles.css.ts +95 -0
  10. package/data/registry/react/components/calendar/index.vanilla-extract.tsx +806 -0
  11. package/data/registry/react/components/calendar/styles.css.ts +250 -0
  12. package/data/registry/react/components/carousel/index.vanilla-extract.tsx +430 -0
  13. package/data/registry/react/components/carousel/styles.css.ts +169 -0
  14. package/data/registry/react/components/checkbox/index.vanilla-extract.tsx +96 -0
  15. package/data/registry/react/components/checkbox/styles.css.ts +74 -0
  16. package/data/registry/react/components/code-editor/index.vanilla-extract.tsx +230 -0
  17. package/data/registry/react/components/code-editor/styles.css.ts +97 -0
  18. package/data/registry/react/components/code-panel/index.vanilla-extract.tsx +191 -0
  19. package/data/registry/react/components/code-panel/styles.css.ts +151 -0
  20. package/data/registry/react/components/color-picker/index.vanilla-extract.tsx +467 -0
  21. package/data/registry/react/components/color-picker/styles.css.ts +169 -0
  22. package/data/registry/react/components/combobox/index.vanilla-extract.tsx +165 -0
  23. package/data/registry/react/components/combobox/styles.css.ts +174 -0
  24. package/data/registry/react/components/context-menu/index.vanilla-extract.tsx +251 -0
  25. package/data/registry/react/components/context-menu/styles.css.ts +167 -0
  26. package/data/registry/react/components/date-picker/index.vanilla-extract.tsx +520 -0
  27. package/data/registry/react/components/date-picker/styles.css.ts +111 -0
  28. package/data/registry/react/components/dialog/index.vanilla-extract.tsx +95 -0
  29. package/data/registry/react/components/dialog/styles.css.ts +140 -0
  30. package/data/registry/react/components/dropdown-menu/index.vanilla-extract.tsx +255 -0
  31. package/data/registry/react/components/dropdown-menu/styles.css.ts +175 -0
  32. package/data/registry/react/components/file-upload/index.vanilla-extract.tsx +487 -0
  33. package/data/registry/react/components/file-upload/styles.css.ts +193 -0
  34. package/data/registry/react/components/form/index.vanilla-extract.tsx +61 -0
  35. package/data/registry/react/components/form/styles.css.ts +56 -0
  36. package/data/registry/react/components/header/index.vanilla-extract.tsx +805 -0
  37. package/data/registry/react/components/header/styles.css.ts +413 -0
  38. package/data/registry/react/components/label/index.vanilla-extract.tsx +52 -0
  39. package/data/registry/react/components/label/styles.css.ts +141 -0
  40. package/data/registry/react/components/markdown-editor/index.vanilla-extract.tsx +119 -0
  41. package/data/registry/react/components/markdown-editor/styles.css.ts +231 -0
  42. package/data/registry/react/components/menubar/index.vanilla-extract.tsx +32 -0
  43. package/data/registry/react/components/menubar/styles.css.ts +53 -0
  44. package/data/registry/react/components/numeric-input/index.vanilla-extract.tsx +148 -0
  45. package/data/registry/react/components/numeric-input/styles.css.ts +65 -0
  46. package/data/registry/react/components/page-toc/index.vanilla-extract.tsx +174 -0
  47. package/data/registry/react/components/page-toc/styles.css.ts +97 -0
  48. package/data/registry/react/components/pagination/index.vanilla-extract.tsx +269 -0
  49. package/data/registry/react/components/pagination/styles.css.ts +113 -0
  50. package/data/registry/react/components/popover/index.vanilla-extract.tsx +113 -0
  51. package/data/registry/react/components/popover/styles.css.ts +78 -0
  52. package/data/registry/react/components/progress/index.vanilla-extract.tsx +54 -0
  53. package/data/registry/react/components/progress/styles.css.ts +53 -0
  54. package/data/registry/react/components/radio/index.vanilla-extract.tsx +65 -0
  55. package/data/registry/react/components/radio/styles.css.ts +79 -0
  56. package/data/registry/react/components/rich-text-editor/index.vanilla-extract.tsx +348 -0
  57. package/data/registry/react/components/rich-text-editor/styles.css.ts +243 -0
  58. package/data/registry/react/components/select/index.vanilla-extract.tsx +234 -0
  59. package/data/registry/react/components/select/styles.css.ts +225 -0
  60. package/data/registry/react/components/separator/index.vanilla-extract.tsx +46 -0
  61. package/data/registry/react/components/separator/styles.css.ts +24 -0
  62. package/data/registry/react/components/sidebar/index.vanilla-extract.tsx +1067 -0
  63. package/data/registry/react/components/sidebar/styles.css.ts +578 -0
  64. package/data/registry/react/components/skeleton/index.vanilla-extract.tsx +22 -0
  65. package/data/registry/react/components/skeleton/styles.css.ts +30 -0
  66. package/data/registry/react/components/slider/index.vanilla-extract.tsx +298 -0
  67. package/data/registry/react/components/slider/styles.css.ts +75 -0
  68. package/data/registry/react/components/spinner/index.vanilla-extract.tsx +38 -0
  69. package/data/registry/react/components/spinner/styles.css.ts +60 -0
  70. package/data/registry/react/components/switch/index.vanilla-extract.tsx +39 -0
  71. package/data/registry/react/components/switch/styles.css.ts +87 -0
  72. package/data/registry/react/components/tabs/index.vanilla-extract.tsx +91 -0
  73. package/data/registry/react/components/tabs/styles.css.ts +145 -0
  74. package/data/registry/react/components/textarea/index.vanilla-extract.tsx +23 -0
  75. package/data/registry/react/components/textarea/styles.css.ts +55 -0
  76. package/data/registry/react/components/toast/index.vanilla-extract.tsx +258 -0
  77. package/data/registry/react/components/toast/styles.css.ts +307 -0
  78. package/data/registry/react/components/toggle/index.vanilla-extract.tsx +131 -0
  79. package/data/registry/react/components/toggle/styles.css.ts +109 -0
  80. package/data/registry/react/components/tooltip/index.vanilla-extract.tsx +83 -0
  81. package/data/registry/react/components/tooltip/styles.css.ts +59 -0
  82. package/data/registry/react/registry.json +853 -36
  83. package/package.json +1 -1
  84. package/src/api.d.ts +4 -3
  85. package/src/constants.js +4 -3
@@ -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 { byKey, mdEditor, mdEditorRight, mdEditorBottom, mdEditorNoPreview, mdEditorSource, mdEditorPreview, mdEditorPreviewInner } from "./styles.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
+ mdEditor,
82
+ preview && byKey[`md-editor--${previewPosition}`],
83
+ !preview && mdEditorNoPreview,
84
+ className,
85
+ )}
86
+ data-readonly={readOnly || undefined}
87
+ >
88
+ <div className={mdEditorSource}>
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={mdEditorPreview}
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={mdEditorPreviewInner}>
113
+ <ReactMarkdown remarkPlugins={[remarkGfm]}>{value}</ReactMarkdown>
114
+ </div>
115
+ </div>
116
+ )}
117
+ </div>
118
+ );
119
+ }
@@ -0,0 +1,231 @@
1
+ import { style } from "@vanilla-extract/css";
2
+
3
+ export const mdEditor = style({
4
+ display: "grid",
5
+ gap: "var(--space-3)",
6
+ });
7
+
8
+ export const mdEditorRight = style({
9
+ gridTemplateColumns: "1fr 1fr",
10
+ "@media": {
11
+ "(max-width: 768px)": {
12
+ gridTemplateColumns: "1fr",
13
+ },
14
+ },
15
+ });
16
+
17
+ export const mdEditorBottom = style({
18
+ gridTemplateColumns: "1fr",
19
+ });
20
+
21
+ export const mdEditorNoPreview = style({
22
+ gridTemplateColumns: "1fr",
23
+ });
24
+
25
+ export const mdEditorSource = style({
26
+ minWidth: 0,
27
+ });
28
+
29
+ export const mdEditorPreview = style({
30
+ minWidth: 0,
31
+ border: "1px solid var(--border)",
32
+ borderRadius: "var(--radius)",
33
+ background: "var(--background)",
34
+ overflow: "hidden",
35
+ });
36
+
37
+ export const mdEditorPreviewInner = style({
38
+ padding: "var(--space-3) var(--space-4)",
39
+ minHeight: "var(--sh-ui-md-editor-min-height, 7.5rem)",
40
+ maxHeight: "var(--sh-ui-md-editor-max-height, 25rem)",
41
+ overflowY: "auto",
42
+ fontSize: "0.875rem",
43
+ lineHeight: 1.65,
44
+ color: "var(--foreground)",
45
+ selectors: {
46
+ "& > :first-child": {
47
+ marginTop: 0,
48
+ },
49
+ "& > :last-child": {
50
+ marginBottom: 0,
51
+ },
52
+ "& h1": {
53
+ marginTop: "var(--space-4)",
54
+ marginBottom: "var(--space-2)",
55
+ fontWeight: 600,
56
+ lineHeight: 1.3,
57
+ color: "var(--foreground)",
58
+ },
59
+ "& h2": {
60
+ marginTop: "var(--space-4)",
61
+ marginBottom: "var(--space-2)",
62
+ fontWeight: 600,
63
+ lineHeight: 1.3,
64
+ color: "var(--foreground)",
65
+ },
66
+ "& h3": {
67
+ marginTop: "var(--space-4)",
68
+ marginBottom: "var(--space-2)",
69
+ fontWeight: 600,
70
+ lineHeight: 1.3,
71
+ color: "var(--foreground)",
72
+ },
73
+ "& h4": {
74
+ marginTop: "var(--space-4)",
75
+ marginBottom: "var(--space-2)",
76
+ fontWeight: 600,
77
+ lineHeight: 1.3,
78
+ color: "var(--foreground)",
79
+ },
80
+ "& h5": {
81
+ marginTop: "var(--space-4)",
82
+ marginBottom: "var(--space-2)",
83
+ fontWeight: 600,
84
+ lineHeight: 1.3,
85
+ color: "var(--foreground)",
86
+ },
87
+ "& h6": {
88
+ marginTop: "var(--space-4)",
89
+ marginBottom: "var(--space-2)",
90
+ fontWeight: 600,
91
+ lineHeight: 1.3,
92
+ color: "var(--foreground)",
93
+ },
94
+ "& h1": {
95
+ fontSize: "1.5rem",
96
+ },
97
+ "& h2": {
98
+ fontSize: "1.25rem",
99
+ },
100
+ "& h3": {
101
+ fontSize: "1.125rem",
102
+ },
103
+ "& h4": {
104
+ fontSize: "1rem",
105
+ },
106
+ "& h5": {
107
+ fontSize: "1rem",
108
+ },
109
+ "& h6": {
110
+ fontSize: "1rem",
111
+ },
112
+ "& p": {
113
+ marginTop: 0,
114
+ marginBottom: "var(--space-3)",
115
+ },
116
+ "& ul": {
117
+ marginTop: 0,
118
+ marginBottom: "var(--space-3)",
119
+ },
120
+ "& ol": {
121
+ marginTop: 0,
122
+ marginBottom: "var(--space-3)",
123
+ },
124
+ "& blockquote": {
125
+ marginTop: 0,
126
+ marginBottom: "var(--space-3)",
127
+ },
128
+ "& pre": {
129
+ marginTop: 0,
130
+ marginBottom: "var(--space-3)",
131
+ },
132
+ "& table": {
133
+ marginTop: 0,
134
+ marginBottom: "var(--space-3)",
135
+ },
136
+ "& ul": {
137
+ paddingLeft: "var(--space-5)",
138
+ },
139
+ "& ol": {
140
+ paddingLeft: "var(--space-5)",
141
+ },
142
+ "& li": {
143
+ marginBottom: "var(--space-1)",
144
+ },
145
+ "& li > input[type="checkbox"]": {
146
+ marginRight: "var(--space-2)",
147
+ },
148
+ "& a": {
149
+ color: "var(--primary)",
150
+ textDecoration: "underline",
151
+ textUnderlineOffset: "2px",
152
+ },
153
+ "& a:hover": {
154
+ textDecorationThickness: "2px",
155
+ },
156
+ "& blockquote": {
157
+ padding: "var(--space-2) var(--space-3)",
158
+ borderLeft: "3px solid var(--border-strong)",
159
+ background: "var(--background-subtle)",
160
+ color: "var(--foreground-muted)",
161
+ borderRadius: "0 calc(var(--radius) - 2px) calc(var(--radius) - 2px) 0",
162
+ },
163
+ "& blockquote > :last-child": {
164
+ marginBottom: 0,
165
+ },
166
+ "& code": {
167
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
168
+ fontSize: "0.875em",
169
+ padding: "0.125rem 0.375rem",
170
+ borderRadius: "calc(var(--radius) - 4px)",
171
+ background: "var(--background-muted)",
172
+ color: "var(--foreground)",
173
+ },
174
+ "& pre": {
175
+ padding: "var(--space-3)",
176
+ border: "1px solid var(--border)",
177
+ borderRadius: "var(--radius)",
178
+ background: "var(--background-subtle)",
179
+ overflowX: "auto",
180
+ fontSize: "0.8125rem",
181
+ lineHeight: 1.6,
182
+ },
183
+ "& pre > code": {
184
+ padding: 0,
185
+ background: "transparent",
186
+ fontSize: "inherit",
187
+ },
188
+ "& hr": {
189
+ border: 0,
190
+ borderTop: "1px solid var(--border)",
191
+ margin: "var(--space-4) 0",
192
+ },
193
+ "& table": {
194
+ width: "100%",
195
+ borderCollapse: "collapse",
196
+ fontSize: "0.875rem",
197
+ },
198
+ "& th": {
199
+ padding: "var(--space-2) var(--space-3)",
200
+ border: "1px solid var(--border)",
201
+ textAlign: "left",
202
+ },
203
+ "& td": {
204
+ padding: "var(--space-2) var(--space-3)",
205
+ border: "1px solid var(--border)",
206
+ textAlign: "left",
207
+ },
208
+ "& thead": {
209
+ background: "var(--background-subtle)",
210
+ },
211
+ "& img": {
212
+ maxWidth: "100%",
213
+ height: "auto",
214
+ borderRadius: "calc(var(--radius) - 2px)",
215
+ },
216
+ "& del": {
217
+ color: "var(--foreground-muted)",
218
+ },
219
+ },
220
+ });
221
+
222
+ /** 동적 키로 클래스 참조용 — `byKey[\`badge--${variant}\`]` 같은 패턴 지원. */
223
+ export const byKey: Record<string, string> = {
224
+ "md-editor": mdEditor,
225
+ "md-editor--right": mdEditorRight,
226
+ "md-editor--bottom": mdEditorBottom,
227
+ "md-editor--no-preview": mdEditorNoPreview,
228
+ "md-editor__source": mdEditorSource,
229
+ "md-editor__preview": mdEditorPreview,
230
+ "md-editor__preview-inner": mdEditorPreviewInner,
231
+ };
@@ -0,0 +1,32 @@
1
+ import * as React from "react";
2
+ import { Menubar as BaseMenubar } from "@base-ui/react/menubar";
3
+ import { byKey, menubar } from "./styles.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(menubar, className)}
29
+ {...props}
30
+ />
31
+ );
32
+ });
@@ -0,0 +1,53 @@
1
+ import { style } from "@vanilla-extract/css";
2
+
3
+ export const menubar = style({
4
+ display: "inline-flex",
5
+ alignItems: "center",
6
+ gap: "var(--space-1)",
7
+ padding: "var(--space-1)",
8
+ background: "var(--background)",
9
+ border: "1px solid var(--border)",
10
+ borderRadius: "var(--radius)",
11
+ boxShadow: "0 1px 2px rgba(0, 0, 0, 0.04)",
12
+ selectors: {
13
+ "& .dm__trigger": {
14
+ display: "inline-flex",
15
+ alignItems: "center",
16
+ gap: "var(--space-1)",
17
+ padding: "var(--space-1) var(--space-3)",
18
+ height: "var(--control-md)",
19
+ border: 0,
20
+ borderRadius: "calc(var(--radius) - 2px)",
21
+ background: "transparent",
22
+ color: "var(--foreground)",
23
+ fontSize: "var(--text-sm)",
24
+ lineHeight: 1,
25
+ cursor: "pointer",
26
+ transition: "background-color var(--duration-fast), color var(--duration-fast)",
27
+ },
28
+ "& .dm__trigger:hover": {
29
+ background: "var(--background-muted)",
30
+ },
31
+ "& .dm__trigger[data-popup-open]": {
32
+ background: "var(--background-muted)",
33
+ },
34
+ "& .dm__trigger:focus-visible": {
35
+ outline: "var(--border-width-strong) solid var(--foreground)",
36
+ outlineOffset: "-1px",
37
+ },
38
+ },
39
+ "@media": {
40
+ "(prefers-reduced-motion: reduce)": {
41
+ selectors: {
42
+ "& .dm__trigger": {
43
+ transition: "none",
44
+ },
45
+ },
46
+ },
47
+ },
48
+ });
49
+
50
+ /** 동적 키로 클래스 참조용 — `byKey[\`badge--${variant}\`]` 같은 패턴 지원. */
51
+ export const byKey: Record<string, string> = {
52
+ "menubar": menubar,
53
+ };
@@ -0,0 +1,148 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { byKey, numericInput, numericInputInput, numericInputUnit } from "./styles.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={numericInput}>
90
+ <input
91
+ ref={ref}
92
+ type="text"
93
+ inputMode="decimal"
94
+ className={cn(numericInputInput, 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={numericInputUnit} aria-hidden>
141
+ {unit}
142
+ </span>
143
+ )}
144
+ </span>
145
+ );
146
+ },
147
+ );
148
+ NumericInput.displayName = "NumericInput";
@@ -0,0 +1,65 @@
1
+ import { style } from "@vanilla-extract/css";
2
+
3
+ export const numericInput = style({
4
+ display: "inline-flex",
5
+ alignItems: "baseline",
6
+ gap: "2px",
7
+ minWidth: "3rem",
8
+ justifyContent: "flex-end",
9
+ });
10
+
11
+ export const numericInputInput = style({
12
+ width: "2.5rem",
13
+ padding: "2px 4px",
14
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
15
+ fontSize: "var(--text-xs)",
16
+ lineHeight: 1.2,
17
+ textAlign: "right",
18
+ border: "1px solid transparent",
19
+ borderRadius: "calc(var(--radius) - 4px)",
20
+ background: "transparent",
21
+ color: "var(--foreground)",
22
+ appearance: "textfield",
23
+ MozAppearance: "textfield",
24
+ transition: "border-color var(--duration-fast) var(--ease-standard),\n background-color var(--duration-fast) var(--ease-standard)",
25
+ selectors: {
26
+ "&::-webkit-inner-spin-button": {
27
+ WebkitAppearance: "none",
28
+ margin: 0,
29
+ },
30
+ "&::-webkit-outer-spin-button": {
31
+ WebkitAppearance: "none",
32
+ margin: 0,
33
+ },
34
+ "&:hover:not(:disabled):not(:focus)": {
35
+ borderColor: "var(--border)",
36
+ },
37
+ "&:focus": {
38
+ outline: "none",
39
+ borderColor: "var(--foreground)",
40
+ background: "var(--background)",
41
+ },
42
+ "&:focus-visible": {
43
+ outline: "none",
44
+ borderColor: "var(--foreground)",
45
+ background: "var(--background)",
46
+ },
47
+ "&:disabled": {
48
+ cursor: "not-allowed",
49
+ opacity: "var(--opacity-disabled)",
50
+ },
51
+ },
52
+ });
53
+
54
+ export const numericInputUnit = style({
55
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
56
+ fontSize: "var(--text-xs)",
57
+ color: "var(--foreground-muted)",
58
+ });
59
+
60
+ /** 동적 키로 클래스 참조용 — `byKey[\`badge--${variant}\`]` 같은 패턴 지원. */
61
+ export const byKey: Record<string, string> = {
62
+ "numeric-input": numericInput,
63
+ "numeric-input__input": numericInputInput,
64
+ "numeric-input__unit": numericInputUnit,
65
+ };