sh-ui-cli 0.52.1 → 0.52.2

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 (88) hide show
  1. package/data/changelog/versions.json +14 -0
  2. package/data/registry/react/components/_smoke/vanilla-extract.test.ts +33 -0
  3. package/data/registry/react/components/input/styles.css.ts +6 -6
  4. package/data/registry/react/registry.json +35 -852
  5. package/package.json +1 -1
  6. package/src/api.d.ts +3 -4
  7. package/src/constants.js +9 -5
  8. package/src/mcp.mjs +0 -1
  9. package/data/registry/react/components/accordion/index.vanilla-extract.tsx +0 -97
  10. package/data/registry/react/components/accordion/styles.css.ts +0 -131
  11. package/data/registry/react/components/avatar/index.vanilla-extract.tsx +0 -73
  12. package/data/registry/react/components/avatar/styles.css.ts +0 -68
  13. package/data/registry/react/components/badge/index.vanilla-extract.tsx +0 -40
  14. package/data/registry/react/components/badge/styles.css.ts +0 -71
  15. package/data/registry/react/components/breadcrumb/index.vanilla-extract.tsx +0 -152
  16. package/data/registry/react/components/breadcrumb/styles.css.ts +0 -95
  17. package/data/registry/react/components/calendar/index.vanilla-extract.tsx +0 -806
  18. package/data/registry/react/components/calendar/styles.css.ts +0 -250
  19. package/data/registry/react/components/carousel/index.vanilla-extract.tsx +0 -430
  20. package/data/registry/react/components/carousel/styles.css.ts +0 -169
  21. package/data/registry/react/components/checkbox/index.vanilla-extract.tsx +0 -96
  22. package/data/registry/react/components/checkbox/styles.css.ts +0 -74
  23. package/data/registry/react/components/code-editor/index.vanilla-extract.tsx +0 -230
  24. package/data/registry/react/components/code-editor/styles.css.ts +0 -97
  25. package/data/registry/react/components/code-panel/index.vanilla-extract.tsx +0 -191
  26. package/data/registry/react/components/code-panel/styles.css.ts +0 -151
  27. package/data/registry/react/components/color-picker/index.vanilla-extract.tsx +0 -467
  28. package/data/registry/react/components/color-picker/styles.css.ts +0 -169
  29. package/data/registry/react/components/combobox/index.vanilla-extract.tsx +0 -165
  30. package/data/registry/react/components/combobox/styles.css.ts +0 -174
  31. package/data/registry/react/components/context-menu/index.vanilla-extract.tsx +0 -251
  32. package/data/registry/react/components/context-menu/styles.css.ts +0 -167
  33. package/data/registry/react/components/date-picker/index.vanilla-extract.tsx +0 -520
  34. package/data/registry/react/components/date-picker/styles.css.ts +0 -111
  35. package/data/registry/react/components/dialog/index.vanilla-extract.tsx +0 -95
  36. package/data/registry/react/components/dialog/styles.css.ts +0 -140
  37. package/data/registry/react/components/dropdown-menu/index.vanilla-extract.tsx +0 -255
  38. package/data/registry/react/components/dropdown-menu/styles.css.ts +0 -175
  39. package/data/registry/react/components/file-upload/index.vanilla-extract.tsx +0 -487
  40. package/data/registry/react/components/file-upload/styles.css.ts +0 -193
  41. package/data/registry/react/components/form/index.vanilla-extract.tsx +0 -61
  42. package/data/registry/react/components/form/styles.css.ts +0 -56
  43. package/data/registry/react/components/header/index.vanilla-extract.tsx +0 -805
  44. package/data/registry/react/components/header/styles.css.ts +0 -413
  45. package/data/registry/react/components/label/index.vanilla-extract.tsx +0 -52
  46. package/data/registry/react/components/label/styles.css.ts +0 -141
  47. package/data/registry/react/components/markdown-editor/index.vanilla-extract.tsx +0 -119
  48. package/data/registry/react/components/markdown-editor/styles.css.ts +0 -231
  49. package/data/registry/react/components/menubar/index.vanilla-extract.tsx +0 -32
  50. package/data/registry/react/components/menubar/styles.css.ts +0 -53
  51. package/data/registry/react/components/numeric-input/index.vanilla-extract.tsx +0 -148
  52. package/data/registry/react/components/numeric-input/styles.css.ts +0 -65
  53. package/data/registry/react/components/page-toc/index.vanilla-extract.tsx +0 -174
  54. package/data/registry/react/components/page-toc/styles.css.ts +0 -97
  55. package/data/registry/react/components/pagination/index.vanilla-extract.tsx +0 -269
  56. package/data/registry/react/components/pagination/styles.css.ts +0 -113
  57. package/data/registry/react/components/popover/index.vanilla-extract.tsx +0 -113
  58. package/data/registry/react/components/popover/styles.css.ts +0 -78
  59. package/data/registry/react/components/progress/index.vanilla-extract.tsx +0 -54
  60. package/data/registry/react/components/progress/styles.css.ts +0 -53
  61. package/data/registry/react/components/radio/index.vanilla-extract.tsx +0 -65
  62. package/data/registry/react/components/radio/styles.css.ts +0 -79
  63. package/data/registry/react/components/rich-text-editor/index.vanilla-extract.tsx +0 -348
  64. package/data/registry/react/components/rich-text-editor/styles.css.ts +0 -243
  65. package/data/registry/react/components/select/index.vanilla-extract.tsx +0 -234
  66. package/data/registry/react/components/select/styles.css.ts +0 -225
  67. package/data/registry/react/components/separator/index.vanilla-extract.tsx +0 -46
  68. package/data/registry/react/components/separator/styles.css.ts +0 -24
  69. package/data/registry/react/components/sidebar/index.vanilla-extract.tsx +0 -1067
  70. package/data/registry/react/components/sidebar/styles.css.ts +0 -578
  71. package/data/registry/react/components/skeleton/index.vanilla-extract.tsx +0 -22
  72. package/data/registry/react/components/skeleton/styles.css.ts +0 -30
  73. package/data/registry/react/components/slider/index.vanilla-extract.tsx +0 -298
  74. package/data/registry/react/components/slider/styles.css.ts +0 -75
  75. package/data/registry/react/components/spinner/index.vanilla-extract.tsx +0 -38
  76. package/data/registry/react/components/spinner/styles.css.ts +0 -60
  77. package/data/registry/react/components/switch/index.vanilla-extract.tsx +0 -39
  78. package/data/registry/react/components/switch/styles.css.ts +0 -87
  79. package/data/registry/react/components/tabs/index.vanilla-extract.tsx +0 -91
  80. package/data/registry/react/components/tabs/styles.css.ts +0 -145
  81. package/data/registry/react/components/textarea/index.vanilla-extract.tsx +0 -23
  82. package/data/registry/react/components/textarea/styles.css.ts +0 -55
  83. package/data/registry/react/components/toast/index.vanilla-extract.tsx +0 -258
  84. package/data/registry/react/components/toast/styles.css.ts +0 -307
  85. package/data/registry/react/components/toggle/index.vanilla-extract.tsx +0 -131
  86. package/data/registry/react/components/toggle/styles.css.ts +0 -109
  87. package/data/registry/react/components/tooltip/index.vanilla-extract.tsx +0 -83
  88. package/data/registry/react/components/tooltip/styles.css.ts +0 -59
@@ -1,119 +0,0 @@
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
- }
@@ -1,231 +0,0 @@
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
- };
@@ -1,32 +0,0 @@
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
- });
@@ -1,53 +0,0 @@
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
- };
@@ -1,148 +0,0 @@
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";
@@ -1,65 +0,0 @@
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
- };