atomsystem 0.2.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/components/ui/Accordion.tsx +49 -0
- package/components/ui/Avatar.tsx +32 -0
- package/components/ui/Breadcrumb.tsx +40 -0
- package/components/ui/Calendar.tsx +74 -0
- package/components/ui/Pagination.tsx +49 -0
- package/components/ui/Progress.tsx +32 -0
- package/components/ui/Radio.tsx +44 -0
- package/components/ui/Select.tsx +72 -0
- package/components/ui/Separator.tsx +29 -0
- package/components/ui/Skeleton.tsx +25 -0
- package/components/ui/Slider.tsx +33 -0
- package/components/ui/Textarea.tsx +25 -0
- package/components/ui/Tooltip.tsx +29 -0
- package/components/ui/index.ts +13 -0
- package/css/atom-ui.css +483 -0
- package/dist/cli.cjs +1 -1
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +1 -1
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +779 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +779 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/tokens/token.json +1 -1
- package/tokens/tokens.json +1 -1
package/README.md
CHANGED
|
@@ -134,7 +134,7 @@ AI Agent는 자동으로 디자인 토큰과 UX 표준을 적용하여 일관된
|
|
|
134
134
|
| :--- | :--- |
|
|
135
135
|
| `atomsystem` | 토큰 데이터 모델, 테마 엔진, 색상/타이포 유틸리티 |
|
|
136
136
|
| `atomsystem/css/atom-ui.css` | 전역 스타일 및 시멘틱 UI 클래스 (`.atom-btn`, `.atom-card` 등) |
|
|
137
|
-
| `atomsystem/components/*` | React UI 컴포넌트 (`Button`, `Card`, `Modal`, `
|
|
137
|
+
| `atomsystem/components/*` | React UI 컴포넌트 풀세트 (`Button`, `Input`, `Card`, `Badge`, `Modal`, `Calendar`, `Select`, `Accordion`, `Radio`, `Slider`, `Avatar`, `Progress`, `Skeleton`, `Breadcrumb`, `Separator`, `Textarea`, `Pagination`, `Tooltip`) |
|
|
138
138
|
| `atomsystem/token.json` | W3C DTCG 포맷 단일 진실 공급원 토큰 파일 |
|
|
139
139
|
| `atomsystem/design.md` | 마크다운 기반 디자인 시스템 가이드 규격 |
|
|
140
140
|
| `atomsystem/uxPattern.md` | 마크다운 기반 UX 인터랙션 패턴 가이드 |
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
|
|
3
|
+
export interface AccordionItem {
|
|
4
|
+
id: string;
|
|
5
|
+
title: string;
|
|
6
|
+
content: React.ReactNode;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface AccordionProps {
|
|
10
|
+
items: AccordionItem[];
|
|
11
|
+
type?: 'single' | 'multiple';
|
|
12
|
+
className?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const Accordion: React.FC<AccordionProps> = ({ items, type = 'single', className = '' }) => {
|
|
16
|
+
const [openIds, setOpenIds] = useState<string[]>([]);
|
|
17
|
+
|
|
18
|
+
const toggle = (id: string) => {
|
|
19
|
+
if (type === 'single') {
|
|
20
|
+
setOpenIds(openIds.includes(id) ? [] : [id]);
|
|
21
|
+
} else {
|
|
22
|
+
setOpenIds(openIds.includes(id) ? openIds.filter((item) => item !== id) : [...openIds, id]);
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div className={`atom-accordion ${className}`}>
|
|
28
|
+
{items.map((item) => {
|
|
29
|
+
const isOpen = openIds.includes(item.id);
|
|
30
|
+
return (
|
|
31
|
+
<div key={item.id} className="atom-accordion-item">
|
|
32
|
+
<button
|
|
33
|
+
type="button"
|
|
34
|
+
className="atom-accordion-trigger"
|
|
35
|
+
onClick={() => toggle(item.id)}
|
|
36
|
+
aria-expanded={isOpen}
|
|
37
|
+
>
|
|
38
|
+
<span>{item.title}</span>
|
|
39
|
+
<span className="atom-accordion-icon" style={{ transform: isOpen ? 'rotate(180deg)' : 'none' }}>
|
|
40
|
+
▾
|
|
41
|
+
</span>
|
|
42
|
+
</button>
|
|
43
|
+
{isOpen && <div className="atom-accordion-content">{item.content}</div>}
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
})}
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
|
|
3
|
+
export interface AvatarProps {
|
|
4
|
+
src?: string;
|
|
5
|
+
alt?: string;
|
|
6
|
+
fallback?: string;
|
|
7
|
+
size?: 'sm' | 'md' | 'lg';
|
|
8
|
+
status?: 'online' | 'offline' | 'busy';
|
|
9
|
+
className?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const Avatar: React.FC<AvatarProps> = ({
|
|
13
|
+
src,
|
|
14
|
+
alt = 'Avatar',
|
|
15
|
+
fallback,
|
|
16
|
+
size = 'md',
|
|
17
|
+
status,
|
|
18
|
+
className = '',
|
|
19
|
+
}) => {
|
|
20
|
+
const [hasError, setHasError] = useState(!src);
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<div className={`atom-avatar atom-avatar-${size} ${className}`}>
|
|
24
|
+
{src && !hasError ? (
|
|
25
|
+
<img src={src} alt={alt} onError={() => setHasError(true)} className="atom-avatar-img" />
|
|
26
|
+
) : (
|
|
27
|
+
<span className="atom-avatar-fallback">{fallback || alt.slice(0, 2).toUpperCase()}</span>
|
|
28
|
+
)}
|
|
29
|
+
{status && <span className={`atom-avatar-status atom-avatar-status-${status}`} />}
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
export interface BreadcrumbItem {
|
|
4
|
+
label: string;
|
|
5
|
+
href?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface BreadcrumbProps {
|
|
9
|
+
items: BreadcrumbItem[];
|
|
10
|
+
separator?: string;
|
|
11
|
+
className?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const Breadcrumb: React.FC<BreadcrumbProps> = ({
|
|
15
|
+
items,
|
|
16
|
+
separator = '/',
|
|
17
|
+
className = '',
|
|
18
|
+
}) => {
|
|
19
|
+
return (
|
|
20
|
+
<nav className={`atom-breadcrumb ${className}`} aria-label="Breadcrumb">
|
|
21
|
+
<ol className="atom-breadcrumb-list">
|
|
22
|
+
{items.map((item, idx) => {
|
|
23
|
+
const isLast = idx === items.length - 1;
|
|
24
|
+
return (
|
|
25
|
+
<React.Fragment key={idx}>
|
|
26
|
+
<li className={`atom-breadcrumb-item ${isLast ? 'atom-breadcrumb-current' : ''}`}>
|
|
27
|
+
{isLast || !item.href ? (
|
|
28
|
+
<span aria-current={isLast ? 'page' : undefined}>{item.label}</span>
|
|
29
|
+
) : (
|
|
30
|
+
<a href={item.href}>{item.label}</a>
|
|
31
|
+
)}
|
|
32
|
+
</li>
|
|
33
|
+
{!isLast && <li className="atom-breadcrumb-separator">{separator}</li>}
|
|
34
|
+
</React.Fragment>
|
|
35
|
+
);
|
|
36
|
+
})}
|
|
37
|
+
</ol>
|
|
38
|
+
</nav>
|
|
39
|
+
);
|
|
40
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
|
|
3
|
+
export interface CalendarProps {
|
|
4
|
+
selected?: Date;
|
|
5
|
+
onSelect?: (date: Date) => void;
|
|
6
|
+
className?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const Calendar: React.FC<CalendarProps> = ({ selected, onSelect, className = '' }) => {
|
|
10
|
+
const [currentDate, setCurrentDate] = useState(selected || new Date());
|
|
11
|
+
|
|
12
|
+
const year = currentDate.getFullYear();
|
|
13
|
+
const month = currentDate.getMonth();
|
|
14
|
+
|
|
15
|
+
const firstDay = new Date(year, month, 1).getDay();
|
|
16
|
+
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
|
17
|
+
|
|
18
|
+
const handlePrevMonth = () => setCurrentDate(new Date(year, month - 1, 1));
|
|
19
|
+
const handleNextMonth = () => setCurrentDate(new Date(year, month + 1, 1));
|
|
20
|
+
|
|
21
|
+
const days = [];
|
|
22
|
+
for (let i = 0; i < firstDay; i++) {
|
|
23
|
+
days.push(null);
|
|
24
|
+
}
|
|
25
|
+
for (let i = 1; i <= daysInMonth; i++) {
|
|
26
|
+
days.push(new Date(year, month, i));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const isSelected = (d: Date | null) => {
|
|
30
|
+
if (!d || !selected) return false;
|
|
31
|
+
return (
|
|
32
|
+
d.getFullYear() === selected.getFullYear() &&
|
|
33
|
+
d.getMonth() === selected.getMonth() &&
|
|
34
|
+
d.getDate() === selected.getDate()
|
|
35
|
+
);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div className={`atom-calendar ${className}`}>
|
|
40
|
+
<div className="atom-calendar-header">
|
|
41
|
+
<button type="button" onClick={handlePrevMonth} className="atom-calendar-nav-btn" aria-label="이전 달">
|
|
42
|
+
<
|
|
43
|
+
</button>
|
|
44
|
+
<span className="atom-calendar-title">
|
|
45
|
+
{year}년 {month + 1}월
|
|
46
|
+
</span>
|
|
47
|
+
<button type="button" onClick={handleNextMonth} className="atom-calendar-nav-btn" aria-label="다음 달">
|
|
48
|
+
>
|
|
49
|
+
</button>
|
|
50
|
+
</div>
|
|
51
|
+
<div className="atom-calendar-grid">
|
|
52
|
+
{['일', '월', '화', '수', '목', '금', '토'].map((w) => (
|
|
53
|
+
<div key={w} className="atom-calendar-weekday">
|
|
54
|
+
{w}
|
|
55
|
+
</div>
|
|
56
|
+
))}
|
|
57
|
+
{days.map((d, idx) =>
|
|
58
|
+
d ? (
|
|
59
|
+
<button
|
|
60
|
+
key={idx}
|
|
61
|
+
type="button"
|
|
62
|
+
onClick={() => onSelect?.(d)}
|
|
63
|
+
className={`atom-calendar-day ${isSelected(d) ? 'atom-calendar-day-selected' : ''}`}
|
|
64
|
+
>
|
|
65
|
+
{d.getDate()}
|
|
66
|
+
</button>
|
|
67
|
+
) : (
|
|
68
|
+
<div key={idx} />
|
|
69
|
+
)
|
|
70
|
+
)}
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
export interface PaginationProps {
|
|
4
|
+
currentPage: number;
|
|
5
|
+
totalPages: number;
|
|
6
|
+
onPageChange?: (page: number) => void;
|
|
7
|
+
className?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const Pagination: React.FC<PaginationProps> = ({
|
|
11
|
+
currentPage,
|
|
12
|
+
totalPages,
|
|
13
|
+
onPageChange,
|
|
14
|
+
className = '',
|
|
15
|
+
}) => {
|
|
16
|
+
const pages = Array.from({ length: totalPages }, (_, i) => i + 1);
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<nav className={`atom-pagination ${className}`} aria-label="페이지 네비게이션">
|
|
20
|
+
<button
|
|
21
|
+
type="button"
|
|
22
|
+
disabled={currentPage <= 1}
|
|
23
|
+
onClick={() => onPageChange?.(currentPage - 1)}
|
|
24
|
+
className="atom-pagination-btn"
|
|
25
|
+
>
|
|
26
|
+
< 이전
|
|
27
|
+
</button>
|
|
28
|
+
{pages.map((p) => (
|
|
29
|
+
<button
|
|
30
|
+
key={p}
|
|
31
|
+
type="button"
|
|
32
|
+
onClick={() => onPageChange?.(p)}
|
|
33
|
+
className={`atom-pagination-btn ${p === currentPage ? 'atom-pagination-active' : ''}`}
|
|
34
|
+
aria-current={p === currentPage ? 'page' : undefined}
|
|
35
|
+
>
|
|
36
|
+
{p}
|
|
37
|
+
</button>
|
|
38
|
+
))}
|
|
39
|
+
<button
|
|
40
|
+
type="button"
|
|
41
|
+
disabled={currentPage >= totalPages}
|
|
42
|
+
onClick={() => onPageChange?.(currentPage + 1)}
|
|
43
|
+
className="atom-pagination-btn"
|
|
44
|
+
>
|
|
45
|
+
다음 >
|
|
46
|
+
</button>
|
|
47
|
+
</nav>
|
|
48
|
+
);
|
|
49
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
export interface ProgressProps {
|
|
4
|
+
value: number;
|
|
5
|
+
max?: number;
|
|
6
|
+
variant?: 'primary' | 'success';
|
|
7
|
+
className?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const Progress: React.FC<ProgressProps> = ({
|
|
11
|
+
value,
|
|
12
|
+
max = 100,
|
|
13
|
+
variant = 'primary',
|
|
14
|
+
className = '',
|
|
15
|
+
}) => {
|
|
16
|
+
const percent = Math.min(100, Math.max(0, (value / max) * 100));
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<div
|
|
20
|
+
className={`atom-progress ${className}`}
|
|
21
|
+
role="progressbar"
|
|
22
|
+
aria-valuenow={value}
|
|
23
|
+
aria-valuemin={0}
|
|
24
|
+
aria-valuemax={max}
|
|
25
|
+
>
|
|
26
|
+
<div
|
|
27
|
+
className={`atom-progress-bar atom-progress-${variant}`}
|
|
28
|
+
style={{ width: `${percent}%` }}
|
|
29
|
+
/>
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
export interface RadioOption {
|
|
4
|
+
value: string;
|
|
5
|
+
label: string;
|
|
6
|
+
description?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface RadioGroupProps {
|
|
10
|
+
name?: string;
|
|
11
|
+
value?: string;
|
|
12
|
+
options: RadioOption[];
|
|
13
|
+
onChange?: (val: string) => void;
|
|
14
|
+
className?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const RadioGroup: React.FC<RadioGroupProps> = ({
|
|
18
|
+
name = 'atom-radio-group',
|
|
19
|
+
value,
|
|
20
|
+
options,
|
|
21
|
+
onChange,
|
|
22
|
+
className = '',
|
|
23
|
+
}) => {
|
|
24
|
+
return (
|
|
25
|
+
<div className={`atom-radio-group ${className}`} role="radiogroup">
|
|
26
|
+
{options.map((opt) => (
|
|
27
|
+
<label key={opt.value} className="atom-radio-label">
|
|
28
|
+
<input
|
|
29
|
+
type="radio"
|
|
30
|
+
name={name}
|
|
31
|
+
value={opt.value}
|
|
32
|
+
checked={value === opt.value}
|
|
33
|
+
onChange={() => onChange?.(opt.value)}
|
|
34
|
+
className="atom-radio"
|
|
35
|
+
/>
|
|
36
|
+
<div className="flex flex-col">
|
|
37
|
+
<span className="font-semibold">{opt.label}</span>
|
|
38
|
+
{opt.description && <span className="text-xs text-neutral-500">{opt.description}</span>}
|
|
39
|
+
</div>
|
|
40
|
+
</label>
|
|
41
|
+
))}
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import React, { useState, useRef, useEffect } from 'react';
|
|
2
|
+
|
|
3
|
+
export interface SelectOption {
|
|
4
|
+
value: string;
|
|
5
|
+
label: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface SelectProps {
|
|
9
|
+
options: SelectOption[];
|
|
10
|
+
value?: string;
|
|
11
|
+
onChange?: (value: string) => void;
|
|
12
|
+
placeholder?: string;
|
|
13
|
+
className?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const Select: React.FC<SelectProps> = ({
|
|
17
|
+
options,
|
|
18
|
+
value,
|
|
19
|
+
onChange,
|
|
20
|
+
placeholder = '선택하세요',
|
|
21
|
+
className = '',
|
|
22
|
+
}) => {
|
|
23
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
24
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
25
|
+
|
|
26
|
+
const selectedOption = options.find((opt) => opt.value === value);
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
const handleClickOutside = (e: MouseEvent) => {
|
|
30
|
+
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
31
|
+
setIsOpen(false);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
35
|
+
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
36
|
+
}, []);
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div ref={containerRef} className={`atom-select ${className}`}>
|
|
40
|
+
<button
|
|
41
|
+
type="button"
|
|
42
|
+
className="atom-select-trigger"
|
|
43
|
+
onClick={() => setIsOpen(!isOpen)}
|
|
44
|
+
aria-haspopup="listbox"
|
|
45
|
+
aria-expanded={isOpen}
|
|
46
|
+
>
|
|
47
|
+
<span>{selectedOption ? selectedOption.label : placeholder}</span>
|
|
48
|
+
<span className="atom-select-arrow" style={{ transform: isOpen ? 'rotate(180deg)' : 'none' }}>
|
|
49
|
+
▼
|
|
50
|
+
</span>
|
|
51
|
+
</button>
|
|
52
|
+
{isOpen && (
|
|
53
|
+
<ul className="atom-select-menu" role="listbox">
|
|
54
|
+
{options.map((opt) => (
|
|
55
|
+
<li
|
|
56
|
+
key={opt.value}
|
|
57
|
+
className="atom-select-option"
|
|
58
|
+
role="option"
|
|
59
|
+
aria-selected={opt.value === value}
|
|
60
|
+
onClick={() => {
|
|
61
|
+
onChange?.(opt.value);
|
|
62
|
+
setIsOpen(false);
|
|
63
|
+
}}
|
|
64
|
+
>
|
|
65
|
+
{opt.label}
|
|
66
|
+
</li>
|
|
67
|
+
))}
|
|
68
|
+
</ul>
|
|
69
|
+
)}
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
export interface SeparatorProps {
|
|
4
|
+
orientation?: 'horizontal' | 'vertical';
|
|
5
|
+
label?: string;
|
|
6
|
+
className?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const Separator: React.FC<SeparatorProps> = ({
|
|
10
|
+
orientation = 'horizontal',
|
|
11
|
+
label,
|
|
12
|
+
className = '',
|
|
13
|
+
}) => {
|
|
14
|
+
if (label && orientation === 'horizontal') {
|
|
15
|
+
return (
|
|
16
|
+
<div className={`atom-separator-with-label ${className}`}>
|
|
17
|
+
<span>{label}</span>
|
|
18
|
+
</div>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<div
|
|
24
|
+
role="separator"
|
|
25
|
+
aria-orientation={orientation}
|
|
26
|
+
className={`atom-separator atom-separator-${orientation} ${className}`}
|
|
27
|
+
/>
|
|
28
|
+
);
|
|
29
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
export interface SkeletonProps {
|
|
4
|
+
width?: string | number;
|
|
5
|
+
height?: string | number;
|
|
6
|
+
circle?: boolean;
|
|
7
|
+
className?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const Skeleton: React.FC<SkeletonProps> = ({
|
|
11
|
+
width = '100%',
|
|
12
|
+
height = '1rem',
|
|
13
|
+
circle = false,
|
|
14
|
+
className = '',
|
|
15
|
+
}) => {
|
|
16
|
+
return (
|
|
17
|
+
<div
|
|
18
|
+
className={`atom-skeleton ${circle ? '!rounded-full' : ''} ${className}`}
|
|
19
|
+
style={{
|
|
20
|
+
width: typeof width === 'number' ? `${width}px` : width,
|
|
21
|
+
height: typeof height === 'number' ? `${height}px` : height,
|
|
22
|
+
}}
|
|
23
|
+
/>
|
|
24
|
+
);
|
|
25
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
export interface SliderProps {
|
|
4
|
+
value: number;
|
|
5
|
+
min?: number;
|
|
6
|
+
max?: number;
|
|
7
|
+
step?: number;
|
|
8
|
+
onChange?: (val: number) => void;
|
|
9
|
+
className?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const Slider: React.FC<SliderProps> = ({
|
|
13
|
+
value,
|
|
14
|
+
min = 0,
|
|
15
|
+
max = 100,
|
|
16
|
+
step = 1,
|
|
17
|
+
onChange,
|
|
18
|
+
className = '',
|
|
19
|
+
}) => {
|
|
20
|
+
return (
|
|
21
|
+
<div className={`atom-slider ${className}`}>
|
|
22
|
+
<input
|
|
23
|
+
type="range"
|
|
24
|
+
min={min}
|
|
25
|
+
max={max}
|
|
26
|
+
step={step}
|
|
27
|
+
value={value}
|
|
28
|
+
onChange={(e) => onChange?.(Number(e.target.value))}
|
|
29
|
+
className="atom-slider-input"
|
|
30
|
+
/>
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
|
4
|
+
maxLength?: number;
|
|
5
|
+
label?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|
9
|
+
({ maxLength, label, value, className = '', ...props }, ref) => {
|
|
10
|
+
const currentLength = typeof value === 'string' ? value.length : 0;
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<div className="atom-textarea-group">
|
|
14
|
+
{label && <label className="text-xs font-semibold text-neutral-600 dark:text-neutral-400">{label}</label>}
|
|
15
|
+
<textarea ref={ref} value={value} maxLength={maxLength} className={`atom-textarea ${className}`} {...props} />
|
|
16
|
+
{maxLength && (
|
|
17
|
+
<div className="atom-textarea-counter">
|
|
18
|
+
{currentLength} / {maxLength}자
|
|
19
|
+
</div>
|
|
20
|
+
)}
|
|
21
|
+
</div>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
);
|
|
25
|
+
Textarea.displayName = 'Textarea';
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
|
|
3
|
+
export interface TooltipProps {
|
|
4
|
+
content: React.ReactNode;
|
|
5
|
+
children: React.ReactNode;
|
|
6
|
+
position?: 'top' | 'bottom';
|
|
7
|
+
className?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const Tooltip: React.FC<TooltipProps> = ({ content, children, position = 'top', className = '' }) => {
|
|
11
|
+
const [isVisible, setIsVisible] = useState(false);
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<div
|
|
15
|
+
className={`atom-tooltip-wrapper ${className}`}
|
|
16
|
+
onMouseEnter={() => setIsVisible(true)}
|
|
17
|
+
onMouseLeave={() => setIsVisible(false)}
|
|
18
|
+
onFocus={() => setIsVisible(true)}
|
|
19
|
+
onBlur={() => setIsVisible(false)}
|
|
20
|
+
>
|
|
21
|
+
{children}
|
|
22
|
+
{isVisible && (
|
|
23
|
+
<div className={`atom-tooltip-bubble atom-tooltip-${position}`} role="tooltip">
|
|
24
|
+
{content}
|
|
25
|
+
</div>
|
|
26
|
+
)}
|
|
27
|
+
</div>
|
|
28
|
+
);
|
|
29
|
+
};
|
package/components/ui/index.ts
CHANGED
|
@@ -3,3 +3,16 @@ export * from './Input';
|
|
|
3
3
|
export * from './Card';
|
|
4
4
|
export * from './Badge';
|
|
5
5
|
export * from './Modal';
|
|
6
|
+
export * from './Calendar';
|
|
7
|
+
export * from './Select';
|
|
8
|
+
export * from './Accordion';
|
|
9
|
+
export * from './Tooltip';
|
|
10
|
+
export * from './Pagination';
|
|
11
|
+
export * from './Radio';
|
|
12
|
+
export * from './Slider';
|
|
13
|
+
export * from './Avatar';
|
|
14
|
+
export * from './Progress';
|
|
15
|
+
export * from './Skeleton';
|
|
16
|
+
export * from './Breadcrumb';
|
|
17
|
+
export * from './Separator';
|
|
18
|
+
export * from './Textarea';
|