sh-ui-cli 0.42.1 → 0.44.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/accordion/index.tailwind.tsx +88 -0
- package/data/registry/react/components/avatar/index.tailwind.tsx +74 -0
- package/data/registry/react/components/badge/index.tailwind.tsx +47 -0
- package/data/registry/react/components/breadcrumb/index.tailwind.tsx +138 -0
- package/data/registry/react/components/button/index.tailwind.tsx +70 -0
- package/data/registry/react/components/card/index.tailwind.tsx +111 -0
- package/data/registry/react/components/checkbox/index.tailwind.tsx +72 -0
- package/data/registry/react/components/code-panel/index.tailwind.tsx +107 -0
- package/data/registry/react/components/combobox/index.tailwind.tsx +160 -0
- package/data/registry/react/components/context-menu/index.tailwind.tsx +170 -0
- package/data/registry/react/components/date-picker/index.tailwind.tsx +294 -0
- package/data/registry/react/components/dialog/index.tailwind.tsx +96 -0
- package/data/registry/react/components/dropdown-menu/index.tailwind.tsx +205 -0
- package/data/registry/react/components/input/index.tailwind.tsx +405 -0
- package/data/registry/react/components/label/index.tailwind.tsx +78 -0
- package/data/registry/react/components/menubar/index.tailwind.tsx +32 -0
- package/data/registry/react/components/numeric-input/index.tailwind.tsx +113 -0
- package/data/registry/react/components/page-toc/index.tailwind.tsx +149 -0
- package/data/registry/react/components/pagination/index.tailwind.tsx +148 -0
- package/data/registry/react/components/popover/index.tailwind.tsx +77 -0
- package/data/registry/react/components/progress/index.tailwind.tsx +60 -0
- package/data/registry/react/components/radio/index.tailwind.tsx +54 -0
- package/data/registry/react/components/select/index.tailwind.tsx +199 -0
- package/data/registry/react/components/separator/index.tailwind.tsx +42 -0
- package/data/registry/react/components/skeleton/index.tailwind.tsx +39 -0
- package/data/registry/react/components/slider/index.tailwind.tsx +255 -0
- package/data/registry/react/components/spinner/index.tailwind.tsx +63 -0
- package/data/registry/react/components/switch/index.tailwind.tsx +62 -0
- package/data/registry/react/components/tabs/index.tailwind.tsx +113 -0
- package/data/registry/react/components/textarea/index.tailwind.tsx +21 -0
- package/data/registry/react/components/toggle/index.tailwind.tsx +111 -0
- package/data/registry/react/components/tooltip/index.tailwind.tsx +55 -0
- package/data/registry/react/peer-versions.json +1 -0
- package/data/registry/react/registry.json +530 -72
- 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";
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
export interface LabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {
|
|
4
|
+
/**
|
|
5
|
+
* 필수 필드 표시. `true`면 LabelTitle 뒤에 `*` 표시.
|
|
6
|
+
* (Tailwind 변종은 plain 변종의 `:has()` 인접 셀렉터 자동 감지를 지원하지 않음 — 명시적으로 prop 사용.)
|
|
7
|
+
*
|
|
8
|
+
* @default false
|
|
9
|
+
*/
|
|
10
|
+
isRequired?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function cx(...args: (string | undefined | false)[]) {
|
|
14
|
+
return args.filter(Boolean).join(" ");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const Label = React.forwardRef<HTMLLabelElement, LabelProps>(
|
|
18
|
+
({ className, children, isRequired, ...props }, ref) => (
|
|
19
|
+
<label
|
|
20
|
+
ref={ref}
|
|
21
|
+
className={cx(
|
|
22
|
+
"flex flex-col gap-0.5 text-[length:var(--text-sm)] font-medium leading-snug text-foreground cursor-pointer select-none not-has-[[data-sh-ui-label-part]]:block",
|
|
23
|
+
// 필수 표시 — title 이 있으면 title 뒤, 없으면 label 뒤에 * 부착
|
|
24
|
+
isRequired &&
|
|
25
|
+
"has-[[data-sh-ui-label-part='title']]:[&>[data-sh-ui-label-part='title']]:after:content-['_*'] has-[[data-sh-ui-label-part='title']]:[&>[data-sh-ui-label-part='title']]:after:text-danger has-[[data-sh-ui-label-part='title']]:[&>[data-sh-ui-label-part='title']]:after:font-semibold not-has-[[data-sh-ui-label-part='title']]:after:content-['_*'] not-has-[[data-sh-ui-label-part='title']]:after:text-danger not-has-[[data-sh-ui-label-part='title']]:after:font-semibold",
|
|
26
|
+
className,
|
|
27
|
+
)}
|
|
28
|
+
data-required={isRequired || undefined}
|
|
29
|
+
{...props}
|
|
30
|
+
>
|
|
31
|
+
{children}
|
|
32
|
+
</label>
|
|
33
|
+
),
|
|
34
|
+
);
|
|
35
|
+
Label.displayName = "Label";
|
|
36
|
+
|
|
37
|
+
export function LabelTitle({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) {
|
|
38
|
+
return (
|
|
39
|
+
<span
|
|
40
|
+
data-sh-ui-label-part="title"
|
|
41
|
+
className={cx("font-semibold text-[length:var(--text-sm)] text-foreground", className)}
|
|
42
|
+
{...props}
|
|
43
|
+
/>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function LabelSubtitle({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) {
|
|
48
|
+
return (
|
|
49
|
+
<span
|
|
50
|
+
data-sh-ui-label-part="subtitle"
|
|
51
|
+
className={cx("font-normal text-[0.8125rem] text-foreground", className)}
|
|
52
|
+
{...props}
|
|
53
|
+
/>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function LabelDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
|
|
58
|
+
return (
|
|
59
|
+
<p
|
|
60
|
+
data-sh-ui-label-part="description"
|
|
61
|
+
className={cx("m-0 font-normal text-[0.8125rem] leading-snug text-foreground-muted", className)}
|
|
62
|
+
{...props}
|
|
63
|
+
/>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function LabelCaption({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
|
|
68
|
+
return (
|
|
69
|
+
<p
|
|
70
|
+
data-sh-ui-label-part="caption"
|
|
71
|
+
className={cx(
|
|
72
|
+
"m-0 font-normal text-[length:var(--text-xs)] leading-tight text-[var(--foreground-subtle,var(--foreground-muted))] opacity-75",
|
|
73
|
+
className,
|
|
74
|
+
)}
|
|
75
|
+
{...props}
|
|
76
|
+
/>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { Menubar as BaseMenubar } from "@base-ui/react/menubar";
|
|
3
|
+
|
|
4
|
+
type WithStringClassName<T> = Omit<T, "className"> & { className?: string };
|
|
5
|
+
|
|
6
|
+
function cx(...args: (string | undefined | false | null)[]) {
|
|
7
|
+
return args.filter(Boolean).join(" ");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 상단 앱 메뉴바 (Tailwind 변종). DropdownMenu 와 함께 사용 — DropdownMenu 의
|
|
12
|
+
* Tailwind 변종이 plain 으로 fallback 된 경우 트리거 스타일은 plain CSS 로 적용됨.
|
|
13
|
+
*
|
|
14
|
+
* Tailwind 변종에서는 메뉴바 안의 DropdownMenu 트리거 재지정을 utility 로 표현하기
|
|
15
|
+
* 어려워 (자식 컴포넌트 클래스 의존), 메뉴바 자체의 외형만 utility 로 변환.
|
|
16
|
+
* 트리거 스타일링이 필요하면 사용자가 trigger 에 직접 className 부여.
|
|
17
|
+
*/
|
|
18
|
+
export const Menubar = React.forwardRef<
|
|
19
|
+
HTMLDivElement,
|
|
20
|
+
WithStringClassName<React.ComponentPropsWithoutRef<typeof BaseMenubar>>
|
|
21
|
+
>(function Menubar({ className, ...props }, ref) {
|
|
22
|
+
return (
|
|
23
|
+
<BaseMenubar
|
|
24
|
+
ref={ref}
|
|
25
|
+
className={cx(
|
|
26
|
+
"inline-flex items-center gap-[var(--space-1)] p-[var(--space-1)] bg-background border border-border rounded-[var(--radius)] shadow-[0_1px_2px_rgba(0,0,0,0.04)]",
|
|
27
|
+
className,
|
|
28
|
+
)}
|
|
29
|
+
{...props}
|
|
30
|
+
/>
|
|
31
|
+
);
|
|
32
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
|
|
5
|
+
function cx(...args: (string | undefined | null | false)[]) {
|
|
6
|
+
return args.filter(Boolean).join(" ");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface NumericInputProps
|
|
10
|
+
extends Omit<
|
|
11
|
+
React.InputHTMLAttributes<HTMLInputElement>,
|
|
12
|
+
"value" | "defaultValue" | "onChange" | "type" | "min" | "max" | "step"
|
|
13
|
+
> {
|
|
14
|
+
value?: number;
|
|
15
|
+
defaultValue?: number;
|
|
16
|
+
onValueChange?: (value: number) => void;
|
|
17
|
+
min?: number;
|
|
18
|
+
max?: number;
|
|
19
|
+
step?: number;
|
|
20
|
+
unit?: React.ReactNode;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const inputClasses =
|
|
24
|
+
"w-10 px-1 py-0.5 font-mono text-[length:var(--text-xs)] leading-tight text-right border border-transparent rounded-[calc(var(--radius)-4px)] bg-transparent text-foreground appearance-none [-moz-appearance:textfield] transition-[border-color,background-color] duration-[var(--duration-fast)] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:m-0 [&::-webkit-outer-spin-button]:m-0 hover:not-disabled:not-focus:border-border focus:outline-none focus:border-foreground focus:bg-background focus-visible:outline-none focus-visible:border-foreground disabled:cursor-not-allowed disabled:opacity-[var(--opacity-disabled)]";
|
|
25
|
+
|
|
26
|
+
export const NumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
|
|
27
|
+
(
|
|
28
|
+
{ value, defaultValue, onValueChange, min, max, step = 1, unit, className, onFocus, onBlur, onKeyDown, ...props },
|
|
29
|
+
ref,
|
|
30
|
+
) => {
|
|
31
|
+
const isControlled = value !== undefined;
|
|
32
|
+
const [internal, setInternal] = React.useState<number>(defaultValue ?? 0);
|
|
33
|
+
const current = isControlled ? value! : internal;
|
|
34
|
+
|
|
35
|
+
const [buffer, setBuffer] = React.useState<string>(() => String(current));
|
|
36
|
+
const focusedRef = React.useRef(false);
|
|
37
|
+
|
|
38
|
+
React.useEffect(() => {
|
|
39
|
+
if (!focusedRef.current) setBuffer(String(current));
|
|
40
|
+
}, [current]);
|
|
41
|
+
|
|
42
|
+
const clamp = (n: number) => {
|
|
43
|
+
let v = n;
|
|
44
|
+
if (min !== undefined && v < min) v = min;
|
|
45
|
+
if (max !== undefined && v > max) v = max;
|
|
46
|
+
return v;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const commit = (n: number): number => {
|
|
50
|
+
const c = clamp(n);
|
|
51
|
+
if (!isControlled) setInternal(c);
|
|
52
|
+
onValueChange?.(c);
|
|
53
|
+
return c;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<span className="inline-flex items-baseline gap-[2px] min-w-[3rem] justify-end">
|
|
58
|
+
<input
|
|
59
|
+
ref={ref}
|
|
60
|
+
type="text"
|
|
61
|
+
inputMode="decimal"
|
|
62
|
+
className={cx(inputClasses, className)}
|
|
63
|
+
value={buffer}
|
|
64
|
+
onChange={(e) => {
|
|
65
|
+
const raw = e.target.value;
|
|
66
|
+
setBuffer(raw);
|
|
67
|
+
if (raw === "" || raw === "-" || raw === "." || raw === "-.") return;
|
|
68
|
+
const n = Number(raw);
|
|
69
|
+
if (Number.isFinite(n)) commit(n);
|
|
70
|
+
}}
|
|
71
|
+
onFocus={(e) => {
|
|
72
|
+
focusedRef.current = true;
|
|
73
|
+
const t = e.currentTarget;
|
|
74
|
+
setTimeout(() => t.select(), 0);
|
|
75
|
+
onFocus?.(e);
|
|
76
|
+
}}
|
|
77
|
+
onBlur={(e) => {
|
|
78
|
+
focusedRef.current = false;
|
|
79
|
+
const n = Number(buffer);
|
|
80
|
+
if (buffer !== "" && Number.isFinite(n)) {
|
|
81
|
+
const c = commit(n);
|
|
82
|
+
setBuffer(String(c));
|
|
83
|
+
} else {
|
|
84
|
+
setBuffer(String(current));
|
|
85
|
+
}
|
|
86
|
+
onBlur?.(e);
|
|
87
|
+
}}
|
|
88
|
+
onKeyDown={(e) => {
|
|
89
|
+
if (e.key === "ArrowUp") {
|
|
90
|
+
e.preventDefault();
|
|
91
|
+
const next = commit(current + step);
|
|
92
|
+
setBuffer(String(next));
|
|
93
|
+
} else if (e.key === "ArrowDown") {
|
|
94
|
+
e.preventDefault();
|
|
95
|
+
const next = commit(current - step);
|
|
96
|
+
setBuffer(String(next));
|
|
97
|
+
} else if (e.key === "Enter") {
|
|
98
|
+
e.currentTarget.blur();
|
|
99
|
+
}
|
|
100
|
+
onKeyDown?.(e);
|
|
101
|
+
}}
|
|
102
|
+
{...props}
|
|
103
|
+
/>
|
|
104
|
+
{unit !== undefined && unit !== "" && (
|
|
105
|
+
<span className="font-mono text-[length:var(--text-xs)] text-foreground-muted" aria-hidden>
|
|
106
|
+
{unit}
|
|
107
|
+
</span>
|
|
108
|
+
)}
|
|
109
|
+
</span>
|
|
110
|
+
);
|
|
111
|
+
},
|
|
112
|
+
);
|
|
113
|
+
NumericInput.displayName = "NumericInput";
|