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 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`, `Input`, `Badge`) |
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
+ &lt;
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
+ &gt;
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
+ &lt; 이전
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
+ 다음 &gt;
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
+ };
@@ -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';