sh-ui-cli 0.14.0 → 0.21.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 (162) hide show
  1. package/bin/sh-ui.mjs +6 -0
  2. package/data/changelog/versions.json +354 -0
  3. package/data/registry/flutter/foundation/sh_ui_tokens.dart +385 -0
  4. package/data/registry/flutter/registry.json +336 -0
  5. package/data/registry/flutter/widgets/sh_ui_accordion.dart +255 -0
  6. package/data/registry/flutter/widgets/sh_ui_app_shell.dart +267 -0
  7. package/data/registry/flutter/widgets/sh_ui_avatar.dart +95 -0
  8. package/data/registry/flutter/widgets/sh_ui_badge.dart +82 -0
  9. package/data/registry/flutter/widgets/sh_ui_breadcrumb.dart +107 -0
  10. package/data/registry/flutter/widgets/sh_ui_button.dart +201 -0
  11. package/data/registry/flutter/widgets/sh_ui_card.dart +159 -0
  12. package/data/registry/flutter/widgets/sh_ui_carousel.dart +204 -0
  13. package/data/registry/flutter/widgets/sh_ui_checkbox.dart +154 -0
  14. package/data/registry/flutter/widgets/sh_ui_color_picker.dart +264 -0
  15. package/data/registry/flutter/widgets/sh_ui_combobox.dart +614 -0
  16. package/data/registry/flutter/widgets/sh_ui_context_menu.dart +71 -0
  17. package/data/registry/flutter/widgets/sh_ui_date_picker.dart +648 -0
  18. package/data/registry/flutter/widgets/sh_ui_dialog.dart +567 -0
  19. package/data/registry/flutter/widgets/sh_ui_dropdown_menu.dart +251 -0
  20. package/data/registry/flutter/widgets/sh_ui_file_upload.dart +200 -0
  21. package/data/registry/flutter/widgets/sh_ui_header.dart +488 -0
  22. package/data/registry/flutter/widgets/sh_ui_input.dart +664 -0
  23. package/data/registry/flutter/widgets/sh_ui_label.dart +145 -0
  24. package/data/registry/flutter/widgets/sh_ui_menubar.dart +98 -0
  25. package/data/registry/flutter/widgets/sh_ui_pagination.dart +276 -0
  26. package/data/registry/flutter/widgets/sh_ui_popover.dart +248 -0
  27. package/data/registry/flutter/widgets/sh_ui_progress.dart +47 -0
  28. package/data/registry/flutter/widgets/sh_ui_radio.dart +108 -0
  29. package/data/registry/flutter/widgets/sh_ui_select.dart +904 -0
  30. package/data/registry/flutter/widgets/sh_ui_separator.dart +42 -0
  31. package/data/registry/flutter/widgets/sh_ui_sidebar.dart +1116 -0
  32. package/data/registry/flutter/widgets/sh_ui_skeleton.dart +129 -0
  33. package/data/registry/flutter/widgets/sh_ui_slider.dart +147 -0
  34. package/data/registry/flutter/widgets/sh_ui_spinner.dart +56 -0
  35. package/data/registry/flutter/widgets/sh_ui_switch.dart +109 -0
  36. package/data/registry/flutter/widgets/sh_ui_tabs.dart +329 -0
  37. package/data/registry/flutter/widgets/sh_ui_textarea.dart +126 -0
  38. package/data/registry/flutter/widgets/sh_ui_toast.dart +362 -0
  39. package/data/registry/flutter/widgets/sh_ui_toggle.dart +229 -0
  40. package/data/registry/flutter/widgets/sh_ui_tooltip.dart +62 -0
  41. package/data/registry/react/components/accordion/index.tsx +85 -0
  42. package/data/registry/react/components/accordion/styles.css +94 -0
  43. package/data/registry/react/components/animations/animations.css +51 -0
  44. package/data/registry/react/components/avatar/index.tsx +75 -0
  45. package/data/registry/react/components/avatar/styles.css +36 -0
  46. package/data/registry/react/components/badge/index.tsx +42 -0
  47. package/data/registry/react/components/badge/styles.css +57 -0
  48. package/data/registry/react/components/base/base.css +102 -0
  49. package/data/registry/react/components/breadcrumb/index.tsx +154 -0
  50. package/data/registry/react/components/breadcrumb/styles.css +82 -0
  51. package/data/registry/react/components/breakpoints/breakpoints.css +17 -0
  52. package/data/registry/react/components/button/index.tsx +47 -0
  53. package/data/registry/react/components/button/styles.css +93 -0
  54. package/data/registry/react/components/card/index.tsx +86 -0
  55. package/data/registry/react/components/card/styles.css +73 -0
  56. package/data/registry/react/components/carousel/index.tsx +432 -0
  57. package/data/registry/react/components/carousel/styles.css +155 -0
  58. package/data/registry/react/components/checkbox/index.tsx +98 -0
  59. package/data/registry/react/components/checkbox/styles.css +75 -0
  60. package/data/registry/react/components/code-panel/copy.tsx +56 -0
  61. package/data/registry/react/components/code-panel/index.tsx +193 -0
  62. package/data/registry/react/components/code-panel/styles.css +124 -0
  63. package/data/registry/react/components/color-picker/index.tsx +466 -0
  64. package/data/registry/react/components/color-picker/styles.css +166 -0
  65. package/data/registry/react/components/combobox/index.tsx +167 -0
  66. package/data/registry/react/components/combobox/styles.css +151 -0
  67. package/data/registry/react/components/context-menu/index.tsx +253 -0
  68. package/data/registry/react/components/context-menu/styles.css +140 -0
  69. package/data/registry/react/components/date-picker/index.tsx +757 -0
  70. package/data/registry/react/components/date-picker/styles.css +279 -0
  71. package/data/registry/react/components/dialog/index.tsx +97 -0
  72. package/data/registry/react/components/dialog/styles.css +127 -0
  73. package/data/registry/react/components/dropdown-menu/index.tsx +257 -0
  74. package/data/registry/react/components/dropdown-menu/styles.css +150 -0
  75. package/data/registry/react/components/file-upload/index.tsx +489 -0
  76. package/data/registry/react/components/file-upload/styles.css +170 -0
  77. package/data/registry/react/components/focus-ring/focus-ring.css +23 -0
  78. package/data/registry/react/components/form/context.ts +92 -0
  79. package/data/registry/react/components/form/field.test.tsx +230 -0
  80. package/data/registry/react/components/form/field.tsx +236 -0
  81. package/data/registry/react/components/form/focus-first-error.ts +54 -0
  82. package/data/registry/react/components/form/form.section.test.tsx +58 -0
  83. package/data/registry/react/components/form/form.test.tsx +146 -0
  84. package/data/registry/react/components/form/form.tsx +180 -0
  85. package/data/registry/react/components/form/index.tsx +61 -0
  86. package/data/registry/react/components/form/steps.test.tsx +106 -0
  87. package/data/registry/react/components/form/steps.tsx +193 -0
  88. package/data/registry/react/components/form/store.test.ts +206 -0
  89. package/data/registry/react/components/form/store.ts +318 -0
  90. package/data/registry/react/components/form/styles.css +47 -0
  91. package/data/registry/react/components/form/types.ts +104 -0
  92. package/data/registry/react/components/form/use-sh-ui-form.ts +15 -0
  93. package/data/registry/react/components/form/utils.test.ts +44 -0
  94. package/data/registry/react/components/form/utils.ts +49 -0
  95. package/data/registry/react/components/form/validation.test.ts +67 -0
  96. package/data/registry/react/components/form/validation.ts +64 -0
  97. package/data/registry/react/components/form-rhf/README.md +27 -0
  98. package/data/registry/react/components/form-rhf/index.tsx +289 -0
  99. package/data/registry/react/components/form-rhf/rhf.test.tsx +42 -0
  100. package/data/registry/react/components/form-tanstack/README.md +27 -0
  101. package/data/registry/react/components/form-tanstack/index.tsx +352 -0
  102. package/data/registry/react/components/form-tanstack/tanstack.test.tsx +45 -0
  103. package/data/registry/react/components/form-yup/README.md +22 -0
  104. package/data/registry/react/components/form-yup/index.tsx +50 -0
  105. package/data/registry/react/components/form-yup/yup.test.ts +27 -0
  106. package/data/registry/react/components/header/index.tsx +257 -0
  107. package/data/registry/react/components/header/styles.css +190 -0
  108. package/data/registry/react/components/input/index.tsx +517 -0
  109. package/data/registry/react/components/input/styles.css +203 -0
  110. package/data/registry/react/components/label/index.tsx +54 -0
  111. package/data/registry/react/components/label/styles.css +90 -0
  112. package/data/registry/react/components/menubar/index.tsx +34 -0
  113. package/data/registry/react/components/menubar/styles.css +45 -0
  114. package/data/registry/react/components/pagination/index.tsx +271 -0
  115. package/data/registry/react/components/pagination/styles.css +105 -0
  116. package/data/registry/react/components/popover/index.tsx +115 -0
  117. package/data/registry/react/components/popover/styles.css +65 -0
  118. package/data/registry/react/components/progress/index.tsx +56 -0
  119. package/data/registry/react/components/progress/styles.css +41 -0
  120. package/data/registry/react/components/radio/index.tsx +67 -0
  121. package/data/registry/react/components/radio/styles.css +80 -0
  122. package/data/registry/react/components/select/index.tsx +236 -0
  123. package/data/registry/react/components/select/styles.css +193 -0
  124. package/data/registry/react/components/separator/index.tsx +48 -0
  125. package/data/registry/react/components/separator/styles.css +15 -0
  126. package/data/registry/react/components/sidebar/index.tsx +1084 -0
  127. package/data/registry/react/components/sidebar/styles.css +502 -0
  128. package/data/registry/react/components/skeleton/index.tsx +24 -0
  129. package/data/registry/react/components/skeleton/styles.css +24 -0
  130. package/data/registry/react/components/slider/index.tsx +300 -0
  131. package/data/registry/react/components/slider/styles.css +64 -0
  132. package/data/registry/react/components/spinner/index.tsx +40 -0
  133. package/data/registry/react/components/spinner/styles.css +37 -0
  134. package/data/registry/react/components/switch/index.tsx +41 -0
  135. package/data/registry/react/components/switch/styles.css +83 -0
  136. package/data/registry/react/components/tabs/index.tsx +93 -0
  137. package/data/registry/react/components/tabs/styles.css +148 -0
  138. package/data/registry/react/components/textarea/index.tsx +25 -0
  139. package/data/registry/react/components/textarea/styles.css +54 -0
  140. package/data/registry/react/components/theme/index.tsx +91 -0
  141. package/data/registry/react/components/toast/index.tsx +257 -0
  142. package/data/registry/react/components/toast/styles.css +290 -0
  143. package/data/registry/react/components/toggle/index.tsx +133 -0
  144. package/data/registry/react/components/toggle/styles.css +85 -0
  145. package/data/registry/react/components/tooltip/index.tsx +85 -0
  146. package/data/registry/react/components/tooltip/styles.css +44 -0
  147. package/data/registry/react/components/z-index/z-index.css +16 -0
  148. package/data/registry/react/hooks/use-active-section.ts +104 -0
  149. package/data/registry/react/hooks/use-media-query.ts +27 -0
  150. package/data/registry/react/lib/cn.ts +39 -0
  151. package/data/registry/react/registry.json +835 -0
  152. package/data/summaries/flutter.json +42 -0
  153. package/data/summaries/react.json +50 -0
  154. package/data/tokens/build.mjs +553 -0
  155. package/data/tokens/src/primitives.json +146 -0
  156. package/data/tokens/src/semantic.json +146 -0
  157. package/package.json +13 -4
  158. package/src/add.mjs +13 -12
  159. package/src/list.mjs +3 -11
  160. package/src/mcp.mjs +308 -0
  161. package/src/paths.mjs +52 -0
  162. package/src/remove.mjs +4 -11
@@ -0,0 +1,517 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import "./styles.css";
5
+
6
+ export interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "prefix"> {
7
+ /** input 우측에 부착할 보조 노드(아이콘·단위·버튼 등). 더 많은 슬롯이 필요하면 InputGroup 사용. */
8
+ suffix?: React.ReactNode;
9
+ /** input 좌측에 부착할 보조 노드. */
10
+ prefix?: React.ReactNode;
11
+ }
12
+
13
+ function cx(...args: (string | undefined | null | false)[]) {
14
+ return args.filter(Boolean).join(" ");
15
+ }
16
+
17
+ /* ───────── InputGroup + InputAdornment (compound) ─────────
18
+ * <InputGroup>
19
+ * <InputAdornment><SearchIcon /></InputAdornment>
20
+ * <Input placeholder="검색..." />
21
+ * <InputAdornment><ClearButton /></InputAdornment>
22
+ * </InputGroup>
23
+ *
24
+ * InputGroup이 공용 보더/포커스 링을 담당하고, 내부 Input은 자신의 보더를
25
+ * 감춘다(data-in-group 기반). Adornment 위치는 children 순서로 결정한다.
26
+ */
27
+
28
+ interface InputGroupContextValue {
29
+ inGroup: true;
30
+ }
31
+
32
+ const InputGroupContext = React.createContext<InputGroupContextValue | null>(
33
+ null,
34
+ );
35
+
36
+ function useInputGroup() {
37
+ return React.useContext(InputGroupContext);
38
+ }
39
+
40
+ export interface InputGroupProps extends React.HTMLAttributes<HTMLDivElement> {
41
+ /**
42
+ * invalid 상태를 그룹 전체에 적용. 자식 input의 보더가 위험색으로 바뀌고,
43
+ * 스크린리더에 오류 상태가 노출된다.
44
+ */
45
+ "aria-invalid"?: boolean | "true" | "false";
46
+ /** disabled 상태를 그룹 전체에 적용. 그룹 내 input/Adornment 모두에 전파된다. */
47
+ disabled?: boolean;
48
+ }
49
+
50
+ /**
51
+ * Input과 좌우 보조 요소(`InputAdornment`)를 한 박스로 묶는 컴파운드 래퍼.
52
+ * 그룹 영역 어디를 클릭해도 내부 input에 포커스가 이동하고, `aria-invalid`/`disabled`가 자식 전체에 전파된다.
53
+ */
54
+ export const InputGroup = React.forwardRef<HTMLDivElement, InputGroupProps>(
55
+ (
56
+ {
57
+ className,
58
+ children,
59
+ "aria-invalid": ariaInvalid,
60
+ disabled,
61
+ onClick,
62
+ ...props
63
+ },
64
+ ref,
65
+ ) => {
66
+ const innerRef = React.useRef<HTMLDivElement | null>(null);
67
+ const mergedRef = React.useCallback(
68
+ (el: HTMLDivElement | null) => {
69
+ innerRef.current = el;
70
+ if (typeof ref === "function") ref(el);
71
+ else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = el;
72
+ },
73
+ [ref],
74
+ );
75
+
76
+ // 그룹 어느 곳을 클릭해도 내부 input에 포커스
77
+ const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
78
+ onClick?.(e);
79
+ if (e.defaultPrevented) return;
80
+ const target = e.target as HTMLElement;
81
+ if (target.closest("button, input, textarea, select, a")) return;
82
+ const input = innerRef.current?.querySelector<HTMLInputElement>("input");
83
+ input?.focus();
84
+ };
85
+
86
+ return (
87
+ <InputGroupContext.Provider value={{ inGroup: true }}>
88
+ <div
89
+ ref={mergedRef}
90
+ className={cx("sh-ui-input-group", className)}
91
+ data-disabled={disabled || undefined}
92
+ aria-invalid={ariaInvalid}
93
+ onClick={handleClick}
94
+ {...props}
95
+ >
96
+ {children}
97
+ </div>
98
+ </InputGroupContext.Provider>
99
+ );
100
+ },
101
+ );
102
+ InputGroup.displayName = "InputGroup";
103
+
104
+ export interface InputAdornmentProps
105
+ extends React.HTMLAttributes<HTMLSpanElement> {
106
+ /**
107
+ * 클릭이 input으로 버블링되지 않도록 한다. 버튼·체크박스 등 인터랙티브 요소를
108
+ * Adornment에 담을 때 켤 것 — 그러지 않으면 클릭이 input 포커스로 가로채진다.
109
+ *
110
+ * @default false
111
+ */
112
+ interactive?: boolean;
113
+ }
114
+
115
+ /**
116
+ * InputGroup 안에 들어가는 보조 슬롯. 위치는 children 순서로 결정한다.
117
+ * 버튼 등 인터랙티브 요소를 담을 때는 `interactive`를 켜 input 포커스 가로채기를 막을 것.
118
+ */
119
+ export const InputAdornment = React.forwardRef<
120
+ HTMLSpanElement,
121
+ InputAdornmentProps
122
+ >(({ className, interactive, ...props }, ref) => {
123
+ return (
124
+ <span
125
+ ref={ref}
126
+ className={cx("sh-ui-input-group__adornment", className)}
127
+ data-interactive={interactive || undefined}
128
+ {...props}
129
+ />
130
+ );
131
+ });
132
+ InputAdornment.displayName = "InputAdornment";
133
+
134
+ /**
135
+ * 한 줄 텍스트 입력. `prefix`/`suffix`로 아이콘이나 단위 등을 한 input 안에 붙일 수 있고,
136
+ * 더 많은 보조 요소가 필요하면 `InputGroup`+`InputAdornment` 조합을 사용한다.
137
+ */
138
+ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
139
+ ({ className, type = "text", prefix, suffix, ...props }, ref) => {
140
+ const group = useInputGroup();
141
+ const hasAffix = Boolean(prefix || suffix);
142
+ const input = (
143
+ <input
144
+ ref={ref}
145
+ type={type}
146
+ className={cx(
147
+ "sh-ui-input",
148
+ !!prefix && "sh-ui-input--with-prefix",
149
+ !!suffix && "sh-ui-input--with-suffix",
150
+ className,
151
+ )}
152
+ data-in-group={group ? "" : undefined}
153
+ {...props}
154
+ />
155
+ );
156
+
157
+ if (!hasAffix) return input;
158
+
159
+ return (
160
+ <div className="sh-ui-input-wrap" data-in-group={group ? "" : undefined}>
161
+ {prefix && <span className="sh-ui-input__affix sh-ui-input__affix--prefix">{prefix}</span>}
162
+ {input}
163
+ {suffix && <span className="sh-ui-input__affix sh-ui-input__affix--suffix">{suffix}</span>}
164
+ </div>
165
+ );
166
+ },
167
+ );
168
+ Input.displayName = "Input";
169
+
170
+ /* ───────── PasswordInput ───────── */
171
+
172
+ function EyeIcon() {
173
+ return (
174
+ <svg viewBox="0 0 20 20" width="16" height="16" fill="none" aria-hidden>
175
+ <path
176
+ d="M2 10s3-5.5 8-5.5S18 10 18 10s-3 5.5-8 5.5S2 10 2 10Z"
177
+ stroke="currentColor"
178
+ strokeWidth="1.5"
179
+ />
180
+ <circle cx="10" cy="10" r="2.25" stroke="currentColor" strokeWidth="1.5" />
181
+ </svg>
182
+ );
183
+ }
184
+
185
+ function EyeOffIcon() {
186
+ return (
187
+ <svg viewBox="0 0 20 20" width="16" height="16" fill="none" aria-hidden>
188
+ <path
189
+ d="M3 3l14 14M8 5a8 8 0 0 1 2-.3c5 0 8 5.3 8 5.3a13 13 0 0 1-2.3 2.9M12 12a2.5 2.5 0 0 1-3.4-3.4m-2.3-2.5A13 13 0 0 0 2 10s3 5.5 8 5.5a8 8 0 0 0 3.3-.7"
190
+ stroke="currentColor"
191
+ strokeWidth="1.5"
192
+ strokeLinecap="round"
193
+ />
194
+ </svg>
195
+ );
196
+ }
197
+
198
+ export interface PasswordInputProps extends Omit<InputProps, "type" | "suffix"> {
199
+ /**
200
+ * 비밀번호 표시 토글 버튼을 숨긴다. 비밀번호를 절대 노출하면 안 되는 화면(결제 등)에서 사용.
201
+ *
202
+ * @default false
203
+ */
204
+ hideToggle?: boolean;
205
+ }
206
+
207
+ /**
208
+ * 비밀번호 입력. 기본으로 표시 토글 버튼이 suffix에 부착되며 `hideToggle`로 숨길 수 있다.
209
+ * 토글은 `aria-pressed`로 상태가 노출되고 Tab 흐름에서 제외(`tabIndex=-1`)된다.
210
+ */
211
+ export const PasswordInput = React.forwardRef<HTMLInputElement, PasswordInputProps>(
212
+ ({ hideToggle, ...props }, ref) => {
213
+ const [visible, setVisible] = React.useState(false);
214
+
215
+ const toggle = hideToggle ? undefined : (
216
+ <button
217
+ type="button"
218
+ className="sh-ui-input__toggle"
219
+ onClick={() => setVisible((v) => !v)}
220
+ aria-label={visible ? "비밀번호 숨기기" : "비밀번호 표시"}
221
+ aria-pressed={visible}
222
+ tabIndex={-1}
223
+ >
224
+ {visible ? <EyeOffIcon /> : <EyeIcon />}
225
+ </button>
226
+ );
227
+
228
+ return (
229
+ <Input
230
+ ref={ref}
231
+ type={visible ? "text" : "password"}
232
+ suffix={toggle}
233
+ {...props}
234
+ />
235
+ );
236
+ },
237
+ );
238
+ PasswordInput.displayName = "PasswordInput";
239
+
240
+ /* ───────── NumberInput ─────────
241
+ * 정수 입력 + 천 단위 콤마(옵션). value/onValueChange는 number | undefined.
242
+ */
243
+
244
+ export interface NumberInputProps
245
+ extends Omit<InputProps, "value" | "defaultValue" | "onChange" | "type"> {
246
+ /** 제어 모드 값. `undefined`는 빈 입력. */
247
+ value?: number;
248
+ /** 비제어 모드 초기값. */
249
+ defaultValue?: number;
250
+ /** 값 변경 콜백. 빈 입력일 때 `undefined`가 전달된다. */
251
+ onValueChange?: (value: number | undefined) => void;
252
+ /**
253
+ * 천 단위 콤마 자동 포맷.
254
+ * @default true
255
+ */
256
+ thousandsSeparator?: boolean;
257
+ /** 허용 최솟값. blur 시 자동 클램프된다. */
258
+ min?: number;
259
+ /** 허용 최댓값. blur 시 자동 클램프된다. */
260
+ max?: number;
261
+ /**
262
+ * 음수 입력 허용 여부.
263
+ * @default true
264
+ */
265
+ allowNegative?: boolean;
266
+ }
267
+
268
+ const formatNumber = (digits: string, thousandsSeparator: boolean): string => {
269
+ if (digits === "" || digits === "-") return digits;
270
+ const negative = digits.startsWith("-");
271
+ const body = negative ? digits.slice(1) : digits;
272
+ if (!body) return negative ? "-" : "";
273
+ const formatted = thousandsSeparator
274
+ ? body.replace(/\B(?=(\d{3})+(?!\d))/g, ",")
275
+ : body;
276
+ return negative ? `-${formatted}` : formatted;
277
+ };
278
+
279
+ const parseNumber = (s: string): number | undefined => {
280
+ const cleaned = s.replace(/[^\d-]/g, "");
281
+ if (!cleaned || cleaned === "-") return undefined;
282
+ const n = Number(cleaned);
283
+ return Number.isFinite(n) ? n : undefined;
284
+ };
285
+
286
+ /**
287
+ * 정수 입력 + 천 단위 콤마 자동 포맷. `value`는 `number | undefined`이고 표시 문자열과 분리되어 있다.
288
+ * blur 시 `min`/`max` 범위로 자동 클램프되며, 음수 허용은 `allowNegative`로 토글한다.
289
+ */
290
+ export const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
291
+ (
292
+ {
293
+ value,
294
+ defaultValue,
295
+ onValueChange,
296
+ thousandsSeparator = true,
297
+ min,
298
+ max,
299
+ allowNegative = true,
300
+ onBlur,
301
+ ...rest
302
+ },
303
+ ref
304
+ ) => {
305
+ const isControlled = value !== undefined;
306
+ const initial =
307
+ defaultValue !== undefined ? formatNumber(String(defaultValue), thousandsSeparator) : "";
308
+ const [internal, setInternal] = React.useState(initial);
309
+
310
+ const display = isControlled
311
+ ? value === undefined
312
+ ? ""
313
+ : formatNumber(String(value), thousandsSeparator)
314
+ : internal;
315
+
316
+ const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
317
+ const raw = e.target.value;
318
+ // 허용 문자만 남김
319
+ const allowedRe = allowNegative ? /[^\d-]/g : /[^\d]/g;
320
+ let cleaned = raw.replace(allowedRe, "");
321
+ // "-"는 맨 앞에만
322
+ if (allowNegative) cleaned = cleaned.replace(/(?!^)-/g, "");
323
+ const formatted = formatNumber(cleaned, thousandsSeparator);
324
+ if (!isControlled) setInternal(formatted);
325
+ onValueChange?.(parseNumber(cleaned));
326
+ };
327
+
328
+ const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
329
+ const n = parseNumber(display);
330
+ if (n !== undefined) {
331
+ let clamped = n;
332
+ if (min !== undefined && clamped < min) clamped = min;
333
+ if (max !== undefined && clamped > max) clamped = max;
334
+ if (clamped !== n) {
335
+ const f = formatNumber(String(clamped), thousandsSeparator);
336
+ if (!isControlled) setInternal(f);
337
+ onValueChange?.(clamped);
338
+ }
339
+ }
340
+ onBlur?.(e);
341
+ };
342
+
343
+ return (
344
+ <Input
345
+ ref={ref}
346
+ type="text"
347
+ inputMode="numeric"
348
+ value={display}
349
+ onChange={handleChange}
350
+ onBlur={handleBlur}
351
+ {...rest}
352
+ />
353
+ );
354
+ }
355
+ );
356
+ NumberInput.displayName = "NumberInput";
357
+
358
+ /* ───────── PhoneInput (KR) ─────────
359
+ * 한국 전화번호 자동 하이픈. 010-1234-5678, 02-1234-5678, 031-123-4567 등.
360
+ * onValueChange는 숫자만(13자 이하) 콜백.
361
+ */
362
+
363
+ const formatPhoneKR = (digits: string): string => {
364
+ const d = digits.replace(/\D/g, "").slice(0, 11);
365
+ if (d.length === 0) return "";
366
+
367
+ // 02로 시작: [2, 3-4, 4]
368
+ if (d.startsWith("02")) {
369
+ if (d.length <= 2) return d;
370
+ if (d.length <= 5) return `${d.slice(0, 2)}-${d.slice(2)}`;
371
+ if (d.length <= 9) return `${d.slice(0, 2)}-${d.slice(2, 5)}-${d.slice(5)}`;
372
+ return `${d.slice(0, 2)}-${d.slice(2, 6)}-${d.slice(6, 10)}`;
373
+ }
374
+
375
+ // 그 외(010, 031, …): [3, 3-4, 4]
376
+ if (d.length <= 3) return d;
377
+ if (d.length <= 6) return `${d.slice(0, 3)}-${d.slice(3)}`;
378
+ if (d.length <= 10) return `${d.slice(0, 3)}-${d.slice(3, 6)}-${d.slice(6)}`;
379
+ return `${d.slice(0, 3)}-${d.slice(3, 7)}-${d.slice(7, 11)}`;
380
+ };
381
+
382
+ export interface PhoneInputProps
383
+ extends Omit<InputProps, "value" | "defaultValue" | "onChange" | "type"> {
384
+ /** 제어 모드 값. 하이픈 포함/제외 모두 허용 — 표시용으로 자동 포맷됨. */
385
+ value?: string;
386
+ /** 비제어 모드 초기값. */
387
+ defaultValue?: string;
388
+ /** 값 변경 콜백. 하이픈을 뺀 숫자 문자열만 전달된다. */
389
+ onValueChange?: (digits: string) => void;
390
+ }
391
+
392
+ /**
393
+ * 한국 휴대폰·지역번호용 자동 하이픈 입력(010/02/031 등). `onValueChange`는 하이픈을 뺀
394
+ * 숫자 문자열만 콜백한다. 국제화가 필요하면 별도 컴포넌트로 분리해 사용할 것.
395
+ */
396
+ export const PhoneInput = React.forwardRef<HTMLInputElement, PhoneInputProps>(
397
+ ({ value, defaultValue, onValueChange, onBlur, ...rest }, ref) => {
398
+ const isControlled = value !== undefined;
399
+ const initial = formatPhoneKR(defaultValue ?? "");
400
+ const [internal, setInternal] = React.useState(initial);
401
+
402
+ const display = isControlled ? formatPhoneKR(value ?? "") : internal;
403
+
404
+ const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
405
+ const digits = e.target.value.replace(/\D/g, "").slice(0, 11);
406
+ const formatted = formatPhoneKR(digits);
407
+ if (!isControlled) setInternal(formatted);
408
+ onValueChange?.(digits);
409
+ };
410
+
411
+ return (
412
+ <Input
413
+ ref={ref}
414
+ type="tel"
415
+ inputMode="tel"
416
+ autoComplete="tel"
417
+ value={display}
418
+ onChange={handleChange}
419
+ onBlur={onBlur}
420
+ {...rest}
421
+ />
422
+ );
423
+ }
424
+ );
425
+ PhoneInput.displayName = "PhoneInput";
426
+
427
+ /* ───────── BusinessNumberInput (KR 사업자등록번호) ─────────
428
+ * XXX-XX-XXXXX (10자리). 옵션으로 체크섬 검증.
429
+ */
430
+
431
+ const formatBRN = (digits: string): string => {
432
+ const d = digits.replace(/\D/g, "").slice(0, 10);
433
+ if (d.length <= 3) return d;
434
+ if (d.length <= 5) return `${d.slice(0, 3)}-${d.slice(3)}`;
435
+ return `${d.slice(0, 3)}-${d.slice(3, 5)}-${d.slice(5)}`;
436
+ };
437
+
438
+
439
+ /**
440
+ * 한국 사업자등록번호(10자리) 체크섬 검증.
441
+ * @param digits - 검증할 사업자번호 문자열. 하이픈 포함/제외 모두 허용.
442
+ * @returns 체크섬 통과 여부. 길이가 10이 아니면 항상 `false`.
443
+ * @example
444
+ * isValidBRN("123-45-67890") // false
445
+ * isValidBRN("1234567890") // 체크섬에 따라
446
+ */
447
+ export function isValidBRN(digits: string): boolean {
448
+ const d = digits.replace(/\D/g, "");
449
+ if (d.length !== 10) return false;
450
+ const w = [1, 3, 7, 1, 3, 7, 1, 3, 5];
451
+ let sum = 0;
452
+ for (let i = 0; i < 9; i++) sum += parseInt(d[i], 10) * w[i];
453
+ sum += Math.floor((parseInt(d[8], 10) * 5) / 10);
454
+ const check = (10 - (sum % 10)) % 10;
455
+ return check === parseInt(d[9], 10);
456
+ }
457
+
458
+ export interface BusinessNumberInputProps
459
+ extends Omit<InputProps, "value" | "defaultValue" | "onChange" | "type"> {
460
+ /** 제어 모드 값. 하이픈 포함/제외 모두 허용. */
461
+ value?: string;
462
+ /** 비제어 모드 초기값. */
463
+ defaultValue?: string;
464
+ /** 값 변경 콜백. 하이픈을 뺀 숫자 문자열만 전달된다. */
465
+ onValueChange?: (digits: string) => void;
466
+ /**
467
+ * 켜면 10자리 입력 시 사업자번호 체크섬을 검증해 `aria-invalid`를 자동 부여한다.
468
+ * 외부에서 `aria-invalid`를 명시하면 그 값이 우선한다.
469
+ *
470
+ * @default false
471
+ */
472
+ validateChecksum?: boolean;
473
+ }
474
+
475
+ /**
476
+ * 한국 사업자등록번호(XXX-XX-XXXXX) 자동 하이픈 입력. `validateChecksum`을 켜면
477
+ * 10자리 입력 시 체크섬을 검증해 `aria-invalid`를 자동 부여한다(외부에서 `aria-invalid`를 명시하면 우선).
478
+ */
479
+ export const BusinessNumberInput = React.forwardRef<HTMLInputElement, BusinessNumberInputProps>(
480
+ (
481
+ { value, defaultValue, onValueChange, validateChecksum, onBlur, "aria-invalid": ariaInvalidProp, ...rest },
482
+ ref
483
+ ) => {
484
+ const isControlled = value !== undefined;
485
+ const initial = formatBRN(defaultValue ?? "");
486
+ const [internal, setInternal] = React.useState(initial);
487
+
488
+ const display = isControlled ? formatBRN(value ?? "") : internal;
489
+ const digits = display.replace(/\D/g, "");
490
+
491
+ const invalid =
492
+ ariaInvalidProp !== undefined
493
+ ? ariaInvalidProp
494
+ : validateChecksum && digits.length === 10 && !isValidBRN(digits);
495
+
496
+ const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
497
+ const next = e.target.value.replace(/\D/g, "").slice(0, 10);
498
+ const formatted = formatBRN(next);
499
+ if (!isControlled) setInternal(formatted);
500
+ onValueChange?.(next);
501
+ };
502
+
503
+ return (
504
+ <Input
505
+ ref={ref}
506
+ type="text"
507
+ inputMode="numeric"
508
+ value={display}
509
+ onChange={handleChange}
510
+ onBlur={onBlur}
511
+ aria-invalid={invalid || undefined}
512
+ {...rest}
513
+ />
514
+ );
515
+ }
516
+ );
517
+ BusinessNumberInput.displayName = "BusinessNumberInput";