sh-ui-cli 0.48.0 → 0.49.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.
@@ -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.49.0",
7
+ "date": "2026-05-04",
8
+ "title": "vanilla-extract 변종 파일럿 — button/card/input",
9
+ "type": "minor",
10
+ "highlights": [
11
+ "**vanilla-extract 변종 파일럿** — button/card/input 에 `index.vanilla-extract.tsx` + `styles.css.ts` 추가. CSS 룰을 TypeScript 객체로 작성하고 빌드 타임에 정적 CSS 로 컴파일 — 런타임 비용 0. 사용자가 `sh-ui.config.json` 의 `cssFramework` 를 `\"vanilla-extract\"` 로 직접 두면 동작 (파일럿 단계라 PLANNED 유지, 전수 롤아웃 후 SUPPORTED 승격 예정).",
12
+ "**peer-versions + 의존성 분기** — `@vanilla-extract/css` (^1.16.0) 가 vanilla-extract 변종에만 install 큐에 들어가도록 registry.json 의 dependencies 분기 추가. plain/tailwind/css-modules 사용자에게는 영향 없음.",
13
+ "**tokens emitter + utils.cn 공유** — vanilla-extract 도 `:root` CSS custom property 를 그대로 참조 (`backgroundColor: \"var(--primary)\"`) 하므로 tokens.css 를 plain 과 그대로 공유. utils.cn.ts 도 같이 매칭되어 추가 cn 변종 불필요.",
14
+ "**docs 페이지 보강** — css-framework 가이드에 vanilla-extract 섹션 추가 (CSS-in-TS 사용 예시 + Next.js/Vite 빌드 플러그인 셋업 안내). VariantSource 컴포넌트도 vanilla-extract 탭을 자동 노출."
15
+ ],
16
+ "url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.49.0"
17
+ },
5
18
  {
6
19
  "version": "0.48.0",
7
20
  "date": "2026-05-04",
@@ -0,0 +1,45 @@
1
+ import * as React from "react";
2
+ import { cn } from "@SH_UI_UTILS@";
3
+ import { button, sizes, variants } from "./styles.css";
4
+
5
+ type Variant = "primary" | "secondary" | "ghost" | "danger" | "link";
6
+ type Size = "sm" | "md" | "lg";
7
+
8
+ export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
9
+ /**
10
+ * 시각적 위계.
11
+ * - `primary` — 페이지의 주요 액션. 한 화면에 하나만 권장.
12
+ * - `secondary` — 보조 액션. 약한 배경 + border.
13
+ * - `ghost` — 배경 없는 hover 강조 액션. 툴바/메뉴 항목에 적합.
14
+ * - `danger` — 파괴적 액션(삭제, 취소 등).
15
+ * - `link` — 텍스트 링크처럼 보이는 인라인 버튼.
16
+ *
17
+ * @default "primary"
18
+ */
19
+ variant?: Variant;
20
+ /**
21
+ * 크기.
22
+ * - `sm` — 조밀한 영역(테이블 행, 툴바)
23
+ * - `md` — 일반
24
+ * - `lg` — CTA·랜딩 영역
25
+ *
26
+ * @default "md"
27
+ */
28
+ size?: Size;
29
+ }
30
+
31
+ /**
32
+ * 사용자 액션을 트리거하는 기본 버튼 (vanilla-extract 변종).
33
+ * variant로 시각적 위계(primary/secondary/ghost/danger/link)를,
34
+ * size로 크기를 결정한다. 페이지 이동 목적이면 anchor를 감싼 `link` variant를 사용할 것.
35
+ */
36
+ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
37
+ ({ variant = "primary", size = "md", className, ...props }, ref) => (
38
+ <button
39
+ ref={ref}
40
+ className={cn(button, sizes[size], variants[variant], className)}
41
+ {...props}
42
+ />
43
+ ),
44
+ );
45
+ Button.displayName = "Button";
@@ -0,0 +1,120 @@
1
+ import { style, styleVariants } from "@vanilla-extract/css";
2
+
3
+ export const button = style({
4
+ display: "inline-flex",
5
+ alignItems: "center",
6
+ justifyContent: "center",
7
+ gap: "var(--space-2)",
8
+ border: "1px solid transparent",
9
+ borderRadius: "var(--radius)",
10
+ fontWeight: "var(--weight-medium)",
11
+ lineHeight: 1,
12
+ cursor: "pointer",
13
+ transition:
14
+ "background-color var(--duration-fast), color var(--duration-fast), border-color var(--duration-fast), transform 80ms ease-out, filter 80ms",
15
+ userSelect: "none",
16
+ WebkitTapHighlightColor: "transparent",
17
+
18
+ selectors: {
19
+ "&:disabled": {
20
+ opacity: "var(--opacity-disabled)",
21
+ pointerEvents: "none",
22
+ },
23
+ "&:focus-visible": {
24
+ outline: "var(--border-width-strong) solid var(--foreground)",
25
+ outlineOffset: "2px",
26
+ },
27
+ "&:active:not(:disabled)": {
28
+ transform: "scale(0.97)",
29
+ filter: "brightness(0.92)",
30
+ transitionDuration: "40ms",
31
+ },
32
+ },
33
+
34
+ "@media": {
35
+ "(prefers-reduced-motion: reduce)": {
36
+ transition: "none",
37
+ },
38
+ },
39
+ });
40
+
41
+ export const sizes = styleVariants({
42
+ sm: {
43
+ height: "var(--control-sm)",
44
+ padding: "0 var(--space-3)",
45
+ fontSize: "var(--text-sm)",
46
+ "@media": {
47
+ "(hover: none) and (pointer: coarse)": {
48
+ height: "2.25rem",
49
+ },
50
+ },
51
+ },
52
+ md: {
53
+ height: "var(--control-md)",
54
+ padding: "0 var(--space-4)",
55
+ fontSize: "var(--text-sm)",
56
+ "@media": {
57
+ "(hover: none) and (pointer: coarse)": {
58
+ height: "2.75rem",
59
+ },
60
+ },
61
+ },
62
+ lg: {
63
+ height: "var(--control-lg)",
64
+ padding: "0 var(--space-5)",
65
+ fontSize: "var(--text-base)",
66
+ },
67
+ });
68
+
69
+ export const variants = styleVariants({
70
+ primary: {
71
+ backgroundColor: "var(--primary)",
72
+ color: "var(--primary-foreground)",
73
+ selectors: {
74
+ "&:hover": {
75
+ backgroundColor: "var(--primary-hover)",
76
+ },
77
+ },
78
+ },
79
+ secondary: {
80
+ backgroundColor: "var(--background-muted)",
81
+ color: "var(--foreground)",
82
+ borderColor: "var(--border)",
83
+ selectors: {
84
+ "&:hover": {
85
+ backgroundColor: "var(--background-subtle)",
86
+ },
87
+ },
88
+ },
89
+ ghost: {
90
+ backgroundColor: "transparent",
91
+ color: "var(--foreground)",
92
+ selectors: {
93
+ "&:hover": {
94
+ backgroundColor: "var(--background-muted)",
95
+ },
96
+ },
97
+ },
98
+ danger: {
99
+ backgroundColor: "var(--danger)",
100
+ color: "var(--danger-foreground)",
101
+ },
102
+ link: {
103
+ backgroundColor: "transparent",
104
+ color: "var(--foreground)",
105
+ borderColor: "transparent",
106
+ height: "auto",
107
+ padding: 0,
108
+ textUnderlineOffset: "3px",
109
+ selectors: {
110
+ "&:hover": {
111
+ textDecoration: "underline",
112
+ },
113
+ "&:active:not(:disabled)": {
114
+ transform: "none",
115
+ filter: "none",
116
+ color: "var(--foreground-muted)",
117
+ },
118
+ },
119
+ },
120
+ });
@@ -0,0 +1,63 @@
1
+ import * as React from "react";
2
+ import { cn } from "@SH_UI_UTILS@";
3
+ import { action, card, content, description, footer, header, title } from "./styles.css";
4
+
5
+ type DivProps = React.HTMLAttributes<HTMLDivElement>;
6
+
7
+ export const Card = React.forwardRef<HTMLDivElement, DivProps>(
8
+ ({ className, ...props }, ref) => (
9
+ <div ref={ref} className={cn(card, className)} {...props} />
10
+ ),
11
+ );
12
+ Card.displayName = "Card";
13
+
14
+ export const CardHeader = React.forwardRef<HTMLDivElement, DivProps>(
15
+ ({ className, ...props }, ref) => (
16
+ <div
17
+ ref={ref}
18
+ data-slot="card-header"
19
+ className={cn(header, className)}
20
+ {...props}
21
+ />
22
+ ),
23
+ );
24
+ CardHeader.displayName = "CardHeader";
25
+
26
+ export const CardTitle = React.forwardRef<HTMLDivElement, DivProps>(
27
+ ({ className, ...props }, ref) => (
28
+ <div ref={ref} className={cn(title, className)} {...props} />
29
+ ),
30
+ );
31
+ CardTitle.displayName = "CardTitle";
32
+
33
+ export const CardDescription = React.forwardRef<HTMLDivElement, DivProps>(
34
+ ({ className, ...props }, ref) => (
35
+ <div ref={ref} className={cn(description, className)} {...props} />
36
+ ),
37
+ );
38
+ CardDescription.displayName = "CardDescription";
39
+
40
+ /**
41
+ * 헤더 우측에 배치되는 슬롯. CardHeader 내부에서 grid 2번째 컬럼을 차지.
42
+ * CardHeader가 `:has(.action)`으로 감지해 레이아웃을 전환한다.
43
+ */
44
+ export const CardAction = React.forwardRef<HTMLDivElement, DivProps>(
45
+ ({ className, ...props }, ref) => (
46
+ <div ref={ref} className={cn(action, className)} {...props} />
47
+ ),
48
+ );
49
+ CardAction.displayName = "CardAction";
50
+
51
+ export const CardContent = React.forwardRef<HTMLDivElement, DivProps>(
52
+ ({ className, ...props }, ref) => (
53
+ <div ref={ref} className={cn(content, className)} {...props} />
54
+ ),
55
+ );
56
+ CardContent.displayName = "CardContent";
57
+
58
+ export const CardFooter = React.forwardRef<HTMLDivElement, DivProps>(
59
+ ({ className, ...props }, ref) => (
60
+ <div ref={ref} className={cn(footer, className)} {...props} />
61
+ ),
62
+ );
63
+ CardFooter.displayName = "CardFooter";
@@ -0,0 +1,88 @@
1
+ import { style } from "@vanilla-extract/css";
2
+
3
+ export const card = style({
4
+ display: "flex",
5
+ flexDirection: "column",
6
+ gap: "var(--space-6)",
7
+ padding: "var(--space-6) 0",
8
+ background: "var(--background)",
9
+ color: "var(--foreground)",
10
+ border: "1px solid var(--border)",
11
+ borderRadius: "var(--radius)",
12
+
13
+ "@media": {
14
+ "(max-width: 640px)": {
15
+ gap: "var(--space-4)",
16
+ padding: "var(--space-4) 0",
17
+ },
18
+ },
19
+ });
20
+
21
+ export const action = style({
22
+ gridColumn: 2,
23
+ gridRow: "1 / span 2",
24
+ alignSelf: "start",
25
+ justifySelf: "end",
26
+ });
27
+
28
+ export const header = style({
29
+ display: "grid",
30
+ gridTemplateColumns: "1fr",
31
+ gridAutoRows: "auto",
32
+ rowGap: "0.375rem",
33
+ padding: "0 var(--space-6)",
34
+
35
+ selectors: {
36
+ [`&:has(.${action})`]: {
37
+ gridTemplateColumns: "1fr auto",
38
+ },
39
+ },
40
+
41
+ "@media": {
42
+ "(max-width: 640px)": {
43
+ paddingLeft: "var(--space-4)",
44
+ paddingRight: "var(--space-4)",
45
+ },
46
+ },
47
+ });
48
+
49
+ export const title = style({
50
+ fontSize: "var(--text-base)",
51
+ fontWeight: "var(--weight-semibold)",
52
+ lineHeight: 1.25,
53
+ letterSpacing: "-0.01em",
54
+ });
55
+
56
+ export const description = style({
57
+ fontSize: "var(--text-sm)",
58
+ lineHeight: 1.5,
59
+ color: "var(--foreground-muted)",
60
+ });
61
+
62
+ export const content = style({
63
+ padding: "0 var(--space-6)",
64
+ fontSize: "var(--text-sm)",
65
+ lineHeight: 1.6,
66
+
67
+ "@media": {
68
+ "(max-width: 640px)": {
69
+ paddingLeft: "var(--space-4)",
70
+ paddingRight: "var(--space-4)",
71
+ },
72
+ },
73
+ });
74
+
75
+ export const footer = style({
76
+ padding: "0 var(--space-6)",
77
+ display: "flex",
78
+ alignItems: "center",
79
+ gap: "var(--space-2)",
80
+
81
+ "@media": {
82
+ "(max-width: 640px)": {
83
+ paddingLeft: "var(--space-4)",
84
+ paddingRight: "var(--space-4)",
85
+ flexWrap: "wrap",
86
+ },
87
+ },
88
+ });
@@ -0,0 +1,425 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { cn } from "@SH_UI_UTILS@";
5
+ import {
6
+ adornment,
7
+ affix,
8
+ affixPrefix,
9
+ affixSuffix,
10
+ group,
11
+ input,
12
+ inputWrap,
13
+ toggle,
14
+ withPrefix,
15
+ withSuffix,
16
+ } from "./styles.css";
17
+
18
+ export interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "prefix"> {
19
+ /** input 우측에 부착할 보조 노드(아이콘·단위·버튼 등). 더 많은 슬롯이 필요하면 InputGroup 사용. */
20
+ suffix?: React.ReactNode;
21
+ /** input 좌측에 부착할 보조 노드. */
22
+ prefix?: React.ReactNode;
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
+ */
43
+ export const InputGroup = React.forwardRef<HTMLDivElement, InputGroupProps>(
44
+ (
45
+ {
46
+ className,
47
+ children,
48
+ "aria-invalid": ariaInvalid,
49
+ disabled,
50
+ onClick,
51
+ ...props
52
+ },
53
+ ref,
54
+ ) => {
55
+ const innerRef = React.useRef<HTMLDivElement | null>(null);
56
+ const mergedRef = React.useCallback(
57
+ (el: HTMLDivElement | null) => {
58
+ innerRef.current = el;
59
+ if (typeof ref === "function") ref(el);
60
+ else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = el;
61
+ },
62
+ [ref],
63
+ );
64
+
65
+ const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
66
+ onClick?.(e);
67
+ if (e.defaultPrevented) return;
68
+ const target = e.target as HTMLElement;
69
+ if (target.closest("button, input, textarea, select, a")) return;
70
+ const inputEl = innerRef.current?.querySelector<HTMLInputElement>("input");
71
+ inputEl?.focus();
72
+ };
73
+
74
+ return (
75
+ <InputGroupContext.Provider value={{ inGroup: true }}>
76
+ <div
77
+ ref={mergedRef}
78
+ className={cn(group, className)}
79
+ data-disabled={disabled || undefined}
80
+ aria-invalid={ariaInvalid}
81
+ onClick={handleClick}
82
+ {...props}
83
+ >
84
+ {children}
85
+ </div>
86
+ </InputGroupContext.Provider>
87
+ );
88
+ },
89
+ );
90
+ InputGroup.displayName = "InputGroup";
91
+
92
+ export interface InputAdornmentProps extends React.HTMLAttributes<HTMLSpanElement> {
93
+ /**
94
+ * 클릭이 input 으로 버블링되지 않도록 한다. 버튼·체크박스 등 인터랙티브 요소를
95
+ * Adornment 에 담을 때 켤 것.
96
+ *
97
+ * @default false
98
+ */
99
+ interactive?: boolean;
100
+ }
101
+
102
+ export const InputAdornment = React.forwardRef<HTMLSpanElement, InputAdornmentProps>(
103
+ ({ className, interactive, ...props }, ref) => {
104
+ return (
105
+ <span
106
+ ref={ref}
107
+ className={cn(adornment, className)}
108
+ data-interactive={interactive || undefined}
109
+ {...props}
110
+ />
111
+ );
112
+ },
113
+ );
114
+ InputAdornment.displayName = "InputAdornment";
115
+
116
+ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
117
+ ({ className, type = "text", prefix, suffix, ...props }, ref) => {
118
+ const groupCtx = useInputGroup();
119
+ const hasAffix = Boolean(prefix || suffix);
120
+ const inputEl = (
121
+ <input
122
+ ref={ref}
123
+ type={type}
124
+ className={cn(input, !!prefix && withPrefix, !!suffix && withSuffix, className)}
125
+ data-in-group={groupCtx ? "" : undefined}
126
+ {...props}
127
+ />
128
+ );
129
+
130
+ if (!hasAffix) return inputEl;
131
+
132
+ return (
133
+ <div className={inputWrap} data-in-group={groupCtx ? "" : undefined}>
134
+ {prefix && <span className={cn(affix, affixPrefix)}>{prefix}</span>}
135
+ {inputEl}
136
+ {suffix && <span className={cn(affix, affixSuffix)}>{suffix}</span>}
137
+ </div>
138
+ );
139
+ },
140
+ );
141
+ Input.displayName = "Input";
142
+
143
+ /* ───────── PasswordInput ───────── */
144
+
145
+ function EyeIcon() {
146
+ return (
147
+ <svg viewBox="0 0 20 20" width="16" height="16" fill="none" aria-hidden>
148
+ <path
149
+ d="M2 10s3-5.5 8-5.5S18 10 18 10s-3 5.5-8 5.5S2 10 2 10Z"
150
+ stroke="currentColor"
151
+ strokeWidth="1.5"
152
+ />
153
+ <circle cx="10" cy="10" r="2.25" stroke="currentColor" strokeWidth="1.5" />
154
+ </svg>
155
+ );
156
+ }
157
+
158
+ function EyeOffIcon() {
159
+ return (
160
+ <svg viewBox="0 0 20 20" width="16" height="16" fill="none" aria-hidden>
161
+ <path
162
+ 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"
163
+ stroke="currentColor"
164
+ strokeWidth="1.5"
165
+ strokeLinecap="round"
166
+ />
167
+ </svg>
168
+ );
169
+ }
170
+
171
+ export interface PasswordInputProps extends Omit<InputProps, "type" | "suffix"> {
172
+ /** 비밀번호 표시 토글 버튼을 숨긴다. @default false */
173
+ hideToggle?: boolean;
174
+ }
175
+
176
+ export const PasswordInput = React.forwardRef<HTMLInputElement, PasswordInputProps>(
177
+ ({ hideToggle, ...props }, ref) => {
178
+ const [visible, setVisible] = React.useState(false);
179
+
180
+ const toggleBtn = hideToggle ? undefined : (
181
+ <button
182
+ type="button"
183
+ className={toggle}
184
+ onClick={() => setVisible((v) => !v)}
185
+ aria-label={visible ? "비밀번호 숨기기" : "비밀번호 표시"}
186
+ aria-pressed={visible}
187
+ tabIndex={-1}
188
+ >
189
+ {visible ? <EyeOffIcon /> : <EyeIcon />}
190
+ </button>
191
+ );
192
+
193
+ return (
194
+ <Input
195
+ ref={ref}
196
+ type={visible ? "text" : "password"}
197
+ suffix={toggleBtn}
198
+ {...props}
199
+ />
200
+ );
201
+ },
202
+ );
203
+ PasswordInput.displayName = "PasswordInput";
204
+
205
+ /* ───────── NumberInput ───────── */
206
+
207
+ export interface NumberInputProps
208
+ extends Omit<InputProps, "value" | "defaultValue" | "onChange" | "type"> {
209
+ value?: number;
210
+ defaultValue?: number;
211
+ onValueChange?: (value: number | undefined) => void;
212
+ thousandsSeparator?: boolean;
213
+ min?: number;
214
+ max?: number;
215
+ allowNegative?: boolean;
216
+ }
217
+
218
+ const formatNumber = (digits: string, thousandsSeparator: boolean): string => {
219
+ if (digits === "" || digits === "-") return digits;
220
+ const negative = digits.startsWith("-");
221
+ const body = negative ? digits.slice(1) : digits;
222
+ if (!body) return negative ? "-" : "";
223
+ const formatted = thousandsSeparator
224
+ ? body.replace(/\B(?=(\d{3})+(?!\d))/g, ",")
225
+ : body;
226
+ return negative ? `-${formatted}` : formatted;
227
+ };
228
+
229
+ const parseNumber = (s: string): number | undefined => {
230
+ const cleaned = s.replace(/[^\d-]/g, "");
231
+ if (!cleaned || cleaned === "-") return undefined;
232
+ const n = Number(cleaned);
233
+ return Number.isFinite(n) ? n : undefined;
234
+ };
235
+
236
+ export const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
237
+ (
238
+ {
239
+ value,
240
+ defaultValue,
241
+ onValueChange,
242
+ thousandsSeparator = true,
243
+ min,
244
+ max,
245
+ allowNegative = true,
246
+ onBlur,
247
+ ...rest
248
+ },
249
+ ref,
250
+ ) => {
251
+ const isControlled = value !== undefined;
252
+ const initial =
253
+ defaultValue !== undefined ? formatNumber(String(defaultValue), thousandsSeparator) : "";
254
+ const [internal, setInternal] = React.useState(initial);
255
+
256
+ const display = isControlled
257
+ ? value === undefined
258
+ ? ""
259
+ : formatNumber(String(value), thousandsSeparator)
260
+ : internal;
261
+
262
+ const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
263
+ const raw = e.target.value;
264
+ const allowedRe = allowNegative ? /[^\d-]/g : /[^\d]/g;
265
+ let cleaned = raw.replace(allowedRe, "");
266
+ if (allowNegative) cleaned = cleaned.replace(/(?!^)-/g, "");
267
+ const formatted = formatNumber(cleaned, thousandsSeparator);
268
+ if (!isControlled) setInternal(formatted);
269
+ onValueChange?.(parseNumber(cleaned));
270
+ };
271
+
272
+ const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
273
+ const n = parseNumber(display);
274
+ if (n !== undefined) {
275
+ let clamped = n;
276
+ if (min !== undefined && clamped < min) clamped = min;
277
+ if (max !== undefined && clamped > max) clamped = max;
278
+ if (clamped !== n) {
279
+ const f = formatNumber(String(clamped), thousandsSeparator);
280
+ if (!isControlled) setInternal(f);
281
+ onValueChange?.(clamped);
282
+ }
283
+ }
284
+ onBlur?.(e);
285
+ };
286
+
287
+ return (
288
+ <Input
289
+ ref={ref}
290
+ type="text"
291
+ inputMode="numeric"
292
+ value={display}
293
+ onChange={handleChange}
294
+ onBlur={handleBlur}
295
+ {...rest}
296
+ />
297
+ );
298
+ },
299
+ );
300
+ NumberInput.displayName = "NumberInput";
301
+
302
+ /* ───────── PhoneInput (KR) ───────── */
303
+
304
+ const formatPhoneKR = (digits: string): string => {
305
+ const d = digits.replace(/\D/g, "").slice(0, 11);
306
+ if (d.length === 0) return "";
307
+
308
+ if (d.startsWith("02")) {
309
+ if (d.length <= 2) return d;
310
+ if (d.length <= 5) return `${d.slice(0, 2)}-${d.slice(2)}`;
311
+ if (d.length <= 9) return `${d.slice(0, 2)}-${d.slice(2, 5)}-${d.slice(5)}`;
312
+ return `${d.slice(0, 2)}-${d.slice(2, 6)}-${d.slice(6, 10)}`;
313
+ }
314
+
315
+ if (d.length <= 3) return d;
316
+ if (d.length <= 6) return `${d.slice(0, 3)}-${d.slice(3)}`;
317
+ if (d.length <= 10) return `${d.slice(0, 3)}-${d.slice(3, 6)}-${d.slice(6)}`;
318
+ return `${d.slice(0, 3)}-${d.slice(3, 7)}-${d.slice(7, 11)}`;
319
+ };
320
+
321
+ export interface PhoneInputProps
322
+ extends Omit<InputProps, "value" | "defaultValue" | "onChange" | "type"> {
323
+ value?: string;
324
+ defaultValue?: string;
325
+ onValueChange?: (digits: string) => void;
326
+ }
327
+
328
+ export const PhoneInput = React.forwardRef<HTMLInputElement, PhoneInputProps>(
329
+ ({ value, defaultValue, onValueChange, onBlur, ...rest }, ref) => {
330
+ const isControlled = value !== undefined;
331
+ const initial = formatPhoneKR(defaultValue ?? "");
332
+ const [internal, setInternal] = React.useState(initial);
333
+
334
+ const display = isControlled ? formatPhoneKR(value ?? "") : internal;
335
+
336
+ const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
337
+ const digits = e.target.value.replace(/\D/g, "").slice(0, 11);
338
+ const formatted = formatPhoneKR(digits);
339
+ if (!isControlled) setInternal(formatted);
340
+ onValueChange?.(digits);
341
+ };
342
+
343
+ return (
344
+ <Input
345
+ ref={ref}
346
+ type="tel"
347
+ inputMode="tel"
348
+ autoComplete="tel"
349
+ value={display}
350
+ onChange={handleChange}
351
+ onBlur={onBlur}
352
+ {...rest}
353
+ />
354
+ );
355
+ },
356
+ );
357
+ PhoneInput.displayName = "PhoneInput";
358
+
359
+ /* ───────── BusinessNumberInput (KR) ───────── */
360
+
361
+ const formatBRN = (digits: string): string => {
362
+ const d = digits.replace(/\D/g, "").slice(0, 10);
363
+ if (d.length <= 3) return d;
364
+ if (d.length <= 5) return `${d.slice(0, 3)}-${d.slice(3)}`;
365
+ return `${d.slice(0, 3)}-${d.slice(3, 5)}-${d.slice(5)}`;
366
+ };
367
+
368
+ export function isValidBRN(digits: string): boolean {
369
+ const d = digits.replace(/\D/g, "");
370
+ if (d.length !== 10) return false;
371
+ const w = [1, 3, 7, 1, 3, 7, 1, 3, 5];
372
+ let sum = 0;
373
+ for (let i = 0; i < 9; i++) sum += parseInt(d[i], 10) * w[i];
374
+ sum += Math.floor((parseInt(d[8], 10) * 5) / 10);
375
+ const check = (10 - (sum % 10)) % 10;
376
+ return check === parseInt(d[9], 10);
377
+ }
378
+
379
+ export interface BusinessNumberInputProps
380
+ extends Omit<InputProps, "value" | "defaultValue" | "onChange" | "type"> {
381
+ value?: string;
382
+ defaultValue?: string;
383
+ onValueChange?: (digits: string) => void;
384
+ validateChecksum?: boolean;
385
+ }
386
+
387
+ export const BusinessNumberInput = React.forwardRef<HTMLInputElement, BusinessNumberInputProps>(
388
+ (
389
+ { value, defaultValue, onValueChange, validateChecksum, onBlur, "aria-invalid": ariaInvalidProp, ...rest },
390
+ ref,
391
+ ) => {
392
+ const isControlled = value !== undefined;
393
+ const initial = formatBRN(defaultValue ?? "");
394
+ const [internal, setInternal] = React.useState(initial);
395
+
396
+ const display = isControlled ? formatBRN(value ?? "") : internal;
397
+ const digits = display.replace(/\D/g, "");
398
+
399
+ const invalid =
400
+ ariaInvalidProp !== undefined
401
+ ? ariaInvalidProp
402
+ : validateChecksum && digits.length === 10 && !isValidBRN(digits);
403
+
404
+ const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
405
+ const next = e.target.value.replace(/\D/g, "").slice(0, 10);
406
+ const formatted = formatBRN(next);
407
+ if (!isControlled) setInternal(formatted);
408
+ onValueChange?.(next);
409
+ };
410
+
411
+ return (
412
+ <Input
413
+ ref={ref}
414
+ type="text"
415
+ inputMode="numeric"
416
+ value={display}
417
+ onChange={handleChange}
418
+ onBlur={onBlur}
419
+ aria-invalid={invalid || undefined}
420
+ {...rest}
421
+ />
422
+ );
423
+ },
424
+ );
425
+ BusinessNumberInput.displayName = "BusinessNumberInput";
@@ -0,0 +1,202 @@
1
+ import { style } from "@vanilla-extract/css";
2
+
3
+ export const input = style({
4
+ display: "block",
5
+ width: "100%",
6
+ height: "var(--control-md)",
7
+ padding: "0 var(--space-3)",
8
+ background: "var(--background)",
9
+ color: "var(--foreground)",
10
+ border: "1px solid var(--border)",
11
+ borderRadius: "var(--radius)",
12
+ fontFamily: "inherit",
13
+ fontSize: "var(--text-sm)",
14
+ lineHeight: 1,
15
+ transition: "border-color var(--duration-fast), box-shadow var(--duration-fast)",
16
+ WebkitTapHighlightColor: "transparent",
17
+
18
+ selectors: {
19
+ "&::placeholder": {
20
+ color: "var(--foreground-subtle)",
21
+ },
22
+ "&:hover:not(:disabled):not(:focus)": {
23
+ borderColor: "var(--border-strong)",
24
+ },
25
+ "&:focus": {
26
+ outline: "none",
27
+ borderColor: "var(--foreground)",
28
+ boxShadow: "0 0 0 1px var(--foreground)",
29
+ },
30
+ "&:disabled": {
31
+ opacity: "var(--opacity-disabled)",
32
+ cursor: "not-allowed",
33
+ background: "var(--background-subtle)",
34
+ },
35
+ "&:read-only": {
36
+ background: "var(--background-subtle)",
37
+ },
38
+ '&[type="number"]::-webkit-outer-spin-button': {
39
+ WebkitAppearance: "none",
40
+ margin: 0,
41
+ },
42
+ '&[type="number"]::-webkit-inner-spin-button': {
43
+ WebkitAppearance: "none",
44
+ margin: 0,
45
+ },
46
+ '&[type="number"]': {
47
+ MozAppearance: "textfield",
48
+ },
49
+ '&[aria-invalid="true"]': {
50
+ borderColor: "var(--danger)",
51
+ },
52
+ '&[aria-invalid="true"]:focus': {
53
+ boxShadow: "0 0 0 1px var(--danger)",
54
+ },
55
+ "&[data-in-group]": {
56
+ flex: "1 1 auto",
57
+ minWidth: 0,
58
+ height: "auto",
59
+ padding: 0,
60
+ background: "transparent",
61
+ border: "none",
62
+ borderRadius: 0,
63
+ boxShadow: "none",
64
+ },
65
+ "&[data-in-group]:focus, &[data-in-group]:hover": {
66
+ border: "none",
67
+ boxShadow: "none",
68
+ outline: "none",
69
+ },
70
+ "&[data-in-group]:disabled": {
71
+ background: "transparent",
72
+ },
73
+ },
74
+
75
+ "@media": {
76
+ "(hover: none) and (pointer: coarse)": {
77
+ height: "2.75rem",
78
+ fontSize: "var(--text-base)",
79
+ },
80
+ },
81
+ });
82
+
83
+ export const inputWrap = style({
84
+ position: "relative",
85
+ width: "100%",
86
+ display: "block",
87
+
88
+ selectors: {
89
+ "&[data-in-group]": {
90
+ flex: "1 1 auto",
91
+ minWidth: 0,
92
+ },
93
+ },
94
+ });
95
+
96
+ export const withPrefix = style({ paddingLeft: "var(--space-10)" });
97
+ export const withSuffix = style({ paddingRight: "var(--space-10)" });
98
+
99
+ export const affix = style({
100
+ position: "absolute",
101
+ top: "50%",
102
+ transform: "translateY(-50%)",
103
+ display: "inline-flex",
104
+ alignItems: "center",
105
+ justifyContent: "center",
106
+ color: "var(--foreground-muted)",
107
+ pointerEvents: "none",
108
+
109
+ selectors: {
110
+ "& > *": {
111
+ pointerEvents: "auto",
112
+ },
113
+ },
114
+ });
115
+
116
+ export const affixPrefix = style({ left: "var(--space-3)" });
117
+ export const affixSuffix = style({ right: "var(--space-1)" });
118
+
119
+ export const toggle = style({
120
+ display: "inline-flex",
121
+ alignItems: "center",
122
+ justifyContent: "center",
123
+ width: "2rem",
124
+ height: "2rem",
125
+ padding: 0,
126
+ background: "transparent",
127
+ border: "none",
128
+ borderRadius: "calc(var(--radius) - 2px)",
129
+ color: "var(--foreground-muted)",
130
+ cursor: "pointer",
131
+ transition: "color var(--duration-fast), background-color var(--duration-fast)",
132
+ WebkitTapHighlightColor: "transparent",
133
+
134
+ selectors: {
135
+ "&:hover": {
136
+ color: "var(--foreground)",
137
+ background: "var(--background-muted)",
138
+ },
139
+ "&:focus-visible": {
140
+ outline: "var(--border-width-strong) solid var(--foreground)",
141
+ outlineOffset: "2px",
142
+ },
143
+ },
144
+ });
145
+
146
+ export const group = style({
147
+ display: "flex",
148
+ alignItems: "center",
149
+ width: "100%",
150
+ minHeight: "var(--control-md)",
151
+ padding: "0 var(--space-2)",
152
+ gap: "var(--space-2)",
153
+ background: "var(--background)",
154
+ color: "var(--foreground)",
155
+ border: "1px solid var(--border)",
156
+ borderRadius: "var(--radius)",
157
+ transition: "border-color var(--duration-fast), box-shadow var(--duration-fast)",
158
+ cursor: "text",
159
+ WebkitTapHighlightColor: "transparent",
160
+
161
+ selectors: {
162
+ "&:hover:not([data-disabled]):not(:focus-within)": {
163
+ borderColor: "var(--border-strong)",
164
+ },
165
+ "&:focus-within": {
166
+ borderColor: "var(--foreground)",
167
+ boxShadow: "0 0 0 1px var(--foreground)",
168
+ },
169
+ '&[aria-invalid="true"]': {
170
+ borderColor: "var(--danger)",
171
+ },
172
+ '&[aria-invalid="true"]:focus-within': {
173
+ boxShadow: "0 0 0 1px var(--danger)",
174
+ },
175
+ "&[data-disabled]": {
176
+ opacity: "var(--opacity-disabled)",
177
+ cursor: "not-allowed",
178
+ background: "var(--background-subtle)",
179
+ },
180
+ },
181
+
182
+ "@media": {
183
+ "(hover: none) and (pointer: coarse)": {
184
+ minHeight: "2.75rem",
185
+ },
186
+ },
187
+ });
188
+
189
+ export const adornment = style({
190
+ display: "inline-flex",
191
+ alignItems: "center",
192
+ justifyContent: "center",
193
+ flex: "0 0 auto",
194
+ color: "var(--foreground-muted)",
195
+ padding: "0 var(--space-1)",
196
+
197
+ selectors: {
198
+ "&[data-interactive]": {
199
+ padding: 0,
200
+ },
201
+ },
202
+ });
@@ -3,6 +3,7 @@
3
3
  "versions": {
4
4
  "@base-ui/react": "^1.4.1",
5
5
  "@tanstack/react-form": "^1.29.1",
6
+ "@vanilla-extract/css": "^1.16.0",
6
7
  "class-variance-authority": "^0.7.1",
7
8
  "clsx": "^2.1.1",
8
9
  "lucide-react": "^1.11.0",
@@ -39,6 +39,20 @@
39
39
  "frameworks": [
40
40
  "css-modules"
41
41
  ]
42
+ },
43
+ {
44
+ "src": "components/button/index.vanilla-extract.tsx",
45
+ "dest": "{components}/button/index.tsx",
46
+ "frameworks": [
47
+ "vanilla-extract"
48
+ ]
49
+ },
50
+ {
51
+ "src": "components/button/styles.css.ts",
52
+ "dest": "{components}/button/styles.css.ts",
53
+ "frameworks": [
54
+ "vanilla-extract"
55
+ ]
42
56
  }
43
57
  ],
44
58
  "dependencies": [
@@ -47,6 +61,12 @@
47
61
  "frameworks": [
48
62
  "tailwind"
49
63
  ]
64
+ },
65
+ {
66
+ "name": "@vanilla-extract/css",
67
+ "frameworks": [
68
+ "vanilla-extract"
69
+ ]
50
70
  }
51
71
  ],
52
72
  "registryDependencies": [
@@ -91,9 +111,30 @@
91
111
  "frameworks": [
92
112
  "css-modules"
93
113
  ]
114
+ },
115
+ {
116
+ "src": "components/card/index.vanilla-extract.tsx",
117
+ "dest": "{components}/card/index.tsx",
118
+ "frameworks": [
119
+ "vanilla-extract"
120
+ ]
121
+ },
122
+ {
123
+ "src": "components/card/styles.css.ts",
124
+ "dest": "{components}/card/styles.css.ts",
125
+ "frameworks": [
126
+ "vanilla-extract"
127
+ ]
128
+ }
129
+ ],
130
+ "dependencies": [
131
+ {
132
+ "name": "@vanilla-extract/css",
133
+ "frameworks": [
134
+ "vanilla-extract"
135
+ ]
94
136
  }
95
137
  ],
96
- "dependencies": [],
97
138
  "registryDependencies": [
98
139
  "utils"
99
140
  ]
@@ -136,9 +177,30 @@
136
177
  "frameworks": [
137
178
  "css-modules"
138
179
  ]
180
+ },
181
+ {
182
+ "src": "components/input/index.vanilla-extract.tsx",
183
+ "dest": "{components}/input/index.tsx",
184
+ "frameworks": [
185
+ "vanilla-extract"
186
+ ]
187
+ },
188
+ {
189
+ "src": "components/input/styles.css.ts",
190
+ "dest": "{components}/input/styles.css.ts",
191
+ "frameworks": [
192
+ "vanilla-extract"
193
+ ]
194
+ }
195
+ ],
196
+ "dependencies": [
197
+ {
198
+ "name": "@vanilla-extract/css",
199
+ "frameworks": [
200
+ "vanilla-extract"
201
+ ]
139
202
  }
140
203
  ],
141
- "dependencies": [],
142
204
  "registryDependencies": [
143
205
  "utils"
144
206
  ]
@@ -2280,7 +2342,8 @@
2280
2342
  "dest": "{utils}",
2281
2343
  "frameworks": [
2282
2344
  "plain",
2283
- "css-modules"
2345
+ "css-modules",
2346
+ "vanilla-extract"
2284
2347
  ]
2285
2348
  },
2286
2349
  {
@@ -578,6 +578,9 @@ const tokenEmitters = {
578
578
  // 그대로 참조하면 되므로 tokens.css 를 공유. .module.css 안에서도
579
579
  // var(--primary) 같은 글로벌 변수는 정상 참조됨.
580
580
  "css-modules": buildTokensCss,
581
+ // vanilla-extract 도 마찬가지 — .css.ts 안에서 'var(--primary)' 처럼
582
+ // 문자열로 CSS var 를 참조할 수 있어 tokens.css 그대로 공유.
583
+ "vanilla-extract": buildTokensCss,
581
584
  },
582
585
  flutter: {
583
586
  plain: buildTokensDart,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sh-ui-cli",
3
- "version": "0.48.0",
3
+ "version": "0.49.0",
4
4
  "description": "sh-ui CLI — 프로젝트 스캐폴드(create) + 컴포넌트 추가(add/list/remove) + IDE-내 AI용 MCP 서버",
5
5
  "license": "MIT",
6
6
  "repository": {