@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.
Files changed (72) hide show
  1. package/assets/components/AppFrame/AppFrame.module.css +6 -0
  2. package/assets/components/AttachmentItem/AttachmentItem.module.css +112 -0
  3. package/assets/components/AttachmentItem/AttachmentItem.tsx +164 -0
  4. package/assets/components/AttachmentItem/index.ts +6 -0
  5. package/assets/components/Button/Button.module.css +11 -2
  6. package/assets/components/Button/Button.tsx +5 -5
  7. package/assets/components/ChatInput/ChatInput.tsx +2 -2
  8. package/assets/components/DatePicker/Calendar.tsx +19 -1
  9. package/assets/components/Drawer/Drawer.tsx +120 -39
  10. package/assets/components/Drawer/index.ts +9 -1
  11. package/assets/components/GeneratedImage/GeneratedImage.module.css +90 -0
  12. package/assets/components/GeneratedImage/GeneratedImage.tsx +70 -0
  13. package/assets/components/GeneratedImage/index.ts +5 -0
  14. package/assets/components/Icon/Icon.tsx +4 -1
  15. package/assets/components/Input/Input.module.css +3 -0
  16. package/assets/components/MessageState/MessageState.module.css +12 -6
  17. package/assets/components/MessageState/MessageState.tsx +23 -5
  18. package/assets/components/Modal/Modal.tsx +114 -33
  19. package/assets/components/Modal/index.ts +12 -2
  20. package/assets/components/Spinner/Spinner.module.css +1 -1
  21. package/assets/components/Spinner/Spinner.tsx +2 -2
  22. package/assets/components/Switch/Switch.tsx +6 -0
  23. package/assets/components/TableHeader/TableHeader.module.css +29 -20
  24. package/assets/components/TableRow/TableRow.module.css +7 -5
  25. package/assets/components/TableRowEdit/TableRowEdit.module.css +10 -3
  26. package/assets/components/TableRowEdit/TableRowEdit.tsx +7 -2
  27. package/assets/components/Toast/Toast.tsx +19 -0
  28. package/assets/components/_data.ts +39 -14
  29. package/assets/components/app-shell.css +17 -0
  30. package/assets/components/index.ts +77 -0
  31. package/assets/components/reset.css +63 -0
  32. package/assets/components/themeBootScript.ts +40 -0
  33. package/assets/components/tokens.css +70 -60
  34. package/assets/prompts/GUIDE.md +1 -1
  35. package/assets/prompts/PRINCIPLES.md +9 -0
  36. package/assets/prompts/axes/cascade.md +49 -0
  37. package/assets/prompts/axes/mode.md +3 -1
  38. package/assets/prompts/components/_variants.md +84 -0
  39. package/assets/prompts/components/attachment-item.md +80 -0
  40. package/assets/prompts/components/attachment-thumb.md +2 -2
  41. package/assets/prompts/components/avatar-upload.md +53 -0
  42. package/assets/prompts/components/bubble.md +2 -2
  43. package/assets/prompts/components/chat-input.md +7 -8
  44. package/assets/prompts/components/code-block.md +2 -2
  45. package/assets/prompts/components/disclosure.md +47 -0
  46. package/assets/prompts/components/file-icon.md +3 -3
  47. package/assets/prompts/components/generated-image.md +38 -0
  48. package/assets/prompts/components/message-quote.md +2 -2
  49. package/assets/prompts/components/message-sources.md +5 -4
  50. package/assets/prompts/components/message-state.md +15 -12
  51. package/assets/prompts/components/message-table.md +9 -6
  52. package/assets/prompts/components/onboarding.md +43 -0
  53. package/assets/prompts/components/quotation-bar.md +1 -1
  54. package/assets/prompts/components/sidebar-split.md +70 -0
  55. package/assets/prompts/components/sidebar.md +2 -0
  56. package/assets/prompts/components/spinner.md +4 -4
  57. package/assets/prompts/components/time-picker.md +69 -0
  58. package/assets/prompts/components/toolbar.md +69 -0
  59. package/assets/prompts/components/user-message.md +2 -2
  60. package/package.json +80 -3
  61. package/src/commands/init.js +53 -23
  62. package/src/utils/deps.js +23 -17
  63. package/src/utils/patch.js +155 -0
  64. package/assets/components/AttachmentChip/AttachmentChip.module.css +0 -73
  65. package/assets/components/AttachmentChip/AttachmentChip.tsx +0 -74
  66. package/assets/components/AttachmentChip/index.ts +0 -2
  67. package/assets/components/Calendar/Calendar.module.css +0 -163
  68. package/assets/components/Calendar/Calendar.tsx +0 -323
  69. package/assets/components/Calendar/index.ts +0 -2
  70. package/assets/prompts/components/attachment-chip.md +0 -46
  71. package/assets/prompts/components/calendar.md +0 -116
  72. 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
+ }
@@ -0,0 +1,6 @@
1
+ export { AttachmentItem } from "./AttachmentItem";
2
+ export type {
3
+ AttachmentItemProps,
4
+ AttachmentItemSize,
5
+ AttachmentItemState,
6
+ } from "./AttachmentItem";
@@ -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 .icon {
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: "xs",
69
- sm: "sm",
70
- md: "md",
71
- lg: "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 { AttachmentChip } from "@/components/ctds/AttachmentChip";
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
- <AttachmentChip
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
- export const Drawer = forwardRef<HTMLDivElement, DrawerProps>(function Drawer(
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
- // non-modal이면 Radix에게 modal=false로 넘겨 뒤 영역 포인터 이벤트 유지.
66
- const isModal = backdrop !== "none";
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
- <div className={styles.header}>
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 { Drawer } from "./Drawer";
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,