sh-ui-cli 0.42.1 → 0.43.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.
package/README.md CHANGED
@@ -151,7 +151,7 @@ npx -y sh-ui-cli mcp init --client claude-code --scope user
151
151
  ```json
152
152
  {
153
153
  "platform": "react",
154
- "style": "default",
154
+ "cssFramework": "plain",
155
155
  "theme": { "base": "neutral", "radius": "md", "mode": "light-dark" },
156
156
  "paths": {
157
157
  "tokens": "src/shared/styles/tokens.css",
@@ -161,6 +161,11 @@ npx -y sh-ui-cli mcp init --client claude-code --scope user
161
161
  }
162
162
  ```
163
163
 
164
+ `cssFramework` 옵션:
165
+
166
+ - `"plain"` — CSS custom properties + 일반 .css 파일. 모든 컴포넌트 지원 (기본).
167
+ - `"tailwind"` — Tailwind v4 utility class TSX 변종 (`class-variance-authority` 기반). button/card/input 부터 시작해 점진적으로 확대 중. 변종 미제공 컴포넌트는 add 시 plain 으로 자동 fallback — Tailwind v4 의 `@theme inline` 브리지가 sh-ui 토큰을 매핑하므로 plain CSS 도 그대로 동작.
168
+
164
169
  ## 더 알아보기
165
170
 
166
171
  - sh-ui 디자인 시스템: https://github.com/sanghyeonKim0201/sh-ui
@@ -2,6 +2,19 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$description": "sh-ui 릴리즈 노트 단일 소스. docs(React)와 showcase(Flutter)가 함께 읽는다. 새 릴리즈마다 맨 앞에 추가.",
4
4
  "versions": [
5
+ {
6
+ "version": "0.43.0",
7
+ "date": "2026-04-30",
8
+ "title": "CSS 프레임워크 변종 시스템 + Tailwind 1차 지원",
9
+ "type": "minor",
10
+ "highlights": [
11
+ "**`cssFramework` 옵션 신설** — `sh-ui.config.json` 에 `cssFramework: \"plain\" | \"tailwind\"`. CLI `--css` / `sh-ui-cli init --cssFramework` / MCP `sh_ui_create_project` 의 `cssFramework` / playground UI 에서 모두 선택 가능. 기존 `style: \"default\"` 필드는 deprecated (무시).",
12
+ "**Tailwind v4 utility-class 변종** — `button`, `card`, `input` 의 utility-class 변종 (`class-variance-authority` 기반) 추가. registry.json 의 `frameworks: [\"plain\" | \"tailwind\"]` 분기 + dependency 분기 (`{name, frameworks}` 객체 형식) 지원. `cssFramework=\"tailwind\"` 인데 변종이 없는 컴포넌트는 plain 으로 자동 fallback — Tailwind v4 환경에서 그대로 동작.",
13
+ "**`@theme inline` 단일 소스** — `tokens.css` 가 Tailwind v4 의 `@theme inline { --color-*: var(--*); --radius-{sm,md,lg,xl}; }` 블록을 자동 emit. 템플릿(`nextjs-standalone`, `ui-app-template`)의 하드코딩 `@theme` 제거 — 토큰이 추가/변경돼도 매핑이 자동 동기화.",
14
+ "**토큰 emitter 디스패처** — `packages/tokens/build.mjs` 에 `(platform × cssFramework) → emitter` 테이블. 향후 `react/css-modules`, `react/vanilla-extract` 등 추가 시 한 줄로 등록."
15
+ ],
16
+ "url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.43.0"
17
+ },
5
18
  {
6
19
  "version": "0.42.1",
7
20
  "date": "2026-04-30",
@@ -1,5 +1,5 @@
1
1
  {
2
- "$description": "Flutter 레지스트리 매니페스트 — CLI가 각 컴포넌트 name으로 조회해 files를 사용자 프로젝트의 lib/ 아래로 복사한다.",
2
+ "$description": "Flutter 레지스트리 매니페스트 — CLI가 각 컴포넌트 name으로 조회해 files를 사용자 프로젝트의 lib/ 아래로 복사한다. 컴포넌트 또는 file 엔트리에 frameworks?: string[] 옵션 — 미지정시 모든 cssFramework 에 적용, 지정시 해당 배열에 포함된 경우만 복사.",
3
3
  "components": {
4
4
  "tokens": {
5
5
  "name": "tokens",
@@ -0,0 +1,70 @@
1
+ import * as React from "react";
2
+ import { cva, type VariantProps } from "class-variance-authority";
3
+
4
+ const buttonVariants = cva(
5
+ "inline-flex items-center justify-center gap-[var(--space-2)] border border-transparent rounded-[var(--radius)] font-medium leading-none cursor-pointer select-none transition-[background-color,color,border-color] duration-[var(--duration-fast)] disabled:opacity-[var(--opacity-disabled)] disabled:pointer-events-none focus-visible:outline-2 focus-visible:outline-foreground focus-visible:outline-offset-2 active:scale-[0.97] active:brightness-90",
6
+ {
7
+ variants: {
8
+ variant: {
9
+ primary:
10
+ "bg-primary text-primary-foreground hover:bg-primary-hover",
11
+ secondary:
12
+ "bg-background-muted text-foreground border-border hover:bg-background-subtle",
13
+ ghost:
14
+ "bg-transparent text-foreground hover:bg-background-muted",
15
+ danger:
16
+ "bg-danger text-danger-foreground hover:brightness-95",
17
+ link:
18
+ "bg-transparent text-primary underline-offset-4 hover:underline",
19
+ },
20
+ size: {
21
+ sm: "h-[var(--control-sm)] px-[var(--space-3)] text-[length:var(--text-sm)]",
22
+ md: "h-[var(--control-md)] px-[var(--space-4)] text-[length:var(--text-sm)]",
23
+ lg: "h-[var(--control-lg)] px-[var(--space-5)] text-[length:var(--text-base)]",
24
+ },
25
+ },
26
+ defaultVariants: { variant: "primary", size: "md" },
27
+ },
28
+ );
29
+
30
+ type Variant = NonNullable<VariantProps<typeof buttonVariants>["variant"]>;
31
+ type Size = NonNullable<VariantProps<typeof buttonVariants>["size"]>;
32
+
33
+ export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
34
+ /**
35
+ * 시각적 위계.
36
+ * - `primary` — 페이지의 주요 액션. 한 화면에 하나만 권장.
37
+ * - `secondary` — 보조 액션. 약한 배경 + border.
38
+ * - `ghost` — 배경 없는 hover 강조 액션. 툴바/메뉴 항목에 적합.
39
+ * - `danger` — 파괴적 액션(삭제, 취소 등).
40
+ * - `link` — 텍스트 링크처럼 보이는 인라인 버튼.
41
+ *
42
+ * @default "primary"
43
+ */
44
+ variant?: Variant;
45
+ /**
46
+ * 크기.
47
+ * - `sm` — 조밀한 영역(테이블 행, 툴바)
48
+ * - `md` — 일반
49
+ * - `lg` — CTA·랜딩 영역
50
+ *
51
+ * @default "md"
52
+ */
53
+ size?: Size;
54
+ }
55
+
56
+ /**
57
+ * 사용자 액션을 트리거하는 기본 버튼 (Tailwind utility class 변종).
58
+ * variant로 시각적 위계(primary/secondary/ghost/danger/link)를,
59
+ * size로 크기를 결정한다. 페이지 이동 목적이면 anchor를 감싼 `link` variant를 사용할 것.
60
+ */
61
+ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
62
+ ({ variant = "primary", size = "md", className, ...props }, ref) => (
63
+ <button
64
+ ref={ref}
65
+ className={[buttonVariants({ variant, size }), className].filter(Boolean).join(" ")}
66
+ {...props}
67
+ />
68
+ ),
69
+ );
70
+ Button.displayName = "Button";
@@ -0,0 +1,111 @@
1
+ import * as React from "react";
2
+
3
+ type DivProps = React.HTMLAttributes<HTMLDivElement>;
4
+
5
+ function mergeClass(base: string, extra?: string) {
6
+ return extra ? `${base} ${extra}` : base;
7
+ }
8
+
9
+ export const Card = React.forwardRef<HTMLDivElement, DivProps>(
10
+ ({ className, ...props }, ref) => (
11
+ <div
12
+ ref={ref}
13
+ className={mergeClass(
14
+ "flex flex-col gap-[var(--space-6)] py-[var(--space-6)] bg-background text-foreground border border-border rounded-[var(--radius)] max-sm:gap-[var(--space-4)] max-sm:py-[var(--space-4)]",
15
+ className,
16
+ )}
17
+ {...props}
18
+ />
19
+ ),
20
+ );
21
+ Card.displayName = "Card";
22
+
23
+ export const CardHeader = React.forwardRef<HTMLDivElement, DivProps>(
24
+ ({ className, ...props }, ref) => (
25
+ <div
26
+ ref={ref}
27
+ data-slot="card-header"
28
+ className={mergeClass(
29
+ "grid grid-cols-1 auto-rows-auto gap-y-1.5 px-[var(--space-6)] has-[[data-slot=card-action]]:grid-cols-[1fr_auto] max-sm:px-[var(--space-4)]",
30
+ className,
31
+ )}
32
+ {...props}
33
+ />
34
+ ),
35
+ );
36
+ CardHeader.displayName = "CardHeader";
37
+
38
+ export const CardTitle = React.forwardRef<HTMLDivElement, DivProps>(
39
+ ({ className, ...props }, ref) => (
40
+ <div
41
+ ref={ref}
42
+ className={mergeClass(
43
+ "text-[length:var(--text-base)] font-semibold leading-tight tracking-tight",
44
+ className,
45
+ )}
46
+ {...props}
47
+ />
48
+ ),
49
+ );
50
+ CardTitle.displayName = "CardTitle";
51
+
52
+ export const CardDescription = React.forwardRef<HTMLDivElement, DivProps>(
53
+ ({ className, ...props }, ref) => (
54
+ <div
55
+ ref={ref}
56
+ className={mergeClass(
57
+ "text-[length:var(--text-sm)] leading-normal text-foreground-muted",
58
+ className,
59
+ )}
60
+ {...props}
61
+ />
62
+ ),
63
+ );
64
+ CardDescription.displayName = "CardDescription";
65
+
66
+ /**
67
+ * 헤더 우측에 배치되는 슬롯. CardHeader 내부에서 grid 2번째 컬럼을 차지.
68
+ * CardHeader가 `has-[[data-slot=card-action]]` 으로 감지해 레이아웃을 전환한다.
69
+ */
70
+ export const CardAction = React.forwardRef<HTMLDivElement, DivProps>(
71
+ ({ className, ...props }, ref) => (
72
+ <div
73
+ ref={ref}
74
+ data-slot="card-action"
75
+ className={mergeClass(
76
+ "col-start-2 row-span-2 self-start justify-self-end",
77
+ className,
78
+ )}
79
+ {...props}
80
+ />
81
+ ),
82
+ );
83
+ CardAction.displayName = "CardAction";
84
+
85
+ export const CardContent = React.forwardRef<HTMLDivElement, DivProps>(
86
+ ({ className, ...props }, ref) => (
87
+ <div
88
+ ref={ref}
89
+ className={mergeClass(
90
+ "px-[var(--space-6)] text-[length:var(--text-sm)] leading-relaxed max-sm:px-[var(--space-4)]",
91
+ className,
92
+ )}
93
+ {...props}
94
+ />
95
+ ),
96
+ );
97
+ CardContent.displayName = "CardContent";
98
+
99
+ export const CardFooter = React.forwardRef<HTMLDivElement, DivProps>(
100
+ ({ className, ...props }, ref) => (
101
+ <div
102
+ ref={ref}
103
+ className={mergeClass(
104
+ "px-[var(--space-6)] flex items-center gap-[var(--space-2)] max-sm:px-[var(--space-4)] max-sm:flex-wrap",
105
+ className,
106
+ )}
107
+ {...props}
108
+ />
109
+ ),
110
+ );
111
+ CardFooter.displayName = "CardFooter";
@@ -0,0 +1,405 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+
5
+ export interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "prefix"> {
6
+ /** input 우측에 부착할 보조 노드(아이콘·단위·버튼 등). 더 많은 슬롯이 필요하면 InputGroup 사용. */
7
+ suffix?: React.ReactNode;
8
+ /** input 좌측에 부착할 보조 노드. */
9
+ prefix?: React.ReactNode;
10
+ }
11
+
12
+ function cx(...args: (string | undefined | null | false)[]) {
13
+ return args.filter(Boolean).join(" ");
14
+ }
15
+
16
+ /* ───────── Base utility 묶음 (반복 줄이기) ───────── */
17
+
18
+ const baseInputClasses =
19
+ "block w-full h-[var(--control-md)] px-[var(--space-3)] bg-background text-foreground border border-border rounded-[var(--radius)] font-[inherit] text-[length:var(--text-sm)] leading-none transition-[border-color,box-shadow] duration-[var(--duration-fast)] placeholder:text-foreground-subtle hover:not-disabled:not-focus:border-border-strong focus:outline-none focus:border-foreground focus:shadow-[0_0_0_1px_var(--foreground)] disabled:opacity-[var(--opacity-disabled)] disabled:cursor-not-allowed disabled:bg-background-subtle read-only:bg-background-subtle aria-[invalid=true]:border-danger aria-[invalid=true]:focus:shadow-[0_0_0_1px_var(--danger)]";
20
+
21
+ const inGroupOverrides =
22
+ "data-[in-group]:flex-1 data-[in-group]:min-w-0 data-[in-group]:h-auto data-[in-group]:p-0 data-[in-group]:bg-transparent data-[in-group]:border-none data-[in-group]:rounded-none data-[in-group]:shadow-none data-[in-group]:hover:border-none data-[in-group]:focus:border-none data-[in-group]:focus:outline-none data-[in-group]:focus:shadow-none data-[in-group]:disabled:bg-transparent";
23
+
24
+ const baseGroupClasses =
25
+ "flex items-center w-full min-h-[var(--control-md)] px-[var(--space-2)] gap-[var(--space-2)] bg-background text-foreground border border-border rounded-[var(--radius)] transition-[border-color,box-shadow] duration-[var(--duration-fast)] cursor-text hover:not-data-[disabled]:not-focus-within:border-border-strong focus-within:border-foreground focus-within:shadow-[0_0_0_1px_var(--foreground)] aria-[invalid=true]:border-danger aria-[invalid=true]:focus-within:shadow-[0_0_0_1px_var(--danger)] data-[disabled]:opacity-[var(--opacity-disabled)] data-[disabled]:cursor-not-allowed data-[disabled]:bg-background-subtle";
26
+
27
+ /* ───────── InputGroup + InputAdornment (compound) ───────── */
28
+
29
+ interface InputGroupContextValue {
30
+ inGroup: true;
31
+ }
32
+
33
+ const InputGroupContext = React.createContext<InputGroupContextValue | null>(null);
34
+
35
+ function useInputGroup() {
36
+ return React.useContext(InputGroupContext);
37
+ }
38
+
39
+ export interface InputGroupProps extends React.HTMLAttributes<HTMLDivElement> {
40
+ "aria-invalid"?: boolean | "true" | "false";
41
+ disabled?: boolean;
42
+ }
43
+
44
+ export const InputGroup = React.forwardRef<HTMLDivElement, InputGroupProps>(
45
+ (
46
+ { className, children, "aria-invalid": ariaInvalid, disabled, onClick, ...props },
47
+ ref,
48
+ ) => {
49
+ const innerRef = React.useRef<HTMLDivElement | null>(null);
50
+ const mergedRef = React.useCallback(
51
+ (el: HTMLDivElement | null) => {
52
+ innerRef.current = el;
53
+ if (typeof ref === "function") ref(el);
54
+ else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = el;
55
+ },
56
+ [ref],
57
+ );
58
+
59
+ const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
60
+ onClick?.(e);
61
+ if (e.defaultPrevented) return;
62
+ const target = e.target as HTMLElement;
63
+ if (target.closest("button, input, textarea, select, a")) return;
64
+ const input = innerRef.current?.querySelector<HTMLInputElement>("input");
65
+ input?.focus();
66
+ };
67
+
68
+ return (
69
+ <InputGroupContext.Provider value={{ inGroup: true }}>
70
+ <div
71
+ ref={mergedRef}
72
+ className={cx(baseGroupClasses, className)}
73
+ data-disabled={disabled || undefined}
74
+ aria-invalid={ariaInvalid}
75
+ onClick={handleClick}
76
+ {...props}
77
+ >
78
+ {children}
79
+ </div>
80
+ </InputGroupContext.Provider>
81
+ );
82
+ },
83
+ );
84
+ InputGroup.displayName = "InputGroup";
85
+
86
+ export interface InputAdornmentProps extends React.HTMLAttributes<HTMLSpanElement> {
87
+ interactive?: boolean;
88
+ }
89
+
90
+ export const InputAdornment = React.forwardRef<HTMLSpanElement, InputAdornmentProps>(
91
+ ({ className, interactive, ...props }, ref) => (
92
+ <span
93
+ ref={ref}
94
+ className={cx(
95
+ "inline-flex items-center justify-center flex-none text-foreground-muted px-[var(--space-1)] data-[interactive]:p-0",
96
+ className,
97
+ )}
98
+ data-interactive={interactive || undefined}
99
+ {...props}
100
+ />
101
+ ),
102
+ );
103
+ InputAdornment.displayName = "InputAdornment";
104
+
105
+ /* ───────── Input (Tailwind utility 변종) ───────── */
106
+
107
+ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
108
+ ({ className, type = "text", prefix, suffix, ...props }, ref) => {
109
+ const group = useInputGroup();
110
+ const hasAffix = Boolean(prefix || suffix);
111
+ const input = (
112
+ <input
113
+ ref={ref}
114
+ type={type}
115
+ className={cx(
116
+ baseInputClasses,
117
+ inGroupOverrides,
118
+ !!prefix && "pl-[var(--space-10)]",
119
+ !!suffix && "pr-[var(--space-10)]",
120
+ className,
121
+ )}
122
+ data-in-group={group ? "" : undefined}
123
+ {...props}
124
+ />
125
+ );
126
+
127
+ if (!hasAffix) return input;
128
+
129
+ return (
130
+ <div
131
+ className="relative w-full block data-[in-group]:flex-1 data-[in-group]:min-w-0"
132
+ data-in-group={group ? "" : undefined}
133
+ >
134
+ {prefix && (
135
+ <span className="absolute top-1/2 -translate-y-1/2 left-[var(--space-3)] inline-flex items-center justify-center text-foreground-muted pointer-events-none [&>*]:pointer-events-auto">
136
+ {prefix}
137
+ </span>
138
+ )}
139
+ {input}
140
+ {suffix && (
141
+ <span className="absolute top-1/2 -translate-y-1/2 right-[var(--space-1)] inline-flex items-center justify-center text-foreground-muted pointer-events-none [&>*]:pointer-events-auto">
142
+ {suffix}
143
+ </span>
144
+ )}
145
+ </div>
146
+ );
147
+ },
148
+ );
149
+ Input.displayName = "Input";
150
+
151
+ /* ───────── PasswordInput ───────── */
152
+
153
+ const passwordToggleClasses =
154
+ "inline-flex items-center justify-center w-8 h-8 p-0 bg-transparent border-none rounded-[calc(var(--radius)-2px)] text-foreground-muted cursor-pointer transition-[color,background-color] duration-[var(--duration-fast)] hover:text-foreground hover:bg-background-muted focus-visible:outline-[length:var(--border-width-strong)] focus-visible:outline-foreground focus-visible:outline-offset-2";
155
+
156
+ function EyeIcon() {
157
+ return (
158
+ <svg viewBox="0 0 20 20" width="16" height="16" fill="none" aria-hidden>
159
+ <path d="M2 10s3-5.5 8-5.5S18 10 18 10s-3 5.5-8 5.5S2 10 2 10Z" stroke="currentColor" strokeWidth="1.5" />
160
+ <circle cx="10" cy="10" r="2.25" stroke="currentColor" strokeWidth="1.5" />
161
+ </svg>
162
+ );
163
+ }
164
+
165
+ function EyeOffIcon() {
166
+ return (
167
+ <svg viewBox="0 0 20 20" width="16" height="16" fill="none" aria-hidden>
168
+ <path
169
+ 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"
170
+ stroke="currentColor"
171
+ strokeWidth="1.5"
172
+ strokeLinecap="round"
173
+ />
174
+ </svg>
175
+ );
176
+ }
177
+
178
+ export interface PasswordInputProps extends Omit<InputProps, "type" | "suffix"> {
179
+ hideToggle?: boolean;
180
+ }
181
+
182
+ export const PasswordInput = React.forwardRef<HTMLInputElement, PasswordInputProps>(
183
+ ({ hideToggle, ...props }, ref) => {
184
+ const [visible, setVisible] = React.useState(false);
185
+
186
+ const toggle = hideToggle ? undefined : (
187
+ <button
188
+ type="button"
189
+ className={passwordToggleClasses}
190
+ onClick={() => setVisible((v) => !v)}
191
+ aria-label={visible ? "비밀번호 숨기기" : "비밀번호 표시"}
192
+ aria-pressed={visible}
193
+ tabIndex={-1}
194
+ >
195
+ {visible ? <EyeOffIcon /> : <EyeIcon />}
196
+ </button>
197
+ );
198
+
199
+ return <Input ref={ref} type={visible ? "text" : "password"} suffix={toggle} {...props} />;
200
+ },
201
+ );
202
+ PasswordInput.displayName = "PasswordInput";
203
+
204
+ /* ───────── NumberInput / PhoneInput / BusinessNumberInput
205
+ * 이 세 컴포넌트는 Input 을 wrap 만 — 자체 className 사용 안 함.
206
+ * plain 변종과 동일한 로직을 그대로 노출. */
207
+
208
+ export interface NumberInputProps
209
+ extends Omit<InputProps, "value" | "defaultValue" | "onChange" | "type"> {
210
+ value?: number;
211
+ defaultValue?: number;
212
+ onValueChange?: (value: number | undefined) => void;
213
+ thousandsSeparator?: boolean;
214
+ min?: number;
215
+ max?: number;
216
+ allowNegative?: boolean;
217
+ }
218
+
219
+ const formatNumber = (digits: string, thousandsSeparator: boolean): string => {
220
+ if (digits === "" || digits === "-") return digits;
221
+ const negative = digits.startsWith("-");
222
+ const body = negative ? digits.slice(1) : digits;
223
+ if (!body) return negative ? "-" : "";
224
+ const formatted = thousandsSeparator ? body.replace(/\B(?=(\d{3})+(?!\d))/g, ",") : body;
225
+ return negative ? `-${formatted}` : formatted;
226
+ };
227
+
228
+ const parseNumber = (s: string): number | undefined => {
229
+ const cleaned = s.replace(/[^\d-]/g, "");
230
+ if (!cleaned || cleaned === "-") return undefined;
231
+ const n = Number(cleaned);
232
+ return Number.isFinite(n) ? n : undefined;
233
+ };
234
+
235
+ export const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
236
+ (
237
+ { value, defaultValue, onValueChange, thousandsSeparator = true, min, max, allowNegative = true, onBlur, ...rest },
238
+ ref,
239
+ ) => {
240
+ const isControlled = value !== undefined;
241
+ const initial =
242
+ defaultValue !== undefined ? formatNumber(String(defaultValue), thousandsSeparator) : "";
243
+ const [internal, setInternal] = React.useState(initial);
244
+
245
+ const display = isControlled
246
+ ? value === undefined
247
+ ? ""
248
+ : formatNumber(String(value), thousandsSeparator)
249
+ : internal;
250
+
251
+ const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
252
+ const raw = e.target.value;
253
+ const allowedRe = allowNegative ? /[^\d-]/g : /[^\d]/g;
254
+ let cleaned = raw.replace(allowedRe, "");
255
+ if (allowNegative) cleaned = cleaned.replace(/(?!^)-/g, "");
256
+ const formatted = formatNumber(cleaned, thousandsSeparator);
257
+ if (!isControlled) setInternal(formatted);
258
+ onValueChange?.(parseNumber(cleaned));
259
+ };
260
+
261
+ const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
262
+ const n = parseNumber(display);
263
+ if (n !== undefined) {
264
+ let clamped = n;
265
+ if (min !== undefined && clamped < min) clamped = min;
266
+ if (max !== undefined && clamped > max) clamped = max;
267
+ if (clamped !== n) {
268
+ const f = formatNumber(String(clamped), thousandsSeparator);
269
+ if (!isControlled) setInternal(f);
270
+ onValueChange?.(clamped);
271
+ }
272
+ }
273
+ onBlur?.(e);
274
+ };
275
+
276
+ return (
277
+ <Input
278
+ ref={ref}
279
+ type="text"
280
+ inputMode="numeric"
281
+ value={display}
282
+ onChange={handleChange}
283
+ onBlur={handleBlur}
284
+ {...rest}
285
+ />
286
+ );
287
+ },
288
+ );
289
+ NumberInput.displayName = "NumberInput";
290
+
291
+ const formatPhoneKR = (digits: string): string => {
292
+ const d = digits.replace(/\D/g, "").slice(0, 11);
293
+ if (d.length === 0) return "";
294
+ if (d.startsWith("02")) {
295
+ if (d.length <= 2) return d;
296
+ if (d.length <= 5) return `${d.slice(0, 2)}-${d.slice(2)}`;
297
+ if (d.length <= 9) return `${d.slice(0, 2)}-${d.slice(2, 5)}-${d.slice(5)}`;
298
+ return `${d.slice(0, 2)}-${d.slice(2, 6)}-${d.slice(6, 10)}`;
299
+ }
300
+ if (d.length <= 3) return d;
301
+ if (d.length <= 6) return `${d.slice(0, 3)}-${d.slice(3)}`;
302
+ if (d.length <= 10) return `${d.slice(0, 3)}-${d.slice(3, 6)}-${d.slice(6)}`;
303
+ return `${d.slice(0, 3)}-${d.slice(3, 7)}-${d.slice(7, 11)}`;
304
+ };
305
+
306
+ export interface PhoneInputProps
307
+ extends Omit<InputProps, "value" | "defaultValue" | "onChange" | "type"> {
308
+ value?: string;
309
+ defaultValue?: string;
310
+ onValueChange?: (digits: string) => void;
311
+ }
312
+
313
+ export const PhoneInput = React.forwardRef<HTMLInputElement, PhoneInputProps>(
314
+ ({ value, defaultValue, onValueChange, onBlur, ...rest }, ref) => {
315
+ const isControlled = value !== undefined;
316
+ const initial = formatPhoneKR(defaultValue ?? "");
317
+ const [internal, setInternal] = React.useState(initial);
318
+
319
+ const display = isControlled ? formatPhoneKR(value ?? "") : internal;
320
+
321
+ const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
322
+ const digits = e.target.value.replace(/\D/g, "").slice(0, 11);
323
+ const formatted = formatPhoneKR(digits);
324
+ if (!isControlled) setInternal(formatted);
325
+ onValueChange?.(digits);
326
+ };
327
+
328
+ return (
329
+ <Input
330
+ ref={ref}
331
+ type="tel"
332
+ inputMode="tel"
333
+ autoComplete="tel"
334
+ value={display}
335
+ onChange={handleChange}
336
+ onBlur={onBlur}
337
+ {...rest}
338
+ />
339
+ );
340
+ },
341
+ );
342
+ PhoneInput.displayName = "PhoneInput";
343
+
344
+ const formatBRN = (digits: string): string => {
345
+ const d = digits.replace(/\D/g, "").slice(0, 10);
346
+ if (d.length <= 3) return d;
347
+ if (d.length <= 5) return `${d.slice(0, 3)}-${d.slice(3)}`;
348
+ return `${d.slice(0, 3)}-${d.slice(3, 5)}-${d.slice(5)}`;
349
+ };
350
+
351
+ export function isValidBRN(digits: string): boolean {
352
+ const d = digits.replace(/\D/g, "");
353
+ if (d.length !== 10) return false;
354
+ const w = [1, 3, 7, 1, 3, 7, 1, 3, 5];
355
+ let sum = 0;
356
+ for (let i = 0; i < 9; i++) sum += parseInt(d[i], 10) * w[i];
357
+ sum += Math.floor((parseInt(d[8], 10) * 5) / 10);
358
+ const check = (10 - (sum % 10)) % 10;
359
+ return check === parseInt(d[9], 10);
360
+ }
361
+
362
+ export interface BusinessNumberInputProps
363
+ extends Omit<InputProps, "value" | "defaultValue" | "onChange" | "type"> {
364
+ value?: string;
365
+ defaultValue?: string;
366
+ onValueChange?: (digits: string) => void;
367
+ validateChecksum?: boolean;
368
+ }
369
+
370
+ export const BusinessNumberInput = React.forwardRef<HTMLInputElement, BusinessNumberInputProps>(
371
+ ({ value, defaultValue, onValueChange, validateChecksum, onBlur, "aria-invalid": ariaInvalidProp, ...rest }, ref) => {
372
+ const isControlled = value !== undefined;
373
+ const initial = formatBRN(defaultValue ?? "");
374
+ const [internal, setInternal] = React.useState(initial);
375
+
376
+ const display = isControlled ? formatBRN(value ?? "") : internal;
377
+ const digits = display.replace(/\D/g, "");
378
+
379
+ const invalid =
380
+ ariaInvalidProp !== undefined
381
+ ? ariaInvalidProp
382
+ : validateChecksum && digits.length === 10 && !isValidBRN(digits);
383
+
384
+ const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
385
+ const next = e.target.value.replace(/\D/g, "").slice(0, 10);
386
+ const formatted = formatBRN(next);
387
+ if (!isControlled) setInternal(formatted);
388
+ onValueChange?.(next);
389
+ };
390
+
391
+ return (
392
+ <Input
393
+ ref={ref}
394
+ type="text"
395
+ inputMode="numeric"
396
+ value={display}
397
+ onChange={handleChange}
398
+ onBlur={onBlur}
399
+ aria-invalid={invalid || undefined}
400
+ {...rest}
401
+ />
402
+ );
403
+ },
404
+ );
405
+ BusinessNumberInput.displayName = "BusinessNumberInput";
@@ -3,6 +3,7 @@
3
3
  "versions": {
4
4
  "@base-ui/react": "^1.4.1",
5
5
  "@tanstack/react-form": "^1.29.1",
6
+ "class-variance-authority": "^0.7.1",
6
7
  "lucide-react": "^1.11.0",
7
8
  "react-hook-form": "^7.74.0",
8
9
  "shiki": "^4.0.2"