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,486 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { cn } from "@SH_UI_UTILS@";
5
+ import styles from "./styles.module.css";
6
+
7
+ export interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "prefix"> {
8
+ /** input 우측에 부착할 보조 노드(아이콘·단위·버튼 등). 더 많은 슬롯이 필요하면 InputGroup 사용. */
9
+ suffix?: React.ReactNode;
10
+ /** input 좌측에 부착할 보조 노드. */
11
+ prefix?: React.ReactNode;
12
+ }
13
+
14
+ /* ───────── InputGroup + InputAdornment (compound) ─────────
15
+ * <InputGroup>
16
+ * <InputAdornment><SearchIcon /></InputAdornment>
17
+ * <Input placeholder="검색..." />
18
+ * <InputAdornment><ClearButton /></InputAdornment>
19
+ * </InputGroup>
20
+ *
21
+ * InputGroup이 공용 보더/포커스 링을 담당하고, 내부 Input은 자신의 보더를
22
+ * 감춘다(data-in-group 기반). Adornment 위치는 children 순서로 결정한다.
23
+ */
24
+
25
+ interface InputGroupContextValue {
26
+ inGroup: true;
27
+ }
28
+
29
+ const InputGroupContext = React.createContext<InputGroupContextValue | null>(null);
30
+
31
+ function useInputGroup() {
32
+ return React.useContext(InputGroupContext);
33
+ }
34
+
35
+ export interface InputGroupProps extends React.HTMLAttributes<HTMLDivElement> {
36
+ "aria-invalid"?: boolean | "true" | "false";
37
+ disabled?: boolean;
38
+ }
39
+
40
+ /**
41
+ * Input과 좌우 보조 요소(`InputAdornment`)를 한 박스로 묶는 컴파운드 래퍼.
42
+ * 그룹 영역 어디를 클릭해도 내부 input에 포커스가 이동하고, `aria-invalid`/`disabled`가 자식 전체에 전파된다.
43
+ */
44
+ export const InputGroup = React.forwardRef<HTMLDivElement, InputGroupProps>(
45
+ (
46
+ {
47
+ className,
48
+ children,
49
+ "aria-invalid": ariaInvalid,
50
+ disabled,
51
+ onClick,
52
+ ...props
53
+ },
54
+ ref,
55
+ ) => {
56
+ const innerRef = React.useRef<HTMLDivElement | null>(null);
57
+ const mergedRef = React.useCallback(
58
+ (el: HTMLDivElement | null) => {
59
+ innerRef.current = el;
60
+ if (typeof ref === "function") ref(el);
61
+ else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = el;
62
+ },
63
+ [ref],
64
+ );
65
+
66
+ const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
67
+ onClick?.(e);
68
+ if (e.defaultPrevented) return;
69
+ const target = e.target as HTMLElement;
70
+ if (target.closest("button, input, textarea, select, a")) return;
71
+ const input = innerRef.current?.querySelector<HTMLInputElement>("input");
72
+ input?.focus();
73
+ };
74
+
75
+ return (
76
+ <InputGroupContext.Provider value={{ inGroup: true }}>
77
+ <div
78
+ ref={mergedRef}
79
+ className={cn(styles.group, className)}
80
+ data-disabled={disabled || undefined}
81
+ aria-invalid={ariaInvalid}
82
+ onClick={handleClick}
83
+ {...props}
84
+ >
85
+ {children}
86
+ </div>
87
+ </InputGroupContext.Provider>
88
+ );
89
+ },
90
+ );
91
+ InputGroup.displayName = "InputGroup";
92
+
93
+ export interface InputAdornmentProps extends React.HTMLAttributes<HTMLSpanElement> {
94
+ /**
95
+ * 클릭이 input으로 버블링되지 않도록 한다. 버튼·체크박스 등 인터랙티브 요소를
96
+ * Adornment에 담을 때 켤 것 — 그러지 않으면 클릭이 input 포커스로 가로채진다.
97
+ *
98
+ * @default false
99
+ */
100
+ interactive?: boolean;
101
+ }
102
+
103
+ /**
104
+ * InputGroup 안에 들어가는 보조 슬롯. 위치는 children 순서로 결정한다.
105
+ * 버튼 등 인터랙티브 요소를 담을 때는 `interactive`를 켜 input 포커스 가로채기를 막을 것.
106
+ */
107
+ export const InputAdornment = React.forwardRef<HTMLSpanElement, InputAdornmentProps>(
108
+ ({ className, interactive, ...props }, ref) => {
109
+ return (
110
+ <span
111
+ ref={ref}
112
+ className={cn(styles.adornment, className)}
113
+ data-interactive={interactive || undefined}
114
+ {...props}
115
+ />
116
+ );
117
+ },
118
+ );
119
+ InputAdornment.displayName = "InputAdornment";
120
+
121
+ /**
122
+ * 한 줄 텍스트 입력. `prefix`/`suffix`로 아이콘이나 단위 등을 한 input 안에 붙일 수 있고,
123
+ * 더 많은 보조 요소가 필요하면 `InputGroup`+`InputAdornment` 조합을 사용한다.
124
+ */
125
+ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
126
+ ({ className, type = "text", prefix, suffix, ...props }, ref) => {
127
+ const group = useInputGroup();
128
+ const hasAffix = Boolean(prefix || suffix);
129
+ const input = (
130
+ <input
131
+ ref={ref}
132
+ type={type}
133
+ className={cn(
134
+ styles.input,
135
+ !!prefix && styles.withPrefix,
136
+ !!suffix && styles.withSuffix,
137
+ className,
138
+ )}
139
+ data-in-group={group ? "" : undefined}
140
+ {...props}
141
+ />
142
+ );
143
+
144
+ if (!hasAffix) return input;
145
+
146
+ return (
147
+ <div className={styles.inputWrap} data-in-group={group ? "" : undefined}>
148
+ {prefix && (
149
+ <span className={cn(styles.affix, styles.affixPrefix)}>{prefix}</span>
150
+ )}
151
+ {input}
152
+ {suffix && (
153
+ <span className={cn(styles.affix, styles.affixSuffix)}>{suffix}</span>
154
+ )}
155
+ </div>
156
+ );
157
+ },
158
+ );
159
+ Input.displayName = "Input";
160
+
161
+ /* ───────── PasswordInput ───────── */
162
+
163
+ function EyeIcon() {
164
+ return (
165
+ <svg viewBox="0 0 20 20" width="16" height="16" fill="none" aria-hidden>
166
+ <path
167
+ d="M2 10s3-5.5 8-5.5S18 10 18 10s-3 5.5-8 5.5S2 10 2 10Z"
168
+ stroke="currentColor"
169
+ strokeWidth="1.5"
170
+ />
171
+ <circle cx="10" cy="10" r="2.25" stroke="currentColor" strokeWidth="1.5" />
172
+ </svg>
173
+ );
174
+ }
175
+
176
+ function EyeOffIcon() {
177
+ return (
178
+ <svg viewBox="0 0 20 20" width="16" height="16" fill="none" aria-hidden>
179
+ <path
180
+ 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"
181
+ stroke="currentColor"
182
+ strokeWidth="1.5"
183
+ strokeLinecap="round"
184
+ />
185
+ </svg>
186
+ );
187
+ }
188
+
189
+ export interface PasswordInputProps extends Omit<InputProps, "type" | "suffix"> {
190
+ /**
191
+ * 비밀번호 표시 토글 버튼을 숨긴다. 비밀번호를 절대 노출하면 안 되는 화면(결제 등)에서 사용.
192
+ *
193
+ * @default false
194
+ */
195
+ hideToggle?: boolean;
196
+ }
197
+
198
+ /**
199
+ * 비밀번호 입력. 기본으로 표시 토글 버튼이 suffix에 부착되며 `hideToggle`로 숨길 수 있다.
200
+ * 토글은 `aria-pressed`로 상태가 노출되고 Tab 흐름에서 제외(`tabIndex=-1`)된다.
201
+ */
202
+ export const PasswordInput = React.forwardRef<HTMLInputElement, PasswordInputProps>(
203
+ ({ hideToggle, ...props }, ref) => {
204
+ const [visible, setVisible] = React.useState(false);
205
+
206
+ const toggle = hideToggle ? undefined : (
207
+ <button
208
+ type="button"
209
+ className={styles.toggle}
210
+ onClick={() => setVisible((v) => !v)}
211
+ aria-label={visible ? "비밀번호 숨기기" : "비밀번호 표시"}
212
+ aria-pressed={visible}
213
+ tabIndex={-1}
214
+ >
215
+ {visible ? <EyeOffIcon /> : <EyeIcon />}
216
+ </button>
217
+ );
218
+
219
+ return (
220
+ <Input
221
+ ref={ref}
222
+ type={visible ? "text" : "password"}
223
+ suffix={toggle}
224
+ {...props}
225
+ />
226
+ );
227
+ },
228
+ );
229
+ PasswordInput.displayName = "PasswordInput";
230
+
231
+ /* ───────── NumberInput ─────────
232
+ * 정수 입력 + 천 단위 콤마(옵션). value/onValueChange는 number | undefined.
233
+ */
234
+
235
+ export interface NumberInputProps
236
+ extends Omit<InputProps, "value" | "defaultValue" | "onChange" | "type"> {
237
+ /** 제어 모드 값. `undefined`는 빈 입력. */
238
+ value?: number;
239
+ /** 비제어 모드 초기값. */
240
+ defaultValue?: number;
241
+ /** 값 변경 콜백. 빈 입력일 때 `undefined`가 전달된다. */
242
+ onValueChange?: (value: number | undefined) => void;
243
+ /**
244
+ * 천 단위 콤마 자동 포맷.
245
+ * @default true
246
+ */
247
+ thousandsSeparator?: boolean;
248
+ /** 허용 최솟값. blur 시 자동 클램프된다. */
249
+ min?: number;
250
+ /** 허용 최댓값. blur 시 자동 클램프된다. */
251
+ max?: number;
252
+ /**
253
+ * 음수 입력 허용 여부.
254
+ * @default true
255
+ */
256
+ allowNegative?: boolean;
257
+ }
258
+
259
+ const formatNumber = (digits: string, thousandsSeparator: boolean): string => {
260
+ if (digits === "" || digits === "-") return digits;
261
+ const negative = digits.startsWith("-");
262
+ const body = negative ? digits.slice(1) : digits;
263
+ if (!body) return negative ? "-" : "";
264
+ const formatted = thousandsSeparator
265
+ ? body.replace(/\B(?=(\d{3})+(?!\d))/g, ",")
266
+ : body;
267
+ return negative ? `-${formatted}` : formatted;
268
+ };
269
+
270
+ const parseNumber = (s: string): number | undefined => {
271
+ const cleaned = s.replace(/[^\d-]/g, "");
272
+ if (!cleaned || cleaned === "-") return undefined;
273
+ const n = Number(cleaned);
274
+ return Number.isFinite(n) ? n : undefined;
275
+ };
276
+
277
+ /**
278
+ * 정수 입력 + 천 단위 콤마 자동 포맷. `value`는 `number | undefined`이고 표시 문자열과 분리되어 있다.
279
+ * blur 시 `min`/`max` 범위로 자동 클램프되며, 음수 허용은 `allowNegative`로 토글한다.
280
+ */
281
+ export const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
282
+ (
283
+ {
284
+ value,
285
+ defaultValue,
286
+ onValueChange,
287
+ thousandsSeparator = true,
288
+ min,
289
+ max,
290
+ allowNegative = true,
291
+ onBlur,
292
+ ...rest
293
+ },
294
+ ref,
295
+ ) => {
296
+ const isControlled = value !== undefined;
297
+ const initial =
298
+ defaultValue !== undefined ? formatNumber(String(defaultValue), thousandsSeparator) : "";
299
+ const [internal, setInternal] = React.useState(initial);
300
+
301
+ const display = isControlled
302
+ ? value === undefined
303
+ ? ""
304
+ : formatNumber(String(value), thousandsSeparator)
305
+ : internal;
306
+
307
+ const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
308
+ const raw = e.target.value;
309
+ const allowedRe = allowNegative ? /[^\d-]/g : /[^\d]/g;
310
+ let cleaned = raw.replace(allowedRe, "");
311
+ if (allowNegative) cleaned = cleaned.replace(/(?!^)-/g, "");
312
+ const formatted = formatNumber(cleaned, thousandsSeparator);
313
+ if (!isControlled) setInternal(formatted);
314
+ onValueChange?.(parseNumber(cleaned));
315
+ };
316
+
317
+ const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
318
+ const n = parseNumber(display);
319
+ if (n !== undefined) {
320
+ let clamped = n;
321
+ if (min !== undefined && clamped < min) clamped = min;
322
+ if (max !== undefined && clamped > max) clamped = max;
323
+ if (clamped !== n) {
324
+ const f = formatNumber(String(clamped), thousandsSeparator);
325
+ if (!isControlled) setInternal(f);
326
+ onValueChange?.(clamped);
327
+ }
328
+ }
329
+ onBlur?.(e);
330
+ };
331
+
332
+ return (
333
+ <Input
334
+ ref={ref}
335
+ type="text"
336
+ inputMode="numeric"
337
+ value={display}
338
+ onChange={handleChange}
339
+ onBlur={handleBlur}
340
+ {...rest}
341
+ />
342
+ );
343
+ },
344
+ );
345
+ NumberInput.displayName = "NumberInput";
346
+
347
+ /* ───────── PhoneInput (KR) ───────── */
348
+
349
+ const formatPhoneKR = (digits: string): string => {
350
+ const d = digits.replace(/\D/g, "").slice(0, 11);
351
+ if (d.length === 0) return "";
352
+
353
+ if (d.startsWith("02")) {
354
+ if (d.length <= 2) return d;
355
+ if (d.length <= 5) return `${d.slice(0, 2)}-${d.slice(2)}`;
356
+ if (d.length <= 9) return `${d.slice(0, 2)}-${d.slice(2, 5)}-${d.slice(5)}`;
357
+ return `${d.slice(0, 2)}-${d.slice(2, 6)}-${d.slice(6, 10)}`;
358
+ }
359
+
360
+ if (d.length <= 3) return d;
361
+ if (d.length <= 6) return `${d.slice(0, 3)}-${d.slice(3)}`;
362
+ if (d.length <= 10) return `${d.slice(0, 3)}-${d.slice(3, 6)}-${d.slice(6)}`;
363
+ return `${d.slice(0, 3)}-${d.slice(3, 7)}-${d.slice(7, 11)}`;
364
+ };
365
+
366
+ export interface PhoneInputProps
367
+ extends Omit<InputProps, "value" | "defaultValue" | "onChange" | "type"> {
368
+ value?: string;
369
+ defaultValue?: string;
370
+ onValueChange?: (digits: string) => void;
371
+ }
372
+
373
+ /**
374
+ * 한국 휴대폰·지역번호용 자동 하이픈 입력(010/02/031 등). `onValueChange`는 하이픈을 뺀
375
+ * 숫자 문자열만 콜백한다. 국제화가 필요하면 별도 컴포넌트로 분리해 사용할 것.
376
+ */
377
+ export const PhoneInput = React.forwardRef<HTMLInputElement, PhoneInputProps>(
378
+ ({ value, defaultValue, onValueChange, onBlur, ...rest }, ref) => {
379
+ const isControlled = value !== undefined;
380
+ const initial = formatPhoneKR(defaultValue ?? "");
381
+ const [internal, setInternal] = React.useState(initial);
382
+
383
+ const display = isControlled ? formatPhoneKR(value ?? "") : internal;
384
+
385
+ const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
386
+ const digits = e.target.value.replace(/\D/g, "").slice(0, 11);
387
+ const formatted = formatPhoneKR(digits);
388
+ if (!isControlled) setInternal(formatted);
389
+ onValueChange?.(digits);
390
+ };
391
+
392
+ return (
393
+ <Input
394
+ ref={ref}
395
+ type="tel"
396
+ inputMode="tel"
397
+ autoComplete="tel"
398
+ value={display}
399
+ onChange={handleChange}
400
+ onBlur={onBlur}
401
+ {...rest}
402
+ />
403
+ );
404
+ },
405
+ );
406
+ PhoneInput.displayName = "PhoneInput";
407
+
408
+ /* ───────── BusinessNumberInput (KR) ───────── */
409
+
410
+ const formatBRN = (digits: string): string => {
411
+ const d = digits.replace(/\D/g, "").slice(0, 10);
412
+ if (d.length <= 3) return d;
413
+ if (d.length <= 5) return `${d.slice(0, 3)}-${d.slice(3)}`;
414
+ return `${d.slice(0, 3)}-${d.slice(3, 5)}-${d.slice(5)}`;
415
+ };
416
+
417
+ /**
418
+ * 한국 사업자등록번호(10자리) 체크섬 검증.
419
+ */
420
+ export function isValidBRN(digits: string): boolean {
421
+ const d = digits.replace(/\D/g, "");
422
+ if (d.length !== 10) return false;
423
+ const w = [1, 3, 7, 1, 3, 7, 1, 3, 5];
424
+ let sum = 0;
425
+ for (let i = 0; i < 9; i++) sum += parseInt(d[i], 10) * w[i];
426
+ sum += Math.floor((parseInt(d[8], 10) * 5) / 10);
427
+ const check = (10 - (sum % 10)) % 10;
428
+ return check === parseInt(d[9], 10);
429
+ }
430
+
431
+ export interface BusinessNumberInputProps
432
+ extends Omit<InputProps, "value" | "defaultValue" | "onChange" | "type"> {
433
+ value?: string;
434
+ defaultValue?: string;
435
+ onValueChange?: (digits: string) => void;
436
+ /**
437
+ * 켜면 10자리 입력 시 사업자번호 체크섬을 검증해 `aria-invalid`를 자동 부여한다.
438
+ * 외부에서 `aria-invalid`를 명시하면 그 값이 우선한다.
439
+ *
440
+ * @default false
441
+ */
442
+ validateChecksum?: boolean;
443
+ }
444
+
445
+ /**
446
+ * 한국 사업자등록번호(XXX-XX-XXXXX) 자동 하이픈 입력.
447
+ */
448
+ export const BusinessNumberInput = React.forwardRef<HTMLInputElement, BusinessNumberInputProps>(
449
+ (
450
+ { value, defaultValue, onValueChange, validateChecksum, onBlur, "aria-invalid": ariaInvalidProp, ...rest },
451
+ ref,
452
+ ) => {
453
+ const isControlled = value !== undefined;
454
+ const initial = formatBRN(defaultValue ?? "");
455
+ const [internal, setInternal] = React.useState(initial);
456
+
457
+ const display = isControlled ? formatBRN(value ?? "") : internal;
458
+ const digits = display.replace(/\D/g, "");
459
+
460
+ const invalid =
461
+ ariaInvalidProp !== undefined
462
+ ? ariaInvalidProp
463
+ : validateChecksum && digits.length === 10 && !isValidBRN(digits);
464
+
465
+ const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
466
+ const next = e.target.value.replace(/\D/g, "").slice(0, 10);
467
+ const formatted = formatBRN(next);
468
+ if (!isControlled) setInternal(formatted);
469
+ onValueChange?.(next);
470
+ };
471
+
472
+ return (
473
+ <Input
474
+ ref={ref}
475
+ type="text"
476
+ inputMode="numeric"
477
+ value={display}
478
+ onChange={handleChange}
479
+ onBlur={onBlur}
480
+ aria-invalid={invalid || undefined}
481
+ {...rest}
482
+ />
483
+ );
484
+ },
485
+ );
486
+ BusinessNumberInput.displayName = "BusinessNumberInput";
@@ -0,0 +1,200 @@
1
+ .input {
2
+ display: block;
3
+ width: 100%;
4
+ height: var(--control-md);
5
+ padding: 0 var(--space-3);
6
+ background: var(--background);
7
+ color: var(--foreground);
8
+ border: 1px solid var(--border);
9
+ border-radius: var(--radius);
10
+ font-family: inherit;
11
+ font-size: var(--text-sm);
12
+ line-height: 1;
13
+ transition: border-color var(--duration-fast), box-shadow var(--duration-fast);
14
+ -webkit-tap-highlight-color: transparent;
15
+ }
16
+
17
+ /* 모바일/터치: 최소 탭 영역 + iOS 자동 줌 방지(16px) */
18
+ @media (hover: none) and (pointer: coarse) {
19
+ .input {
20
+ height: 2.75rem;
21
+ font-size: var(--text-base);
22
+ }
23
+ }
24
+
25
+ .input::placeholder {
26
+ color: var(--foreground-subtle);
27
+ }
28
+
29
+ .input:hover:not(:disabled):not(:focus) {
30
+ border-color: var(--border-strong);
31
+ }
32
+
33
+ .input:focus {
34
+ outline: none;
35
+ border-color: var(--foreground);
36
+ box-shadow: 0 0 0 1px var(--foreground);
37
+ }
38
+
39
+ .input:disabled {
40
+ opacity: var(--opacity-disabled);
41
+ cursor: not-allowed;
42
+ background: var(--background-subtle);
43
+ }
44
+
45
+ .input:read-only {
46
+ background: var(--background-subtle);
47
+ }
48
+
49
+ /* 숫자 input 화살표 제거 */
50
+ .input[type="number"]::-webkit-outer-spin-button,
51
+ .input[type="number"]::-webkit-inner-spin-button {
52
+ -webkit-appearance: none;
53
+ margin: 0;
54
+ }
55
+ .input[type="number"] {
56
+ -moz-appearance: textfield;
57
+ }
58
+
59
+ /* ───────── prefix / suffix ───────── */
60
+
61
+ .inputWrap {
62
+ position: relative;
63
+ width: 100%;
64
+ display: block;
65
+ }
66
+
67
+ .withPrefix { padding-left: var(--space-10); }
68
+ .withSuffix { padding-right: var(--space-10); }
69
+
70
+ .affix {
71
+ position: absolute;
72
+ top: 50%;
73
+ transform: translateY(-50%);
74
+ display: inline-flex;
75
+ align-items: center;
76
+ justify-content: center;
77
+ color: var(--foreground-muted);
78
+ pointer-events: none;
79
+ }
80
+ .affixPrefix { left: var(--space-3); }
81
+ .affixSuffix { right: var(--space-1); }
82
+
83
+ .affix > * { pointer-events: auto; }
84
+
85
+ /* 비밀번호 토글 버튼 */
86
+ .toggle {
87
+ display: inline-flex;
88
+ align-items: center;
89
+ justify-content: center;
90
+ width: 2rem;
91
+ height: 2rem;
92
+ padding: 0;
93
+ background: transparent;
94
+ border: none;
95
+ border-radius: calc(var(--radius) - 2px);
96
+ color: var(--foreground-muted);
97
+ cursor: pointer;
98
+ transition: color var(--duration-fast), background-color var(--duration-fast);
99
+ -webkit-tap-highlight-color: transparent;
100
+ }
101
+ .toggle:hover { color: var(--foreground); background: var(--background-muted); }
102
+ .toggle:focus-visible {
103
+ outline: var(--border-width-strong) solid var(--foreground);
104
+ outline-offset: 2px;
105
+ }
106
+
107
+ /* 에러 상태 — aria-invalid="true" 기반 */
108
+ .input[aria-invalid="true"] {
109
+ border-color: var(--danger);
110
+ }
111
+ .input[aria-invalid="true"]:focus {
112
+ box-shadow: 0 0 0 1px var(--danger);
113
+ }
114
+
115
+ /* ───────── InputGroup + InputAdornment ───────── */
116
+
117
+ .group {
118
+ display: flex;
119
+ align-items: center;
120
+ width: 100%;
121
+ min-height: var(--control-md);
122
+ padding: 0 var(--space-2);
123
+ gap: var(--space-2);
124
+ background: var(--background);
125
+ color: var(--foreground);
126
+ border: 1px solid var(--border);
127
+ border-radius: var(--radius);
128
+ transition: border-color var(--duration-fast), box-shadow var(--duration-fast);
129
+ cursor: text;
130
+ -webkit-tap-highlight-color: transparent;
131
+ }
132
+
133
+ @media (hover: none) and (pointer: coarse) {
134
+ .group {
135
+ min-height: 2.75rem;
136
+ }
137
+ }
138
+
139
+ .group:hover:not([data-disabled]):not(:focus-within) {
140
+ border-color: var(--border-strong);
141
+ }
142
+
143
+ .group:focus-within {
144
+ border-color: var(--foreground);
145
+ box-shadow: 0 0 0 1px var(--foreground);
146
+ }
147
+
148
+ .group[aria-invalid="true"] {
149
+ border-color: var(--danger);
150
+ }
151
+ .group[aria-invalid="true"]:focus-within {
152
+ box-shadow: 0 0 0 1px var(--danger);
153
+ }
154
+
155
+ .group[data-disabled] {
156
+ opacity: var(--opacity-disabled);
157
+ cursor: not-allowed;
158
+ background: var(--background-subtle);
159
+ }
160
+
161
+ /* 그룹 내부의 Input은 자체 보더/배경/포커스 링을 모두 감춘다 */
162
+ .input[data-in-group] {
163
+ flex: 1 1 auto;
164
+ min-width: 0;
165
+ height: auto;
166
+ padding: 0;
167
+ background: transparent;
168
+ border: none;
169
+ border-radius: 0;
170
+ box-shadow: none;
171
+ }
172
+
173
+ .input[data-in-group]:focus,
174
+ .input[data-in-group]:hover {
175
+ border: none;
176
+ box-shadow: none;
177
+ outline: none;
178
+ }
179
+
180
+ .input[data-in-group]:disabled {
181
+ background: transparent;
182
+ }
183
+
184
+ .inputWrap[data-in-group] {
185
+ flex: 1 1 auto;
186
+ min-width: 0;
187
+ }
188
+
189
+ .adornment {
190
+ display: inline-flex;
191
+ align-items: center;
192
+ justify-content: center;
193
+ flex: 0 0 auto;
194
+ color: var(--foreground-muted);
195
+ padding: 0 var(--space-1);
196
+ }
197
+
198
+ .adornment[data-interactive] {
199
+ padding: 0;
200
+ }