@conscience-technology/ctds 0.3.2 → 0.3.4
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/Checkbox/Checkbox.module.css +1 -0
- package/assets/components/MenuItem/MenuItem.module.css +30 -5
- package/assets/components/MenuItem/MenuItem.tsx +86 -5
- package/assets/components/Radio/Radio.module.css +1 -0
- package/assets/components/Sidebar/Sidebar.tsx +4 -1
- package/assets/components/_data.ts +3 -7
- package/assets/components/index.ts +0 -1
- package/assets/prompts/components/menu-item.md +1 -0
- package/package.json +1 -1
- package/assets/components/Accordion/Accordion.module.css +0 -247
- package/assets/components/Accordion/Accordion.tsx +0 -207
- package/assets/components/Accordion/index.ts +0 -8
- package/assets/components/CommandPalette/CommandPalette.module.css +0 -203
- package/assets/components/CommandPalette/CommandPalette.tsx +0 -262
- package/assets/components/CommandPalette/index.ts +0 -9
- package/assets/components/CommandPalette/useCommandPaletteHotkey.ts +0 -49
- package/assets/components/EventCard/EventCard.module.css +0 -133
- package/assets/components/EventCard/EventCard.tsx +0 -149
- package/assets/components/EventCard/index.ts +0 -2
- package/assets/prompts/components/accordion.md +0 -126
- package/assets/prompts/components/command-palette.md +0 -148
- package/assets/prompts/components/event-card.md +0 -78
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* useCommandPaletteHotkey ─ Command Palette 토글 전역 단축키 훅.
|
|
5
|
-
* 기본값 ⌘K / Ctrl+K. 입력 요소에서도 동작한다(검색 창은 어디서든 열려야 함).
|
|
6
|
-
*
|
|
7
|
-
* 사용
|
|
8
|
-
* const [open, setOpen] = useState(false);
|
|
9
|
-
* useCommandPaletteHotkey(() => setOpen((v) => !v));
|
|
10
|
-
*
|
|
11
|
-
* 커스텀 키
|
|
12
|
-
* useCommandPaletteHotkey(toggle, { key: "p", mod: true, shift: true }); // ⌘⇧P
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { useEffect } from "react";
|
|
16
|
-
|
|
17
|
-
export type HotkeyOptions = {
|
|
18
|
-
/** 단일 문자 키 (소문자). 기본 "k". */
|
|
19
|
-
key?: string;
|
|
20
|
-
/** Meta(⌘)/Ctrl 필수 여부. 기본 true. */
|
|
21
|
-
mod?: boolean;
|
|
22
|
-
/** Shift 필수 여부. 기본 false. */
|
|
23
|
-
shift?: boolean;
|
|
24
|
-
/** Alt 필수 여부. 기본 false. */
|
|
25
|
-
alt?: boolean;
|
|
26
|
-
/** 비활성 플래그. 조건부 활성 때 유용. */
|
|
27
|
-
disabled?: boolean;
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
export function useCommandPaletteHotkey(
|
|
31
|
-
callback: () => void,
|
|
32
|
-
options: HotkeyOptions = {},
|
|
33
|
-
): void {
|
|
34
|
-
const { key = "k", mod = true, shift = false, alt = false, disabled = false } = options;
|
|
35
|
-
useEffect(() => {
|
|
36
|
-
if (disabled) return;
|
|
37
|
-
function onKeyDown(e: KeyboardEvent) {
|
|
38
|
-
if (e.key.toLowerCase() !== key.toLowerCase()) return;
|
|
39
|
-
if (mod && !(e.metaKey || e.ctrlKey)) return;
|
|
40
|
-
if (!mod && (e.metaKey || e.ctrlKey)) return;
|
|
41
|
-
if (shift !== e.shiftKey) return;
|
|
42
|
-
if (alt !== e.altKey) return;
|
|
43
|
-
e.preventDefault();
|
|
44
|
-
callback();
|
|
45
|
-
}
|
|
46
|
-
window.addEventListener("keydown", onKeyDown);
|
|
47
|
-
return () => window.removeEventListener("keydown", onKeyDown);
|
|
48
|
-
}, [callback, key, mod, shift, alt, disabled]);
|
|
49
|
-
}
|
|
@@ -1,133 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* EventCard — 리스트에 쌓이는 일정 아이템.
|
|
3
|
-
* 좌: 날짜 블록(큰 숫자 + 요일 소문자) · 세로 hairline · 우: 제목 + status + meta.
|
|
4
|
-
*
|
|
5
|
-
* chrome: --radius-lg + 1px --border + --bg. 그림자 금지.
|
|
6
|
-
* interactive: href 있을 때만 hover/focus ring.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
.root {
|
|
10
|
-
display: flex;
|
|
11
|
-
align-items: stretch;
|
|
12
|
-
gap: var(--space-3);
|
|
13
|
-
padding: var(--space-3) var(--space-4);
|
|
14
|
-
background: var(--bg);
|
|
15
|
-
border: 1px solid var(--border);
|
|
16
|
-
border-radius: var(--radius-lg);
|
|
17
|
-
color: inherit;
|
|
18
|
-
text-decoration: none;
|
|
19
|
-
min-width: 0;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
.interactive {
|
|
23
|
-
cursor: pointer;
|
|
24
|
-
transition: var(--transition-colors), var(--transition-ring);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
.interactive:hover {
|
|
28
|
-
background: var(--hover);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
.interactive:focus-visible {
|
|
32
|
-
outline: none;
|
|
33
|
-
box-shadow: var(--ring-shadow);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/* 날짜 블록 — 좌측 고정 폭. 숫자가 2자리 기준으로 흔들리지 않도록 min-width 트랙. */
|
|
37
|
-
.dateBlock {
|
|
38
|
-
display: flex;
|
|
39
|
-
flex-direction: column;
|
|
40
|
-
align-items: center;
|
|
41
|
-
justify-content: center;
|
|
42
|
-
min-width: var(--space-8); /* 2자리 숫자 트랙 (한 단계 조임) */
|
|
43
|
-
padding-inline: var(--space-0_5);
|
|
44
|
-
flex-shrink: 0;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
.day {
|
|
48
|
-
font-size: var(--text-xl);
|
|
49
|
-
line-height: var(--lh-tight);
|
|
50
|
-
font-weight: var(--font-weight-semibold); /* xl=20px → semibold 허용 */
|
|
51
|
-
font-variant-numeric: tabular-nums;
|
|
52
|
-
color: var(--fg-strong);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
.weekday {
|
|
56
|
-
font-size: var(--text-3xs);
|
|
57
|
-
line-height: var(--lh-3xs);
|
|
58
|
-
font-weight: var(--font-weight-regular);
|
|
59
|
-
color: var(--fg-muted);
|
|
60
|
-
/* 소문자 유지. uppercase 금지 */
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/* 세로 hairline — border-left shorthand로 (리터럴 width 금지 회피) */
|
|
64
|
-
.divider {
|
|
65
|
-
border-inline-start: 1px solid var(--border);
|
|
66
|
-
align-self: stretch;
|
|
67
|
-
flex-shrink: 0;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/* 본문 — 제목 + 메타 라인 */
|
|
71
|
-
.body {
|
|
72
|
-
flex: 1;
|
|
73
|
-
min-width: 0;
|
|
74
|
-
display: flex;
|
|
75
|
-
flex-direction: column;
|
|
76
|
-
gap: var(--space-1);
|
|
77
|
-
justify-content: center;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
.title {
|
|
81
|
-
margin: 0;
|
|
82
|
-
font-size: var(--text-xs);
|
|
83
|
-
line-height: var(--lh-xs);
|
|
84
|
-
font-weight: var(--font-weight-medium); /* 20px 미만 → medium */
|
|
85
|
-
color: var(--fg-strong);
|
|
86
|
-
min-width: 0;
|
|
87
|
-
overflow: hidden;
|
|
88
|
-
text-overflow: ellipsis;
|
|
89
|
-
white-space: nowrap;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/* 메타 라인 — status Badge + 아이콘+텍스트 N개. 사이는 짧은 세로 hairline. */
|
|
93
|
-
.metaRow {
|
|
94
|
-
display: flex;
|
|
95
|
-
align-items: center;
|
|
96
|
-
gap: var(--space-1_5);
|
|
97
|
-
flex-wrap: wrap;
|
|
98
|
-
min-width: 0;
|
|
99
|
-
color: var(--fg-muted);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
.metaItem {
|
|
103
|
-
display: inline-flex;
|
|
104
|
-
align-items: center;
|
|
105
|
-
gap: var(--space-1);
|
|
106
|
-
font-size: var(--text-2xs);
|
|
107
|
-
line-height: var(--lh-2xs);
|
|
108
|
-
color: var(--fg-muted);
|
|
109
|
-
min-width: 0;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/* 메타 라인 안에서 "앞선 형제가 있을 때만" 왼쪽 hairline. 첫 아이템은 항상 제거,
|
|
113
|
-
앞 slot이 비었다가 뒤에 하나만 나오는 경우에도 자동으로 안 그려진다. */
|
|
114
|
-
.metaRow > * + .metaItem {
|
|
115
|
-
padding-inline-start: var(--space-1_5);
|
|
116
|
-
border-inline-start: 1px solid var(--border);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
.metaIcon {
|
|
120
|
-
display: inline-flex;
|
|
121
|
-
align-items: center;
|
|
122
|
-
justify-content: center;
|
|
123
|
-
color: var(--fg-muted);
|
|
124
|
-
line-height: 1;
|
|
125
|
-
flex-shrink: 0;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
.metaLabel {
|
|
129
|
-
min-width: 0;
|
|
130
|
-
overflow: hidden;
|
|
131
|
-
text-overflow: ellipsis;
|
|
132
|
-
white-space: nowrap;
|
|
133
|
-
}
|
|
@@ -1,149 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
forwardRef,
|
|
5
|
-
type AnchorHTMLAttributes,
|
|
6
|
-
type HTMLAttributes,
|
|
7
|
-
type ReactNode,
|
|
8
|
-
type Ref,
|
|
9
|
-
} from "react";
|
|
10
|
-
|
|
11
|
-
import { Badge } from "../Badge";
|
|
12
|
-
import { Icon, type IconName } from "../Icon";
|
|
13
|
-
|
|
14
|
-
import styles from "./EventCard.module.css";
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* EventCard — 리스트에 쌓이는 "하나의 일정" 아이템.
|
|
18
|
-
* 좌: 날짜 블록 (큰 숫자 + 요일), 우: 제목 + status + meta.
|
|
19
|
-
*
|
|
20
|
-
* EventChip은 캘린더 셀 안 색 바 전용. 이 컴포넌트는 리스트 카드.
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
export type EventCardStatus = "upcoming" | "pending" | "completed" | "cancelled";
|
|
24
|
-
|
|
25
|
-
export type EventCardMeta = {
|
|
26
|
-
icon?: IconName;
|
|
27
|
-
label: ReactNode;
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
type Common = {
|
|
31
|
-
date: Date;
|
|
32
|
-
title: ReactNode;
|
|
33
|
-
status?: EventCardStatus;
|
|
34
|
-
statusLabel?: ReactNode;
|
|
35
|
-
meta?: EventCardMeta[];
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
type DivProps = Common &
|
|
39
|
-
Omit<HTMLAttributes<HTMLDivElement>, keyof Common> & { href?: undefined };
|
|
40
|
-
|
|
41
|
-
type AnchorProps = Common &
|
|
42
|
-
Omit<AnchorHTMLAttributes<HTMLAnchorElement>, keyof Common> & { href: string };
|
|
43
|
-
|
|
44
|
-
export type EventCardProps = DivProps | AnchorProps;
|
|
45
|
-
|
|
46
|
-
const statusMap: Record<
|
|
47
|
-
EventCardStatus,
|
|
48
|
-
{ badgeStatus: "info" | "warning" | "success" | "destructive"; defaultLabel: string }
|
|
49
|
-
> = {
|
|
50
|
-
upcoming: { badgeStatus: "info", defaultLabel: "Upcoming" },
|
|
51
|
-
pending: { badgeStatus: "warning", defaultLabel: "Pending" },
|
|
52
|
-
completed: { badgeStatus: "success", defaultLabel: "Completed" },
|
|
53
|
-
cancelled: { badgeStatus: "destructive", defaultLabel: "Cancelled" },
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
/* locale 고정 없이 사용자 로케일에 맡기면 리스트 안에서 길이가 들쭉날쭉.
|
|
57
|
-
요일 3글자 약어는 en-US 기준으로 맞추고, 대문자 금지 규칙에 따라 수동 소문자화. */
|
|
58
|
-
function formatDay(date: Date): string {
|
|
59
|
-
return String(date.getDate()).padStart(2, "0");
|
|
60
|
-
}
|
|
61
|
-
function formatWeekday(date: Date): string {
|
|
62
|
-
return date
|
|
63
|
-
.toLocaleDateString("en-US", { weekday: "short" })
|
|
64
|
-
.toLowerCase();
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const EventCard = forwardRef<HTMLElement, EventCardProps>((props, ref) => {
|
|
68
|
-
const {
|
|
69
|
-
className,
|
|
70
|
-
date,
|
|
71
|
-
title,
|
|
72
|
-
status,
|
|
73
|
-
statusLabel,
|
|
74
|
-
meta,
|
|
75
|
-
...rest
|
|
76
|
-
} = props as Common & { className?: string } & Record<string, unknown>;
|
|
77
|
-
|
|
78
|
-
const isLink = typeof (props as AnchorProps).href === "string";
|
|
79
|
-
|
|
80
|
-
const rootClass = [
|
|
81
|
-
styles.root,
|
|
82
|
-
isLink ? styles.interactive : "",
|
|
83
|
-
className ?? "",
|
|
84
|
-
]
|
|
85
|
-
.filter(Boolean)
|
|
86
|
-
.join(" ");
|
|
87
|
-
|
|
88
|
-
const mapped = status ? statusMap[status] : undefined;
|
|
89
|
-
const effectiveStatusLabel = statusLabel ?? mapped?.defaultLabel;
|
|
90
|
-
|
|
91
|
-
const content = (
|
|
92
|
-
<>
|
|
93
|
-
<div className={styles.dateBlock} aria-hidden>
|
|
94
|
-
<span className={styles.day}>{formatDay(date)}</span>
|
|
95
|
-
<span className={styles.weekday}>{formatWeekday(date)}</span>
|
|
96
|
-
</div>
|
|
97
|
-
<div className={styles.divider} aria-hidden />
|
|
98
|
-
<div className={styles.body}>
|
|
99
|
-
<h3 className={styles.title}>{title}</h3>
|
|
100
|
-
{(mapped || meta?.length) && (
|
|
101
|
-
<div className={styles.metaRow}>
|
|
102
|
-
{mapped && effectiveStatusLabel && (
|
|
103
|
-
<Badge variant="soft" status={mapped.badgeStatus}>
|
|
104
|
-
{effectiveStatusLabel}
|
|
105
|
-
</Badge>
|
|
106
|
-
)}
|
|
107
|
-
{meta?.map((m, i) => (
|
|
108
|
-
<span key={i} className={styles.metaItem}>
|
|
109
|
-
{m.icon && (
|
|
110
|
-
<span className={styles.metaIcon} aria-hidden>
|
|
111
|
-
<Icon name={m.icon} size={12} />
|
|
112
|
-
</span>
|
|
113
|
-
)}
|
|
114
|
-
<span className={styles.metaLabel}>{m.label}</span>
|
|
115
|
-
</span>
|
|
116
|
-
))}
|
|
117
|
-
</div>
|
|
118
|
-
)}
|
|
119
|
-
</div>
|
|
120
|
-
</>
|
|
121
|
-
);
|
|
122
|
-
|
|
123
|
-
if (isLink) {
|
|
124
|
-
return (
|
|
125
|
-
<a
|
|
126
|
-
ref={ref as Ref<HTMLAnchorElement>}
|
|
127
|
-
className={rootClass}
|
|
128
|
-
{...(rest as AnchorHTMLAttributes<HTMLAnchorElement>)}
|
|
129
|
-
href={(props as AnchorProps).href}
|
|
130
|
-
>
|
|
131
|
-
{content}
|
|
132
|
-
</a>
|
|
133
|
-
);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
return (
|
|
137
|
-
<div
|
|
138
|
-
ref={ref as Ref<HTMLDivElement>}
|
|
139
|
-
className={rootClass}
|
|
140
|
-
{...(rest as HTMLAttributes<HTMLDivElement>)}
|
|
141
|
-
>
|
|
142
|
-
{content}
|
|
143
|
-
</div>
|
|
144
|
-
);
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
EventCard.displayName = "EventCard";
|
|
148
|
-
|
|
149
|
-
export { EventCard };
|
|
@@ -1,126 +0,0 @@
|
|
|
1
|
-
# Accordion
|
|
2
|
-
|
|
3
|
-
### 원칙
|
|
4
|
-
여러 섹션을 **접었다 폈다** 하는 구조 컴포넌트. 긴 콘텐츠를 압축해 "지금 중요한 것"만 보여주고, 사용자가 원할 때 세부를 펼친다. 설정 그룹·FAQ·상세 사양·단계별 체크리스트가 전형적인 자리. Radix `@radix-ui/react-accordion`을 감싼 compound 어댑터 — 열림 상태·높이 측정·키보드 네비·ARIA는 Radix가 처리하고, CTDS는 Trigger 레이아웃·caret·divider·card variant만 담당. [[components/collapsible]]과 비슷하지만 **여러 아이템**인 점이 다르다. 하나짜리면 Collapsible로.
|
|
5
|
-
|
|
6
|
-
### Do
|
|
7
|
-
- **"지금 보이는 것 vs 숨긴 것"의 비율이 1:3 이상일 때 쓴다.** 전부 다 중요하면 그냥 나열하는 게 낫다. Accordion은 "요약은 다 보이고, 세부가 선택적"인 리스트용.
|
|
8
|
-
- **섹션 라벨은 명사 하나로 짧게.** "일반" "알림" "결제" — 긴 문장형은 Accordion 자리가 아님. 사용자는 라벨만 보고 "펼칠지 말지" 판단.
|
|
9
|
-
- **`type="single" collapsible` 기본값.** 한 번에 한 섹션만 열림 + 열린 걸 다시 닫을 수 있음. 설정 UI의 표준 동작.
|
|
10
|
-
- **여러 섹션 동시 열기는 `type="multiple"`**. 체크리스트·비교·FAQ(사용자가 여러 질문 동시 열람)에만.
|
|
11
|
-
- **variant 선택**: 설명 블록 사이에 투명하게 녹여야 하면 `bare`(선·좌우 여백 전부 없음). 설정 그룹·FAQ 리스트는 `plain`(기본). 각 아이템이 독립 블록으로 보여야 하면 `card`.
|
|
12
|
-
- **leading 아이콘은 섹션 성격 명시용**. 설정 그룹의 "⚙ 일반 / 🔔 알림 / 💳 결제"처럼 **섹션끼리 아이콘 세트가 균질할 때만** 쓴다. 하나만 아이콘 있고 나머지 없으면 혼란.
|
|
13
|
-
- **trailing은 메타 정보**. "3개 변경됨" 배지, "4/8 완료" 숫자 등 사용자가 라벨만 보고 "열어볼 가치"를 판단하도록.
|
|
14
|
-
- **첫 섹션 defaultOpen**: 사용자가 가장 자주 보는 섹션이 명확하면 `defaultValue`로 열어둔다. 모르면 전부 닫힌 상태 OK.
|
|
15
|
-
|
|
16
|
-
### Don't
|
|
17
|
-
- **중첩 금지.** Accordion 안에 또 Accordion 금지. 깊이가 필요하면 섹션을 쪼개서 **별도 페이지**로 보내거나 좌측 Nav로 분리.
|
|
18
|
-
- **본문에 또 다른 Primary CTA 넣지 말 것.** 사용자가 섹션을 "펼쳐야만" 중요한 액션을 볼 수 있는 구조는 발견 실패를 유발. 핵심 CTA는 Accordion 밖 상단에.
|
|
19
|
-
- **길이가 들쭉날쭉한 본문 금지.** 어떤 섹션은 1줄, 어떤 섹션은 20줄이면 레이아웃이 요동친다. 긴 섹션은 아예 별도 페이지로.
|
|
20
|
-
- **Accordion과 Tabs 혼용 금지.** 둘 다 "숨기기 + 보이기"이지만: **Accordion은 세로 리스트, Tabs는 가로 전환**. 섹션 수가 3개 이하 + 서로 배타적이면 Tabs가 맞음. 섹션 수가 많거나 동시 열람 가능하면 Accordion.
|
|
21
|
-
- **애니메이션 커스터마이즈 금지.** Radix의 `--radix-accordion-content-height` 변수에 맞춰 CTDS 모션 토큰(`--duration-base` + `--ease-out`)으로 이미 처리. duration·ease 하드코딩은 [[feedback_no_hardcoded_values]] 위반.
|
|
22
|
-
- **라벨에 아이콘만 넣고 텍스트 빼는 것 금지.** 접근성·스캐닝 모두 깨짐.
|
|
23
|
-
|
|
24
|
-
### Fallback
|
|
25
|
-
type 애매하면 `single + collapsible`. variant 애매하면 `plain`. size 애매하면 `sm` ([[feedback_default_size_sm]]). defaultValue 애매하면 비워두기(전부 닫힘).
|
|
26
|
-
|
|
27
|
-
### 관련
|
|
28
|
-
- [[components/collapsible]] — Accordion의 1-아이템 버전. "더 보기" 토글 하나짜리.
|
|
29
|
-
- [[components/tabs]] — 섹션 수 ≤ 3 + 가로 전환이 자연스러우면 Tabs. Accordion은 세로 리스트.
|
|
30
|
-
- [[components/menu-item]] — Section variant는 사이드바 섹션 접힘에 사용. 메뉴 트리의 접힘은 Accordion이 아님.
|
|
31
|
-
- [[tokens/motion]] — `--duration-base` · `--ease-out`이 slide 애니메이션을 만든다.
|
|
32
|
-
- [[tokens/color]] — `--fg` · `--fg-strong` · `--hover` · `--border` · `--border` hairline.
|
|
33
|
-
|
|
34
|
-
### Props 시그니처
|
|
35
|
-
```tsx
|
|
36
|
-
<Accordion
|
|
37
|
-
type="single | multiple" // Radix 통과. 기본은 소비자가 결정 (단일/다중)
|
|
38
|
-
collapsible={boolean} // single에서 열린 걸 다시 닫을 수 있는지. 기본 undefined → false
|
|
39
|
-
value={string | string[]} // controlled
|
|
40
|
-
defaultValue={string | string[]} // uncontrolled 초기값
|
|
41
|
-
onValueChange={(v) => void}
|
|
42
|
-
size="sm | md" // default: sm — Trigger 높이 · 폰트 크기
|
|
43
|
-
variant="bare | plain | card" // default: plain
|
|
44
|
-
>
|
|
45
|
-
<Accordion.Item value="unique-id" disabled={boolean}>
|
|
46
|
-
<Accordion.Trigger
|
|
47
|
-
leadingIcon="gear" // IconName — 간결 사용
|
|
48
|
-
leadingActions={AccordionAction[]}// leadingIcon보다 우선
|
|
49
|
-
trailingActions={AccordionAction[]}
|
|
50
|
-
>
|
|
51
|
-
섹션 라벨
|
|
52
|
-
</Accordion.Trigger>
|
|
53
|
-
<Accordion.Content>
|
|
54
|
-
{/* 자유 콘텐츠 */}
|
|
55
|
-
</Accordion.Content>
|
|
56
|
-
</Accordion.Item>
|
|
57
|
-
</Accordion>
|
|
58
|
-
```
|
|
59
|
-
|
|
60
|
-
### 상태 매트릭스
|
|
61
|
-
| 상태 | 시각 |
|
|
62
|
-
|---|---|
|
|
63
|
-
| closed | 라벨 `--fg`, caret 아래, 본문 숨김 |
|
|
64
|
-
| open | 라벨 `--fg-strong`, caret 180도 회전, 본문 slide-down |
|
|
65
|
-
| hover (closed) | Trigger 배경 `--hover` + 라벨 `--fg-strong` |
|
|
66
|
-
| hover (open) | 배경 변화 없음 — 상태 signal 유지 ([[feedback_hover_over_active]]) |
|
|
67
|
-
| focus-visible | `--focus-ring` outline |
|
|
68
|
-
| disabled | `--fg-disabled`, cursor not-allowed, 클릭·키보드 모두 꺼짐 |
|
|
69
|
-
|
|
70
|
-
### Size 가이드
|
|
71
|
-
| size | 언제 |
|
|
72
|
-
|---|---|
|
|
73
|
-
| `sm` | **기본.** 설정 패널·FAQ·관리 UI 밀도 높은 자리 |
|
|
74
|
-
| `md` | 콘텐츠 사이트의 섹션 리스트·랜딩 FAQ — 라벨이 조금 더 커야 할 때 |
|
|
75
|
-
|
|
76
|
-
### 조합 예시
|
|
77
|
-
```tsx
|
|
78
|
-
// 설정 패널 — plain + leading 아이콘 세트
|
|
79
|
-
<Accordion type="single" collapsible defaultValue="general">
|
|
80
|
-
<Accordion.Item value="general">
|
|
81
|
-
<Accordion.Trigger leadingIcon="gear">일반</Accordion.Trigger>
|
|
82
|
-
<Accordion.Content>
|
|
83
|
-
<Input label="프로필 이름" />
|
|
84
|
-
<Switch label="자동 저장" />
|
|
85
|
-
</Accordion.Content>
|
|
86
|
-
</Accordion.Item>
|
|
87
|
-
|
|
88
|
-
<Accordion.Item value="notifications">
|
|
89
|
-
<Accordion.Trigger
|
|
90
|
-
leadingIcon="bell"
|
|
91
|
-
trailingActions={[<Badge key="c" size="sm">3</Badge>]}
|
|
92
|
-
>
|
|
93
|
-
알림
|
|
94
|
-
</Accordion.Trigger>
|
|
95
|
-
<Accordion.Content>...</Accordion.Content>
|
|
96
|
-
</Accordion.Item>
|
|
97
|
-
|
|
98
|
-
<Accordion.Item value="billing" disabled>
|
|
99
|
-
<Accordion.Trigger leadingIcon="info">결제 (준비 중)</Accordion.Trigger>
|
|
100
|
-
<Accordion.Content>...</Accordion.Content>
|
|
101
|
-
</Accordion.Item>
|
|
102
|
-
</Accordion>
|
|
103
|
-
|
|
104
|
-
// FAQ — multiple + card variant (여러 질문 동시 열람 OK)
|
|
105
|
-
<Accordion type="multiple" variant="card">
|
|
106
|
-
<Accordion.Item value="q1">
|
|
107
|
-
<Accordion.Trigger>CTDS는 어떻게 설치하나요?</Accordion.Trigger>
|
|
108
|
-
<Accordion.Content>npm으로 설치 후 ctds init 실행...</Accordion.Content>
|
|
109
|
-
</Accordion.Item>
|
|
110
|
-
<Accordion.Item value="q2">
|
|
111
|
-
<Accordion.Trigger>테마는 어떻게 바꾸나요?</Accordion.Trigger>
|
|
112
|
-
<Accordion.Content>...</Accordion.Content>
|
|
113
|
-
</Accordion.Item>
|
|
114
|
-
</Accordion>
|
|
115
|
-
|
|
116
|
-
// Controlled — 외부 상태에 묶기
|
|
117
|
-
const [open, setOpen] = useState<string>("step-1");
|
|
118
|
-
<Accordion type="single" value={open} onValueChange={setOpen}>
|
|
119
|
-
<Accordion.Item value="step-1">
|
|
120
|
-
<Accordion.Trigger trailingActions={[<span key="s">1/4</span>]}>
|
|
121
|
-
기본 정보
|
|
122
|
-
</Accordion.Trigger>
|
|
123
|
-
<Accordion.Content>...</Accordion.Content>
|
|
124
|
-
</Accordion.Item>
|
|
125
|
-
</Accordion>
|
|
126
|
-
```
|
|
@@ -1,148 +0,0 @@
|
|
|
1
|
-
# CommandPalette
|
|
2
|
-
|
|
3
|
-
### 원칙
|
|
4
|
-
⌘K로 열리는 **전역 명령·검색 창**. 페이지 이동과 자주 쓰는 액션을 키보드만으로 끝내는 자리. 사이드바·메뉴·설정을 뒤지지 않고 "이름으로 바로 간다". VSCode·Linear·Notion·Figma가 공유하는 관용. CTDS는 쉘(Radix Dialog)·검색/필터/네비(cmdk)·시각(MenuItem)을 **얇게 묶은 어댑터**. 상호작용·접근성·fuzzy 매칭은 라이브러리에 맡기고, CTDS는 스킨과 API 다듬기에 집중한다. [[components/context-menu]]·[[components/dropdown-menu]]와 같은 메뉴 패밀리 — 아이템 시각 규칙은 [[components/menu-item]] 하나로 수렴.
|
|
5
|
-
|
|
6
|
-
### Do
|
|
7
|
-
- **데이터·관리 UI에선 거의 필수로 넣는다.** 페이지 수가 10개 넘고 액션이 반복될수록 ⌘K 하나로 사용자 숙련도의 천장이 올라간다. 콘텐츠 사이트에선 선택적.
|
|
8
|
-
- **두 종류를 섞는다 — 페이지 이동 + 액션.** 그룹 heading으로 명시 분리하면 사용자는 "어디로 갈까"와 "뭘 할까"를 같은 입구에서 결정한다.
|
|
9
|
-
- **라벨은 동사·명사 짧게.** 리스트 폭이 넓긴 하지만 한 줄에 자연스레 읽히는 길이로. "선택한 항목을 휴지통으로 이동" 같은 문장형 금지 — 동사 하나로 압축.
|
|
10
|
-
- **아이콘은 의미가 명확할 때만**. [[components/context-menu]]와 같은 규칙 — 억지 아이콘보다 라벨만 두는 게 낫다. 아이콘 이름은 [[web/components/Icon/Icon.tsx]] 세트에서만.
|
|
11
|
-
- **단축키는 실제 심볼로 표시.** `⌘N` · `⇧⌘P` · `⌫`. uppercase 금지 — [[feedback_no_uppercase]].
|
|
12
|
-
- **선택 후 자동 닫힘.** `onSelect` 안에서 라우팅/액션을 실행한 다음 `onOpenChange(false)`를 명시적으로 호출. cmdk는 자동으로 닫지 않는다.
|
|
13
|
-
- **열기는 Hook으로 위임.** `useCommandPaletteHotkey(() => setOpen((v) => !v))` 한 줄이면 ⌘K/Ctrl+K가 어디서든 토글된다. 입력 중에도 발화 — 검색은 어디서나 열려야.
|
|
14
|
-
- **Empty state는 꼭 넣는다.** 결과 0일 때 그냥 사라지면 사용자는 "뭐가 잘못됐나" 혼란. 짧은 한 줄로 "결과 없음" 명시, 필요하면 "다른 검색어" 유도.
|
|
15
|
-
- **최근 사용·즐겨찾기는 상단 그룹**. 입력이 비었을 때만 노출. 입력이 생기면 그 그룹은 숨기거나 가중치 낮게.
|
|
16
|
-
- **그룹은 3~5개 이하로.** 더 많으면 스캐닝이 무너진다. "페이지 / 액션 / 설정" 정도가 상한.
|
|
17
|
-
|
|
18
|
-
### Don't
|
|
19
|
-
- **핵심 플로우를 ⌘K 뒤에만 두지 말 것.** 신규 가입·결제 같은 CTA는 눈에 보이는 자리에. 팔레트는 **알고 있는 사용자용 지름길**이지 발견 입구가 아니다.
|
|
20
|
-
- 아이템에 큰 썸네일·멀티라인 설명 금지. 리스트 밀도가 핵심 — 설명이 필요하면 `description` prop 한 줄까지. 그보다 길면 결과 페이지로.
|
|
21
|
-
- 트리거 버튼을 "Open Palette"라고 라벨 붙이지 말 것. 사용자는 그 용어를 모른다. **「검색…」 + ⌘K 힌트**가 관용.
|
|
22
|
-
- 열림 상태로 영구 고정 금지 (`defaultOpen` 패턴 등). 팔레트는 **임시 입구** — 상주하는 UI는 사이드바·툴바.
|
|
23
|
-
- Modal과 혼용 금지. 결정·입력 다이얼로그는 [[components/modal]], 전역 명령 검색은 Palette. 서로 포커스 레벨이 다르다.
|
|
24
|
-
- 색·radius·shadow 하드코딩 금지 — [[feedback_no_hardcoded_values]]. 쉘 시각은 CommandPalette CSS가 토큰 경유로 이미 처리.
|
|
25
|
-
|
|
26
|
-
### Fallback
|
|
27
|
-
size 애매하면 `md`. 그룹 분류 애매하면 "액션" 하나로. 트리거 UI 애매하면 상단 툴바의 **"검색… ⌘K" 버튼** — Button `variant="outline"` + `leftIcon={<Icon name="search" />}` + `rightIcon={<Shortcut>⌘K</Shortcut>}`.
|
|
28
|
-
|
|
29
|
-
### 관련
|
|
30
|
-
- [[components/menu-item]] — 아이템 시각·슬롯·active 규칙의 원본. CommandPalette.Item은 이 위에 cmdk `data-selected`를 매핑.
|
|
31
|
-
- [[components/modal]] — 쉘 상호작용 비교군. Modal은 바깥 클릭 안 닫힘·중앙 배치, Palette는 바깥 클릭 닫힘·상단 배치.
|
|
32
|
-
- [[components/dropdown-menu]] · [[components/context-menu]] — 같은 메뉴 패밀리. 트리거만 다름(클릭 vs 우클릭 vs 전역 단축키).
|
|
33
|
-
- [[components/shortcut]] — ⌘K 힌트는 Shortcut 컴포넌트로.
|
|
34
|
-
- [[components/input]] — Palette의 Input은 전용 스타일. 일반 폼 Input과 섞지 말 것.
|
|
35
|
-
- [[tokens/color]] · [[tokens/shadow]] — `--bg` · `--border` · `--shadow-xl` · `--scrim`이 쉘 시각을 만든다.
|
|
36
|
-
|
|
37
|
-
### Props 시그니처
|
|
38
|
-
```tsx
|
|
39
|
-
<CommandPalette
|
|
40
|
-
open={boolean}
|
|
41
|
-
onOpenChange={(open: boolean) => void}
|
|
42
|
-
size="sm | md" // default: md — 아이템 기본 size
|
|
43
|
-
label="명령 검색" // 스크린리더용. 다이얼로그 접근성 네임
|
|
44
|
-
>
|
|
45
|
-
<CommandPalette.Input
|
|
46
|
-
placeholder="검색..."
|
|
47
|
-
leadingIcon="search | IconName | null" // default: search
|
|
48
|
-
/>
|
|
49
|
-
<CommandPalette.List>
|
|
50
|
-
<CommandPalette.Empty>결과가 없어요</CommandPalette.Empty>
|
|
51
|
-
|
|
52
|
-
<CommandPalette.Group heading="페이지">
|
|
53
|
-
<CommandPalette.Item
|
|
54
|
-
leadingIcon="house" // 또는 leadingActions={[...]}
|
|
55
|
-
shortcut="⌘,"
|
|
56
|
-
description="보조 설명 한 줄"
|
|
57
|
-
disabled={boolean}
|
|
58
|
-
onSelect={() => void} // 자동 닫힘 ❌ — 안에서 onOpenChange(false) 명시
|
|
59
|
-
>
|
|
60
|
-
대시보드
|
|
61
|
-
</CommandPalette.Item>
|
|
62
|
-
</CommandPalette.Group>
|
|
63
|
-
|
|
64
|
-
<CommandPalette.Separator />
|
|
65
|
-
|
|
66
|
-
<CommandPalette.Group heading="액션">...</CommandPalette.Group>
|
|
67
|
-
</CommandPalette.List>
|
|
68
|
-
</CommandPalette>
|
|
69
|
-
|
|
70
|
-
// 전역 단축키 Hook
|
|
71
|
-
useCommandPaletteHotkey(
|
|
72
|
-
() => setOpen((v) => !v),
|
|
73
|
-
{ key: "k", mod: true, shift: false, alt: false, disabled: false },
|
|
74
|
-
);
|
|
75
|
-
```
|
|
76
|
-
|
|
77
|
-
### 상태 매트릭스
|
|
78
|
-
| 상태 | 시각 |
|
|
79
|
-
|---|---|
|
|
80
|
-
| 열림 · 닫힘 | 쉘은 Radix Dialog 애니메이션(scale 0.98→1 + fade). 상단 20vh 고정 배치. |
|
|
81
|
-
| 아이템 highlight (↑↓·마우스) | cmdk `[data-selected="true"]` → MenuItem fill 스타일(`--hover` 배경 + `fg-strong`) 자동 매핑 |
|
|
82
|
-
| 입력 필터링 | cmdk fuzzy 매칭. 매치되는 아이템만 표시, 그룹 heading은 매치된 아이템이 있을 때만 노출 |
|
|
83
|
-
| Empty | 매치 0일 때 `Empty` children 렌더 — 다른 아이템 전부 숨김 |
|
|
84
|
-
| disabled 아이템 | `fg-disabled` + 선택/엔터 모두 꺼짐 (cmdk + MenuItem 공통 처리) |
|
|
85
|
-
|
|
86
|
-
### Size 가이드
|
|
87
|
-
| size | 언제 |
|
|
88
|
-
|---|---|
|
|
89
|
-
| `sm` | 밀도 높은 관리 UI · 결과가 많이 나와야 할 때 |
|
|
90
|
-
| `md` | **기본.** 일반 ⌘K |
|
|
91
|
-
|
|
92
|
-
### 조합 예시
|
|
93
|
-
```tsx
|
|
94
|
-
// 기본 — 페이지 + 액션 두 그룹
|
|
95
|
-
const [open, setOpen] = useState(false);
|
|
96
|
-
const router = useRouter();
|
|
97
|
-
useCommandPaletteHotkey(() => setOpen((v) => !v));
|
|
98
|
-
|
|
99
|
-
function go(path: string) {
|
|
100
|
-
router.push(path);
|
|
101
|
-
setOpen(false);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
<CommandPalette open={open} onOpenChange={setOpen}>
|
|
105
|
-
<CommandPalette.Input placeholder="검색 또는 명령어 입력" />
|
|
106
|
-
<CommandPalette.List>
|
|
107
|
-
<CommandPalette.Empty>결과가 없어요</CommandPalette.Empty>
|
|
108
|
-
<CommandPalette.Group heading="페이지">
|
|
109
|
-
<CommandPalette.Item leadingIcon="house" onSelect={() => go("/dashboard")}>
|
|
110
|
-
대시보드
|
|
111
|
-
</CommandPalette.Item>
|
|
112
|
-
<CommandPalette.Item leadingIcon="gear" shortcut="⌘," onSelect={() => go("/settings")}>
|
|
113
|
-
설정
|
|
114
|
-
</CommandPalette.Item>
|
|
115
|
-
</CommandPalette.Group>
|
|
116
|
-
<CommandPalette.Separator />
|
|
117
|
-
<CommandPalette.Group heading="액션">
|
|
118
|
-
<CommandPalette.Item leadingIcon="plus" shortcut="⌘N" onSelect={newDoc}>
|
|
119
|
-
새 문서
|
|
120
|
-
</CommandPalette.Item>
|
|
121
|
-
</CommandPalette.Group>
|
|
122
|
-
</CommandPalette.List>
|
|
123
|
-
</CommandPalette>
|
|
124
|
-
|
|
125
|
-
// 상단 툴바 트리거 — 「검색… ⌘K」 관용
|
|
126
|
-
<Button
|
|
127
|
-
variant="outline" size="sm"
|
|
128
|
-
leftIcon={<Icon name="search" />}
|
|
129
|
-
rightIcon={<Shortcut>⌘K</Shortcut>}
|
|
130
|
-
onClick={() => setOpen(true)}
|
|
131
|
-
>
|
|
132
|
-
검색
|
|
133
|
-
</Button>
|
|
134
|
-
|
|
135
|
-
// 최근 사용 — 입력이 비었을 때만
|
|
136
|
-
<CommandPalette.List>
|
|
137
|
-
{query === "" && (
|
|
138
|
-
<CommandPalette.Group heading="최근">
|
|
139
|
-
{recents.map((r) => (
|
|
140
|
-
<CommandPalette.Item key={r.id} leadingIcon="clock" onSelect={() => go(r.href)}>
|
|
141
|
-
{r.label}
|
|
142
|
-
</CommandPalette.Item>
|
|
143
|
-
))}
|
|
144
|
-
</CommandPalette.Group>
|
|
145
|
-
)}
|
|
146
|
-
<CommandPalette.Group heading="페이지">...</CommandPalette.Group>
|
|
147
|
-
</CommandPalette.List>
|
|
148
|
-
```
|