sh-ui-cli 0.42.0 → 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 +6 -1
- package/data/changelog/versions.json +25 -0
- package/data/registry/flutter/registry.json +1 -1
- package/data/registry/react/components/button/index.tailwind.tsx +70 -0
- package/data/registry/react/components/calendar/index.tsx +26 -20
- package/data/registry/react/components/calendar/styles.css +30 -44
- package/data/registry/react/components/card/index.tailwind.tsx +111 -0
- package/data/registry/react/components/input/index.tailwind.tsx +405 -0
- package/data/registry/react/peer-versions.json +1 -0
- package/data/registry/react/registry.json +31 -8
- package/data/tokens/build.mjs +66 -0
- package/package.json +1 -1
- package/src/add.mjs +54 -6
- package/src/api.d.ts +14 -0
- package/src/api.js +4 -0
- package/src/constants.js +19 -0
- package/src/create/cli-args.js +18 -2
- package/src/create/generator.js +55 -6
- package/src/create/index.mjs +3 -1
- package/src/init.mjs +25 -7
- package/src/mcp.mjs +13 -2
- package/templates/flutter-standalone/sh-ui.config.json +1 -1
- package/templates/nextjs-standalone/app/globals.css +1 -21
- package/templates/nextjs-standalone/sh-ui.config.json +1 -1
- package/templates/ui-app-template/sh-ui.config.json +1 -1
- package/templates/ui-app-template/src/styles/globals.css +1 -21
|
@@ -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";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$description": "React 레지스트리 매니페스트 — CLI가 각 컴포넌트 name으로 조회해 files를 사용자 프로젝트로 복사한다.",
|
|
2
|
+
"$description": "React 레지스트리 매니페스트 — CLI가 각 컴포넌트 name으로 조회해 files를 사용자 프로젝트로 복사한다. 컴포넌트 또는 file 엔트리에 frameworks?: string[] 옵션 — 미지정시 모든 cssFramework 에 적용, 지정시 해당 배열에 포함된 경우만 복사.",
|
|
3
3
|
"components": {
|
|
4
4
|
"button": {
|
|
5
5
|
"name": "button",
|
|
@@ -7,14 +7,23 @@
|
|
|
7
7
|
"files": [
|
|
8
8
|
{
|
|
9
9
|
"src": "components/button/index.tsx",
|
|
10
|
-
"dest": "{components}/button/index.tsx"
|
|
10
|
+
"dest": "{components}/button/index.tsx",
|
|
11
|
+
"frameworks": ["plain"]
|
|
11
12
|
},
|
|
12
13
|
{
|
|
13
14
|
"src": "components/button/styles.css",
|
|
14
|
-
"dest": "{components}/button/styles.css"
|
|
15
|
+
"dest": "{components}/button/styles.css",
|
|
16
|
+
"frameworks": ["plain"]
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"src": "components/button/index.tailwind.tsx",
|
|
20
|
+
"dest": "{components}/button/index.tsx",
|
|
21
|
+
"frameworks": ["tailwind"]
|
|
15
22
|
}
|
|
16
23
|
],
|
|
17
|
-
"dependencies": [
|
|
24
|
+
"dependencies": [
|
|
25
|
+
{ "name": "class-variance-authority", "frameworks": ["tailwind"] }
|
|
26
|
+
],
|
|
18
27
|
"registryDependencies": []
|
|
19
28
|
},
|
|
20
29
|
"card": {
|
|
@@ -23,11 +32,18 @@
|
|
|
23
32
|
"files": [
|
|
24
33
|
{
|
|
25
34
|
"src": "components/card/index.tsx",
|
|
26
|
-
"dest": "{components}/card/index.tsx"
|
|
35
|
+
"dest": "{components}/card/index.tsx",
|
|
36
|
+
"frameworks": ["plain"]
|
|
27
37
|
},
|
|
28
38
|
{
|
|
29
39
|
"src": "components/card/styles.css",
|
|
30
|
-
"dest": "{components}/card/styles.css"
|
|
40
|
+
"dest": "{components}/card/styles.css",
|
|
41
|
+
"frameworks": ["plain"]
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"src": "components/card/index.tailwind.tsx",
|
|
45
|
+
"dest": "{components}/card/index.tsx",
|
|
46
|
+
"frameworks": ["tailwind"]
|
|
31
47
|
}
|
|
32
48
|
],
|
|
33
49
|
"dependencies": [],
|
|
@@ -39,11 +55,18 @@
|
|
|
39
55
|
"files": [
|
|
40
56
|
{
|
|
41
57
|
"src": "components/input/index.tsx",
|
|
42
|
-
"dest": "{components}/input/index.tsx"
|
|
58
|
+
"dest": "{components}/input/index.tsx",
|
|
59
|
+
"frameworks": ["plain"]
|
|
43
60
|
},
|
|
44
61
|
{
|
|
45
62
|
"src": "components/input/styles.css",
|
|
46
|
-
"dest": "{components}/input/styles.css"
|
|
63
|
+
"dest": "{components}/input/styles.css",
|
|
64
|
+
"frameworks": ["plain"]
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"src": "components/input/index.tailwind.tsx",
|
|
68
|
+
"dest": "{components}/input/index.tsx",
|
|
69
|
+
"frameworks": ["tailwind"]
|
|
47
70
|
}
|
|
48
71
|
],
|
|
49
72
|
"dependencies": [],
|
package/data/tokens/build.mjs
CHANGED
|
@@ -102,6 +102,29 @@ function mergeThemeIndependent(tokens) {
|
|
|
102
102
|
return out;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Tailwind v4 의 @theme inline 블록에 sh-ui 토큰을 매핑해
|
|
107
|
+
* `bg-primary`, `border-border`, `rounded-md` 같은 Tailwind utility 가
|
|
108
|
+
* 동작하도록 한다. 매핑은 light 토큰 맵에서 파생 — 모든 색 키를
|
|
109
|
+
* Tailwind 의 --color-* 네임스페이스로 옮김.
|
|
110
|
+
*
|
|
111
|
+
* radius 는 단일 토큰 (--radius) 이지만 Tailwind 의 rounded-{sm,md,lg,xl}
|
|
112
|
+
* 4 단계로 expand. 템플릿이 손으로 박아두던 패턴을 단일 소스로 옮긴 것.
|
|
113
|
+
*/
|
|
114
|
+
function buildTailwindThemeBlock(lightTokens) {
|
|
115
|
+
const lines = [];
|
|
116
|
+
for (const path of Object.keys(lightTokens)) {
|
|
117
|
+
const cssVar = toCssVar(path);
|
|
118
|
+
const themeKey = cssVar.replace(/^--/, "--color-");
|
|
119
|
+
lines.push(` ${themeKey}: var(${cssVar});`);
|
|
120
|
+
}
|
|
121
|
+
lines.push(` --radius-sm: calc(var(--radius) - 2px);`);
|
|
122
|
+
lines.push(` --radius-md: var(--radius);`);
|
|
123
|
+
lines.push(` --radius-lg: calc(var(--radius) + 2px);`);
|
|
124
|
+
lines.push(` --radius-xl: calc(var(--radius) + 4px);`);
|
|
125
|
+
return `@theme inline {\n${lines.join("\n")}\n}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
105
128
|
export async function buildTokensCss(config) {
|
|
106
129
|
const tokens = await resolveTokens(config);
|
|
107
130
|
const mode = config.theme.mode;
|
|
@@ -116,6 +139,7 @@ export async function buildTokensCss(config) {
|
|
|
116
139
|
if (mode === "light-dark") {
|
|
117
140
|
blocks.push(emitCssBlock(".dark", tokens.dark));
|
|
118
141
|
}
|
|
142
|
+
blocks.push(buildTailwindThemeBlock(tokens.light));
|
|
119
143
|
const header = `/* Generated by @sh-ui/tokens — do not edit directly */\n/* base=${config.theme.base} radius=${config.theme.radius} mode=${config.theme.mode} */\n`;
|
|
120
144
|
return header + "\n" + blocks.join("\n\n") + "\n";
|
|
121
145
|
}
|
|
@@ -533,6 +557,48 @@ import 'package:flutter/material.dart';
|
|
|
533
557
|
return `${header}\n${classes.join("\n\n")}\n\n${themeClass}\n`;
|
|
534
558
|
}
|
|
535
559
|
|
|
560
|
+
/* ───────── Emitter 디스패처 ─────────
|
|
561
|
+
*
|
|
562
|
+
* (platform × cssFramework) → 토큰 emitter.
|
|
563
|
+
* 향후 Tailwind theme config / CSS Modules / vanilla-extract 추가 시
|
|
564
|
+
* 이 테이블에 한 줄만 등록하면 끝나도록 구조를 미리 잡아 둠.
|
|
565
|
+
*
|
|
566
|
+
* Flutter 는 CSS 프레임워크 개념이 무의미하지만, 일관된 디스패치를 위해
|
|
567
|
+
* "plain" 키로 통일 — 호출부가 platform 별 분기 없이 같은 진입점을 쓸 수 있게.
|
|
568
|
+
*/
|
|
569
|
+
const tokenEmitters = {
|
|
570
|
+
react: {
|
|
571
|
+
plain: buildTokensCss,
|
|
572
|
+
// Tailwind 변종은 같은 tokens.css 를 공유. 컴포넌트의 utility class 가
|
|
573
|
+
// var(--primary) 를 @theme inline 매핑을 통해 참조하므로 — 토큰 자체는
|
|
574
|
+
// 동일. 향후 Tailwind v3 theme.config.ts 를 별도 emit 하고 싶으면
|
|
575
|
+
// 여기에 다른 함수를 등록.
|
|
576
|
+
tailwind: buildTokensCss,
|
|
577
|
+
},
|
|
578
|
+
flutter: {
|
|
579
|
+
plain: buildTokensDart,
|
|
580
|
+
},
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
export async function buildTokens(config) {
|
|
584
|
+
const platform = config.platform;
|
|
585
|
+
const fw = config.cssFramework ?? "plain";
|
|
586
|
+
const platformEmitters = tokenEmitters[platform];
|
|
587
|
+
if (!platformEmitters) {
|
|
588
|
+
throw new Error(
|
|
589
|
+
`tokens: 알 수 없는 platform '${platform}'. 지원: ${Object.keys(tokenEmitters).join(", ")}`,
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
const emit = platformEmitters[fw];
|
|
593
|
+
if (!emit) {
|
|
594
|
+
const supported = Object.keys(platformEmitters).join(", ");
|
|
595
|
+
throw new Error(
|
|
596
|
+
`tokens emitter 미구현: ${platform}/${fw}. 현재 지원: ${platform}/{${supported}}`,
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
return emit(config);
|
|
600
|
+
}
|
|
601
|
+
|
|
536
602
|
/* ───────── CLI ───────── */
|
|
537
603
|
|
|
538
604
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
package/package.json
CHANGED
package/src/add.mjs
CHANGED
|
@@ -149,11 +149,8 @@ async function addTokens(config, cwd, diffMode, summary, conflictResolver) {
|
|
|
149
149
|
if (!destRel) throw new Error("paths.tokens 가 설정에 없습니다.");
|
|
150
150
|
const dest = resolve(cwd, destRel);
|
|
151
151
|
|
|
152
|
-
const {
|
|
153
|
-
const content =
|
|
154
|
-
config.platform === "react"
|
|
155
|
-
? await buildTokensCss(config)
|
|
156
|
-
: await buildTokensDart(config);
|
|
152
|
+
const { buildTokens } = await loadTokensBuilder();
|
|
153
|
+
const content = await buildTokens(config);
|
|
157
154
|
|
|
158
155
|
const result = await writeOrDiff({ dest, content, cwd, diffMode, summary, conflictResolver });
|
|
159
156
|
if (!diffMode && result !== "unchanged") {
|
|
@@ -163,6 +160,31 @@ async function addTokens(config, cwd, diffMode, summary, conflictResolver) {
|
|
|
163
160
|
}
|
|
164
161
|
}
|
|
165
162
|
|
|
163
|
+
/**
|
|
164
|
+
* registry 엔트리의 frameworks 필드와 현재 cssFramework 가 호환되는지.
|
|
165
|
+
* 필드가 없으면 "모든 프레임워크에 적용" — 기본 케이스.
|
|
166
|
+
*/
|
|
167
|
+
function frameworkMatches(entry, cssFramework) {
|
|
168
|
+
if (!entry.frameworks) return true;
|
|
169
|
+
return entry.frameworks.includes(cssFramework);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* cssFramework="tailwind" 인데 컴포넌트에 tailwind 전용 변종 파일이 없으면
|
|
174
|
+
* plain 으로 fallback. plain CSS 컴포넌트도 @theme inline 브리지 덕분에
|
|
175
|
+
* Tailwind v4 프로젝트에서 그대로 동작하므로 깨지지 않음.
|
|
176
|
+
*
|
|
177
|
+
* 점진적 rollout 전략 — 모든 컴포넌트가 한 번에 tailwind 변종을 갖출 필요 없이
|
|
178
|
+
* 가능한 것부터 utility-class 변종을 제공하고, 나머지는 plain 으로 자연 처리.
|
|
179
|
+
*/
|
|
180
|
+
function effectiveFramework(entry, cssFramework) {
|
|
181
|
+
if (cssFramework !== "tailwind") return cssFramework;
|
|
182
|
+
const hasTailwindVariant = (entry.files ?? []).some(
|
|
183
|
+
(f) => f.frameworks && f.frameworks.includes("tailwind"),
|
|
184
|
+
);
|
|
185
|
+
return hasTailwindVariant ? "tailwind" : "plain";
|
|
186
|
+
}
|
|
187
|
+
|
|
166
188
|
async function addComponent(name, config, cwd, installed, pendingDeps, diffMode, summary, conflictResolver) {
|
|
167
189
|
const registryRoot = getRegistryRoot(config.platform);
|
|
168
190
|
const registry = JSON.parse(
|
|
@@ -175,11 +197,30 @@ async function addComponent(name, config, cwd, installed, pendingDeps, diffMode,
|
|
|
175
197
|
);
|
|
176
198
|
}
|
|
177
199
|
|
|
200
|
+
const requestedFw = config.cssFramework ?? "plain";
|
|
201
|
+
const cssFramework = effectiveFramework(entry, requestedFw);
|
|
202
|
+
|
|
203
|
+
// 사용자가 tailwind 를 골랐는데 이 컴포넌트는 plain 으로 fallback 된 경우 한 줄 알림.
|
|
204
|
+
// 동작에 문제는 없지만 일관성에 대한 기대를 정확히 셋업하기 위함.
|
|
205
|
+
if (requestedFw === "tailwind" && cssFramework === "plain" && !diffMode) {
|
|
206
|
+
console.log(
|
|
207
|
+
`ℹ ${name} — Tailwind 변종 미제공, plain 변종으로 설치 (Tailwind v4 환경에서 그대로 동작)`,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (!frameworkMatches(entry, cssFramework)) {
|
|
212
|
+
console.log(
|
|
213
|
+
`↷ ${name} skipped — cssFramework=${cssFramework} 미지원 (지원: ${entry.frameworks.join(", ")})`,
|
|
214
|
+
);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
178
218
|
for (const dep of entry.registryDependencies ?? []) {
|
|
179
219
|
await addOne(dep, config, cwd, installed, pendingDeps, diffMode, summary, conflictResolver);
|
|
180
220
|
}
|
|
181
221
|
|
|
182
222
|
for (const file of entry.files) {
|
|
223
|
+
if (!frameworkMatches(file, cssFramework)) continue;
|
|
183
224
|
const src = resolve(registryRoot, file.src);
|
|
184
225
|
const dest = resolve(cwd, resolveDest(file.dest, config));
|
|
185
226
|
const content = await readFile(src, "utf8");
|
|
@@ -192,7 +233,14 @@ async function addComponent(name, config, cwd, installed, pendingDeps, diffMode,
|
|
|
192
233
|
}
|
|
193
234
|
|
|
194
235
|
for (const dep of entry.dependencies ?? []) {
|
|
195
|
-
|
|
236
|
+
// dep 은 string ("react-hook-form") 또는 object ({name, frameworks?: string[]}).
|
|
237
|
+
// 후자는 cssFramework 에 따라 의존성을 분기 (예: cva 는 tailwind 변종에만 필요).
|
|
238
|
+
if (typeof dep === "string") {
|
|
239
|
+
pendingDeps.add(dep);
|
|
240
|
+
} else if (dep && typeof dep === "object" && dep.name) {
|
|
241
|
+
if (dep.frameworks && !dep.frameworks.includes(cssFramework)) continue;
|
|
242
|
+
pendingDeps.add(dep.name);
|
|
243
|
+
}
|
|
196
244
|
}
|
|
197
245
|
}
|
|
198
246
|
|