@conscience-technology/ctds 0.3.1 → 0.3.3
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/Breadcrumb/Breadcrumb.module.css +64 -0
- package/assets/components/Breadcrumb/Breadcrumb.tsx +120 -47
- package/assets/components/Icon/Icon.tsx +823 -114
- package/assets/components/Onboarding/Onboarding.module.css +186 -0
- package/assets/components/Sidebar/Sidebar.module.css +36 -74
- package/assets/components/Sidebar/Sidebar.tsx +15 -2
- package/assets/components/_data.ts +3 -7
- package/assets/components/_utils.css +105 -0
- package/assets/components/index.ts +0 -1
- package/assets/prompts/components/breadcrumb.md +44 -4
- package/assets/prompts/components/sidebar.md +14 -5
- package/assets/skills/ctds/SKILL.md +1 -3
- 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
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
- **URL 계층이 그대로 라벨** — 사이트맵의 실제 위치를 그대로 반영. 계층에 없는 임의 카테고리를 만들어 붙이지 않는다.
|
|
12
12
|
- **모바일에서는 축약** — 첫 항목 + `Breadcrumb.Ellipsis` + 마지막 두 항목. 6개 이상이면 축약이 필수.
|
|
13
13
|
- **Next.js `<Link>` 등 라우터**는 `<Breadcrumb.Link asChild>`로 감싼다. 스타일 유지한 채 SPA 네비게이션 위임.
|
|
14
|
+
- **형제 전환이 필요한 segment**는 `Breadcrumb.Menu`로. ▾ caret이 붙은 트리거를 눌러 같은 레벨의 다른 페이지(프로젝트·워크스페이스·브랜치 등)로 바로 이동. 현재 페이지 자리에 쓸 때는 `active` 필수 — `aria-current="page"`와 `--fg-strong` 톤을 자동으로 맞춘다.
|
|
14
15
|
- **접근성**: 루트가 자동으로 `<nav aria-label="Breadcrumb">` + `<ol>`, 마지막 항목에 `aria-current="page"`를 붙인다. separator는 `aria-hidden`.
|
|
15
16
|
|
|
16
17
|
### Don't
|
|
@@ -20,6 +21,7 @@
|
|
|
20
21
|
- **활성 색을 `--primary`로 덮지 않는다** — 활성은 `--fg-strong`(무채). 브랜드 컬러를 여기 쓰면 CTA와 위계가 충돌.
|
|
21
22
|
- **아이콘 남발 금지** — 첫 항목 외에 아이콘 붙이면 라벨 스캔이 무너진다.
|
|
22
23
|
- **draggable/sortable 만들지 않는다** — 계층은 URL이 정한다. UI에서 조작할 대상이 아님.
|
|
24
|
+
- **`Breadcrumb.Menu`를 탭/필터로 쓰지 않는다** — Menu는 "같은 레벨의 다른 페이지로 URL 이동" 전용. 같은 페이지 안에서 뷰만 바꾸는 건 [[components/tabs]].
|
|
23
25
|
|
|
24
26
|
### Fallback
|
|
25
27
|
- 라우터 결정 애매하면 기본 `<a href>` 렌더. 프레임워크 붙일 때 `asChild`로 교체.
|
|
@@ -41,10 +43,15 @@
|
|
|
41
43
|
separator={<Icon name="caret-right" />} // default: caret-right icon
|
|
42
44
|
>
|
|
43
45
|
<Breadcrumb.Link href="/" leftIcon={<Icon name="house" />}>Home</Breadcrumb.Link>
|
|
44
|
-
<Breadcrumb.
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
<Breadcrumb.Menu
|
|
47
|
+
items={[
|
|
48
|
+
{ label: "Alpha", href: "/p/alpha", active: true },
|
|
49
|
+
{ label: "Beta", href: "/p/beta" },
|
|
50
|
+
]}
|
|
51
|
+
>
|
|
52
|
+
Alpha
|
|
53
|
+
</Breadcrumb.Menu>
|
|
54
|
+
<Breadcrumb.Ellipsis items={[...]} />
|
|
48
55
|
<Breadcrumb.Current>Breadcrumb</Breadcrumb.Current>
|
|
49
56
|
</Breadcrumb>
|
|
50
57
|
```
|
|
@@ -55,6 +62,7 @@
|
|
|
55
62
|
| `Breadcrumb.Link` | 상위 계층 링크. Button xs · ghost 톤. hover 시 `--hover` 표면. | 마지막 항목엔 쓰지 않음. |
|
|
56
63
|
| `Breadcrumb.Current` | 현재 페이지. `--fg-strong` 텍스트, hover 반응 없음. | `href`·`onClick` 붙이지 않음. |
|
|
57
64
|
| `Breadcrumb.Ellipsis` | 축약 트리거. 항상 클릭 가능. [[components/select]]와 동일한 팝오버 표면으로 숨긴 항목 리스트를 연다. | `items`에 5개 이상 넣지 않는다 — 그러면 축약이 아니라 실질적 서브메뉴가 됨. 정적(클릭 불가) 마커는 지원하지 않는다. |
|
|
65
|
+
| `Breadcrumb.Menu` | 형제 전환 트리거. ▾ caret이 붙은 segment를 눌러 같은 레벨 페이지로 이동. `active=true`로 현재 페이지 자리에도 쓸 수 있다 (aria-current + fg-strong 자동). | 아이콘 단독 모드 지원 안 함 — 트리거 라벨 필수. 탭 전환용으로 쓰지 않는다. |
|
|
58
66
|
|
|
59
67
|
### 조합 예시
|
|
60
68
|
```tsx
|
|
@@ -74,4 +82,36 @@
|
|
|
74
82
|
</Breadcrumb.Link>
|
|
75
83
|
<Breadcrumb.Current>Breadcrumb</Breadcrumb.Current>
|
|
76
84
|
</Breadcrumb>
|
|
85
|
+
|
|
86
|
+
// 프로젝트 스위처 — Menu로 형제 전환
|
|
87
|
+
<Breadcrumb aria-label="프로젝트 경로">
|
|
88
|
+
<Breadcrumb.Link asChild leftIcon={<Icon name="user" />}>
|
|
89
|
+
<Link href="/">Workspace</Link>
|
|
90
|
+
</Breadcrumb.Link>
|
|
91
|
+
<Breadcrumb.Menu
|
|
92
|
+
items={[
|
|
93
|
+
{ label: "Alpha", href: "/p/alpha", active: true },
|
|
94
|
+
{ label: "Beta", href: "/p/beta" },
|
|
95
|
+
{ label: "Gamma", href: "/p/gamma" },
|
|
96
|
+
]}
|
|
97
|
+
>
|
|
98
|
+
Alpha
|
|
99
|
+
</Breadcrumb.Menu>
|
|
100
|
+
<Breadcrumb.Current>Settings</Breadcrumb.Current>
|
|
101
|
+
</Breadcrumb>
|
|
102
|
+
|
|
103
|
+
// 현재 페이지 자체가 메뉴 — active=true로 aria-current="page" 유지
|
|
104
|
+
<Breadcrumb aria-label="브랜치 경로">
|
|
105
|
+
<Breadcrumb.Link href="/">Repo</Breadcrumb.Link>
|
|
106
|
+
<Breadcrumb.Menu
|
|
107
|
+
active
|
|
108
|
+
items={[
|
|
109
|
+
{ label: "main", onSelect: () => switchBranch("main"), active: true },
|
|
110
|
+
{ label: "develop", onSelect: () => switchBranch("develop") },
|
|
111
|
+
{ label: "staging", onSelect: () => switchBranch("staging") },
|
|
112
|
+
]}
|
|
113
|
+
>
|
|
114
|
+
main
|
|
115
|
+
</Breadcrumb.Menu>
|
|
116
|
+
</Breadcrumb>
|
|
77
117
|
```
|
|
@@ -1,12 +1,21 @@
|
|
|
1
|
+
<!-- lint-sidebar-legacy-tokens: ignore-file 이 문서는 폐기 토큰 사용 금지를 설명한다. -->
|
|
2
|
+
|
|
1
3
|
# Sidebar
|
|
2
4
|
|
|
3
5
|
### 원칙
|
|
4
6
|
좌측 세로 nav. **라우팅 전용** — Tabs/필터는 쓸 곳이 아님. Header(고정) + Body(스크롤) + Footer(고정) 3영역 세로 쉘에, Brand + Section + Item + Separator + Profile을 compound로 조합. `collapsed`로 icon-only 축소. Item은 [[components/menu-item]]을 그대로 어댑트 — 시각·상태 규칙 공유.
|
|
5
7
|
|
|
6
|
-
###
|
|
7
|
-
Sidebar
|
|
8
|
+
### 좌우/상하 축 공급 (중요)
|
|
9
|
+
**벤더 Sidebar는 좌우/상하 padding을 공급하지 않는다**. `.root/.header/.body/.footer` 어디에도 `padding-inline`·`padding-block`을 깔지 않음. preset(`.brand/.profile/.section`)도 자체 padding 없음. `--sidebar-pad-x`·`--sidebar-pad-y` 토큰은 폐기.
|
|
10
|
+
|
|
11
|
+
소비처가 공급 책임. 세 가지 패턴:
|
|
12
|
+
- **Slot 전역**: `:global([data-sidebar-slot="brand|section|profile"])` 셀렉터로 preset에 일괄 공급. 벤더가 preset마다 `data-sidebar-slot` 속성을 자동 부여함. theme-demo(expressive/spare/pure)가 이 패턴.
|
|
13
|
+
- **영역별 콘텐츠 래퍼**: Sidebar.Header 자식 또는 Body 안 inner div에 className 걸고 거기에 padding. docs 사이드바·workspace 템플릿.
|
|
14
|
+
- **Full-bleed**: minimal/aurora처럼 특정 요소(brand/profile)만 수동 들여쓰기, 나머지는 가장자리까지 flush.
|
|
8
15
|
|
|
9
|
-
|
|
16
|
+
**금지**:
|
|
17
|
+
- Sidebar 큰 컨테이너(root/Header/Body/Footer wrapper)에 `padding-inline` 금지. 벤더가 공급 안 하므로 중첩 함정은 없지만, "큰 컨테이너에 padding" 자체가 스크롤 트랙·안쪽 요소 정렬을 틀어뜨리는 패턴.
|
|
18
|
+
- Body에 직접 `padding-inline` 금지 — 스크롤바가 가장자리와 떨어진다. Body 안쪽에 inner div 두고 거기에.
|
|
10
19
|
|
|
11
20
|
사이드바 안쪽 세로 간격은 `--space-2`(섹션 사이·블록 사이) / `--space-1`(섹션 헤더 아래) / `--space-0_5`(아이템 사이) 세 리듬만. 다른 값 금지.
|
|
12
21
|
|
|
@@ -54,8 +63,8 @@ Header는 **가로 행**이기 때문에 Section을 넣으면 nav 전체가 가
|
|
|
54
63
|
- 페이지 안 뷰 전환에 쓰지 않는다 — 그건 [[components/tabs]].
|
|
55
64
|
- Item 안에 variant로 다른 톤을 쓰지 않는다. active 외의 강조 금지.
|
|
56
65
|
- Sidebar 자체에 padding/width를 CSS로 덮지 않는다. `--sidebar-w*` 토큰 경유.
|
|
57
|
-
- `.root`에 padding을 꽂지 않는다 — Body가 스크롤 컨테이너라 스크롤바가 안쪽으로 밀려 가장자리에 안
|
|
58
|
-
- **Sidebar
|
|
66
|
+
- `.root`에 padding을 꽂지 않는다 — Body가 스크롤 컨테이너라 스크롤바가 안쪽으로 밀려 가장자리에 안 붙는다 ([[project_scroll_rule]]).
|
|
67
|
+
- **Sidebar 큰 컨테이너(root/Header/Body/Footer wrapper)에 좌우 패딩 금지**. 좌우/상하 축은 preset slot(`data-sidebar-slot`) 또는 안쪽 콘텐츠 래퍼에 공급. `--sidebar-pad-x`·`--sidebar-pad-y` 토큰 사용 금지(벤더에서 폐기됨).
|
|
59
68
|
- **Sidebar 내부 Input은 `line` 금지** — `outline`(기본) 또는 `filled`만. 사이드바 안은 ghost 리듬이라 밑줄 variant가 녹아 보임.
|
|
60
69
|
- Brand symbol·wordmark 크기 override 금지. `--sidebar-brand-symbol-max`·`--sidebar-brand-wordmark-max` 상한 안에서만.
|
|
61
70
|
- **Body 안 섹션 리스트가 길어서 스크롤이 필요할 때**, 섹션들에 `flex-shrink`를 그대로 두지 않는다. Body는 `display:flex column`이라 자식(Section)이 기본 `flex-shrink:1`로 수축되어 header가 몇 px로 뭉개지고 아이템이 사라져 보인다. 해결: Body 직속 자식에 `flex: 0 0 auto`를 걸어 각 섹션이 자기 콘텐츠 전체 높이를 유지하게 하고, 스크롤은 Body의 `overflow-y:auto`가 담당하게 한다.
|
|
@@ -9,9 +9,7 @@ Layer 1(shadcn 방식 컴포넌트 코드) + Layer 2(AI 프롬프트 컨텍스
|
|
|
9
9
|
|
|
10
10
|
## 발동 시 — 온보딩 페이지 자동 오픈
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
2. ok → `open http://localhost:3000/onboarding`
|
|
14
|
-
3. down → "dev 서버 꺼져있어요. `cd web && pnpm dev` 후 다시" 안내
|
|
12
|
+
`ctds-onboarding` 스킬로 위임 (dev 서버 체크 + `open http://localhost:3000/onboarding`).
|
|
15
13
|
|
|
16
14
|
## 절대 규칙 (항상 적용)
|
|
17
15
|
|
package/package.json
CHANGED
|
@@ -1,247 +0,0 @@
|
|
|
1
|
-
/* Accordion ─────────────────────────────────────────────────────
|
|
2
|
-
여러 섹션 접힘/펴짐. Radix Accordion 위의 시각 레이어.
|
|
3
|
-
두 variant:
|
|
4
|
-
- plain: 테두리 없음, 아이템 사이 hairline만. 설정·FAQ 흐름.
|
|
5
|
-
- card: 각 아이템이 독립 블록(테두리 + radius). 콘텐츠 섹션 리스트.
|
|
6
|
-
아이템 레이아웃(leading·라벨·trailing·caret)은 trigger 내부에서 담당. */
|
|
7
|
-
|
|
8
|
-
/* Root ─────────────────────────────────────────────────── */
|
|
9
|
-
.root {
|
|
10
|
-
display: flex;
|
|
11
|
-
flex-direction: column;
|
|
12
|
-
width: 100%;
|
|
13
|
-
color: var(--fg);
|
|
14
|
-
font-family: var(--font-sans);
|
|
15
|
-
--accordion-trigger-h: calc(var(--space-2) * 5); /* sm 기본 40 */
|
|
16
|
-
/* leading 아이콘 크기 — MenuItem과 동일 톤(sm 14 / md 16).
|
|
17
|
-
caret은 한 스텝 더 작게 — 상태 보조 신호라 라벨·아이콘보다 작아야 리듬이 잡힘. */
|
|
18
|
-
--accordion-icon-size: calc(var(--space-1) * 3.5); /* 14 */
|
|
19
|
-
--accordion-caret-size: var(--space-3); /* 12 */
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
.root[data-size="md"] {
|
|
23
|
-
--accordion-trigger-h: calc(var(--space-2) * 6); /* md 48 */
|
|
24
|
-
--accordion-icon-size: var(--space-4); /* 16 */
|
|
25
|
-
--accordion-caret-size: calc(var(--space-1) * 3.5); /* 14 */
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
.root[data-size="lg"] {
|
|
29
|
-
--accordion-trigger-h: calc(var(--space-2) * 7); /* lg 56 */
|
|
30
|
-
--accordion-icon-size: calc(var(--space-1) * 4.5); /* 18 */
|
|
31
|
-
--accordion-caret-size: var(--space-4); /* 16 */
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
.root[data-variant="card"] {
|
|
35
|
-
gap: var(--space-2);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/* Item ─────────────────────────────────────────────────── */
|
|
39
|
-
/* bare: 선·여백 전부 없음. 본문 흐름에 녹아듦.
|
|
40
|
-
plain: 아이템 사이 hairline. 첫 아이템은 상단 선 없음.
|
|
41
|
-
card: 각 아이템이 테두리 + radius. 사이 gap은 Root에서 담당. */
|
|
42
|
-
.item {
|
|
43
|
-
display: flex;
|
|
44
|
-
flex-direction: column;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
.root[data-variant="plain"] .item + .item {
|
|
48
|
-
border-top: var(--hairline) solid var(--border);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
.root[data-variant="card"] .item {
|
|
52
|
-
border: var(--hairline) solid var(--border);
|
|
53
|
-
border-radius: var(--radius-md);
|
|
54
|
-
background: var(--bg);
|
|
55
|
-
overflow: hidden;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/* bare — Trigger 좌우 padding 제거, content도 좌우 0.
|
|
59
|
-
세로 리듬은 그대로 유지(상/하 padding은 contentInner가 담당). */
|
|
60
|
-
.root[data-variant="bare"] .trigger {
|
|
61
|
-
padding-left: 0;
|
|
62
|
-
padding-right: 0;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
.root[data-variant="bare"] .contentInner {
|
|
66
|
-
padding-left: 0;
|
|
67
|
-
padding-right: 0;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/* bare에서는 hover 배경도 꺼야 "선 없는" 톤이 유지됨. 라벨 색만 상태 신호. */
|
|
71
|
-
.root[data-variant="bare"] .trigger:hover:not([data-state="open"]):not([data-disabled]) {
|
|
72
|
-
background: transparent;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/* Header + Trigger ─────────────────────────────────────── */
|
|
76
|
-
/* Radix의 Header는 <h3>로 렌더됨. 폰트/weight은 Trigger가 결정 — Header는 wrapper. */
|
|
77
|
-
.header {
|
|
78
|
-
margin: 0;
|
|
79
|
-
font-size: inherit;
|
|
80
|
-
font-weight: inherit;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
.trigger {
|
|
84
|
-
display: flex;
|
|
85
|
-
align-items: center;
|
|
86
|
-
gap: var(--space-2);
|
|
87
|
-
width: 100%;
|
|
88
|
-
min-height: var(--accordion-trigger-h);
|
|
89
|
-
padding: 0 var(--space-3);
|
|
90
|
-
border: 0;
|
|
91
|
-
background: transparent;
|
|
92
|
-
color: var(--fg);
|
|
93
|
-
font-family: inherit;
|
|
94
|
-
font-size: var(--text-sm);
|
|
95
|
-
line-height: var(--lh-sm);
|
|
96
|
-
font-weight: var(--font-weight-medium);
|
|
97
|
-
text-align: left;
|
|
98
|
-
cursor: pointer;
|
|
99
|
-
outline: none;
|
|
100
|
-
transition: var(--transition-colors);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
.root[data-size="md"] .trigger {
|
|
104
|
-
font-size: var(--text-base);
|
|
105
|
-
line-height: var(--lh-base);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/* hover: closed일 때만 배경 변화. open에선 상태 signal(fg-strong)만 유지. */
|
|
109
|
-
.trigger:hover:not([data-state="open"]):not([data-disabled]) {
|
|
110
|
-
background: var(--hover);
|
|
111
|
-
color: var(--fg-strong);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
.trigger[data-state="open"] {
|
|
115
|
-
color: var(--fg-strong);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
.trigger:focus-visible {
|
|
119
|
-
outline: none;
|
|
120
|
-
box-shadow: var(--ring-shadow);
|
|
121
|
-
border-radius: var(--radius-sm);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
.trigger[data-disabled] {
|
|
125
|
-
color: var(--fg-disabled);
|
|
126
|
-
cursor: not-allowed;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/* Leading / Label / Trailing ──────────────────────────── */
|
|
130
|
-
.leading {
|
|
131
|
-
display: inline-flex;
|
|
132
|
-
align-items: center;
|
|
133
|
-
gap: var(--space-1);
|
|
134
|
-
color: var(--fg-muted);
|
|
135
|
-
flex-shrink: 0;
|
|
136
|
-
/* MenuItem과 동일 — 슬롯에 들어온 svg/img는 정사각 아이콘 크기로 수렴. */
|
|
137
|
-
font-size: var(--accordion-icon-size);
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
.leading > *,
|
|
141
|
-
.leading svg,
|
|
142
|
-
.leading img {
|
|
143
|
-
width: var(--accordion-icon-size);
|
|
144
|
-
height: var(--accordion-icon-size);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
.trigger[data-state="open"] .leading {
|
|
148
|
-
color: var(--fg-strong);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
.leadingItem {
|
|
152
|
-
display: inline-flex;
|
|
153
|
-
align-items: center;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
.label {
|
|
157
|
-
flex: 1 1 auto;
|
|
158
|
-
min-width: 0;
|
|
159
|
-
overflow: hidden;
|
|
160
|
-
text-overflow: ellipsis;
|
|
161
|
-
white-space: nowrap;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
.trailing {
|
|
165
|
-
display: inline-flex;
|
|
166
|
-
align-items: center;
|
|
167
|
-
gap: var(--space-1);
|
|
168
|
-
color: var(--fg-muted);
|
|
169
|
-
flex-shrink: 0;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
.trailingItem {
|
|
173
|
-
display: inline-flex;
|
|
174
|
-
align-items: center;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
/* Caret — open이면 180도 회전.
|
|
178
|
-
라벨·leading 아이콘보다 한 스텝 작게. 상태 보조 신호라 작아야 리듬이 잡힘. */
|
|
179
|
-
.caret {
|
|
180
|
-
display: inline-flex;
|
|
181
|
-
align-items: center;
|
|
182
|
-
color: var(--fg-muted);
|
|
183
|
-
flex-shrink: 0;
|
|
184
|
-
font-size: var(--accordion-caret-size);
|
|
185
|
-
transition: transform var(--duration-base) var(--ease-out);
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
.caret svg {
|
|
189
|
-
width: var(--accordion-caret-size);
|
|
190
|
-
height: var(--accordion-caret-size);
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
.trigger[data-state="open"] .caret {
|
|
194
|
-
transform: rotate(180deg);
|
|
195
|
-
color: var(--fg-strong);
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/* Content ──────────────────────────────────────────────── */
|
|
199
|
-
/* Radix가 측정한 --radix-accordion-content-height로 slide 애니메이션.
|
|
200
|
-
contentInner에 padding을 깔아 열림 애니메이션이 깔끔하게 흐르도록. */
|
|
201
|
-
.content {
|
|
202
|
-
overflow: hidden;
|
|
203
|
-
font-size: var(--text-sm);
|
|
204
|
-
line-height: var(--lh-sm);
|
|
205
|
-
color: var(--fg);
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
.content[data-state="open"] {
|
|
209
|
-
animation: accordion-slide-down var(--duration-base) var(--ease-out);
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
.content[data-state="closed"] {
|
|
213
|
-
animation: accordion-slide-up var(--duration-fast) var(--ease-in);
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
.contentInner {
|
|
217
|
-
/* 상단은 Trigger와 리듬 유지(겹치지 않게 살짝), 하단은 다음 Trigger와 거리. */
|
|
218
|
-
padding: var(--space-1) var(--space-3) var(--space-4);
|
|
219
|
-
color: var(--fg);
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
.root[data-size="md"] .contentInner {
|
|
223
|
-
font-size: var(--text-base);
|
|
224
|
-
line-height: var(--lh-base);
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
/* Animations ───────────────────────────────────────────── */
|
|
228
|
-
@keyframes accordion-slide-down {
|
|
229
|
-
from { height: 0; opacity: 0; }
|
|
230
|
-
to { height: var(--radix-accordion-content-height); opacity: 1; }
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
@keyframes accordion-slide-up {
|
|
234
|
-
from { height: var(--radix-accordion-content-height); opacity: 1; }
|
|
235
|
-
to { height: 0; opacity: 0; }
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
@media (prefers-reduced-motion: reduce) {
|
|
239
|
-
.content,
|
|
240
|
-
.content[data-state="open"],
|
|
241
|
-
.content[data-state="closed"] {
|
|
242
|
-
animation: none;
|
|
243
|
-
}
|
|
244
|
-
.caret {
|
|
245
|
-
transition: none;
|
|
246
|
-
}
|
|
247
|
-
}
|
|
@@ -1,207 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Accordion ─ 여러 섹션을 접었다 폈다 하는 구조 컴포넌트.
|
|
5
|
-
*
|
|
6
|
-
* 스택: Radix `@radix-ui/react-accordion` 어댑터.
|
|
7
|
-
* - 열림/닫힘 상태·키보드 네비(↑↓ Home End)·ARIA(aria-expanded/controls)·
|
|
8
|
-
* 콘텐츠 높이 측정은 전부 Radix가 처리.
|
|
9
|
-
* - 시각(Trigger 레이아웃·caret·divider·card variant)만 CTDS가 담당.
|
|
10
|
-
*
|
|
11
|
-
* variant
|
|
12
|
-
* - plain (기본): 테두리 없음, 아이템 사이 hairline만. 설정 그룹·FAQ용.
|
|
13
|
-
* - card: 각 아이템이 테두리 + radius로 독립 블록. 콘텐츠 사이트의 섹션 리스트용.
|
|
14
|
-
*
|
|
15
|
-
* 소비 표면
|
|
16
|
-
* <Accordion type="single" collapsible defaultValue="general">
|
|
17
|
-
* <Accordion.Item value="general">
|
|
18
|
-
* <Accordion.Trigger leadingIcon="gear">일반</Accordion.Trigger>
|
|
19
|
-
* <Accordion.Content>...</Accordion.Content>
|
|
20
|
-
* </Accordion.Item>
|
|
21
|
-
* </Accordion>
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
import * as RadixAccordion from "@radix-ui/react-accordion";
|
|
25
|
-
import {
|
|
26
|
-
createContext,
|
|
27
|
-
forwardRef,
|
|
28
|
-
useContext,
|
|
29
|
-
type ComponentPropsWithoutRef,
|
|
30
|
-
type ReactNode,
|
|
31
|
-
} from "react";
|
|
32
|
-
|
|
33
|
-
import { Icon, type IconName } from "../Icon";
|
|
34
|
-
import styles from "./Accordion.module.css";
|
|
35
|
-
|
|
36
|
-
export type AccordionSize = "sm" | "md" | "lg";
|
|
37
|
-
/**
|
|
38
|
-
* - bare: 선·좌우 여백 전부 없음. 본문 흐름에 가장 투명하게 녹음(툴바·설명 블록 내부).
|
|
39
|
-
* - plain: 테두리 없음, 아이템 사이 hairline, Trigger 좌우 padding 있음. 설정·FAQ 흐름.
|
|
40
|
-
* - card: 각 아이템이 테두리 + radius로 독립 블록. 콘텐츠 섹션 리스트.
|
|
41
|
-
*/
|
|
42
|
-
export type AccordionVariant = "bare" | "plain" | "card";
|
|
43
|
-
|
|
44
|
-
export type AccordionAction =
|
|
45
|
-
| ReactNode
|
|
46
|
-
| { node: ReactNode; key?: string };
|
|
47
|
-
|
|
48
|
-
/* Size·variant context — Trigger가 Root 설정 상속. */
|
|
49
|
-
type AccordionContextValue = {
|
|
50
|
-
size: AccordionSize;
|
|
51
|
-
variant: AccordionVariant;
|
|
52
|
-
};
|
|
53
|
-
const AccordionContext = createContext<AccordionContextValue>({
|
|
54
|
-
size: "sm",
|
|
55
|
-
variant: "plain",
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
/* Root ─────────────────────────────────────────────────────── */
|
|
59
|
-
|
|
60
|
-
type SingleRootProps = ComponentPropsWithoutRef<typeof RadixAccordion.Root> & {
|
|
61
|
-
type: "single";
|
|
62
|
-
};
|
|
63
|
-
type MultipleRootProps = ComponentPropsWithoutRef<typeof RadixAccordion.Root> & {
|
|
64
|
-
type: "multiple";
|
|
65
|
-
};
|
|
66
|
-
export type AccordionProps = (SingleRootProps | MultipleRootProps) & {
|
|
67
|
-
size?: AccordionSize;
|
|
68
|
-
variant?: AccordionVariant;
|
|
69
|
-
};
|
|
70
|
-
|
|
71
|
-
const Root = forwardRef<HTMLDivElement, AccordionProps>(function Accordion(
|
|
72
|
-
{ className, size = "sm", variant = "plain", children, ...rest },
|
|
73
|
-
ref,
|
|
74
|
-
) {
|
|
75
|
-
return (
|
|
76
|
-
<AccordionContext.Provider value={{ size, variant }}>
|
|
77
|
-
<RadixAccordion.Root
|
|
78
|
-
ref={ref}
|
|
79
|
-
data-size={size}
|
|
80
|
-
data-variant={variant}
|
|
81
|
-
className={[styles.root, className ?? ""].filter(Boolean).join(" ")}
|
|
82
|
-
{...(rest as ComponentPropsWithoutRef<typeof RadixAccordion.Root>)}
|
|
83
|
-
>
|
|
84
|
-
{children}
|
|
85
|
-
</RadixAccordion.Root>
|
|
86
|
-
</AccordionContext.Provider>
|
|
87
|
-
);
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
/* Item ─────────────────────────────────────────────────────── */
|
|
91
|
-
|
|
92
|
-
const Item = forwardRef<HTMLDivElement, ComponentPropsWithoutRef<typeof RadixAccordion.Item>>(
|
|
93
|
-
function AccordionItem({ className, children, ...rest }, ref) {
|
|
94
|
-
return (
|
|
95
|
-
<RadixAccordion.Item
|
|
96
|
-
ref={ref}
|
|
97
|
-
className={[styles.item, className ?? ""].filter(Boolean).join(" ")}
|
|
98
|
-
{...rest}
|
|
99
|
-
>
|
|
100
|
-
{children}
|
|
101
|
-
</RadixAccordion.Item>
|
|
102
|
-
);
|
|
103
|
-
},
|
|
104
|
-
);
|
|
105
|
-
|
|
106
|
-
/* Trigger ──────────────────────────────────────────────────── */
|
|
107
|
-
|
|
108
|
-
export interface TriggerProps
|
|
109
|
-
extends Omit<ComponentPropsWithoutRef<typeof RadixAccordion.Trigger>, "children"> {
|
|
110
|
-
/** 좌측 아이콘 이름. leading 슬롯 간결 사용. */
|
|
111
|
-
leadingIcon?: IconName;
|
|
112
|
-
/** 커스텀 좌측 노드 배열. leadingIcon보다 우선. */
|
|
113
|
-
leadingActions?: AccordionAction[];
|
|
114
|
-
/** 우측 메타(배지·숫자 등). caret은 자동으로 가장 끝. */
|
|
115
|
-
trailingActions?: AccordionAction[];
|
|
116
|
-
children?: ReactNode;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const Trigger = forwardRef<HTMLButtonElement, TriggerProps>(function AccordionTrigger(
|
|
120
|
-
{ className, leadingIcon, leadingActions, trailingActions, children, ...rest },
|
|
121
|
-
ref,
|
|
122
|
-
) {
|
|
123
|
-
const leading =
|
|
124
|
-
leadingActions ?? (leadingIcon ? [<Icon key="icon" name={leadingIcon} />] : undefined);
|
|
125
|
-
return (
|
|
126
|
-
<RadixAccordion.Header className={styles.header}>
|
|
127
|
-
<RadixAccordion.Trigger
|
|
128
|
-
ref={ref}
|
|
129
|
-
className={[styles.trigger, className ?? ""].filter(Boolean).join(" ")}
|
|
130
|
-
{...rest}
|
|
131
|
-
>
|
|
132
|
-
{leading && leading.length > 0 && (
|
|
133
|
-
<span className={styles.leading} data-slot="leading" aria-hidden>
|
|
134
|
-
{leading.map((a, i) => {
|
|
135
|
-
const key =
|
|
136
|
-
typeof a === "object" && a !== null && "key" in a && a.key
|
|
137
|
-
? a.key
|
|
138
|
-
: String(i);
|
|
139
|
-
const node = typeof a === "object" && a !== null && "node" in a ? a.node : a;
|
|
140
|
-
return (
|
|
141
|
-
<span key={key} className={styles.leadingItem}>
|
|
142
|
-
{node as ReactNode}
|
|
143
|
-
</span>
|
|
144
|
-
);
|
|
145
|
-
})}
|
|
146
|
-
</span>
|
|
147
|
-
)}
|
|
148
|
-
<span className={styles.label}>{children}</span>
|
|
149
|
-
{trailingActions && trailingActions.length > 0 && (
|
|
150
|
-
<span className={styles.trailing} data-slot="trailing">
|
|
151
|
-
{trailingActions.map((a, i) => {
|
|
152
|
-
const key =
|
|
153
|
-
typeof a === "object" && a !== null && "key" in a && a.key
|
|
154
|
-
? a.key
|
|
155
|
-
: String(i);
|
|
156
|
-
const node = typeof a === "object" && a !== null && "node" in a ? a.node : a;
|
|
157
|
-
return (
|
|
158
|
-
<span key={key} className={styles.trailingItem}>
|
|
159
|
-
{node as ReactNode}
|
|
160
|
-
</span>
|
|
161
|
-
);
|
|
162
|
-
})}
|
|
163
|
-
</span>
|
|
164
|
-
)}
|
|
165
|
-
<span className={styles.caret} aria-hidden>
|
|
166
|
-
<Icon name="caret-down" />
|
|
167
|
-
</span>
|
|
168
|
-
</RadixAccordion.Trigger>
|
|
169
|
-
</RadixAccordion.Header>
|
|
170
|
-
);
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
/* Content ──────────────────────────────────────────────────── */
|
|
174
|
-
|
|
175
|
-
const Content = forwardRef<HTMLDivElement, ComponentPropsWithoutRef<typeof RadixAccordion.Content>>(
|
|
176
|
-
function AccordionContent({ className, children, ...rest }, ref) {
|
|
177
|
-
return (
|
|
178
|
-
<RadixAccordion.Content
|
|
179
|
-
ref={ref}
|
|
180
|
-
className={[styles.content, className ?? ""].filter(Boolean).join(" ")}
|
|
181
|
-
{...rest}
|
|
182
|
-
>
|
|
183
|
-
<div className={styles.contentInner}>{children}</div>
|
|
184
|
-
</RadixAccordion.Content>
|
|
185
|
-
);
|
|
186
|
-
},
|
|
187
|
-
);
|
|
188
|
-
|
|
189
|
-
/* Hook — 내부 Trigger가 size 참조할 때 (현재는 미사용, 향후 확장용) */
|
|
190
|
-
export function useAccordionContext() {
|
|
191
|
-
return useContext(AccordionContext);
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
/* Compound export ─────────────────────────────────────────── */
|
|
195
|
-
|
|
196
|
-
type AccordionComponent = typeof Root & {
|
|
197
|
-
Item: typeof Item;
|
|
198
|
-
Trigger: typeof Trigger;
|
|
199
|
-
Content: typeof Content;
|
|
200
|
-
};
|
|
201
|
-
|
|
202
|
-
const Accordion = Root as AccordionComponent;
|
|
203
|
-
Accordion.Item = Item;
|
|
204
|
-
Accordion.Trigger = Trigger;
|
|
205
|
-
Accordion.Content = Content;
|
|
206
|
-
|
|
207
|
-
export { Accordion };
|