sh-ui-cli 0.48.0 → 0.50.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 (93) hide show
  1. package/data/changelog/versions.json +27 -0
  2. package/data/registry/react/components/accordion/index.vanilla-extract.tsx +97 -0
  3. package/data/registry/react/components/accordion/styles.css.ts +131 -0
  4. package/data/registry/react/components/avatar/index.vanilla-extract.tsx +73 -0
  5. package/data/registry/react/components/avatar/styles.css.ts +68 -0
  6. package/data/registry/react/components/badge/index.vanilla-extract.tsx +40 -0
  7. package/data/registry/react/components/badge/styles.css.ts +71 -0
  8. package/data/registry/react/components/breadcrumb/index.vanilla-extract.tsx +152 -0
  9. package/data/registry/react/components/breadcrumb/styles.css.ts +95 -0
  10. package/data/registry/react/components/button/index.vanilla-extract.tsx +45 -0
  11. package/data/registry/react/components/button/styles.css.ts +120 -0
  12. package/data/registry/react/components/calendar/index.vanilla-extract.tsx +806 -0
  13. package/data/registry/react/components/calendar/styles.css.ts +250 -0
  14. package/data/registry/react/components/card/index.vanilla-extract.tsx +63 -0
  15. package/data/registry/react/components/card/styles.css.ts +88 -0
  16. package/data/registry/react/components/carousel/index.vanilla-extract.tsx +430 -0
  17. package/data/registry/react/components/carousel/styles.css.ts +169 -0
  18. package/data/registry/react/components/checkbox/index.vanilla-extract.tsx +96 -0
  19. package/data/registry/react/components/checkbox/styles.css.ts +74 -0
  20. package/data/registry/react/components/code-editor/index.vanilla-extract.tsx +230 -0
  21. package/data/registry/react/components/code-editor/styles.css.ts +97 -0
  22. package/data/registry/react/components/code-panel/index.vanilla-extract.tsx +191 -0
  23. package/data/registry/react/components/code-panel/styles.css.ts +151 -0
  24. package/data/registry/react/components/color-picker/index.vanilla-extract.tsx +467 -0
  25. package/data/registry/react/components/color-picker/styles.css.ts +169 -0
  26. package/data/registry/react/components/combobox/index.vanilla-extract.tsx +165 -0
  27. package/data/registry/react/components/combobox/styles.css.ts +174 -0
  28. package/data/registry/react/components/context-menu/index.vanilla-extract.tsx +251 -0
  29. package/data/registry/react/components/context-menu/styles.css.ts +167 -0
  30. package/data/registry/react/components/date-picker/index.vanilla-extract.tsx +520 -0
  31. package/data/registry/react/components/date-picker/styles.css.ts +111 -0
  32. package/data/registry/react/components/dialog/index.vanilla-extract.tsx +95 -0
  33. package/data/registry/react/components/dialog/styles.css.ts +140 -0
  34. package/data/registry/react/components/dropdown-menu/index.vanilla-extract.tsx +255 -0
  35. package/data/registry/react/components/dropdown-menu/styles.css.ts +175 -0
  36. package/data/registry/react/components/file-upload/index.vanilla-extract.tsx +487 -0
  37. package/data/registry/react/components/file-upload/styles.css.ts +193 -0
  38. package/data/registry/react/components/form/index.vanilla-extract.tsx +61 -0
  39. package/data/registry/react/components/form/styles.css.ts +56 -0
  40. package/data/registry/react/components/header/index.vanilla-extract.tsx +805 -0
  41. package/data/registry/react/components/header/styles.css.ts +413 -0
  42. package/data/registry/react/components/input/index.vanilla-extract.tsx +425 -0
  43. package/data/registry/react/components/input/styles.css.ts +202 -0
  44. package/data/registry/react/components/label/index.vanilla-extract.tsx +52 -0
  45. package/data/registry/react/components/label/styles.css.ts +141 -0
  46. package/data/registry/react/components/markdown-editor/index.vanilla-extract.tsx +119 -0
  47. package/data/registry/react/components/markdown-editor/styles.css.ts +231 -0
  48. package/data/registry/react/components/menubar/index.vanilla-extract.tsx +32 -0
  49. package/data/registry/react/components/menubar/styles.css.ts +53 -0
  50. package/data/registry/react/components/numeric-input/index.vanilla-extract.tsx +148 -0
  51. package/data/registry/react/components/numeric-input/styles.css.ts +65 -0
  52. package/data/registry/react/components/page-toc/index.vanilla-extract.tsx +174 -0
  53. package/data/registry/react/components/page-toc/styles.css.ts +97 -0
  54. package/data/registry/react/components/pagination/index.vanilla-extract.tsx +269 -0
  55. package/data/registry/react/components/pagination/styles.css.ts +113 -0
  56. package/data/registry/react/components/popover/index.vanilla-extract.tsx +113 -0
  57. package/data/registry/react/components/popover/styles.css.ts +78 -0
  58. package/data/registry/react/components/progress/index.vanilla-extract.tsx +54 -0
  59. package/data/registry/react/components/progress/styles.css.ts +53 -0
  60. package/data/registry/react/components/radio/index.vanilla-extract.tsx +65 -0
  61. package/data/registry/react/components/radio/styles.css.ts +79 -0
  62. package/data/registry/react/components/rich-text-editor/index.vanilla-extract.tsx +348 -0
  63. package/data/registry/react/components/rich-text-editor/styles.css.ts +243 -0
  64. package/data/registry/react/components/select/index.vanilla-extract.tsx +234 -0
  65. package/data/registry/react/components/select/styles.css.ts +225 -0
  66. package/data/registry/react/components/separator/index.vanilla-extract.tsx +46 -0
  67. package/data/registry/react/components/separator/styles.css.ts +24 -0
  68. package/data/registry/react/components/sidebar/index.vanilla-extract.tsx +1067 -0
  69. package/data/registry/react/components/sidebar/styles.css.ts +578 -0
  70. package/data/registry/react/components/skeleton/index.vanilla-extract.tsx +22 -0
  71. package/data/registry/react/components/skeleton/styles.css.ts +30 -0
  72. package/data/registry/react/components/slider/index.vanilla-extract.tsx +298 -0
  73. package/data/registry/react/components/slider/styles.css.ts +75 -0
  74. package/data/registry/react/components/spinner/index.vanilla-extract.tsx +38 -0
  75. package/data/registry/react/components/spinner/styles.css.ts +60 -0
  76. package/data/registry/react/components/switch/index.vanilla-extract.tsx +39 -0
  77. package/data/registry/react/components/switch/styles.css.ts +87 -0
  78. package/data/registry/react/components/tabs/index.vanilla-extract.tsx +91 -0
  79. package/data/registry/react/components/tabs/styles.css.ts +145 -0
  80. package/data/registry/react/components/textarea/index.vanilla-extract.tsx +23 -0
  81. package/data/registry/react/components/textarea/styles.css.ts +55 -0
  82. package/data/registry/react/components/toast/index.vanilla-extract.tsx +258 -0
  83. package/data/registry/react/components/toast/styles.css.ts +307 -0
  84. package/data/registry/react/components/toggle/index.vanilla-extract.tsx +131 -0
  85. package/data/registry/react/components/toggle/styles.css.ts +109 -0
  86. package/data/registry/react/components/tooltip/index.vanilla-extract.tsx +83 -0
  87. package/data/registry/react/components/tooltip/styles.css.ts +59 -0
  88. package/data/registry/react/peer-versions.json +1 -0
  89. package/data/registry/react/registry.json +922 -42
  90. package/data/tokens/build.mjs +3 -0
  91. package/package.json +1 -1
  92. package/src/api.d.ts +4 -3
  93. package/src/constants.js +4 -3
@@ -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
+ });
@@ -0,0 +1,52 @@
1
+ import * as React from "react";
2
+ import { byKey, label, label__subtitle, label__description, label__caption, label__title } from "./styles.css";
3
+
4
+ import { cn } from "@SH_UI_UTILS@";
5
+ export interface LabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {
6
+ /**
7
+ * 필수 필드 표시. `true`면 `::after`로 `*` 표시가 붙는다.
8
+ * Input에 `required` 속성이 있으면 CSS `:has()`로 자동 감지되므로 보통 명시 불필요.
9
+ *
10
+ * @default false
11
+ */
12
+ isRequired?: boolean;
13
+ }
14
+
15
+
16
+ /**
17
+ * 폼 컨트롤과 1:1로 연결되는 레이블. `htmlFor`로 컨트롤의 `id`와 매칭하거나
18
+ * Label 안에 컨트롤을 감싸 묵시적으로 연결한다. 클릭 시 자동으로 컨트롤에 포커스가 간다.
19
+ */
20
+ export const Label = React.forwardRef<HTMLLabelElement, LabelProps>(
21
+ ({ className, children, isRequired, ...props }, ref) => (
22
+ <label
23
+ ref={ref}
24
+ className={cn(label, className)}
25
+ data-required={isRequired || undefined}
26
+ {...props}
27
+ >
28
+ {children}
29
+ </label>
30
+ ),
31
+ );
32
+ Label.displayName = "Label";
33
+
34
+ /** Label 안의 주 라벨 텍스트. 구조적 그룹핑이 필요할 때 Label과 함께 사용. */
35
+ export function LabelTitle({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) {
36
+ return <span className={cn(label__title, className)} {...props} />;
37
+ }
38
+
39
+ /** 라벨 옆에 약하게 표시되는 보조 텍스트(예: "선택 사항"). */
40
+ export function LabelSubtitle({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) {
41
+ return <span className={cn(label__subtitle, className)} {...props} />;
42
+ }
43
+
44
+ /** 라벨 아래에 붙는 안내 문구. 컨트롤과 `aria-describedby`로 연결할 것. */
45
+ export function LabelDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
46
+ return <p className={cn(label__description, className)} {...props} />;
47
+ }
48
+
49
+ /** 라벨 아래의 보조 캡션(예: 입력 형식 예시, 글자 수 제한). */
50
+ export function LabelCaption({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
51
+ return <p className={cn(label__caption, className)} {...props} />;
52
+ }