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.
Files changed (53) hide show
  1. package/README.md +6 -1
  2. package/data/changelog/versions.json +25 -0
  3. package/data/registry/flutter/registry.json +1 -1
  4. package/data/registry/react/components/accordion/index.tailwind.tsx +88 -0
  5. package/data/registry/react/components/avatar/index.tailwind.tsx +74 -0
  6. package/data/registry/react/components/badge/index.tailwind.tsx +47 -0
  7. package/data/registry/react/components/breadcrumb/index.tailwind.tsx +138 -0
  8. package/data/registry/react/components/button/index.tailwind.tsx +70 -0
  9. package/data/registry/react/components/card/index.tailwind.tsx +111 -0
  10. package/data/registry/react/components/checkbox/index.tailwind.tsx +72 -0
  11. package/data/registry/react/components/code-panel/index.tailwind.tsx +107 -0
  12. package/data/registry/react/components/combobox/index.tailwind.tsx +160 -0
  13. package/data/registry/react/components/context-menu/index.tailwind.tsx +170 -0
  14. package/data/registry/react/components/date-picker/index.tailwind.tsx +294 -0
  15. package/data/registry/react/components/dialog/index.tailwind.tsx +96 -0
  16. package/data/registry/react/components/dropdown-menu/index.tailwind.tsx +205 -0
  17. package/data/registry/react/components/input/index.tailwind.tsx +405 -0
  18. package/data/registry/react/components/label/index.tailwind.tsx +78 -0
  19. package/data/registry/react/components/menubar/index.tailwind.tsx +32 -0
  20. package/data/registry/react/components/numeric-input/index.tailwind.tsx +113 -0
  21. package/data/registry/react/components/page-toc/index.tailwind.tsx +149 -0
  22. package/data/registry/react/components/pagination/index.tailwind.tsx +148 -0
  23. package/data/registry/react/components/popover/index.tailwind.tsx +77 -0
  24. package/data/registry/react/components/progress/index.tailwind.tsx +60 -0
  25. package/data/registry/react/components/radio/index.tailwind.tsx +54 -0
  26. package/data/registry/react/components/select/index.tailwind.tsx +199 -0
  27. package/data/registry/react/components/separator/index.tailwind.tsx +42 -0
  28. package/data/registry/react/components/skeleton/index.tailwind.tsx +39 -0
  29. package/data/registry/react/components/slider/index.tailwind.tsx +255 -0
  30. package/data/registry/react/components/spinner/index.tailwind.tsx +63 -0
  31. package/data/registry/react/components/switch/index.tailwind.tsx +62 -0
  32. package/data/registry/react/components/tabs/index.tailwind.tsx +113 -0
  33. package/data/registry/react/components/textarea/index.tailwind.tsx +21 -0
  34. package/data/registry/react/components/toggle/index.tailwind.tsx +111 -0
  35. package/data/registry/react/components/tooltip/index.tailwind.tsx +55 -0
  36. package/data/registry/react/peer-versions.json +1 -0
  37. package/data/registry/react/registry.json +530 -72
  38. package/data/tokens/build.mjs +66 -0
  39. package/package.json +1 -1
  40. package/src/add.mjs +54 -6
  41. package/src/api.d.ts +14 -0
  42. package/src/api.js +4 -0
  43. package/src/constants.js +19 -0
  44. package/src/create/cli-args.js +18 -2
  45. package/src/create/generator.js +55 -6
  46. package/src/create/index.mjs +3 -1
  47. package/src/init.mjs +25 -7
  48. package/src/mcp.mjs +13 -2
  49. package/templates/flutter-standalone/sh-ui.config.json +1 -1
  50. package/templates/nextjs-standalone/app/globals.css +1 -21
  51. package/templates/nextjs-standalone/sh-ui.config.json +1 -1
  52. package/templates/ui-app-template/sh-ui.config.json +1 -1
  53. 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";