sh-ui-cli 0.45.2 → 0.46.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/data/changelog/versions.json +26 -0
- package/data/registry/react/components/accordion/index.tailwind.tsx +5 -7
- package/data/registry/react/components/accordion/index.tsx +5 -7
- package/data/registry/react/components/avatar/index.tailwind.tsx +4 -6
- package/data/registry/react/components/avatar/index.tsx +4 -6
- package/data/registry/react/components/badge/index.tailwind.tsx +2 -4
- package/data/registry/react/components/badge/index.tsx +2 -4
- package/data/registry/react/components/breadcrumb/index.tailwind.tsx +8 -10
- package/data/registry/react/components/breadcrumb/index.tsx +8 -10
- package/data/registry/react/components/button/index.module.tsx +45 -0
- package/data/registry/react/components/button/index.tailwind.tsx +2 -1
- package/data/registry/react/components/button/index.tsx +3 -4
- package/data/registry/react/components/button/styles.module.css +92 -0
- package/data/registry/react/components/calendar/index.tailwind.tsx +10 -12
- package/data/registry/react/components/calendar/index.tsx +9 -11
- package/data/registry/react/components/card/index.module.tsx +63 -0
- package/data/registry/react/components/card/index.tailwind.tsx +8 -10
- package/data/registry/react/components/card/index.tsx +8 -10
- package/data/registry/react/components/card/styles.module.css +73 -0
- package/data/registry/react/components/carousel/index.tailwind.tsx +7 -9
- package/data/registry/react/components/carousel/index.tsx +7 -9
- package/data/registry/react/components/checkbox/index.tailwind.tsx +3 -5
- package/data/registry/react/components/checkbox/index.tsx +3 -5
- package/data/registry/react/components/code-editor/index.tailwind.tsx +2 -4
- package/data/registry/react/components/code-editor/index.tsx +2 -4
- package/data/registry/react/components/code-panel/index.tailwind.tsx +5 -7
- package/data/registry/react/components/code-panel/index.tsx +5 -7
- package/data/registry/react/components/color-picker/index.tailwind.tsx +7 -6
- package/data/registry/react/components/color-picker/index.tsx +7 -6
- package/data/registry/react/components/combobox/index.tailwind.tsx +8 -10
- package/data/registry/react/components/combobox/index.tsx +8 -10
- package/data/registry/react/components/context-menu/index.tailwind.tsx +10 -12
- package/data/registry/react/components/context-menu/index.tsx +10 -12
- package/data/registry/react/components/date-picker/index.tailwind.tsx +7 -9
- package/data/registry/react/components/date-picker/index.tsx +7 -9
- package/data/registry/react/components/dialog/index.tailwind.tsx +6 -8
- package/data/registry/react/components/dialog/index.tsx +6 -8
- package/data/registry/react/components/dropdown-menu/index.tailwind.tsx +10 -12
- package/data/registry/react/components/dropdown-menu/index.tsx +10 -12
- package/data/registry/react/components/file-upload/index.tailwind.tsx +6 -8
- package/data/registry/react/components/file-upload/index.tsx +6 -8
- package/data/registry/react/components/form/field.tailwind.tsx +2 -1
- package/data/registry/react/components/form/field.tsx +2 -3
- package/data/registry/react/components/header/index.tailwind.tsx +17 -19
- package/data/registry/react/components/header/index.tsx +17 -19
- package/data/registry/react/components/input/index.module.tsx +486 -0
- package/data/registry/react/components/input/index.tailwind.tsx +4 -6
- package/data/registry/react/components/input/index.tsx +4 -6
- package/data/registry/react/components/input/styles.module.css +200 -0
- package/data/registry/react/components/label/index.tailwind.tsx +6 -8
- package/data/registry/react/components/label/index.tsx +6 -8
- package/data/registry/react/components/markdown-editor/index.tailwind.tsx +2 -4
- package/data/registry/react/components/markdown-editor/index.tsx +2 -4
- package/data/registry/react/components/menubar/index.tailwind.tsx +2 -4
- package/data/registry/react/components/menubar/index.tsx +2 -4
- package/data/registry/react/components/numeric-input/index.tailwind.tsx +2 -4
- package/data/registry/react/components/numeric-input/index.tsx +2 -4
- package/data/registry/react/components/page-toc/index.tailwind.tsx +3 -2
- package/data/registry/react/components/page-toc/index.tsx +2 -3
- package/data/registry/react/components/pagination/index.tailwind.tsx +8 -10
- package/data/registry/react/components/pagination/index.tsx +8 -10
- package/data/registry/react/components/popover/index.tailwind.tsx +4 -6
- package/data/registry/react/components/popover/index.tsx +4 -6
- package/data/registry/react/components/progress/index.tailwind.tsx +3 -5
- package/data/registry/react/components/progress/index.tsx +2 -4
- package/data/registry/react/components/radio/index.tailwind.tsx +3 -5
- package/data/registry/react/components/radio/index.tsx +3 -5
- package/data/registry/react/components/rich-text-editor/index.tailwind.tsx +3 -5
- package/data/registry/react/components/rich-text-editor/index.tsx +3 -5
- package/data/registry/react/components/select/index.tailwind.tsx +8 -10
- package/data/registry/react/components/select/index.tsx +8 -10
- package/data/registry/react/components/separator/index.tailwind.tsx +2 -4
- package/data/registry/react/components/separator/index.tsx +2 -4
- package/data/registry/react/components/sidebar/index.tailwind.tsx +32 -43
- package/data/registry/react/components/sidebar/index.tsx +29 -46
- package/data/registry/react/components/skeleton/index.tailwind.tsx +2 -4
- package/data/registry/react/components/skeleton/index.tsx +2 -4
- package/data/registry/react/components/slider/index.tailwind.tsx +5 -7
- package/data/registry/react/components/slider/index.tsx +5 -7
- package/data/registry/react/components/spinner/index.tailwind.tsx +3 -5
- package/data/registry/react/components/spinner/index.tsx +2 -4
- package/data/registry/react/components/switch/index.tailwind.tsx +3 -5
- package/data/registry/react/components/switch/index.tsx +2 -4
- package/data/registry/react/components/tabs/index.tailwind.tsx +6 -8
- package/data/registry/react/components/tabs/index.tsx +6 -8
- package/data/registry/react/components/textarea/index.tailwind.tsx +2 -4
- package/data/registry/react/components/textarea/index.tsx +2 -4
- package/data/registry/react/components/toggle/index.tailwind.tsx +4 -6
- package/data/registry/react/components/toggle/index.tsx +4 -6
- package/data/registry/react/components/tooltip/index.tailwind.tsx +2 -4
- package/data/registry/react/components/tooltip/index.tsx +2 -4
- package/data/registry/react/lib/cn.tailwind.ts +17 -0
- package/data/registry/react/peer-versions.json +3 -1
- package/data/registry/react/registry.json +202 -43
- package/data/tokens/build.mjs +4 -0
- package/package.json +1 -1
- package/src/add.mjs +37 -13
- package/templates/ui-app-template/sh-ui.config.json +5 -0
|
@@ -4,10 +4,8 @@ import * as React from "react";
|
|
|
4
4
|
import { createPortal } from "react-dom";
|
|
5
5
|
import "./styles.css";
|
|
6
6
|
|
|
7
|
-
function cx(...args: (string | undefined | false | null)[]) {
|
|
8
|
-
return args.filter(Boolean).join(" ");
|
|
9
|
-
}
|
|
10
7
|
|
|
8
|
+
import { cn } from "@SH_UI_UTILS@";
|
|
11
9
|
const FOCUSABLE_SELECTOR =
|
|
12
10
|
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
13
11
|
|
|
@@ -264,7 +262,7 @@ export const Header = React.forwardRef<HTMLElement, HeaderProps>(function Header
|
|
|
264
262
|
<HeaderContext.Provider value={ctx}>
|
|
265
263
|
<header
|
|
266
264
|
ref={setRefs}
|
|
267
|
-
className={
|
|
265
|
+
className={cn("sh-ui-header", `sh-ui-header--${variant}`, className)}
|
|
268
266
|
data-drawer-open={open ? "" : undefined}
|
|
269
267
|
data-sticky-hide={stickyHide ? "" : undefined}
|
|
270
268
|
data-hidden={hidden ? "" : undefined}
|
|
@@ -282,21 +280,21 @@ export const HeaderBrand = React.forwardRef<
|
|
|
282
280
|
HTMLDivElement,
|
|
283
281
|
React.HTMLAttributes<HTMLDivElement>
|
|
284
282
|
>(function HeaderBrand({ className, ...props }, ref) {
|
|
285
|
-
return <div ref={ref} className={
|
|
283
|
+
return <div ref={ref} className={cn("sh-ui-header__brand", className)} {...props} />;
|
|
286
284
|
});
|
|
287
285
|
|
|
288
286
|
export const HeaderLogo = React.forwardRef<
|
|
289
287
|
HTMLSpanElement,
|
|
290
288
|
React.HTMLAttributes<HTMLSpanElement>
|
|
291
289
|
>(function HeaderLogo({ className, ...props }, ref) {
|
|
292
|
-
return <span ref={ref} className={
|
|
290
|
+
return <span ref={ref} className={cn("sh-ui-header__logo", className)} {...props} />;
|
|
293
291
|
});
|
|
294
292
|
|
|
295
293
|
export const HeaderTitle = React.forwardRef<
|
|
296
294
|
HTMLSpanElement,
|
|
297
295
|
React.HTMLAttributes<HTMLSpanElement>
|
|
298
296
|
>(function HeaderTitle({ className, ...props }, ref) {
|
|
299
|
-
return <span ref={ref} className={
|
|
297
|
+
return <span ref={ref} className={cn("sh-ui-header__title", className)} {...props} />;
|
|
300
298
|
});
|
|
301
299
|
|
|
302
300
|
/* ───────── Trigger ─────────
|
|
@@ -322,7 +320,7 @@ export const HeaderTrigger = React.forwardRef<
|
|
|
322
320
|
<button
|
|
323
321
|
ref={setRefs}
|
|
324
322
|
type="button"
|
|
325
|
-
className={
|
|
323
|
+
className={cn("sh-ui-header__trigger", className)}
|
|
326
324
|
aria-label={open ? "메뉴 닫기" : "메뉴 열기"}
|
|
327
325
|
aria-expanded={open}
|
|
328
326
|
data-open={open ? "" : undefined}
|
|
@@ -399,7 +397,7 @@ export const HeaderNav = React.forwardRef<HTMLElement, HeaderNavProps>(
|
|
|
399
397
|
return (
|
|
400
398
|
<NavMatchContext.Provider value={navMatch}>
|
|
401
399
|
<NavLocationContext.Provider value="inline">
|
|
402
|
-
<nav ref={ref} className={
|
|
400
|
+
<nav ref={ref} className={cn("sh-ui-header__nav", className)} {...props}>
|
|
403
401
|
{children}
|
|
404
402
|
</nav>
|
|
405
403
|
</NavLocationContext.Provider>
|
|
@@ -457,7 +455,7 @@ export const HeaderItem = React.forwardRef<
|
|
|
457
455
|
<a
|
|
458
456
|
ref={ref}
|
|
459
457
|
href={href}
|
|
460
|
-
className={
|
|
458
|
+
className={cn("sh-ui-header__item", className)}
|
|
461
459
|
data-active={computedActive ? "" : undefined}
|
|
462
460
|
aria-current={computedActive ? "page" : undefined}
|
|
463
461
|
onClick={(e) => {
|
|
@@ -478,7 +476,7 @@ export const HeaderActions = React.forwardRef<
|
|
|
478
476
|
React.HTMLAttributes<HTMLDivElement>
|
|
479
477
|
>(function HeaderActions({ className, ...props }, ref) {
|
|
480
478
|
return (
|
|
481
|
-
<div ref={ref} className={
|
|
479
|
+
<div ref={ref} className={cn("sh-ui-header__actions", className)} {...props} />
|
|
482
480
|
);
|
|
483
481
|
});
|
|
484
482
|
|
|
@@ -493,7 +491,7 @@ export const HeaderDesktopOnly = React.forwardRef<
|
|
|
493
491
|
React.HTMLAttributes<HTMLDivElement>
|
|
494
492
|
>(function HeaderDesktopOnly({ className, ...props }, ref) {
|
|
495
493
|
return (
|
|
496
|
-
<div ref={ref} className={
|
|
494
|
+
<div ref={ref} className={cn("sh-ui-header__desktop-only", className)} {...props} />
|
|
497
495
|
);
|
|
498
496
|
});
|
|
499
497
|
|
|
@@ -503,7 +501,7 @@ export const HeaderMobileOnly = React.forwardRef<
|
|
|
503
501
|
React.HTMLAttributes<HTMLDivElement>
|
|
504
502
|
>(function HeaderMobileOnly({ className, ...props }, ref) {
|
|
505
503
|
return (
|
|
506
|
-
<div ref={ref} className={
|
|
504
|
+
<div ref={ref} className={cn("sh-ui-header__mobile-only", className)} {...props} />
|
|
507
505
|
);
|
|
508
506
|
});
|
|
509
507
|
|
|
@@ -524,7 +522,7 @@ export const HeaderNavGroup = React.forwardRef<HTMLDivElement, HeaderNavGroupPro
|
|
|
524
522
|
return (
|
|
525
523
|
<div
|
|
526
524
|
ref={ref}
|
|
527
|
-
className={
|
|
525
|
+
className={cn("sh-ui-header__group sh-ui-header__group--inline", className)}
|
|
528
526
|
{...props}
|
|
529
527
|
>
|
|
530
528
|
{children}
|
|
@@ -534,7 +532,7 @@ export const HeaderNavGroup = React.forwardRef<HTMLDivElement, HeaderNavGroupPro
|
|
|
534
532
|
return (
|
|
535
533
|
<div
|
|
536
534
|
ref={ref}
|
|
537
|
-
className={
|
|
535
|
+
className={cn("sh-ui-header__group sh-ui-header__group--drawer", className)}
|
|
538
536
|
role="group"
|
|
539
537
|
aria-label={typeof label === "string" ? label : undefined}
|
|
540
538
|
{...props}
|
|
@@ -626,7 +624,7 @@ export function HeaderMenu({
|
|
|
626
624
|
<MenuContext.Provider value={ctx}>
|
|
627
625
|
<div
|
|
628
626
|
ref={containerRef}
|
|
629
|
-
className={
|
|
627
|
+
className={cn(
|
|
630
628
|
"sh-ui-header__menu",
|
|
631
629
|
`sh-ui-header__menu--${location}`,
|
|
632
630
|
open && "is-open",
|
|
@@ -665,7 +663,7 @@ export const HeaderMenuTrigger = React.forwardRef<
|
|
|
665
663
|
aria-expanded={open}
|
|
666
664
|
aria-controls={contentId}
|
|
667
665
|
data-open={open ? "" : undefined}
|
|
668
|
-
className={
|
|
666
|
+
className={cn("sh-ui-header__menu-trigger", className)}
|
|
669
667
|
onClick={(e) => {
|
|
670
668
|
setOpen(!open);
|
|
671
669
|
onClick?.(e);
|
|
@@ -704,7 +702,7 @@ export const HeaderMenuContent = React.forwardRef<
|
|
|
704
702
|
aria-labelledby={triggerId}
|
|
705
703
|
data-open={open ? "" : undefined}
|
|
706
704
|
hidden={!open}
|
|
707
|
-
className={
|
|
705
|
+
className={cn("sh-ui-header__menu-content", className)}
|
|
708
706
|
style={style}
|
|
709
707
|
{...props}
|
|
710
708
|
>
|
|
@@ -754,7 +752,7 @@ export const HeaderMenuContent = React.forwardRef<
|
|
|
754
752
|
role="menu"
|
|
755
753
|
aria-labelledby={triggerId}
|
|
756
754
|
data-open=""
|
|
757
|
-
className={
|
|
755
|
+
className={cn("sh-ui-header__menu-content sh-ui-header__menu-content--portal", className)}
|
|
758
756
|
style={{
|
|
759
757
|
position: "absolute",
|
|
760
758
|
top: pos.top,
|
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { cn } from "@SH_UI_UTILS@";
|
|
5
|
+
import styles from "./styles.module.css";
|
|
6
|
+
|
|
7
|
+
export interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "prefix"> {
|
|
8
|
+
/** input 우측에 부착할 보조 노드(아이콘·단위·버튼 등). 더 많은 슬롯이 필요하면 InputGroup 사용. */
|
|
9
|
+
suffix?: React.ReactNode;
|
|
10
|
+
/** input 좌측에 부착할 보조 노드. */
|
|
11
|
+
prefix?: React.ReactNode;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/* ───────── InputGroup + InputAdornment (compound) ─────────
|
|
15
|
+
* <InputGroup>
|
|
16
|
+
* <InputAdornment><SearchIcon /></InputAdornment>
|
|
17
|
+
* <Input placeholder="검색..." />
|
|
18
|
+
* <InputAdornment><ClearButton /></InputAdornment>
|
|
19
|
+
* </InputGroup>
|
|
20
|
+
*
|
|
21
|
+
* InputGroup이 공용 보더/포커스 링을 담당하고, 내부 Input은 자신의 보더를
|
|
22
|
+
* 감춘다(data-in-group 기반). Adornment 위치는 children 순서로 결정한다.
|
|
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
|
+
* 그룹 영역 어디를 클릭해도 내부 input에 포커스가 이동하고, `aria-invalid`/`disabled`가 자식 전체에 전파된다.
|
|
43
|
+
*/
|
|
44
|
+
export const InputGroup = React.forwardRef<HTMLDivElement, InputGroupProps>(
|
|
45
|
+
(
|
|
46
|
+
{
|
|
47
|
+
className,
|
|
48
|
+
children,
|
|
49
|
+
"aria-invalid": ariaInvalid,
|
|
50
|
+
disabled,
|
|
51
|
+
onClick,
|
|
52
|
+
...props
|
|
53
|
+
},
|
|
54
|
+
ref,
|
|
55
|
+
) => {
|
|
56
|
+
const innerRef = React.useRef<HTMLDivElement | null>(null);
|
|
57
|
+
const mergedRef = React.useCallback(
|
|
58
|
+
(el: HTMLDivElement | null) => {
|
|
59
|
+
innerRef.current = el;
|
|
60
|
+
if (typeof ref === "function") ref(el);
|
|
61
|
+
else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = el;
|
|
62
|
+
},
|
|
63
|
+
[ref],
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
67
|
+
onClick?.(e);
|
|
68
|
+
if (e.defaultPrevented) return;
|
|
69
|
+
const target = e.target as HTMLElement;
|
|
70
|
+
if (target.closest("button, input, textarea, select, a")) return;
|
|
71
|
+
const input = innerRef.current?.querySelector<HTMLInputElement>("input");
|
|
72
|
+
input?.focus();
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<InputGroupContext.Provider value={{ inGroup: true }}>
|
|
77
|
+
<div
|
|
78
|
+
ref={mergedRef}
|
|
79
|
+
className={cn(styles.group, className)}
|
|
80
|
+
data-disabled={disabled || undefined}
|
|
81
|
+
aria-invalid={ariaInvalid}
|
|
82
|
+
onClick={handleClick}
|
|
83
|
+
{...props}
|
|
84
|
+
>
|
|
85
|
+
{children}
|
|
86
|
+
</div>
|
|
87
|
+
</InputGroupContext.Provider>
|
|
88
|
+
);
|
|
89
|
+
},
|
|
90
|
+
);
|
|
91
|
+
InputGroup.displayName = "InputGroup";
|
|
92
|
+
|
|
93
|
+
export interface InputAdornmentProps extends React.HTMLAttributes<HTMLSpanElement> {
|
|
94
|
+
/**
|
|
95
|
+
* 클릭이 input으로 버블링되지 않도록 한다. 버튼·체크박스 등 인터랙티브 요소를
|
|
96
|
+
* Adornment에 담을 때 켤 것 — 그러지 않으면 클릭이 input 포커스로 가로채진다.
|
|
97
|
+
*
|
|
98
|
+
* @default false
|
|
99
|
+
*/
|
|
100
|
+
interactive?: boolean;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* InputGroup 안에 들어가는 보조 슬롯. 위치는 children 순서로 결정한다.
|
|
105
|
+
* 버튼 등 인터랙티브 요소를 담을 때는 `interactive`를 켜 input 포커스 가로채기를 막을 것.
|
|
106
|
+
*/
|
|
107
|
+
export const InputAdornment = React.forwardRef<HTMLSpanElement, InputAdornmentProps>(
|
|
108
|
+
({ className, interactive, ...props }, ref) => {
|
|
109
|
+
return (
|
|
110
|
+
<span
|
|
111
|
+
ref={ref}
|
|
112
|
+
className={cn(styles.adornment, className)}
|
|
113
|
+
data-interactive={interactive || undefined}
|
|
114
|
+
{...props}
|
|
115
|
+
/>
|
|
116
|
+
);
|
|
117
|
+
},
|
|
118
|
+
);
|
|
119
|
+
InputAdornment.displayName = "InputAdornment";
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 한 줄 텍스트 입력. `prefix`/`suffix`로 아이콘이나 단위 등을 한 input 안에 붙일 수 있고,
|
|
123
|
+
* 더 많은 보조 요소가 필요하면 `InputGroup`+`InputAdornment` 조합을 사용한다.
|
|
124
|
+
*/
|
|
125
|
+
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|
126
|
+
({ className, type = "text", prefix, suffix, ...props }, ref) => {
|
|
127
|
+
const group = useInputGroup();
|
|
128
|
+
const hasAffix = Boolean(prefix || suffix);
|
|
129
|
+
const input = (
|
|
130
|
+
<input
|
|
131
|
+
ref={ref}
|
|
132
|
+
type={type}
|
|
133
|
+
className={cn(
|
|
134
|
+
styles.input,
|
|
135
|
+
!!prefix && styles.withPrefix,
|
|
136
|
+
!!suffix && styles.withSuffix,
|
|
137
|
+
className,
|
|
138
|
+
)}
|
|
139
|
+
data-in-group={group ? "" : undefined}
|
|
140
|
+
{...props}
|
|
141
|
+
/>
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
if (!hasAffix) return input;
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<div className={styles.inputWrap} data-in-group={group ? "" : undefined}>
|
|
148
|
+
{prefix && (
|
|
149
|
+
<span className={cn(styles.affix, styles.affixPrefix)}>{prefix}</span>
|
|
150
|
+
)}
|
|
151
|
+
{input}
|
|
152
|
+
{suffix && (
|
|
153
|
+
<span className={cn(styles.affix, styles.affixSuffix)}>{suffix}</span>
|
|
154
|
+
)}
|
|
155
|
+
</div>
|
|
156
|
+
);
|
|
157
|
+
},
|
|
158
|
+
);
|
|
159
|
+
Input.displayName = "Input";
|
|
160
|
+
|
|
161
|
+
/* ───────── PasswordInput ───────── */
|
|
162
|
+
|
|
163
|
+
function EyeIcon() {
|
|
164
|
+
return (
|
|
165
|
+
<svg viewBox="0 0 20 20" width="16" height="16" fill="none" aria-hidden>
|
|
166
|
+
<path
|
|
167
|
+
d="M2 10s3-5.5 8-5.5S18 10 18 10s-3 5.5-8 5.5S2 10 2 10Z"
|
|
168
|
+
stroke="currentColor"
|
|
169
|
+
strokeWidth="1.5"
|
|
170
|
+
/>
|
|
171
|
+
<circle cx="10" cy="10" r="2.25" stroke="currentColor" strokeWidth="1.5" />
|
|
172
|
+
</svg>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function EyeOffIcon() {
|
|
177
|
+
return (
|
|
178
|
+
<svg viewBox="0 0 20 20" width="16" height="16" fill="none" aria-hidden>
|
|
179
|
+
<path
|
|
180
|
+
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"
|
|
181
|
+
stroke="currentColor"
|
|
182
|
+
strokeWidth="1.5"
|
|
183
|
+
strokeLinecap="round"
|
|
184
|
+
/>
|
|
185
|
+
</svg>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export interface PasswordInputProps extends Omit<InputProps, "type" | "suffix"> {
|
|
190
|
+
/**
|
|
191
|
+
* 비밀번호 표시 토글 버튼을 숨긴다. 비밀번호를 절대 노출하면 안 되는 화면(결제 등)에서 사용.
|
|
192
|
+
*
|
|
193
|
+
* @default false
|
|
194
|
+
*/
|
|
195
|
+
hideToggle?: boolean;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* 비밀번호 입력. 기본으로 표시 토글 버튼이 suffix에 부착되며 `hideToggle`로 숨길 수 있다.
|
|
200
|
+
* 토글은 `aria-pressed`로 상태가 노출되고 Tab 흐름에서 제외(`tabIndex=-1`)된다.
|
|
201
|
+
*/
|
|
202
|
+
export const PasswordInput = React.forwardRef<HTMLInputElement, PasswordInputProps>(
|
|
203
|
+
({ hideToggle, ...props }, ref) => {
|
|
204
|
+
const [visible, setVisible] = React.useState(false);
|
|
205
|
+
|
|
206
|
+
const toggle = hideToggle ? undefined : (
|
|
207
|
+
<button
|
|
208
|
+
type="button"
|
|
209
|
+
className={styles.toggle}
|
|
210
|
+
onClick={() => setVisible((v) => !v)}
|
|
211
|
+
aria-label={visible ? "비밀번호 숨기기" : "비밀번호 표시"}
|
|
212
|
+
aria-pressed={visible}
|
|
213
|
+
tabIndex={-1}
|
|
214
|
+
>
|
|
215
|
+
{visible ? <EyeOffIcon /> : <EyeIcon />}
|
|
216
|
+
</button>
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
return (
|
|
220
|
+
<Input
|
|
221
|
+
ref={ref}
|
|
222
|
+
type={visible ? "text" : "password"}
|
|
223
|
+
suffix={toggle}
|
|
224
|
+
{...props}
|
|
225
|
+
/>
|
|
226
|
+
);
|
|
227
|
+
},
|
|
228
|
+
);
|
|
229
|
+
PasswordInput.displayName = "PasswordInput";
|
|
230
|
+
|
|
231
|
+
/* ───────── NumberInput ─────────
|
|
232
|
+
* 정수 입력 + 천 단위 콤마(옵션). value/onValueChange는 number | undefined.
|
|
233
|
+
*/
|
|
234
|
+
|
|
235
|
+
export interface NumberInputProps
|
|
236
|
+
extends Omit<InputProps, "value" | "defaultValue" | "onChange" | "type"> {
|
|
237
|
+
/** 제어 모드 값. `undefined`는 빈 입력. */
|
|
238
|
+
value?: number;
|
|
239
|
+
/** 비제어 모드 초기값. */
|
|
240
|
+
defaultValue?: number;
|
|
241
|
+
/** 값 변경 콜백. 빈 입력일 때 `undefined`가 전달된다. */
|
|
242
|
+
onValueChange?: (value: number | undefined) => void;
|
|
243
|
+
/**
|
|
244
|
+
* 천 단위 콤마 자동 포맷.
|
|
245
|
+
* @default true
|
|
246
|
+
*/
|
|
247
|
+
thousandsSeparator?: boolean;
|
|
248
|
+
/** 허용 최솟값. blur 시 자동 클램프된다. */
|
|
249
|
+
min?: number;
|
|
250
|
+
/** 허용 최댓값. blur 시 자동 클램프된다. */
|
|
251
|
+
max?: number;
|
|
252
|
+
/**
|
|
253
|
+
* 음수 입력 허용 여부.
|
|
254
|
+
* @default true
|
|
255
|
+
*/
|
|
256
|
+
allowNegative?: boolean;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const formatNumber = (digits: string, thousandsSeparator: boolean): string => {
|
|
260
|
+
if (digits === "" || digits === "-") return digits;
|
|
261
|
+
const negative = digits.startsWith("-");
|
|
262
|
+
const body = negative ? digits.slice(1) : digits;
|
|
263
|
+
if (!body) return negative ? "-" : "";
|
|
264
|
+
const formatted = thousandsSeparator
|
|
265
|
+
? body.replace(/\B(?=(\d{3})+(?!\d))/g, ",")
|
|
266
|
+
: body;
|
|
267
|
+
return negative ? `-${formatted}` : formatted;
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const parseNumber = (s: string): number | undefined => {
|
|
271
|
+
const cleaned = s.replace(/[^\d-]/g, "");
|
|
272
|
+
if (!cleaned || cleaned === "-") return undefined;
|
|
273
|
+
const n = Number(cleaned);
|
|
274
|
+
return Number.isFinite(n) ? n : undefined;
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* 정수 입력 + 천 단위 콤마 자동 포맷. `value`는 `number | undefined`이고 표시 문자열과 분리되어 있다.
|
|
279
|
+
* blur 시 `min`/`max` 범위로 자동 클램프되며, 음수 허용은 `allowNegative`로 토글한다.
|
|
280
|
+
*/
|
|
281
|
+
export const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
|
|
282
|
+
(
|
|
283
|
+
{
|
|
284
|
+
value,
|
|
285
|
+
defaultValue,
|
|
286
|
+
onValueChange,
|
|
287
|
+
thousandsSeparator = true,
|
|
288
|
+
min,
|
|
289
|
+
max,
|
|
290
|
+
allowNegative = true,
|
|
291
|
+
onBlur,
|
|
292
|
+
...rest
|
|
293
|
+
},
|
|
294
|
+
ref,
|
|
295
|
+
) => {
|
|
296
|
+
const isControlled = value !== undefined;
|
|
297
|
+
const initial =
|
|
298
|
+
defaultValue !== undefined ? formatNumber(String(defaultValue), thousandsSeparator) : "";
|
|
299
|
+
const [internal, setInternal] = React.useState(initial);
|
|
300
|
+
|
|
301
|
+
const display = isControlled
|
|
302
|
+
? value === undefined
|
|
303
|
+
? ""
|
|
304
|
+
: formatNumber(String(value), thousandsSeparator)
|
|
305
|
+
: internal;
|
|
306
|
+
|
|
307
|
+
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
308
|
+
const raw = e.target.value;
|
|
309
|
+
const allowedRe = allowNegative ? /[^\d-]/g : /[^\d]/g;
|
|
310
|
+
let cleaned = raw.replace(allowedRe, "");
|
|
311
|
+
if (allowNegative) cleaned = cleaned.replace(/(?!^)-/g, "");
|
|
312
|
+
const formatted = formatNumber(cleaned, thousandsSeparator);
|
|
313
|
+
if (!isControlled) setInternal(formatted);
|
|
314
|
+
onValueChange?.(parseNumber(cleaned));
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
|
|
318
|
+
const n = parseNumber(display);
|
|
319
|
+
if (n !== undefined) {
|
|
320
|
+
let clamped = n;
|
|
321
|
+
if (min !== undefined && clamped < min) clamped = min;
|
|
322
|
+
if (max !== undefined && clamped > max) clamped = max;
|
|
323
|
+
if (clamped !== n) {
|
|
324
|
+
const f = formatNumber(String(clamped), thousandsSeparator);
|
|
325
|
+
if (!isControlled) setInternal(f);
|
|
326
|
+
onValueChange?.(clamped);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
onBlur?.(e);
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
return (
|
|
333
|
+
<Input
|
|
334
|
+
ref={ref}
|
|
335
|
+
type="text"
|
|
336
|
+
inputMode="numeric"
|
|
337
|
+
value={display}
|
|
338
|
+
onChange={handleChange}
|
|
339
|
+
onBlur={handleBlur}
|
|
340
|
+
{...rest}
|
|
341
|
+
/>
|
|
342
|
+
);
|
|
343
|
+
},
|
|
344
|
+
);
|
|
345
|
+
NumberInput.displayName = "NumberInput";
|
|
346
|
+
|
|
347
|
+
/* ───────── PhoneInput (KR) ───────── */
|
|
348
|
+
|
|
349
|
+
const formatPhoneKR = (digits: string): string => {
|
|
350
|
+
const d = digits.replace(/\D/g, "").slice(0, 11);
|
|
351
|
+
if (d.length === 0) return "";
|
|
352
|
+
|
|
353
|
+
if (d.startsWith("02")) {
|
|
354
|
+
if (d.length <= 2) return d;
|
|
355
|
+
if (d.length <= 5) return `${d.slice(0, 2)}-${d.slice(2)}`;
|
|
356
|
+
if (d.length <= 9) return `${d.slice(0, 2)}-${d.slice(2, 5)}-${d.slice(5)}`;
|
|
357
|
+
return `${d.slice(0, 2)}-${d.slice(2, 6)}-${d.slice(6, 10)}`;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (d.length <= 3) return d;
|
|
361
|
+
if (d.length <= 6) return `${d.slice(0, 3)}-${d.slice(3)}`;
|
|
362
|
+
if (d.length <= 10) return `${d.slice(0, 3)}-${d.slice(3, 6)}-${d.slice(6)}`;
|
|
363
|
+
return `${d.slice(0, 3)}-${d.slice(3, 7)}-${d.slice(7, 11)}`;
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
export interface PhoneInputProps
|
|
367
|
+
extends Omit<InputProps, "value" | "defaultValue" | "onChange" | "type"> {
|
|
368
|
+
value?: string;
|
|
369
|
+
defaultValue?: string;
|
|
370
|
+
onValueChange?: (digits: string) => void;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* 한국 휴대폰·지역번호용 자동 하이픈 입력(010/02/031 등). `onValueChange`는 하이픈을 뺀
|
|
375
|
+
* 숫자 문자열만 콜백한다. 국제화가 필요하면 별도 컴포넌트로 분리해 사용할 것.
|
|
376
|
+
*/
|
|
377
|
+
export const PhoneInput = React.forwardRef<HTMLInputElement, PhoneInputProps>(
|
|
378
|
+
({ value, defaultValue, onValueChange, onBlur, ...rest }, ref) => {
|
|
379
|
+
const isControlled = value !== undefined;
|
|
380
|
+
const initial = formatPhoneKR(defaultValue ?? "");
|
|
381
|
+
const [internal, setInternal] = React.useState(initial);
|
|
382
|
+
|
|
383
|
+
const display = isControlled ? formatPhoneKR(value ?? "") : internal;
|
|
384
|
+
|
|
385
|
+
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
386
|
+
const digits = e.target.value.replace(/\D/g, "").slice(0, 11);
|
|
387
|
+
const formatted = formatPhoneKR(digits);
|
|
388
|
+
if (!isControlled) setInternal(formatted);
|
|
389
|
+
onValueChange?.(digits);
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
return (
|
|
393
|
+
<Input
|
|
394
|
+
ref={ref}
|
|
395
|
+
type="tel"
|
|
396
|
+
inputMode="tel"
|
|
397
|
+
autoComplete="tel"
|
|
398
|
+
value={display}
|
|
399
|
+
onChange={handleChange}
|
|
400
|
+
onBlur={onBlur}
|
|
401
|
+
{...rest}
|
|
402
|
+
/>
|
|
403
|
+
);
|
|
404
|
+
},
|
|
405
|
+
);
|
|
406
|
+
PhoneInput.displayName = "PhoneInput";
|
|
407
|
+
|
|
408
|
+
/* ───────── BusinessNumberInput (KR) ───────── */
|
|
409
|
+
|
|
410
|
+
const formatBRN = (digits: string): string => {
|
|
411
|
+
const d = digits.replace(/\D/g, "").slice(0, 10);
|
|
412
|
+
if (d.length <= 3) return d;
|
|
413
|
+
if (d.length <= 5) return `${d.slice(0, 3)}-${d.slice(3)}`;
|
|
414
|
+
return `${d.slice(0, 3)}-${d.slice(3, 5)}-${d.slice(5)}`;
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* 한국 사업자등록번호(10자리) 체크섬 검증.
|
|
419
|
+
*/
|
|
420
|
+
export function isValidBRN(digits: string): boolean {
|
|
421
|
+
const d = digits.replace(/\D/g, "");
|
|
422
|
+
if (d.length !== 10) return false;
|
|
423
|
+
const w = [1, 3, 7, 1, 3, 7, 1, 3, 5];
|
|
424
|
+
let sum = 0;
|
|
425
|
+
for (let i = 0; i < 9; i++) sum += parseInt(d[i], 10) * w[i];
|
|
426
|
+
sum += Math.floor((parseInt(d[8], 10) * 5) / 10);
|
|
427
|
+
const check = (10 - (sum % 10)) % 10;
|
|
428
|
+
return check === parseInt(d[9], 10);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
export interface BusinessNumberInputProps
|
|
432
|
+
extends Omit<InputProps, "value" | "defaultValue" | "onChange" | "type"> {
|
|
433
|
+
value?: string;
|
|
434
|
+
defaultValue?: string;
|
|
435
|
+
onValueChange?: (digits: string) => void;
|
|
436
|
+
/**
|
|
437
|
+
* 켜면 10자리 입력 시 사업자번호 체크섬을 검증해 `aria-invalid`를 자동 부여한다.
|
|
438
|
+
* 외부에서 `aria-invalid`를 명시하면 그 값이 우선한다.
|
|
439
|
+
*
|
|
440
|
+
* @default false
|
|
441
|
+
*/
|
|
442
|
+
validateChecksum?: boolean;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* 한국 사업자등록번호(XXX-XX-XXXXX) 자동 하이픈 입력.
|
|
447
|
+
*/
|
|
448
|
+
export const BusinessNumberInput = React.forwardRef<HTMLInputElement, BusinessNumberInputProps>(
|
|
449
|
+
(
|
|
450
|
+
{ value, defaultValue, onValueChange, validateChecksum, onBlur, "aria-invalid": ariaInvalidProp, ...rest },
|
|
451
|
+
ref,
|
|
452
|
+
) => {
|
|
453
|
+
const isControlled = value !== undefined;
|
|
454
|
+
const initial = formatBRN(defaultValue ?? "");
|
|
455
|
+
const [internal, setInternal] = React.useState(initial);
|
|
456
|
+
|
|
457
|
+
const display = isControlled ? formatBRN(value ?? "") : internal;
|
|
458
|
+
const digits = display.replace(/\D/g, "");
|
|
459
|
+
|
|
460
|
+
const invalid =
|
|
461
|
+
ariaInvalidProp !== undefined
|
|
462
|
+
? ariaInvalidProp
|
|
463
|
+
: validateChecksum && digits.length === 10 && !isValidBRN(digits);
|
|
464
|
+
|
|
465
|
+
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
466
|
+
const next = e.target.value.replace(/\D/g, "").slice(0, 10);
|
|
467
|
+
const formatted = formatBRN(next);
|
|
468
|
+
if (!isControlled) setInternal(formatted);
|
|
469
|
+
onValueChange?.(next);
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
return (
|
|
473
|
+
<Input
|
|
474
|
+
ref={ref}
|
|
475
|
+
type="text"
|
|
476
|
+
inputMode="numeric"
|
|
477
|
+
value={display}
|
|
478
|
+
onChange={handleChange}
|
|
479
|
+
onBlur={onBlur}
|
|
480
|
+
aria-invalid={invalid || undefined}
|
|
481
|
+
{...rest}
|
|
482
|
+
/>
|
|
483
|
+
);
|
|
484
|
+
},
|
|
485
|
+
);
|
|
486
|
+
BusinessNumberInput.displayName = "BusinessNumberInput";
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
|
|
5
|
+
import { cn } from "@SH_UI_UTILS@";
|
|
5
6
|
export interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "prefix"> {
|
|
6
7
|
/** input 우측에 부착할 보조 노드(아이콘·단위·버튼 등). 더 많은 슬롯이 필요하면 InputGroup 사용. */
|
|
7
8
|
suffix?: React.ReactNode;
|
|
@@ -9,9 +10,6 @@ export interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElem
|
|
|
9
10
|
prefix?: React.ReactNode;
|
|
10
11
|
}
|
|
11
12
|
|
|
12
|
-
function cx(...args: (string | undefined | null | false)[]) {
|
|
13
|
-
return args.filter(Boolean).join(" ");
|
|
14
|
-
}
|
|
15
13
|
|
|
16
14
|
/* ───────── Base utility 묶음 (반복 줄이기) ───────── */
|
|
17
15
|
|
|
@@ -69,7 +67,7 @@ export const InputGroup = React.forwardRef<HTMLDivElement, InputGroupProps>(
|
|
|
69
67
|
<InputGroupContext.Provider value={{ inGroup: true }}>
|
|
70
68
|
<div
|
|
71
69
|
ref={mergedRef}
|
|
72
|
-
className={
|
|
70
|
+
className={cn(baseGroupClasses, className)}
|
|
73
71
|
data-disabled={disabled || undefined}
|
|
74
72
|
aria-invalid={ariaInvalid}
|
|
75
73
|
onClick={handleClick}
|
|
@@ -91,7 +89,7 @@ export const InputAdornment = React.forwardRef<HTMLSpanElement, InputAdornmentPr
|
|
|
91
89
|
({ className, interactive, ...props }, ref) => (
|
|
92
90
|
<span
|
|
93
91
|
ref={ref}
|
|
94
|
-
className={
|
|
92
|
+
className={cn(
|
|
95
93
|
"inline-flex items-center justify-center flex-none text-foreground-muted px-[var(--space-1)] data-[interactive]:p-0",
|
|
96
94
|
className,
|
|
97
95
|
)}
|
|
@@ -112,7 +110,7 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|
|
112
110
|
<input
|
|
113
111
|
ref={ref}
|
|
114
112
|
type={type}
|
|
115
|
-
className={
|
|
113
|
+
className={cn(
|
|
116
114
|
baseInputClasses,
|
|
117
115
|
inGroupOverrides,
|
|
118
116
|
!!prefix && "pl-[var(--space-10)]",
|