sh-ui-cli 0.45.3 → 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 +13 -0
- package/data/registry/react/components/button/index.module.tsx +45 -0
- package/data/registry/react/components/button/styles.module.css +92 -0
- package/data/registry/react/components/card/index.module.tsx +63 -0
- package/data/registry/react/components/card/styles.module.css +73 -0
- package/data/registry/react/components/input/index.module.tsx +486 -0
- package/data/registry/react/components/input/styles.module.css +200 -0
- package/data/registry/react/registry.json +44 -1
- package/data/tokens/build.mjs +4 -0
- package/package.json +1 -1
- package/src/add.mjs +12 -12
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
3
|
"$description": "sh-ui 릴리즈 노트 단일 소스. docs(React)와 showcase(Flutter)가 함께 읽는다. 새 릴리즈마다 맨 앞에 추가.",
|
|
4
4
|
"versions": [
|
|
5
|
+
{
|
|
6
|
+
"version": "0.46.0",
|
|
7
|
+
"date": "2026-05-04",
|
|
8
|
+
"title": "CSS Modules 변종 파일럿 — button/card/input",
|
|
9
|
+
"type": "minor",
|
|
10
|
+
"highlights": [
|
|
11
|
+
"**CSS Modules 변종 파일럿** — `button`, `card`, `input` 에 `index.module.tsx` + `styles.module.css` 변종 추가. `sh-ui.config.json` 의 `cssFramework` 를 `\"css-modules\"` 로 두면 CLI 가 `import styles from \"./styles.module.css\"` 형태로 설치하고, 클래스 이름은 모듈 해시로 격리됨. 토큰은 plain/tailwind 와 동일한 `tokens.css` (`:root` CSS custom properties) 를 공유.",
|
|
12
|
+
"**Fallback 일반화** — 기존엔 tailwind 한정이던 `effectiveFramework` 가 모든 변종 공통으로 동작. `cssFramework` 에 변종이 없는 컴포넌트는 plain 으로 자동 fallback 되며 `ℹ <name> — <fw> 변종 미제공, plain 변종으로 설치` 한 줄 안내 출력. 점진적 rollout 패턴 그대로 — 컴포넌트마다 변종을 갖출 필요 없이 가능한 것부터 제공.",
|
|
13
|
+
"**`utils` 의 frameworks 분기** — `lib/cn.ts` 가 `[\"plain\", \"css-modules\"]` 로 매칭. CSS Modules 환경도 zero-dep `cn` 을 그대로 공유 (clsx/tailwind-merge 가 필요한 건 tailwind 변종뿐).",
|
|
14
|
+
"css-modules 는 여전히 `CSS_FRAMEWORKS_PLANNED` 유지 — 파일럿 3개만 변종이 있어 UI 노출은 다음 라운드에서 전체 컴포넌트 롤아웃 후 SUPPORTED 로 승격. 지금도 사용자가 직접 `sh-ui.config.json` 을 손대면 동작."
|
|
15
|
+
],
|
|
16
|
+
"url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.46.0"
|
|
17
|
+
},
|
|
5
18
|
{
|
|
6
19
|
"version": "0.45.3",
|
|
7
20
|
"date": "2026-04-30",
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cn } from "@SH_UI_UTILS@";
|
|
3
|
+
import styles from "./styles.module.css";
|
|
4
|
+
|
|
5
|
+
type Variant = "primary" | "secondary" | "ghost" | "danger" | "link";
|
|
6
|
+
type Size = "sm" | "md" | "lg";
|
|
7
|
+
|
|
8
|
+
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
9
|
+
/**
|
|
10
|
+
* 시각적 위계.
|
|
11
|
+
* - `primary` — 페이지의 주요 액션. 한 화면에 하나만 권장.
|
|
12
|
+
* - `secondary` — 보조 액션. 약한 배경 + border.
|
|
13
|
+
* - `ghost` — 배경 없는 hover 강조 액션. 툴바/메뉴 항목에 적합.
|
|
14
|
+
* - `danger` — 파괴적 액션(삭제, 취소 등).
|
|
15
|
+
* - `link` — 텍스트 링크처럼 보이는 인라인 버튼.
|
|
16
|
+
*
|
|
17
|
+
* @default "primary"
|
|
18
|
+
*/
|
|
19
|
+
variant?: Variant;
|
|
20
|
+
/**
|
|
21
|
+
* 크기.
|
|
22
|
+
* - `sm` — 조밀한 영역(테이블 행, 툴바)
|
|
23
|
+
* - `md` — 일반
|
|
24
|
+
* - `lg` — CTA·랜딩 영역
|
|
25
|
+
*
|
|
26
|
+
* @default "md"
|
|
27
|
+
*/
|
|
28
|
+
size?: Size;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 사용자 액션을 트리거하는 기본 버튼 (CSS Modules 변종).
|
|
33
|
+
* variant로 시각적 위계(primary/secondary/ghost/danger/link)를,
|
|
34
|
+
* size로 크기를 결정한다. 페이지 이동 목적이면 anchor를 감싼 `link` variant를 사용할 것.
|
|
35
|
+
*/
|
|
36
|
+
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
37
|
+
({ variant = "primary", size = "md", className, ...props }, ref) => (
|
|
38
|
+
<button
|
|
39
|
+
ref={ref}
|
|
40
|
+
className={cn(styles.button, styles[variant], styles[size], className)}
|
|
41
|
+
{...props}
|
|
42
|
+
/>
|
|
43
|
+
),
|
|
44
|
+
);
|
|
45
|
+
Button.displayName = "Button";
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
.button {
|
|
2
|
+
display: inline-flex;
|
|
3
|
+
align-items: center;
|
|
4
|
+
justify-content: center;
|
|
5
|
+
gap: var(--space-2);
|
|
6
|
+
border: 1px solid transparent;
|
|
7
|
+
border-radius: var(--radius);
|
|
8
|
+
font-weight: var(--weight-medium);
|
|
9
|
+
line-height: 1;
|
|
10
|
+
cursor: pointer;
|
|
11
|
+
transition: background-color var(--duration-fast), color var(--duration-fast), border-color var(--duration-fast),
|
|
12
|
+
transform 80ms ease-out, filter 80ms;
|
|
13
|
+
user-select: none;
|
|
14
|
+
-webkit-tap-highlight-color: transparent;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.button:disabled {
|
|
18
|
+
opacity: var(--opacity-disabled);
|
|
19
|
+
pointer-events: none;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.button:focus-visible {
|
|
23
|
+
outline: var(--border-width-strong) solid var(--foreground);
|
|
24
|
+
outline-offset: 2px;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.button:active:not(:disabled) {
|
|
28
|
+
transform: scale(0.97);
|
|
29
|
+
filter: brightness(0.92);
|
|
30
|
+
transition-duration: 40ms;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/* sizes */
|
|
34
|
+
.sm { height: var(--control-sm); padding: 0 var(--space-3); font-size: var(--text-sm); }
|
|
35
|
+
.md { height: var(--control-md); padding: 0 var(--space-4); font-size: var(--text-sm); }
|
|
36
|
+
.lg { height: var(--control-lg); padding: 0 var(--space-5); font-size: var(--text-base); }
|
|
37
|
+
|
|
38
|
+
/* 모바일/터치 디바이스: 최소 탭 영역 보장 */
|
|
39
|
+
@media (hover: none) and (pointer: coarse) {
|
|
40
|
+
.sm { height: 2.25rem; }
|
|
41
|
+
.md { height: 2.75rem; }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/* variants */
|
|
45
|
+
.primary {
|
|
46
|
+
background-color: var(--primary);
|
|
47
|
+
color: var(--primary-foreground);
|
|
48
|
+
}
|
|
49
|
+
.primary:hover { background-color: var(--primary-hover); }
|
|
50
|
+
|
|
51
|
+
.secondary {
|
|
52
|
+
background-color: var(--background-muted);
|
|
53
|
+
color: var(--foreground);
|
|
54
|
+
border-color: var(--border);
|
|
55
|
+
}
|
|
56
|
+
.secondary:hover { background-color: var(--background-subtle); }
|
|
57
|
+
|
|
58
|
+
.ghost {
|
|
59
|
+
background-color: transparent;
|
|
60
|
+
color: var(--foreground);
|
|
61
|
+
}
|
|
62
|
+
.ghost:hover { background-color: var(--background-muted); }
|
|
63
|
+
|
|
64
|
+
.danger {
|
|
65
|
+
background-color: var(--danger);
|
|
66
|
+
color: var(--danger-foreground);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.link {
|
|
70
|
+
background-color: transparent;
|
|
71
|
+
color: var(--foreground);
|
|
72
|
+
border-color: transparent;
|
|
73
|
+
height: auto;
|
|
74
|
+
padding: 0;
|
|
75
|
+
text-underline-offset: 3px;
|
|
76
|
+
}
|
|
77
|
+
.link:hover { text-decoration: underline; }
|
|
78
|
+
.link:active:not(:disabled) {
|
|
79
|
+
transform: none;
|
|
80
|
+
filter: none;
|
|
81
|
+
color: var(--foreground-muted);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
@media (prefers-reduced-motion: reduce) {
|
|
85
|
+
.button {
|
|
86
|
+
transition: none;
|
|
87
|
+
}
|
|
88
|
+
.button:active:not(:disabled) {
|
|
89
|
+
transform: none;
|
|
90
|
+
filter: none;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cn } from "@SH_UI_UTILS@";
|
|
3
|
+
import styles from "./styles.module.css";
|
|
4
|
+
|
|
5
|
+
type DivProps = React.HTMLAttributes<HTMLDivElement>;
|
|
6
|
+
|
|
7
|
+
export const Card = React.forwardRef<HTMLDivElement, DivProps>(
|
|
8
|
+
({ className, ...props }, ref) => (
|
|
9
|
+
<div ref={ref} className={cn(styles.card, className)} {...props} />
|
|
10
|
+
),
|
|
11
|
+
);
|
|
12
|
+
Card.displayName = "Card";
|
|
13
|
+
|
|
14
|
+
export const CardHeader = React.forwardRef<HTMLDivElement, DivProps>(
|
|
15
|
+
({ className, ...props }, ref) => (
|
|
16
|
+
<div
|
|
17
|
+
ref={ref}
|
|
18
|
+
data-slot="card-header"
|
|
19
|
+
className={cn(styles.header, className)}
|
|
20
|
+
{...props}
|
|
21
|
+
/>
|
|
22
|
+
),
|
|
23
|
+
);
|
|
24
|
+
CardHeader.displayName = "CardHeader";
|
|
25
|
+
|
|
26
|
+
export const CardTitle = React.forwardRef<HTMLDivElement, DivProps>(
|
|
27
|
+
({ className, ...props }, ref) => (
|
|
28
|
+
<div ref={ref} className={cn(styles.title, className)} {...props} />
|
|
29
|
+
),
|
|
30
|
+
);
|
|
31
|
+
CardTitle.displayName = "CardTitle";
|
|
32
|
+
|
|
33
|
+
export const CardDescription = React.forwardRef<HTMLDivElement, DivProps>(
|
|
34
|
+
({ className, ...props }, ref) => (
|
|
35
|
+
<div ref={ref} className={cn(styles.description, className)} {...props} />
|
|
36
|
+
),
|
|
37
|
+
);
|
|
38
|
+
CardDescription.displayName = "CardDescription";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 헤더 우측에 배치되는 슬롯. CardHeader 내부에서 grid 2번째 컬럼을 차지.
|
|
42
|
+
* CardHeader가 `:has(.action)`으로 감지해 레이아웃을 전환한다.
|
|
43
|
+
*/
|
|
44
|
+
export const CardAction = React.forwardRef<HTMLDivElement, DivProps>(
|
|
45
|
+
({ className, ...props }, ref) => (
|
|
46
|
+
<div ref={ref} className={cn(styles.action, className)} {...props} />
|
|
47
|
+
),
|
|
48
|
+
);
|
|
49
|
+
CardAction.displayName = "CardAction";
|
|
50
|
+
|
|
51
|
+
export const CardContent = React.forwardRef<HTMLDivElement, DivProps>(
|
|
52
|
+
({ className, ...props }, ref) => (
|
|
53
|
+
<div ref={ref} className={cn(styles.content, className)} {...props} />
|
|
54
|
+
),
|
|
55
|
+
);
|
|
56
|
+
CardContent.displayName = "CardContent";
|
|
57
|
+
|
|
58
|
+
export const CardFooter = React.forwardRef<HTMLDivElement, DivProps>(
|
|
59
|
+
({ className, ...props }, ref) => (
|
|
60
|
+
<div ref={ref} className={cn(styles.footer, className)} {...props} />
|
|
61
|
+
),
|
|
62
|
+
);
|
|
63
|
+
CardFooter.displayName = "CardFooter";
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
.card {
|
|
2
|
+
display: flex;
|
|
3
|
+
flex-direction: column;
|
|
4
|
+
gap: var(--space-6);
|
|
5
|
+
padding: var(--space-6) 0;
|
|
6
|
+
background: var(--background);
|
|
7
|
+
color: var(--foreground);
|
|
8
|
+
border: 1px solid var(--border);
|
|
9
|
+
border-radius: var(--radius);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/* 헤더: 기본은 타이틀/설명 세로 스택. action 있으면 2열 그리드로 전환. */
|
|
13
|
+
.header {
|
|
14
|
+
display: grid;
|
|
15
|
+
grid-template-columns: 1fr;
|
|
16
|
+
grid-auto-rows: auto;
|
|
17
|
+
row-gap: 0.375rem;
|
|
18
|
+
padding: 0 var(--space-6);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.header:has(.action) {
|
|
22
|
+
grid-template-columns: 1fr auto;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.title {
|
|
26
|
+
font-size: var(--text-base);
|
|
27
|
+
font-weight: var(--weight-semibold);
|
|
28
|
+
line-height: 1.25;
|
|
29
|
+
letter-spacing: -0.01em;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.description {
|
|
33
|
+
font-size: var(--text-sm);
|
|
34
|
+
line-height: 1.5;
|
|
35
|
+
color: var(--foreground-muted);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.action {
|
|
39
|
+
grid-column: 2;
|
|
40
|
+
grid-row: 1 / span 2;
|
|
41
|
+
align-self: start;
|
|
42
|
+
justify-self: end;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.content {
|
|
46
|
+
padding: 0 var(--space-6);
|
|
47
|
+
font-size: var(--text-sm);
|
|
48
|
+
line-height: 1.6;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.footer {
|
|
52
|
+
padding: 0 var(--space-6);
|
|
53
|
+
display: flex;
|
|
54
|
+
align-items: center;
|
|
55
|
+
gap: var(--space-2);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/* 모바일: 패딩 축소 */
|
|
59
|
+
@media (max-width: 640px) {
|
|
60
|
+
.card {
|
|
61
|
+
gap: var(--space-4);
|
|
62
|
+
padding: var(--space-4) 0;
|
|
63
|
+
}
|
|
64
|
+
.header,
|
|
65
|
+
.content,
|
|
66
|
+
.footer {
|
|
67
|
+
padding-left: var(--space-4);
|
|
68
|
+
padding-right: var(--space-4);
|
|
69
|
+
}
|
|
70
|
+
.footer {
|
|
71
|
+
flex-wrap: wrap;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -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";
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
.input {
|
|
2
|
+
display: block;
|
|
3
|
+
width: 100%;
|
|
4
|
+
height: var(--control-md);
|
|
5
|
+
padding: 0 var(--space-3);
|
|
6
|
+
background: var(--background);
|
|
7
|
+
color: var(--foreground);
|
|
8
|
+
border: 1px solid var(--border);
|
|
9
|
+
border-radius: var(--radius);
|
|
10
|
+
font-family: inherit;
|
|
11
|
+
font-size: var(--text-sm);
|
|
12
|
+
line-height: 1;
|
|
13
|
+
transition: border-color var(--duration-fast), box-shadow var(--duration-fast);
|
|
14
|
+
-webkit-tap-highlight-color: transparent;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/* 모바일/터치: 최소 탭 영역 + iOS 자동 줌 방지(16px) */
|
|
18
|
+
@media (hover: none) and (pointer: coarse) {
|
|
19
|
+
.input {
|
|
20
|
+
height: 2.75rem;
|
|
21
|
+
font-size: var(--text-base);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.input::placeholder {
|
|
26
|
+
color: var(--foreground-subtle);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.input:hover:not(:disabled):not(:focus) {
|
|
30
|
+
border-color: var(--border-strong);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.input:focus {
|
|
34
|
+
outline: none;
|
|
35
|
+
border-color: var(--foreground);
|
|
36
|
+
box-shadow: 0 0 0 1px var(--foreground);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.input:disabled {
|
|
40
|
+
opacity: var(--opacity-disabled);
|
|
41
|
+
cursor: not-allowed;
|
|
42
|
+
background: var(--background-subtle);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.input:read-only {
|
|
46
|
+
background: var(--background-subtle);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/* 숫자 input 화살표 제거 */
|
|
50
|
+
.input[type="number"]::-webkit-outer-spin-button,
|
|
51
|
+
.input[type="number"]::-webkit-inner-spin-button {
|
|
52
|
+
-webkit-appearance: none;
|
|
53
|
+
margin: 0;
|
|
54
|
+
}
|
|
55
|
+
.input[type="number"] {
|
|
56
|
+
-moz-appearance: textfield;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/* ───────── prefix / suffix ───────── */
|
|
60
|
+
|
|
61
|
+
.inputWrap {
|
|
62
|
+
position: relative;
|
|
63
|
+
width: 100%;
|
|
64
|
+
display: block;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.withPrefix { padding-left: var(--space-10); }
|
|
68
|
+
.withSuffix { padding-right: var(--space-10); }
|
|
69
|
+
|
|
70
|
+
.affix {
|
|
71
|
+
position: absolute;
|
|
72
|
+
top: 50%;
|
|
73
|
+
transform: translateY(-50%);
|
|
74
|
+
display: inline-flex;
|
|
75
|
+
align-items: center;
|
|
76
|
+
justify-content: center;
|
|
77
|
+
color: var(--foreground-muted);
|
|
78
|
+
pointer-events: none;
|
|
79
|
+
}
|
|
80
|
+
.affixPrefix { left: var(--space-3); }
|
|
81
|
+
.affixSuffix { right: var(--space-1); }
|
|
82
|
+
|
|
83
|
+
.affix > * { pointer-events: auto; }
|
|
84
|
+
|
|
85
|
+
/* 비밀번호 토글 버튼 */
|
|
86
|
+
.toggle {
|
|
87
|
+
display: inline-flex;
|
|
88
|
+
align-items: center;
|
|
89
|
+
justify-content: center;
|
|
90
|
+
width: 2rem;
|
|
91
|
+
height: 2rem;
|
|
92
|
+
padding: 0;
|
|
93
|
+
background: transparent;
|
|
94
|
+
border: none;
|
|
95
|
+
border-radius: calc(var(--radius) - 2px);
|
|
96
|
+
color: var(--foreground-muted);
|
|
97
|
+
cursor: pointer;
|
|
98
|
+
transition: color var(--duration-fast), background-color var(--duration-fast);
|
|
99
|
+
-webkit-tap-highlight-color: transparent;
|
|
100
|
+
}
|
|
101
|
+
.toggle:hover { color: var(--foreground); background: var(--background-muted); }
|
|
102
|
+
.toggle:focus-visible {
|
|
103
|
+
outline: var(--border-width-strong) solid var(--foreground);
|
|
104
|
+
outline-offset: 2px;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/* 에러 상태 — aria-invalid="true" 기반 */
|
|
108
|
+
.input[aria-invalid="true"] {
|
|
109
|
+
border-color: var(--danger);
|
|
110
|
+
}
|
|
111
|
+
.input[aria-invalid="true"]:focus {
|
|
112
|
+
box-shadow: 0 0 0 1px var(--danger);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/* ───────── InputGroup + InputAdornment ───────── */
|
|
116
|
+
|
|
117
|
+
.group {
|
|
118
|
+
display: flex;
|
|
119
|
+
align-items: center;
|
|
120
|
+
width: 100%;
|
|
121
|
+
min-height: var(--control-md);
|
|
122
|
+
padding: 0 var(--space-2);
|
|
123
|
+
gap: var(--space-2);
|
|
124
|
+
background: var(--background);
|
|
125
|
+
color: var(--foreground);
|
|
126
|
+
border: 1px solid var(--border);
|
|
127
|
+
border-radius: var(--radius);
|
|
128
|
+
transition: border-color var(--duration-fast), box-shadow var(--duration-fast);
|
|
129
|
+
cursor: text;
|
|
130
|
+
-webkit-tap-highlight-color: transparent;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
@media (hover: none) and (pointer: coarse) {
|
|
134
|
+
.group {
|
|
135
|
+
min-height: 2.75rem;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.group:hover:not([data-disabled]):not(:focus-within) {
|
|
140
|
+
border-color: var(--border-strong);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.group:focus-within {
|
|
144
|
+
border-color: var(--foreground);
|
|
145
|
+
box-shadow: 0 0 0 1px var(--foreground);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.group[aria-invalid="true"] {
|
|
149
|
+
border-color: var(--danger);
|
|
150
|
+
}
|
|
151
|
+
.group[aria-invalid="true"]:focus-within {
|
|
152
|
+
box-shadow: 0 0 0 1px var(--danger);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.group[data-disabled] {
|
|
156
|
+
opacity: var(--opacity-disabled);
|
|
157
|
+
cursor: not-allowed;
|
|
158
|
+
background: var(--background-subtle);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/* 그룹 내부의 Input은 자체 보더/배경/포커스 링을 모두 감춘다 */
|
|
162
|
+
.input[data-in-group] {
|
|
163
|
+
flex: 1 1 auto;
|
|
164
|
+
min-width: 0;
|
|
165
|
+
height: auto;
|
|
166
|
+
padding: 0;
|
|
167
|
+
background: transparent;
|
|
168
|
+
border: none;
|
|
169
|
+
border-radius: 0;
|
|
170
|
+
box-shadow: none;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.input[data-in-group]:focus,
|
|
174
|
+
.input[data-in-group]:hover {
|
|
175
|
+
border: none;
|
|
176
|
+
box-shadow: none;
|
|
177
|
+
outline: none;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.input[data-in-group]:disabled {
|
|
181
|
+
background: transparent;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.inputWrap[data-in-group] {
|
|
185
|
+
flex: 1 1 auto;
|
|
186
|
+
min-width: 0;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.adornment {
|
|
190
|
+
display: inline-flex;
|
|
191
|
+
align-items: center;
|
|
192
|
+
justify-content: center;
|
|
193
|
+
flex: 0 0 auto;
|
|
194
|
+
color: var(--foreground-muted);
|
|
195
|
+
padding: 0 var(--space-1);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
.adornment[data-interactive] {
|
|
199
|
+
padding: 0;
|
|
200
|
+
}
|
|
@@ -25,6 +25,20 @@
|
|
|
25
25
|
"frameworks": [
|
|
26
26
|
"tailwind"
|
|
27
27
|
]
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"src": "components/button/index.module.tsx",
|
|
31
|
+
"dest": "{components}/button/index.tsx",
|
|
32
|
+
"frameworks": [
|
|
33
|
+
"css-modules"
|
|
34
|
+
]
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"src": "components/button/styles.module.css",
|
|
38
|
+
"dest": "{components}/button/styles.module.css",
|
|
39
|
+
"frameworks": [
|
|
40
|
+
"css-modules"
|
|
41
|
+
]
|
|
28
42
|
}
|
|
29
43
|
],
|
|
30
44
|
"dependencies": [
|
|
@@ -63,6 +77,20 @@
|
|
|
63
77
|
"frameworks": [
|
|
64
78
|
"tailwind"
|
|
65
79
|
]
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
"src": "components/card/index.module.tsx",
|
|
83
|
+
"dest": "{components}/card/index.tsx",
|
|
84
|
+
"frameworks": [
|
|
85
|
+
"css-modules"
|
|
86
|
+
]
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
"src": "components/card/styles.module.css",
|
|
90
|
+
"dest": "{components}/card/styles.module.css",
|
|
91
|
+
"frameworks": [
|
|
92
|
+
"css-modules"
|
|
93
|
+
]
|
|
66
94
|
}
|
|
67
95
|
],
|
|
68
96
|
"dependencies": [],
|
|
@@ -94,6 +122,20 @@
|
|
|
94
122
|
"frameworks": [
|
|
95
123
|
"tailwind"
|
|
96
124
|
]
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
"src": "components/input/index.module.tsx",
|
|
128
|
+
"dest": "{components}/input/index.tsx",
|
|
129
|
+
"frameworks": [
|
|
130
|
+
"css-modules"
|
|
131
|
+
]
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
"src": "components/input/styles.module.css",
|
|
135
|
+
"dest": "{components}/input/styles.module.css",
|
|
136
|
+
"frameworks": [
|
|
137
|
+
"css-modules"
|
|
138
|
+
]
|
|
97
139
|
}
|
|
98
140
|
],
|
|
99
141
|
"dependencies": [],
|
|
@@ -1677,7 +1719,8 @@
|
|
|
1677
1719
|
"src": "lib/cn.ts",
|
|
1678
1720
|
"dest": "{utils}",
|
|
1679
1721
|
"frameworks": [
|
|
1680
|
-
"plain"
|
|
1722
|
+
"plain",
|
|
1723
|
+
"css-modules"
|
|
1681
1724
|
]
|
|
1682
1725
|
},
|
|
1683
1726
|
{
|
package/data/tokens/build.mjs
CHANGED
|
@@ -574,6 +574,10 @@ const tokenEmitters = {
|
|
|
574
574
|
// 동일. 향후 Tailwind v3 theme.config.ts 를 별도 emit 하고 싶으면
|
|
575
575
|
// 여기에 다른 함수를 등록.
|
|
576
576
|
tailwind: buildTokensCss,
|
|
577
|
+
// CSS Modules 변종도 토큰은 평문 CSS custom properties — :root 변수를
|
|
578
|
+
// 그대로 참조하면 되므로 tokens.css 를 공유. .module.css 안에서도
|
|
579
|
+
// var(--primary) 같은 글로벌 변수는 정상 참조됨.
|
|
580
|
+
"css-modules": buildTokensCss,
|
|
577
581
|
},
|
|
578
582
|
flutter: {
|
|
579
583
|
plain: buildTokensDart,
|
package/package.json
CHANGED
package/src/add.mjs
CHANGED
|
@@ -193,19 +193,19 @@ function frameworkMatches(entry, cssFramework) {
|
|
|
193
193
|
}
|
|
194
194
|
|
|
195
195
|
/**
|
|
196
|
-
*
|
|
197
|
-
* plain
|
|
198
|
-
*
|
|
196
|
+
* 컴포넌트에 요청된 cssFramework 전용 변종 파일이 없으면 plain 으로 fallback.
|
|
197
|
+
* plain CSS 컴포넌트는 :root 변수만 의존하므로 어떤 환경(Tailwind v4, CSS Modules,
|
|
198
|
+
* vanilla CSS) 에서도 그대로 동작 — 깨지지 않음.
|
|
199
199
|
*
|
|
200
|
-
* 점진적 rollout 전략 — 모든 컴포넌트가 한 번에
|
|
201
|
-
* 가능한 것부터
|
|
200
|
+
* 점진적 rollout 전략 — 모든 컴포넌트가 한 번에 새 변종을 갖출 필요 없이
|
|
201
|
+
* 가능한 것부터 변종을 제공하고, 나머지는 plain 으로 자연 처리.
|
|
202
202
|
*/
|
|
203
203
|
function effectiveFramework(entry, cssFramework) {
|
|
204
|
-
if (cssFramework
|
|
205
|
-
const
|
|
206
|
-
(f) => f.frameworks && f.frameworks.includes(
|
|
204
|
+
if (cssFramework === "plain") return cssFramework;
|
|
205
|
+
const hasVariant = (entry.files ?? []).some(
|
|
206
|
+
(f) => f.frameworks && f.frameworks.includes(cssFramework),
|
|
207
207
|
);
|
|
208
|
-
return
|
|
208
|
+
return hasVariant ? cssFramework : "plain";
|
|
209
209
|
}
|
|
210
210
|
|
|
211
211
|
async function addComponent(name, config, cwd, installed, pendingDeps, diffMode, summary, conflictResolver) {
|
|
@@ -223,11 +223,11 @@ async function addComponent(name, config, cwd, installed, pendingDeps, diffMode,
|
|
|
223
223
|
const requestedFw = config.cssFramework ?? "plain";
|
|
224
224
|
const cssFramework = effectiveFramework(entry, requestedFw);
|
|
225
225
|
|
|
226
|
-
// 사용자가
|
|
226
|
+
// 사용자가 plain 외 변종을 골랐는데 이 컴포넌트는 plain 으로 fallback 된 경우 한 줄 알림.
|
|
227
227
|
// 동작에 문제는 없지만 일관성에 대한 기대를 정확히 셋업하기 위함.
|
|
228
|
-
if (requestedFw
|
|
228
|
+
if (requestedFw !== "plain" && cssFramework === "plain" && !diffMode) {
|
|
229
229
|
console.log(
|
|
230
|
-
`ℹ ${name} —
|
|
230
|
+
`ℹ ${name} — ${requestedFw} 변종 미제공, plain 변종으로 설치 (어떤 환경에서도 그대로 동작)`,
|
|
231
231
|
);
|
|
232
232
|
}
|
|
233
233
|
|