@conscience-technology/ctds 0.1.13 → 0.2.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/assets/components/AppFrame/AppFrame.module.css +6 -0
- package/assets/components/AttachmentItem/AttachmentItem.module.css +112 -0
- package/assets/components/AttachmentItem/AttachmentItem.tsx +164 -0
- package/assets/components/AttachmentItem/index.ts +6 -0
- package/assets/components/Button/Button.module.css +11 -2
- package/assets/components/Button/Button.tsx +5 -5
- package/assets/components/ChatInput/ChatInput.tsx +2 -2
- package/assets/components/DatePicker/Calendar.tsx +19 -1
- package/assets/components/Drawer/Drawer.tsx +120 -39
- package/assets/components/Drawer/index.ts +9 -1
- package/assets/components/GeneratedImage/GeneratedImage.module.css +90 -0
- package/assets/components/GeneratedImage/GeneratedImage.tsx +70 -0
- package/assets/components/GeneratedImage/index.ts +5 -0
- package/assets/components/Icon/Icon.tsx +4 -1
- package/assets/components/Input/Input.module.css +3 -0
- package/assets/components/MessageState/MessageState.module.css +12 -6
- package/assets/components/MessageState/MessageState.tsx +23 -5
- package/assets/components/Modal/Modal.tsx +114 -33
- package/assets/components/Modal/index.ts +12 -2
- package/assets/components/Spinner/Spinner.module.css +1 -1
- package/assets/components/Spinner/Spinner.tsx +2 -2
- package/assets/components/Switch/Switch.tsx +6 -0
- package/assets/components/TableHeader/TableHeader.module.css +29 -20
- package/assets/components/TableRow/TableRow.module.css +7 -5
- package/assets/components/TableRowEdit/TableRowEdit.module.css +10 -3
- package/assets/components/TableRowEdit/TableRowEdit.tsx +7 -2
- package/assets/components/Toast/Toast.tsx +19 -0
- package/assets/components/_data.ts +39 -14
- package/assets/components/app-shell.css +17 -0
- package/assets/components/index.ts +77 -0
- package/assets/components/reset.css +63 -0
- package/assets/components/themeBootScript.ts +40 -0
- package/assets/components/tokens.css +70 -60
- package/assets/prompts/GUIDE.md +1 -1
- package/assets/prompts/PRINCIPLES.md +9 -0
- package/assets/prompts/axes/cascade.md +49 -0
- package/assets/prompts/axes/mode.md +3 -1
- package/assets/prompts/components/_variants.md +84 -0
- package/assets/prompts/components/attachment-item.md +80 -0
- package/assets/prompts/components/attachment-thumb.md +2 -2
- package/assets/prompts/components/avatar-upload.md +53 -0
- package/assets/prompts/components/bubble.md +2 -2
- package/assets/prompts/components/chat-input.md +7 -8
- package/assets/prompts/components/code-block.md +2 -2
- package/assets/prompts/components/disclosure.md +47 -0
- package/assets/prompts/components/file-icon.md +3 -3
- package/assets/prompts/components/generated-image.md +38 -0
- package/assets/prompts/components/message-quote.md +2 -2
- package/assets/prompts/components/message-sources.md +5 -4
- package/assets/prompts/components/message-state.md +15 -12
- package/assets/prompts/components/message-table.md +9 -6
- package/assets/prompts/components/onboarding.md +43 -0
- package/assets/prompts/components/quotation-bar.md +1 -1
- package/assets/prompts/components/sidebar-split.md +70 -0
- package/assets/prompts/components/sidebar.md +2 -0
- package/assets/prompts/components/spinner.md +4 -4
- package/assets/prompts/components/time-picker.md +69 -0
- package/assets/prompts/components/toolbar.md +69 -0
- package/assets/prompts/components/user-message.md +2 -2
- package/package.json +80 -3
- package/src/commands/init.js +53 -23
- package/src/utils/deps.js +23 -17
- package/src/utils/patch.js +155 -0
- package/assets/components/AttachmentChip/AttachmentChip.module.css +0 -73
- package/assets/components/AttachmentChip/AttachmentChip.tsx +0 -74
- package/assets/components/AttachmentChip/index.ts +0 -2
- package/assets/components/Calendar/Calendar.module.css +0 -163
- package/assets/components/Calendar/Calendar.tsx +0 -323
- package/assets/components/Calendar/index.ts +0 -2
- package/assets/prompts/components/attachment-chip.md +0 -46
- package/assets/prompts/components/calendar.md +0 -116
- package/assets/prompts/components/form-field.md +0 -74
|
@@ -133,6 +133,12 @@
|
|
|
133
133
|
border-inline-end: var(--hairline) solid var(--border);
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
/* opt-out: 소비처가 data-sidebar-border="none"을 걸면 사이드바 오른쪽 hairline을 숨김.
|
|
137
|
+
docs처럼 사이드바가 bg·padding만으로 자체 영역을 표현할 때 사용. */
|
|
138
|
+
.root[data-sidebar-border="none"] .sidebarSlot {
|
|
139
|
+
border-inline-end: none;
|
|
140
|
+
}
|
|
141
|
+
|
|
136
142
|
/* floating ────────────────────────────────────────────
|
|
137
143
|
sidebar/topbar/content가 각각 카드로 떠 있음. */
|
|
138
144
|
.root[data-variant="floating"] {
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/* AttachmentItem — [leading] filename (meta) [actions]
|
|
2
|
+
size sm/md: 1줄 pill
|
|
3
|
+
size lg: 2줄 큰 카드 (파일명 위 / meta 아래)
|
|
4
|
+
폭은 전부 --input-w-lg (400px) 고정 — 소비처가 폭 걱정 없이 넣기만. */
|
|
5
|
+
|
|
6
|
+
.root {
|
|
7
|
+
display: inline-flex;
|
|
8
|
+
align-items: center;
|
|
9
|
+
gap: var(--space-2);
|
|
10
|
+
width: var(--input-w-lg);
|
|
11
|
+
min-width: 0;
|
|
12
|
+
background: var(--bg);
|
|
13
|
+
border: var(--hairline) solid var(--border);
|
|
14
|
+
color: var(--fg);
|
|
15
|
+
transition: var(--transition-colors);
|
|
16
|
+
box-sizing: border-box;
|
|
17
|
+
/* 아주 연한 drop shadow — 종이가 살짝 떠 있는 정도 (hairline보다 아주 약간만 강조). */
|
|
18
|
+
box-shadow: var(--shadow-xs);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/* ───────── size ─────────
|
|
22
|
+
sm/md: 1줄. radius-md.
|
|
23
|
+
lg: 2줄 세로 스택. radius-lg + 넉넉한 padding. */
|
|
24
|
+
.sizeSm {
|
|
25
|
+
padding: var(--space-1) var(--space-1) var(--space-1) var(--space-2);
|
|
26
|
+
gap: var(--space-1_5);
|
|
27
|
+
font-size: var(--text-xs);
|
|
28
|
+
border-radius: var(--radius-md);
|
|
29
|
+
}
|
|
30
|
+
.sizeMd {
|
|
31
|
+
padding: var(--space-1_5) var(--space-1_5) var(--space-1_5) var(--space-2_5);
|
|
32
|
+
gap: var(--space-2);
|
|
33
|
+
font-size: var(--text-sm);
|
|
34
|
+
border-radius: var(--radius-md);
|
|
35
|
+
}
|
|
36
|
+
.sizeLg {
|
|
37
|
+
padding: var(--space-2_5) var(--space-1_5) var(--space-2_5) var(--space-2_5);
|
|
38
|
+
gap: var(--space-2_5);
|
|
39
|
+
font-size: var(--text-sm);
|
|
40
|
+
border-radius: var(--radius-lg);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/* ───────── text block ───────── */
|
|
44
|
+
.text {
|
|
45
|
+
display: flex;
|
|
46
|
+
flex: 1 1 auto;
|
|
47
|
+
min-width: 0;
|
|
48
|
+
align-items: baseline;
|
|
49
|
+
gap: var(--space-1);
|
|
50
|
+
}
|
|
51
|
+
.sizeLg .text {
|
|
52
|
+
flex-direction: column;
|
|
53
|
+
align-items: flex-start;
|
|
54
|
+
gap: var(--space-0_5);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.label {
|
|
58
|
+
flex: 0 1 auto;
|
|
59
|
+
min-width: 0;
|
|
60
|
+
overflow: hidden;
|
|
61
|
+
text-overflow: ellipsis;
|
|
62
|
+
white-space: nowrap;
|
|
63
|
+
color: var(--fg-strong);
|
|
64
|
+
font-weight: var(--font-weight-medium);
|
|
65
|
+
line-height: 1.2;
|
|
66
|
+
}
|
|
67
|
+
.sizeLg .label {
|
|
68
|
+
font-size: var(--text-sm);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.meta {
|
|
72
|
+
flex: 1 1 auto;
|
|
73
|
+
min-width: 0;
|
|
74
|
+
color: var(--fg-subtle);
|
|
75
|
+
font-weight: var(--font-weight-regular);
|
|
76
|
+
line-height: 1.2;
|
|
77
|
+
overflow: hidden;
|
|
78
|
+
text-overflow: ellipsis;
|
|
79
|
+
white-space: nowrap;
|
|
80
|
+
display: inline-flex;
|
|
81
|
+
align-items: center;
|
|
82
|
+
gap: var(--space-1);
|
|
83
|
+
font-size: var(--text-xs);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/* ───────── loading slot ─────────
|
|
87
|
+
FileIcon 자리에 들어가는 정사각 — 각 사이즈 FileIcon 치수와 동일.
|
|
88
|
+
내부 Spinner(lottie-dots)가 꽉 채운다. */
|
|
89
|
+
.loadingSlot {
|
|
90
|
+
display: inline-flex;
|
|
91
|
+
align-items: center;
|
|
92
|
+
justify-content: center;
|
|
93
|
+
flex: 0 0 auto;
|
|
94
|
+
color: var(--primary);
|
|
95
|
+
width: var(--space-6);
|
|
96
|
+
height: var(--space-6);
|
|
97
|
+
}
|
|
98
|
+
.sizeSm .loadingSlot {
|
|
99
|
+
width: var(--space-4);
|
|
100
|
+
height: var(--space-4);
|
|
101
|
+
}
|
|
102
|
+
.sizeLg .loadingSlot {
|
|
103
|
+
width: var(--space-8);
|
|
104
|
+
height: var(--space-8);
|
|
105
|
+
}
|
|
106
|
+
/* ───────── actions ───────── */
|
|
107
|
+
.actions {
|
|
108
|
+
display: inline-flex;
|
|
109
|
+
align-items: center;
|
|
110
|
+
gap: var(--space-1);
|
|
111
|
+
flex: 0 0 auto;
|
|
112
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { CSSProperties, HTMLAttributes, MouseEvent, ReactNode } from "react";
|
|
4
|
+
import {
|
|
5
|
+
DotsThree as PhDotsThree,
|
|
6
|
+
DownloadSimple as PhDownload,
|
|
7
|
+
X as PhX,
|
|
8
|
+
} from "@phosphor-icons/react";
|
|
9
|
+
import { FileIcon, type FileIconSize } from "@/components/ctds/FileIcon";
|
|
10
|
+
import { IconButton, type IconButtonSize } from "@/components/ctds/IconButton";
|
|
11
|
+
import { Spinner } from "@/components/ctds/Spinner";
|
|
12
|
+
import styles from "./AttachmentItem.module.css";
|
|
13
|
+
|
|
14
|
+
export type AttachmentItemSize = "sm" | "md" | "lg";
|
|
15
|
+
export type AttachmentItemState = "idle" | "loading";
|
|
16
|
+
|
|
17
|
+
export interface AttachmentItemProps
|
|
18
|
+
extends Omit<HTMLAttributes<HTMLDivElement>, "children" | "onError"> {
|
|
19
|
+
/** 파일명. 확장자가 FileIcon 색/라벨을 결정. */
|
|
20
|
+
filename: string;
|
|
21
|
+
/** sm/md=1줄, lg=2줄(파일명 위 / meta 아래). 폭은 전부 400px 고정. */
|
|
22
|
+
size?: AttachmentItemSize;
|
|
23
|
+
/** loading이면 좌측 뱃지가 Spinner(lottie-dots)로 교체. */
|
|
24
|
+
state?: AttachmentItemState;
|
|
25
|
+
/** sm/md: 파일명 옆 inline 보조 영역. lg: 파일명 아래 2번째 줄.
|
|
26
|
+
* 문자열이면 muted 라벨로, ReactNode면 그대로 렌더 (ex. Button ghost xs). */
|
|
27
|
+
meta?: ReactNode;
|
|
28
|
+
/** 지정 시 우측 X. */
|
|
29
|
+
onRemove?: (e: MouseEvent<HTMLButtonElement>) => void;
|
|
30
|
+
/** 지정 시 우측 다운로드 아이콘. */
|
|
31
|
+
onDownload?: (e: MouseEvent<HTMLButtonElement>) => void;
|
|
32
|
+
/** 지정 시 우측 '더보기' 아이콘. */
|
|
33
|
+
onMore?: (e: MouseEvent<HTMLButtonElement>) => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const sizeClass: Record<AttachmentItemSize, string> = {
|
|
37
|
+
sm: styles.sizeSm,
|
|
38
|
+
md: styles.sizeMd,
|
|
39
|
+
lg: styles.sizeLg,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** 좌측 FileIcon 크기 — sm=sm(16), md=md(24), lg=lg(32). */
|
|
43
|
+
const FILE_ICON_SIZE: Record<AttachmentItemSize, FileIconSize> = {
|
|
44
|
+
sm: "sm",
|
|
45
|
+
md: "md",
|
|
46
|
+
lg: "lg",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** trailing IconButton 크기 — sm/md=sm(20), lg=md(24). IconButton 공식 사이즈만 사용. */
|
|
50
|
+
const ACTION_BTN_SIZE: Record<AttachmentItemSize, IconButtonSize> = {
|
|
51
|
+
sm: "sm",
|
|
52
|
+
md: "sm",
|
|
53
|
+
lg: "md",
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** loading 자리의 Spinner 크기 — loadingSlot(=FileIcon 치수) 16/24/32와 일치시켜야
|
|
57
|
+
* lottie 내부 scale/offset 계산이 안 깨짐. slot 치수를 그대로 --loader-size로 override. */
|
|
58
|
+
const SPINNER_LOADER_SIZE: Record<AttachmentItemSize, string> = {
|
|
59
|
+
sm: "var(--space-4)",
|
|
60
|
+
md: "var(--space-6)",
|
|
61
|
+
lg: "var(--space-8)",
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** middle ellipsis — 폭 400px 안에서 충분히 들어가는 글자 수.
|
|
65
|
+
* 확장자는 항상 끝에 남긴다. CSS text-overflow:ellipsis가 뒤에서
|
|
66
|
+
* 확장자를 다시 자르지 않도록 폭 cap은 CSS와 함께 넉넉히 잡는다. */
|
|
67
|
+
function middleEllipsis(filename: string, max: number): string {
|
|
68
|
+
if (filename.length <= max) return filename;
|
|
69
|
+
const dot = filename.lastIndexOf(".");
|
|
70
|
+
const extLen = filename.length - dot;
|
|
71
|
+
if (dot <= 0 || extLen > 8 || extLen >= max - 4) {
|
|
72
|
+
return filename.slice(0, Math.max(1, max - 3)) + "...";
|
|
73
|
+
}
|
|
74
|
+
const ext = filename.slice(dot);
|
|
75
|
+
const head = filename.slice(0, Math.max(1, max - ext.length - 3));
|
|
76
|
+
return head + "..." + ext;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function AttachmentItem({
|
|
80
|
+
filename,
|
|
81
|
+
size = "md",
|
|
82
|
+
state = "idle",
|
|
83
|
+
meta,
|
|
84
|
+
onRemove,
|
|
85
|
+
onDownload,
|
|
86
|
+
onMore,
|
|
87
|
+
className,
|
|
88
|
+
...rest
|
|
89
|
+
}: AttachmentItemProps) {
|
|
90
|
+
const cls = [
|
|
91
|
+
styles.root,
|
|
92
|
+
sizeClass[size],
|
|
93
|
+
state === "loading" ? styles.isLoading : null,
|
|
94
|
+
className,
|
|
95
|
+
]
|
|
96
|
+
.filter(Boolean)
|
|
97
|
+
.join(" ");
|
|
98
|
+
|
|
99
|
+
const maxChars = size === "lg" ? 44 : size === "md" ? 40 : 36;
|
|
100
|
+
const label = middleEllipsis(filename, maxChars);
|
|
101
|
+
|
|
102
|
+
const leading =
|
|
103
|
+
state === "loading" ? (
|
|
104
|
+
<span className={styles.loadingSlot} aria-hidden>
|
|
105
|
+
<Spinner
|
|
106
|
+
variant="lottie-dots"
|
|
107
|
+
label="업로드 중"
|
|
108
|
+
style={{ "--loader-size": SPINNER_LOADER_SIZE[size] } as CSSProperties}
|
|
109
|
+
/>
|
|
110
|
+
</span>
|
|
111
|
+
) : (
|
|
112
|
+
<FileIcon ext={filename} size={FILE_ICON_SIZE[size]} />
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const actionSize = ACTION_BTN_SIZE[size];
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<div className={cls} {...rest}>
|
|
119
|
+
{leading}
|
|
120
|
+
|
|
121
|
+
<span className={styles.text}>
|
|
122
|
+
<span className={styles.label} title={filename}>
|
|
123
|
+
{label}
|
|
124
|
+
</span>
|
|
125
|
+
{meta != null && meta !== "" ? (
|
|
126
|
+
<span className={styles.meta}>{meta}</span>
|
|
127
|
+
) : null}
|
|
128
|
+
</span>
|
|
129
|
+
|
|
130
|
+
{(onDownload || onMore || onRemove) && (
|
|
131
|
+
<span className={styles.actions}>
|
|
132
|
+
{onDownload ? (
|
|
133
|
+
<IconButton
|
|
134
|
+
size={actionSize}
|
|
135
|
+
variant="ghost"
|
|
136
|
+
onClick={onDownload}
|
|
137
|
+
aria-label="첨부 다운로드"
|
|
138
|
+
icon={<PhDownload />}
|
|
139
|
+
/>
|
|
140
|
+
) : null}
|
|
141
|
+
{onMore ? (
|
|
142
|
+
<IconButton
|
|
143
|
+
size={actionSize}
|
|
144
|
+
variant="ghost"
|
|
145
|
+
onClick={onMore}
|
|
146
|
+
aria-label="첨부 메뉴 열기"
|
|
147
|
+
aria-haspopup="menu"
|
|
148
|
+
icon={<PhDotsThree weight="bold" />}
|
|
149
|
+
/>
|
|
150
|
+
) : null}
|
|
151
|
+
{onRemove ? (
|
|
152
|
+
<IconButton
|
|
153
|
+
size={actionSize}
|
|
154
|
+
variant="ghost"
|
|
155
|
+
onClick={onRemove}
|
|
156
|
+
aria-label="첨부 제거"
|
|
157
|
+
icon={<PhX />}
|
|
158
|
+
/>
|
|
159
|
+
) : null}
|
|
160
|
+
</span>
|
|
161
|
+
)}
|
|
162
|
+
</div>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
@@ -38,14 +38,17 @@
|
|
|
38
38
|
/* Disabled — variant 무관 통일 스타일.
|
|
39
39
|
회색 버튼(--bg-muted) + 실버 글자(--fg-disabled) + 투명 보더.
|
|
40
40
|
loading은 opacity로 유지 (스피너 컨텍스트 보존). */
|
|
41
|
-
.root:disabled {
|
|
41
|
+
.root:disabled:not([data-loading="true"]) {
|
|
42
42
|
cursor: not-allowed;
|
|
43
43
|
background: var(--bg-muted);
|
|
44
44
|
color: var(--fg-disabled);
|
|
45
45
|
border-color: transparent;
|
|
46
46
|
box-shadow: none;
|
|
47
47
|
}
|
|
48
|
-
.root:disabled
|
|
48
|
+
.root:disabled[data-loading="true"] {
|
|
49
|
+
cursor: progress;
|
|
50
|
+
}
|
|
51
|
+
.root:disabled:not([data-loading="true"]) .icon {
|
|
49
52
|
opacity: 1;
|
|
50
53
|
}
|
|
51
54
|
/* Loading — 라벨/아이콘은 보이지만 톤 다운, Spinner가 중앙에 overlay.
|
|
@@ -227,3 +230,9 @@
|
|
|
227
230
|
.spinner {
|
|
228
231
|
display: inline-flex;
|
|
229
232
|
}
|
|
233
|
+
/* 투명·배경 위 variant는 로딩 스피너를 muted gray로 — primary 색이 흰 배경 위에서
|
|
234
|
+
혼자 둥둥 떠 보이는 걸 막는다. */
|
|
235
|
+
.outline .spinner,
|
|
236
|
+
.ghost .spinner {
|
|
237
|
+
color: var(--fg-muted);
|
|
238
|
+
}
|
|
@@ -65,10 +65,10 @@ const ICON_SIZE: Record<ButtonSize, number> = {
|
|
|
65
65
|
};
|
|
66
66
|
|
|
67
67
|
const SPINNER_SIZE: Record<ButtonSize, SpinnerSize> = {
|
|
68
|
-
xs: "
|
|
69
|
-
sm: "
|
|
70
|
-
md: "
|
|
71
|
-
lg: "
|
|
68
|
+
xs: "xl",
|
|
69
|
+
sm: "xl",
|
|
70
|
+
md: "xl",
|
|
71
|
+
lg: "xl",
|
|
72
72
|
};
|
|
73
73
|
|
|
74
74
|
function sizedIcon(node: ReactNode, size: number): ReactNode {
|
|
@@ -149,7 +149,7 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
|
|
149
149
|
)}
|
|
150
150
|
{loading && (
|
|
151
151
|
<span className={styles.spinner} aria-hidden>
|
|
152
|
-
<Spinner size={SPINNER_SIZE[size]} />
|
|
152
|
+
<Spinner variant="lottie-spinner" size={SPINNER_SIZE[size]} />
|
|
153
153
|
</span>
|
|
154
154
|
)}
|
|
155
155
|
</Comp>
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
type ReactNode,
|
|
13
13
|
} from "react";
|
|
14
14
|
import { Paperclip as PhPaperclip } from "@phosphor-icons/react";
|
|
15
|
-
import {
|
|
15
|
+
import { AttachmentItem } from "@/components/ctds/AttachmentItem";
|
|
16
16
|
import { AttachmentThumb } from "@/components/ctds/AttachmentThumb";
|
|
17
17
|
import { DropdownMenu } from "@/components/ctds/DropdownMenu";
|
|
18
18
|
import { QuotationBar } from "@/components/ctds/QuotationBar";
|
|
@@ -262,7 +262,7 @@ export const ChatInput = forwardRef<HTMLDivElement, ChatInputProps>(
|
|
|
262
262
|
}
|
|
263
263
|
/>
|
|
264
264
|
) : (
|
|
265
|
-
<
|
|
265
|
+
<AttachmentItem
|
|
266
266
|
filename={a.filename}
|
|
267
267
|
onRemove={
|
|
268
268
|
onRemoveAttachment
|
|
@@ -104,7 +104,7 @@ const MonthView = memo(function MonthView({
|
|
|
104
104
|
))}
|
|
105
105
|
</div>
|
|
106
106
|
|
|
107
|
-
<div className={styles.grid} role="grid">
|
|
107
|
+
<div className={styles.grid} role="grid" aria-label={monthLabelText(month)}>
|
|
108
108
|
{cells.map((day) => {
|
|
109
109
|
const outside = !isSameMonth(day, month);
|
|
110
110
|
const disabled =
|
|
@@ -144,6 +144,8 @@ const MonthView = memo(function MonthView({
|
|
|
144
144
|
onMouseEnter={() => onHoverDay(day)}
|
|
145
145
|
onMouseLeave={() => onHoverDay(null)}
|
|
146
146
|
aria-selected={isSelected}
|
|
147
|
+
aria-disabled={disabled || undefined}
|
|
148
|
+
aria-label={dayAriaLabel(day, { isToday, isSelected })}
|
|
147
149
|
role="gridcell"
|
|
148
150
|
>
|
|
149
151
|
<span className={styles.dayLabel}>{day.getDate()}</span>
|
|
@@ -155,6 +157,22 @@ const MonthView = memo(function MonthView({
|
|
|
155
157
|
);
|
|
156
158
|
});
|
|
157
159
|
|
|
160
|
+
/** "2026년 10월" 류 — grid aria-label용. 월 이름이 바뀌면 utils 쪽 MONTH_LABELS_KO 교체. */
|
|
161
|
+
function monthLabelText(d: Date): string {
|
|
162
|
+
return `${d.getFullYear()}년 ${MONTH_LABELS_KO[d.getMonth()]}`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** "2026년 10월 9일 (금), 오늘, 선택됨" 류 스크린리더용 라벨. */
|
|
166
|
+
function dayAriaLabel(d: Date, flags: { isToday: boolean; isSelected: boolean }): string {
|
|
167
|
+
const parts = [
|
|
168
|
+
`${d.getFullYear()}년 ${MONTH_LABELS_KO[d.getMonth()]} ${d.getDate()}일`,
|
|
169
|
+
`(${WEEKDAY_LABELS_KO[d.getDay()]})`,
|
|
170
|
+
];
|
|
171
|
+
if (flags.isToday) parts.push("오늘");
|
|
172
|
+
if (flags.isSelected) parts.push("선택됨");
|
|
173
|
+
return parts.join(", ");
|
|
174
|
+
}
|
|
175
|
+
|
|
158
176
|
export interface CalendarProps {
|
|
159
177
|
mode: "single" | "range";
|
|
160
178
|
months: 1 | 2;
|
|
@@ -44,7 +44,16 @@ export interface DrawerProps {
|
|
|
44
44
|
backdrop?: DrawerBackdrop;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
/**
|
|
48
|
+
* 간편 모드 — 가장 흔한 "title + body + footer" 레이아웃을 flat prop으로 넘긴다.
|
|
49
|
+
* 커스터마이즈가 필요하면 compound 모드:
|
|
50
|
+
* <Drawer.Root open onOpenChange={set} side="right" size="md">
|
|
51
|
+
* <Drawer.Header title="…" />
|
|
52
|
+
* <Drawer.Body>…</Drawer.Body>
|
|
53
|
+
* <Drawer.Footer>…</Drawer.Footer>
|
|
54
|
+
* </Drawer.Root>
|
|
55
|
+
*/
|
|
56
|
+
const DrawerComponent = forwardRef<HTMLDivElement, DrawerProps>(function Drawer(
|
|
48
57
|
{
|
|
49
58
|
open,
|
|
50
59
|
onOpenChange,
|
|
@@ -62,15 +71,48 @@ export const Drawer = forwardRef<HTMLDivElement, DrawerProps>(function Drawer(
|
|
|
62
71
|
},
|
|
63
72
|
ref,
|
|
64
73
|
) {
|
|
65
|
-
|
|
66
|
-
|
|
74
|
+
return (
|
|
75
|
+
<DrawerRoot
|
|
76
|
+
open={open}
|
|
77
|
+
onOpenChange={onOpenChange}
|
|
78
|
+
disableEscape={disableEscape}
|
|
79
|
+
side={side}
|
|
80
|
+
size={size}
|
|
81
|
+
backdrop={backdrop}
|
|
82
|
+
ref={ref}
|
|
83
|
+
>
|
|
84
|
+
<DrawerHeader
|
|
85
|
+
title={title}
|
|
86
|
+
description={description}
|
|
87
|
+
titleSrOnly={titleSrOnly}
|
|
88
|
+
dismissible={dismissible}
|
|
89
|
+
dismissLabel={dismissLabel}
|
|
90
|
+
/>
|
|
91
|
+
{children !== undefined && children !== null && <DrawerBody>{children}</DrawerBody>}
|
|
92
|
+
{footer && <DrawerFooter>{footer}</DrawerFooter>}
|
|
93
|
+
</DrawerRoot>
|
|
94
|
+
);
|
|
95
|
+
});
|
|
67
96
|
|
|
97
|
+
export interface DrawerRootProps {
|
|
98
|
+
open: boolean;
|
|
99
|
+
onOpenChange: (open: boolean) => void;
|
|
100
|
+
children: ReactNode;
|
|
101
|
+
disableEscape?: boolean;
|
|
102
|
+
side?: DrawerSide;
|
|
103
|
+
size?: DrawerSize;
|
|
104
|
+
backdrop?: DrawerBackdrop;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export const DrawerRoot = forwardRef<HTMLDivElement, DrawerRootProps>(function DrawerRoot(
|
|
108
|
+
{ open, onOpenChange, children, disableEscape, side = "right", size = "md", backdrop = "scrim" },
|
|
109
|
+
ref,
|
|
110
|
+
) {
|
|
111
|
+
const isModal = backdrop !== "none";
|
|
68
112
|
return (
|
|
69
113
|
<RadixDialog.Root open={open} onOpenChange={onOpenChange} modal={isModal}>
|
|
70
114
|
<RadixDialog.Portal>
|
|
71
|
-
{backdrop !== "none" &&
|
|
72
|
-
<RadixDialog.Overlay className={styles.overlay} />
|
|
73
|
-
)}
|
|
115
|
+
{backdrop !== "none" && <RadixDialog.Overlay className={styles.overlay} />}
|
|
74
116
|
<RadixDialog.Content
|
|
75
117
|
ref={ref}
|
|
76
118
|
className={styles.content}
|
|
@@ -80,47 +122,86 @@ export const Drawer = forwardRef<HTMLDivElement, DrawerProps>(function Drawer(
|
|
|
80
122
|
if (disableEscape) e.preventDefault();
|
|
81
123
|
}}
|
|
82
124
|
onPointerDownOutside={(e) => {
|
|
83
|
-
// scrim만 바깥 클릭 닫힘. locked/none은 Radix 기본 닫힘 차단.
|
|
84
125
|
if (backdrop !== "scrim") e.preventDefault();
|
|
85
126
|
}}
|
|
86
127
|
onInteractOutside={(e) => {
|
|
87
128
|
if (backdrop === "none") e.preventDefault();
|
|
88
129
|
}}
|
|
89
130
|
>
|
|
90
|
-
|
|
91
|
-
<div className={styles.headerText}>
|
|
92
|
-
<RadixDialog.Title
|
|
93
|
-
className={[styles.title, titleSrOnly && "visually-hidden"]
|
|
94
|
-
.filter(Boolean)
|
|
95
|
-
.join(" ")}
|
|
96
|
-
>
|
|
97
|
-
{title}
|
|
98
|
-
</RadixDialog.Title>
|
|
99
|
-
{description && (
|
|
100
|
-
<RadixDialog.Description className={styles.description}>
|
|
101
|
-
{description}
|
|
102
|
-
</RadixDialog.Description>
|
|
103
|
-
)}
|
|
104
|
-
</div>
|
|
105
|
-
{dismissible && (
|
|
106
|
-
<RadixDialog.Close asChild>
|
|
107
|
-
<IconButton
|
|
108
|
-
className={styles.close}
|
|
109
|
-
variant="ghost"
|
|
110
|
-
icon={<Icon name="x" />}
|
|
111
|
-
aria-label={dismissLabel}
|
|
112
|
-
/>
|
|
113
|
-
</RadixDialog.Close>
|
|
114
|
-
)}
|
|
115
|
-
</div>
|
|
116
|
-
{children !== undefined && children !== null && (
|
|
117
|
-
<div className={styles.body}>
|
|
118
|
-
<div className={styles.bodyInner}>{children}</div>
|
|
119
|
-
</div>
|
|
120
|
-
)}
|
|
121
|
-
{footer && <div className={styles.footer}>{footer}</div>}
|
|
131
|
+
{children}
|
|
122
132
|
</RadixDialog.Content>
|
|
123
133
|
</RadixDialog.Portal>
|
|
124
134
|
</RadixDialog.Root>
|
|
125
135
|
);
|
|
126
136
|
});
|
|
137
|
+
|
|
138
|
+
export interface DrawerHeaderProps {
|
|
139
|
+
title: ReactNode;
|
|
140
|
+
description?: ReactNode;
|
|
141
|
+
titleSrOnly?: boolean;
|
|
142
|
+
dismissible?: boolean;
|
|
143
|
+
dismissLabel?: string;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function DrawerHeader({
|
|
147
|
+
title,
|
|
148
|
+
description,
|
|
149
|
+
titleSrOnly,
|
|
150
|
+
dismissible = true,
|
|
151
|
+
dismissLabel,
|
|
152
|
+
}: DrawerHeaderProps) {
|
|
153
|
+
return (
|
|
154
|
+
<div className={styles.header}>
|
|
155
|
+
<div className={styles.headerText}>
|
|
156
|
+
<RadixDialog.Title
|
|
157
|
+
className={[styles.title, titleSrOnly && "visually-hidden"]
|
|
158
|
+
.filter(Boolean)
|
|
159
|
+
.join(" ")}
|
|
160
|
+
>
|
|
161
|
+
{title}
|
|
162
|
+
</RadixDialog.Title>
|
|
163
|
+
{description && (
|
|
164
|
+
<RadixDialog.Description className={styles.description}>
|
|
165
|
+
{description}
|
|
166
|
+
</RadixDialog.Description>
|
|
167
|
+
)}
|
|
168
|
+
</div>
|
|
169
|
+
{dismissible && (
|
|
170
|
+
<RadixDialog.Close asChild>
|
|
171
|
+
<IconButton
|
|
172
|
+
className={styles.close}
|
|
173
|
+
variant="ghost"
|
|
174
|
+
icon={<Icon name="x" />}
|
|
175
|
+
aria-label={dismissLabel}
|
|
176
|
+
/>
|
|
177
|
+
</RadixDialog.Close>
|
|
178
|
+
)}
|
|
179
|
+
</div>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function DrawerBody({ children }: { children: ReactNode }) {
|
|
184
|
+
return (
|
|
185
|
+
<div className={styles.body}>
|
|
186
|
+
<div className={styles.bodyInner}>{children}</div>
|
|
187
|
+
</div>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function DrawerFooter({ children }: { children: ReactNode }) {
|
|
192
|
+
return <div className={styles.footer}>{children}</div>;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
type DrawerCompound = typeof DrawerComponent & {
|
|
196
|
+
Root: typeof DrawerRoot;
|
|
197
|
+
Header: typeof DrawerHeader;
|
|
198
|
+
Body: typeof DrawerBody;
|
|
199
|
+
Footer: typeof DrawerFooter;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
export const Drawer = Object.assign(DrawerComponent, {
|
|
203
|
+
Root: DrawerRoot,
|
|
204
|
+
Header: DrawerHeader,
|
|
205
|
+
Body: DrawerBody,
|
|
206
|
+
Footer: DrawerFooter,
|
|
207
|
+
}) as DrawerCompound;
|
|
@@ -1,6 +1,14 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export {
|
|
2
|
+
Drawer,
|
|
3
|
+
DrawerRoot,
|
|
4
|
+
DrawerHeader,
|
|
5
|
+
DrawerBody,
|
|
6
|
+
DrawerFooter,
|
|
7
|
+
} from "./Drawer";
|
|
2
8
|
export type {
|
|
3
9
|
DrawerProps,
|
|
10
|
+
DrawerRootProps,
|
|
11
|
+
DrawerHeaderProps,
|
|
4
12
|
DrawerSide,
|
|
5
13
|
DrawerSize,
|
|
6
14
|
DrawerBackdrop,
|