sh-ui-cli 0.45.2 → 0.46.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 (98) hide show
  1. package/data/changelog/versions.json +26 -0
  2. package/data/registry/react/components/accordion/index.tailwind.tsx +5 -7
  3. package/data/registry/react/components/accordion/index.tsx +5 -7
  4. package/data/registry/react/components/avatar/index.tailwind.tsx +4 -6
  5. package/data/registry/react/components/avatar/index.tsx +4 -6
  6. package/data/registry/react/components/badge/index.tailwind.tsx +2 -4
  7. package/data/registry/react/components/badge/index.tsx +2 -4
  8. package/data/registry/react/components/breadcrumb/index.tailwind.tsx +8 -10
  9. package/data/registry/react/components/breadcrumb/index.tsx +8 -10
  10. package/data/registry/react/components/button/index.module.tsx +45 -0
  11. package/data/registry/react/components/button/index.tailwind.tsx +2 -1
  12. package/data/registry/react/components/button/index.tsx +3 -4
  13. package/data/registry/react/components/button/styles.module.css +92 -0
  14. package/data/registry/react/components/calendar/index.tailwind.tsx +10 -12
  15. package/data/registry/react/components/calendar/index.tsx +9 -11
  16. package/data/registry/react/components/card/index.module.tsx +63 -0
  17. package/data/registry/react/components/card/index.tailwind.tsx +8 -10
  18. package/data/registry/react/components/card/index.tsx +8 -10
  19. package/data/registry/react/components/card/styles.module.css +73 -0
  20. package/data/registry/react/components/carousel/index.tailwind.tsx +7 -9
  21. package/data/registry/react/components/carousel/index.tsx +7 -9
  22. package/data/registry/react/components/checkbox/index.tailwind.tsx +3 -5
  23. package/data/registry/react/components/checkbox/index.tsx +3 -5
  24. package/data/registry/react/components/code-editor/index.tailwind.tsx +2 -4
  25. package/data/registry/react/components/code-editor/index.tsx +2 -4
  26. package/data/registry/react/components/code-panel/index.tailwind.tsx +5 -7
  27. package/data/registry/react/components/code-panel/index.tsx +5 -7
  28. package/data/registry/react/components/color-picker/index.tailwind.tsx +7 -6
  29. package/data/registry/react/components/color-picker/index.tsx +7 -6
  30. package/data/registry/react/components/combobox/index.tailwind.tsx +8 -10
  31. package/data/registry/react/components/combobox/index.tsx +8 -10
  32. package/data/registry/react/components/context-menu/index.tailwind.tsx +10 -12
  33. package/data/registry/react/components/context-menu/index.tsx +10 -12
  34. package/data/registry/react/components/date-picker/index.tailwind.tsx +7 -9
  35. package/data/registry/react/components/date-picker/index.tsx +7 -9
  36. package/data/registry/react/components/dialog/index.tailwind.tsx +6 -8
  37. package/data/registry/react/components/dialog/index.tsx +6 -8
  38. package/data/registry/react/components/dropdown-menu/index.tailwind.tsx +10 -12
  39. package/data/registry/react/components/dropdown-menu/index.tsx +10 -12
  40. package/data/registry/react/components/file-upload/index.tailwind.tsx +6 -8
  41. package/data/registry/react/components/file-upload/index.tsx +6 -8
  42. package/data/registry/react/components/form/field.tailwind.tsx +2 -1
  43. package/data/registry/react/components/form/field.tsx +2 -3
  44. package/data/registry/react/components/header/index.tailwind.tsx +17 -19
  45. package/data/registry/react/components/header/index.tsx +17 -19
  46. package/data/registry/react/components/input/index.module.tsx +486 -0
  47. package/data/registry/react/components/input/index.tailwind.tsx +4 -6
  48. package/data/registry/react/components/input/index.tsx +4 -6
  49. package/data/registry/react/components/input/styles.module.css +200 -0
  50. package/data/registry/react/components/label/index.tailwind.tsx +6 -8
  51. package/data/registry/react/components/label/index.tsx +6 -8
  52. package/data/registry/react/components/markdown-editor/index.tailwind.tsx +2 -4
  53. package/data/registry/react/components/markdown-editor/index.tsx +2 -4
  54. package/data/registry/react/components/menubar/index.tailwind.tsx +2 -4
  55. package/data/registry/react/components/menubar/index.tsx +2 -4
  56. package/data/registry/react/components/numeric-input/index.tailwind.tsx +2 -4
  57. package/data/registry/react/components/numeric-input/index.tsx +2 -4
  58. package/data/registry/react/components/page-toc/index.tailwind.tsx +3 -2
  59. package/data/registry/react/components/page-toc/index.tsx +2 -3
  60. package/data/registry/react/components/pagination/index.tailwind.tsx +8 -10
  61. package/data/registry/react/components/pagination/index.tsx +8 -10
  62. package/data/registry/react/components/popover/index.tailwind.tsx +4 -6
  63. package/data/registry/react/components/popover/index.tsx +4 -6
  64. package/data/registry/react/components/progress/index.tailwind.tsx +3 -5
  65. package/data/registry/react/components/progress/index.tsx +2 -4
  66. package/data/registry/react/components/radio/index.tailwind.tsx +3 -5
  67. package/data/registry/react/components/radio/index.tsx +3 -5
  68. package/data/registry/react/components/rich-text-editor/index.tailwind.tsx +3 -5
  69. package/data/registry/react/components/rich-text-editor/index.tsx +3 -5
  70. package/data/registry/react/components/select/index.tailwind.tsx +8 -10
  71. package/data/registry/react/components/select/index.tsx +8 -10
  72. package/data/registry/react/components/separator/index.tailwind.tsx +2 -4
  73. package/data/registry/react/components/separator/index.tsx +2 -4
  74. package/data/registry/react/components/sidebar/index.tailwind.tsx +32 -43
  75. package/data/registry/react/components/sidebar/index.tsx +29 -46
  76. package/data/registry/react/components/skeleton/index.tailwind.tsx +2 -4
  77. package/data/registry/react/components/skeleton/index.tsx +2 -4
  78. package/data/registry/react/components/slider/index.tailwind.tsx +5 -7
  79. package/data/registry/react/components/slider/index.tsx +5 -7
  80. package/data/registry/react/components/spinner/index.tailwind.tsx +3 -5
  81. package/data/registry/react/components/spinner/index.tsx +2 -4
  82. package/data/registry/react/components/switch/index.tailwind.tsx +3 -5
  83. package/data/registry/react/components/switch/index.tsx +2 -4
  84. package/data/registry/react/components/tabs/index.tailwind.tsx +6 -8
  85. package/data/registry/react/components/tabs/index.tsx +6 -8
  86. package/data/registry/react/components/textarea/index.tailwind.tsx +2 -4
  87. package/data/registry/react/components/textarea/index.tsx +2 -4
  88. package/data/registry/react/components/toggle/index.tailwind.tsx +4 -6
  89. package/data/registry/react/components/toggle/index.tsx +4 -6
  90. package/data/registry/react/components/tooltip/index.tailwind.tsx +2 -4
  91. package/data/registry/react/components/tooltip/index.tsx +2 -4
  92. package/data/registry/react/lib/cn.tailwind.ts +17 -0
  93. package/data/registry/react/peer-versions.json +3 -1
  94. package/data/registry/react/registry.json +202 -43
  95. package/data/tokens/build.mjs +4 -0
  96. package/package.json +1 -1
  97. package/src/add.mjs +37 -13
  98. package/templates/ui-app-template/sh-ui.config.json +5 -0
@@ -4,10 +4,8 @@ import * as React from "react";
4
4
  import { createPortal } from "react-dom";
5
5
  import "./styles.css";
6
6
 
7
- function cx(...args: (string | undefined | false | null)[]) {
8
- return args.filter(Boolean).join(" ");
9
- }
10
7
 
8
+ import { cn } from "@SH_UI_UTILS@";
11
9
  const FOCUSABLE_SELECTOR =
12
10
  'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
13
11
 
@@ -264,7 +262,7 @@ export const Header = React.forwardRef<HTMLElement, HeaderProps>(function Header
264
262
  <HeaderContext.Provider value={ctx}>
265
263
  <header
266
264
  ref={setRefs}
267
- className={cx("sh-ui-header", `sh-ui-header--${variant}`, className)}
265
+ className={cn("sh-ui-header", `sh-ui-header--${variant}`, className)}
268
266
  data-drawer-open={open ? "" : undefined}
269
267
  data-sticky-hide={stickyHide ? "" : undefined}
270
268
  data-hidden={hidden ? "" : undefined}
@@ -282,21 +280,21 @@ export const HeaderBrand = React.forwardRef<
282
280
  HTMLDivElement,
283
281
  React.HTMLAttributes<HTMLDivElement>
284
282
  >(function HeaderBrand({ className, ...props }, ref) {
285
- return <div ref={ref} className={cx("sh-ui-header__brand", className)} {...props} />;
283
+ return <div ref={ref} className={cn("sh-ui-header__brand", className)} {...props} />;
286
284
  });
287
285
 
288
286
  export const HeaderLogo = React.forwardRef<
289
287
  HTMLSpanElement,
290
288
  React.HTMLAttributes<HTMLSpanElement>
291
289
  >(function HeaderLogo({ className, ...props }, ref) {
292
- return <span ref={ref} className={cx("sh-ui-header__logo", className)} {...props} />;
290
+ return <span ref={ref} className={cn("sh-ui-header__logo", className)} {...props} />;
293
291
  });
294
292
 
295
293
  export const HeaderTitle = React.forwardRef<
296
294
  HTMLSpanElement,
297
295
  React.HTMLAttributes<HTMLSpanElement>
298
296
  >(function HeaderTitle({ className, ...props }, ref) {
299
- return <span ref={ref} className={cx("sh-ui-header__title", className)} {...props} />;
297
+ return <span ref={ref} className={cn("sh-ui-header__title", className)} {...props} />;
300
298
  });
301
299
 
302
300
  /* ───────── Trigger ─────────
@@ -322,7 +320,7 @@ export const HeaderTrigger = React.forwardRef<
322
320
  <button
323
321
  ref={setRefs}
324
322
  type="button"
325
- className={cx("sh-ui-header__trigger", className)}
323
+ className={cn("sh-ui-header__trigger", className)}
326
324
  aria-label={open ? "메뉴 닫기" : "메뉴 열기"}
327
325
  aria-expanded={open}
328
326
  data-open={open ? "" : undefined}
@@ -399,7 +397,7 @@ export const HeaderNav = React.forwardRef<HTMLElement, HeaderNavProps>(
399
397
  return (
400
398
  <NavMatchContext.Provider value={navMatch}>
401
399
  <NavLocationContext.Provider value="inline">
402
- <nav ref={ref} className={cx("sh-ui-header__nav", className)} {...props}>
400
+ <nav ref={ref} className={cn("sh-ui-header__nav", className)} {...props}>
403
401
  {children}
404
402
  </nav>
405
403
  </NavLocationContext.Provider>
@@ -457,7 +455,7 @@ export const HeaderItem = React.forwardRef<
457
455
  <a
458
456
  ref={ref}
459
457
  href={href}
460
- className={cx("sh-ui-header__item", className)}
458
+ className={cn("sh-ui-header__item", className)}
461
459
  data-active={computedActive ? "" : undefined}
462
460
  aria-current={computedActive ? "page" : undefined}
463
461
  onClick={(e) => {
@@ -478,7 +476,7 @@ export const HeaderActions = React.forwardRef<
478
476
  React.HTMLAttributes<HTMLDivElement>
479
477
  >(function HeaderActions({ className, ...props }, ref) {
480
478
  return (
481
- <div ref={ref} className={cx("sh-ui-header__actions", className)} {...props} />
479
+ <div ref={ref} className={cn("sh-ui-header__actions", className)} {...props} />
482
480
  );
483
481
  });
484
482
 
@@ -493,7 +491,7 @@ export const HeaderDesktopOnly = React.forwardRef<
493
491
  React.HTMLAttributes<HTMLDivElement>
494
492
  >(function HeaderDesktopOnly({ className, ...props }, ref) {
495
493
  return (
496
- <div ref={ref} className={cx("sh-ui-header__desktop-only", className)} {...props} />
494
+ <div ref={ref} className={cn("sh-ui-header__desktop-only", className)} {...props} />
497
495
  );
498
496
  });
499
497
 
@@ -503,7 +501,7 @@ export const HeaderMobileOnly = React.forwardRef<
503
501
  React.HTMLAttributes<HTMLDivElement>
504
502
  >(function HeaderMobileOnly({ className, ...props }, ref) {
505
503
  return (
506
- <div ref={ref} className={cx("sh-ui-header__mobile-only", className)} {...props} />
504
+ <div ref={ref} className={cn("sh-ui-header__mobile-only", className)} {...props} />
507
505
  );
508
506
  });
509
507
 
@@ -524,7 +522,7 @@ export const HeaderNavGroup = React.forwardRef<HTMLDivElement, HeaderNavGroupPro
524
522
  return (
525
523
  <div
526
524
  ref={ref}
527
- className={cx("sh-ui-header__group sh-ui-header__group--inline", className)}
525
+ className={cn("sh-ui-header__group sh-ui-header__group--inline", className)}
528
526
  {...props}
529
527
  >
530
528
  {children}
@@ -534,7 +532,7 @@ export const HeaderNavGroup = React.forwardRef<HTMLDivElement, HeaderNavGroupPro
534
532
  return (
535
533
  <div
536
534
  ref={ref}
537
- className={cx("sh-ui-header__group sh-ui-header__group--drawer", className)}
535
+ className={cn("sh-ui-header__group sh-ui-header__group--drawer", className)}
538
536
  role="group"
539
537
  aria-label={typeof label === "string" ? label : undefined}
540
538
  {...props}
@@ -626,7 +624,7 @@ export function HeaderMenu({
626
624
  <MenuContext.Provider value={ctx}>
627
625
  <div
628
626
  ref={containerRef}
629
- className={cx(
627
+ className={cn(
630
628
  "sh-ui-header__menu",
631
629
  `sh-ui-header__menu--${location}`,
632
630
  open && "is-open",
@@ -665,7 +663,7 @@ export const HeaderMenuTrigger = React.forwardRef<
665
663
  aria-expanded={open}
666
664
  aria-controls={contentId}
667
665
  data-open={open ? "" : undefined}
668
- className={cx("sh-ui-header__menu-trigger", className)}
666
+ className={cn("sh-ui-header__menu-trigger", className)}
669
667
  onClick={(e) => {
670
668
  setOpen(!open);
671
669
  onClick?.(e);
@@ -704,7 +702,7 @@ export const HeaderMenuContent = React.forwardRef<
704
702
  aria-labelledby={triggerId}
705
703
  data-open={open ? "" : undefined}
706
704
  hidden={!open}
707
- className={cx("sh-ui-header__menu-content", className)}
705
+ className={cn("sh-ui-header__menu-content", className)}
708
706
  style={style}
709
707
  {...props}
710
708
  >
@@ -754,7 +752,7 @@ export const HeaderMenuContent = React.forwardRef<
754
752
  role="menu"
755
753
  aria-labelledby={triggerId}
756
754
  data-open=""
757
- className={cx("sh-ui-header__menu-content sh-ui-header__menu-content--portal", className)}
755
+ className={cn("sh-ui-header__menu-content sh-ui-header__menu-content--portal", className)}
758
756
  style={{
759
757
  position: "absolute",
760
758
  top: pos.top,
@@ -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";
@@ -2,6 +2,7 @@
2
2
 
3
3
  import * as React from "react";
4
4
 
5
+ import { cn } from "@SH_UI_UTILS@";
5
6
  export interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "prefix"> {
6
7
  /** input 우측에 부착할 보조 노드(아이콘·단위·버튼 등). 더 많은 슬롯이 필요하면 InputGroup 사용. */
7
8
  suffix?: React.ReactNode;
@@ -9,9 +10,6 @@ export interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElem
9
10
  prefix?: React.ReactNode;
10
11
  }
11
12
 
12
- function cx(...args: (string | undefined | null | false)[]) {
13
- return args.filter(Boolean).join(" ");
14
- }
15
13
 
16
14
  /* ───────── Base utility 묶음 (반복 줄이기) ───────── */
17
15
 
@@ -69,7 +67,7 @@ export const InputGroup = React.forwardRef<HTMLDivElement, InputGroupProps>(
69
67
  <InputGroupContext.Provider value={{ inGroup: true }}>
70
68
  <div
71
69
  ref={mergedRef}
72
- className={cx(baseGroupClasses, className)}
70
+ className={cn(baseGroupClasses, className)}
73
71
  data-disabled={disabled || undefined}
74
72
  aria-invalid={ariaInvalid}
75
73
  onClick={handleClick}
@@ -91,7 +89,7 @@ export const InputAdornment = React.forwardRef<HTMLSpanElement, InputAdornmentPr
91
89
  ({ className, interactive, ...props }, ref) => (
92
90
  <span
93
91
  ref={ref}
94
- className={cx(
92
+ className={cn(
95
93
  "inline-flex items-center justify-center flex-none text-foreground-muted px-[var(--space-1)] data-[interactive]:p-0",
96
94
  className,
97
95
  )}
@@ -112,7 +110,7 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
112
110
  <input
113
111
  ref={ref}
114
112
  type={type}
115
- className={cx(
113
+ className={cn(
116
114
  baseInputClasses,
117
115
  inGroupOverrides,
118
116
  !!prefix && "pl-[var(--space-10)]",