moru-krds-react 0.1.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/LICENSE +21 -0
- package/README.md +61 -0
- package/dist/accordion/index.d.ts +63 -0
- package/dist/accordion/index.d.ts.map +1 -0
- package/dist/accordion/index.js +145 -0
- package/dist/accordion/index.js.map +1 -0
- package/dist/alert/index.d.ts +44 -0
- package/dist/alert/index.d.ts.map +1 -0
- package/dist/alert/index.js +66 -0
- package/dist/alert/index.js.map +1 -0
- package/dist/avatar/index.d.ts +102 -0
- package/dist/avatar/index.d.ts.map +1 -0
- package/dist/avatar/index.js +146 -0
- package/dist/avatar/index.js.map +1 -0
- package/dist/badge/index.d.ts +49 -0
- package/dist/badge/index.d.ts.map +1 -0
- package/dist/badge/index.js +41 -0
- package/dist/badge/index.js.map +1 -0
- package/dist/breadcrumb/index.d.ts +43 -0
- package/dist/breadcrumb/index.d.ts.map +1 -0
- package/dist/breadcrumb/index.js +43 -0
- package/dist/breadcrumb/index.js.map +1 -0
- package/dist/button/index.d.ts +78 -0
- package/dist/button/index.d.ts.map +1 -0
- package/dist/button/index.js +83 -0
- package/dist/button/index.js.map +1 -0
- package/dist/checkbox/index.d.ts +97 -0
- package/dist/checkbox/index.d.ts.map +1 -0
- package/dist/checkbox/index.js +110 -0
- package/dist/checkbox/index.js.map +1 -0
- package/dist/combobox/index.d.ts +135 -0
- package/dist/combobox/index.d.ts.map +1 -0
- package/dist/combobox/index.js +416 -0
- package/dist/combobox/index.js.map +1 -0
- package/dist/context-menu/index.d.ts +94 -0
- package/dist/context-menu/index.d.ts.map +1 -0
- package/dist/context-menu/index.js +399 -0
- package/dist/context-menu/index.js.map +1 -0
- package/dist/critical-alert/index.d.ts +39 -0
- package/dist/critical-alert/index.d.ts.map +1 -0
- package/dist/critical-alert/index.js +50 -0
- package/dist/critical-alert/index.js.map +1 -0
- package/dist/data-table/index.d.ts +94 -0
- package/dist/data-table/index.d.ts.map +1 -0
- package/dist/data-table/index.js +128 -0
- package/dist/data-table/index.js.map +1 -0
- package/dist/disclosure/index.d.ts +35 -0
- package/dist/disclosure/index.d.ts.map +1 -0
- package/dist/disclosure/index.js +54 -0
- package/dist/disclosure/index.js.map +1 -0
- package/dist/drawer/index.d.ts +106 -0
- package/dist/drawer/index.d.ts.map +1 -0
- package/dist/drawer/index.js +352 -0
- package/dist/drawer/index.js.map +1 -0
- package/dist/empty-state/index.d.ts +86 -0
- package/dist/empty-state/index.d.ts.map +1 -0
- package/dist/empty-state/index.js +138 -0
- package/dist/empty-state/index.js.map +1 -0
- package/dist/env.d.js +1 -0
- package/dist/env.d.js.map +1 -0
- package/dist/field/index.d.ts +149 -0
- package/dist/field/index.d.ts.map +1 -0
- package/dist/field/index.js +121 -0
- package/dist/field/index.js.map +1 -0
- package/dist/file-upload/index.d.ts +140 -0
- package/dist/file-upload/index.d.ts.map +1 -0
- package/dist/file-upload/index.js +249 -0
- package/dist/file-upload/index.js.map +1 -0
- package/dist/footer/index.d.ts +155 -0
- package/dist/footer/index.d.ts.map +1 -0
- package/dist/footer/index.js +186 -0
- package/dist/footer/index.js.map +1 -0
- package/dist/header/index.d.ts +170 -0
- package/dist/header/index.d.ts.map +1 -0
- package/dist/header/index.js +258 -0
- package/dist/header/index.js.map +1 -0
- package/dist/identifier/index.d.ts +38 -0
- package/dist/identifier/index.d.ts.map +1 -0
- package/dist/identifier/index.js +55 -0
- package/dist/identifier/index.js.map +1 -0
- package/dist/in-page-navigation/index.d.ts +71 -0
- package/dist/in-page-navigation/index.d.ts.map +1 -0
- package/dist/in-page-navigation/index.js +137 -0
- package/dist/in-page-navigation/index.js.map +1 -0
- package/dist/index.d.ts +57 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +47 -0
- package/dist/index.js.map +1 -0
- package/dist/input-group/index.d.ts +66 -0
- package/dist/input-group/index.d.ts.map +1 -0
- package/dist/input-group/index.js +85 -0
- package/dist/input-group/index.js.map +1 -0
- package/dist/lib/krds.d.ts +45 -0
- package/dist/lib/krds.d.ts.map +1 -0
- package/dist/lib/krds.js +11 -0
- package/dist/lib/krds.js.map +1 -0
- package/dist/lib/link.d.ts +34 -0
- package/dist/lib/link.d.ts.map +1 -0
- package/dist/lib/link.js +8 -0
- package/dist/lib/link.js.map +1 -0
- package/dist/main-menu/index.d.ts +123 -0
- package/dist/main-menu/index.d.ts.map +1 -0
- package/dist/main-menu/index.js +613 -0
- package/dist/main-menu/index.js.map +1 -0
- package/dist/masthead/index.d.ts +25 -0
- package/dist/masthead/index.d.ts.map +1 -0
- package/dist/masthead/index.js +21 -0
- package/dist/masthead/index.js.map +1 -0
- package/dist/modal/index.d.ts +105 -0
- package/dist/modal/index.d.ts.map +1 -0
- package/dist/modal/index.js +285 -0
- package/dist/modal/index.js.map +1 -0
- package/dist/pagination/index.d.ts +58 -0
- package/dist/pagination/index.d.ts.map +1 -0
- package/dist/pagination/index.js +82 -0
- package/dist/pagination/index.js.map +1 -0
- package/dist/progress/index.d.ts +58 -0
- package/dist/progress/index.d.ts.map +1 -0
- package/dist/progress/index.js +125 -0
- package/dist/progress/index.js.map +1 -0
- package/dist/radio/index.d.ts +111 -0
- package/dist/radio/index.d.ts.map +1 -0
- package/dist/radio/index.js +136 -0
- package/dist/radio/index.js.map +1 -0
- package/dist/resize/index.d.ts +118 -0
- package/dist/resize/index.d.ts.map +1 -0
- package/dist/resize/index.js +342 -0
- package/dist/resize/index.js.map +1 -0
- package/dist/scroll-area/index.d.ts +40 -0
- package/dist/scroll-area/index.d.ts.map +1 -0
- package/dist/scroll-area/index.js +52 -0
- package/dist/scroll-area/index.js.map +1 -0
- package/dist/select/index.d.ts +67 -0
- package/dist/select/index.d.ts.map +1 -0
- package/dist/select/index.js +61 -0
- package/dist/select/index.js.map +1 -0
- package/dist/separator/index.d.ts +47 -0
- package/dist/separator/index.d.ts.map +1 -0
- package/dist/separator/index.js +61 -0
- package/dist/separator/index.js.map +1 -0
- package/dist/side-navigation/index.d.ts +78 -0
- package/dist/side-navigation/index.d.ts.map +1 -0
- package/dist/side-navigation/index.js +188 -0
- package/dist/side-navigation/index.js.map +1 -0
- package/dist/skeleton/index.d.ts +76 -0
- package/dist/skeleton/index.d.ts.map +1 -0
- package/dist/skeleton/index.js +78 -0
- package/dist/skeleton/index.js.map +1 -0
- package/dist/skip-link/index.d.ts +46 -0
- package/dist/skip-link/index.d.ts.map +1 -0
- package/dist/skip-link/index.js +37 -0
- package/dist/skip-link/index.js.map +1 -0
- package/dist/spinner/index.d.ts +58 -0
- package/dist/spinner/index.d.ts.map +1 -0
- package/dist/spinner/index.js +36 -0
- package/dist/spinner/index.js.map +1 -0
- package/dist/step-indicator/index.d.ts +41 -0
- package/dist/step-indicator/index.d.ts.map +1 -0
- package/dist/step-indicator/index.js +63 -0
- package/dist/step-indicator/index.js.map +1 -0
- package/dist/structured-list/index.d.ts +150 -0
- package/dist/structured-list/index.d.ts.map +1 -0
- package/dist/structured-list/index.js +194 -0
- package/dist/structured-list/index.js.map +1 -0
- package/dist/tab/index.d.ts +108 -0
- package/dist/tab/index.d.ts.map +1 -0
- package/dist/tab/index.js +198 -0
- package/dist/tab/index.js.map +1 -0
- package/dist/table/index.d.ts +75 -0
- package/dist/table/index.d.ts.map +1 -0
- package/dist/table/index.js +91 -0
- package/dist/table/index.js.map +1 -0
- package/dist/tag/index.d.ts +71 -0
- package/dist/tag/index.d.ts.map +1 -0
- package/dist/tag/index.js +45 -0
- package/dist/tag/index.js.map +1 -0
- package/dist/text-input/index.d.ts +69 -0
- package/dist/text-input/index.d.ts.map +1 -0
- package/dist/text-input/index.js +70 -0
- package/dist/text-input/index.js.map +1 -0
- package/dist/text-list/index.d.ts +71 -0
- package/dist/text-list/index.d.ts.map +1 -0
- package/dist/text-list/index.js +79 -0
- package/dist/text-list/index.js.map +1 -0
- package/dist/textarea/index.d.ts +29 -0
- package/dist/textarea/index.d.ts.map +1 -0
- package/dist/textarea/index.js +69 -0
- package/dist/textarea/index.js.map +1 -0
- package/dist/toast/index.d.ts +76 -0
- package/dist/toast/index.d.ts.map +1 -0
- package/dist/toast/index.js +183 -0
- package/dist/toast/index.js.map +1 -0
- package/dist/tooltip/index.d.ts +84 -0
- package/dist/tooltip/index.d.ts.map +1 -0
- package/dist/tooltip/index.js +329 -0
- package/dist/tooltip/index.js.map +1 -0
- package/package.json +57 -0
- package/src/accordion/index.tsx +251 -0
- package/src/alert/index.tsx +111 -0
- package/src/avatar/index.tsx +272 -0
- package/src/badge/index.tsx +91 -0
- package/src/breadcrumb/index.tsx +125 -0
- package/src/button/index.tsx +190 -0
- package/src/checkbox/index.tsx +247 -0
- package/src/combobox/index.tsx +700 -0
- package/src/context-menu/index.tsx +623 -0
- package/src/critical-alert/index.tsx +102 -0
- package/src/data-table/index.tsx +289 -0
- package/src/disclosure/index.tsx +87 -0
- package/src/drawer/index.tsx +516 -0
- package/src/empty-state/index.tsx +256 -0
- package/src/env.d.ts +10 -0
- package/src/field/index.tsx +307 -0
- package/src/file-upload/index.tsx +492 -0
- package/src/footer/index.tsx +393 -0
- package/src/header/index.tsx +498 -0
- package/src/identifier/index.tsx +96 -0
- package/src/in-page-navigation/index.tsx +258 -0
- package/src/index.ts +72 -0
- package/src/input-group/index.tsx +197 -0
- package/src/lib/krds.ts +75 -0
- package/src/lib/link.tsx +35 -0
- package/src/main-menu/index.tsx +912 -0
- package/src/masthead/index.tsx +50 -0
- package/src/modal/index.tsx +457 -0
- package/src/pagination/index.tsx +198 -0
- package/src/progress/index.tsx +198 -0
- package/src/radio/index.tsx +283 -0
- package/src/resize/index.tsx +541 -0
- package/src/scroll-area/index.tsx +96 -0
- package/src/select/index.tsx +136 -0
- package/src/separator/index.tsx +107 -0
- package/src/side-navigation/index.tsx +336 -0
- package/src/skeleton/index.tsx +172 -0
- package/src/skip-link/index.tsx +89 -0
- package/src/spinner/index.tsx +98 -0
- package/src/step-indicator/index.tsx +122 -0
- package/src/structured-list/index.tsx +395 -0
- package/src/tab/index.tsx +348 -0
- package/src/table/index.tsx +186 -0
- package/src/tag/index.tsx +127 -0
- package/src/text-input/index.tsx +156 -0
- package/src/text-list/index.tsx +177 -0
- package/src/textarea/index.tsx +113 -0
- package/src/toast/index.tsx +311 -0
- package/src/tooltip/index.tsx +496 -0
- package/styles/krds-tailwind.css +335 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
useCallback,
|
|
5
|
+
useEffect,
|
|
6
|
+
useState,
|
|
7
|
+
type ComponentPropsWithRef,
|
|
8
|
+
type MouseEvent as ReactMouseEvent,
|
|
9
|
+
type ReactNode,
|
|
10
|
+
} from 'react';
|
|
11
|
+
import { cx } from '../lib/krds';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 콘텐츠 내 탐색(목차) — KRDS `krds-in-page-navigation-area`.
|
|
15
|
+
*
|
|
16
|
+
* 긴 상세 화면의 섹션 목차. 본문 섹션에 `id` 만 주면 스크롤 감시·부드러운 이동·초점 이동을
|
|
17
|
+
* 이 부품이 한다(킷 JS 를 싣지 않는다). 항목 이름은 **연결된 섹션 제목과 같은 문구**로 쓴다.
|
|
18
|
+
* 컨테이너에 {@link InPageNavigationLayout} 을 붙여야 목차 자리가 열린다 —
|
|
19
|
+
* 목차에만 붙이면 고정된 목차가 본문 위를 덮는다.
|
|
20
|
+
* 마크업 자리는 제목과 본문 **사이**다. 서비스 정보구조 탐색은 이것이 아니라 `ui/side-navigation` 이다.
|
|
21
|
+
* `action` 에 넣은 버튼은 **모바일에서 킷이 숨기므로** 유일한 실행 수단을 여기 두지 마라.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* <InPageNavigationLayout id="container">
|
|
25
|
+
* <div className="inner">
|
|
26
|
+
* <div className="contents">
|
|
27
|
+
* <InPageNavigation
|
|
28
|
+
* title="장애아동수당"
|
|
29
|
+
* items={[
|
|
30
|
+
* { id: 'sec-overview', label: '서비스 개요' },
|
|
31
|
+
* { id: 'sec-detail', label: '서비스 상세' },
|
|
32
|
+
* ]}
|
|
33
|
+
* />
|
|
34
|
+
* <section id="sec-overview"><h2>서비스 개요</h2>…</section>
|
|
35
|
+
* </div>
|
|
36
|
+
* </div>
|
|
37
|
+
* </InPageNavigationLayout>
|
|
38
|
+
*
|
|
39
|
+
* 자세히: docs/krds/09-부품-노트.md#페이지내탐색
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/** 목차 한 줄. `id` 는 본문 섹션 요소의 `id`(`#` 없이). */
|
|
43
|
+
export type InPageNavigationItem = {
|
|
44
|
+
/** 이동 대상 섹션의 `id`. `href="#{id}"` 로 나간다. */
|
|
45
|
+
id: string;
|
|
46
|
+
/** 목록에 보일 글자. 연결된 섹션 제목과 같은 문구여야 한다. 길면 두 줄로 접힌다. */
|
|
47
|
+
label: ReactNode;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type InPageNavigationProps = Omit<ComponentPropsWithRef<'div'>, 'children' | 'title'> & {
|
|
51
|
+
/** 목차 항목. 빈 배열이면 아무것도 그리지 않는다. 8개 안팎으로 줄인다. */
|
|
52
|
+
items: InPageNavigationItem[];
|
|
53
|
+
/** 머리말 위 작은 글자. 기본 「이 페이지의 구성」. `null` 을 주면 그리지 않는다. */
|
|
54
|
+
caption?: ReactNode;
|
|
55
|
+
/** 머리말 제목. 보통 현재 화면 이름. 제목 태그가 아니라 `<p>` 로 그린다. */
|
|
56
|
+
title?: ReactNode;
|
|
57
|
+
/** `<nav>` 의 접근 이름. 생략하면 `caption` 을 쓴다. */
|
|
58
|
+
navLabel?: string;
|
|
59
|
+
/** 목록 아래 실행 버튼. **모바일에서는 킷이 숨긴다.** */
|
|
60
|
+
action?: ReactNode;
|
|
61
|
+
/** 실행 버튼 아래 보조 문구. */
|
|
62
|
+
info?: ReactNode;
|
|
63
|
+
/** 활성 항목을 밖에서 정한다. 주면 스크롤 감시를 끄고 이 값만 따른다. */
|
|
64
|
+
activeId?: string;
|
|
65
|
+
/** 이동한 뒤 섹션 위에 남길 여백(px). 안 주면 마스트헤드 + 헤더 높이를 잰다. */
|
|
66
|
+
offsetTop?: number;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** 고정 헤더 높이. 킷 JS `calculateHeaderHeight()` 와 같은 계산이다. */
|
|
70
|
+
function headerHeight(): number {
|
|
71
|
+
const masthead = document.getElementById('krds-masthead')?.clientHeight ?? 0;
|
|
72
|
+
const headerIn =
|
|
73
|
+
document.querySelector<HTMLElement>('#krds-header .header-in')?.clientHeight ?? 0;
|
|
74
|
+
return masthead + headerIn;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 지금 보고 있는 섹션을 고른다. 킷 JS `updateActiveSection()` 을 옮기되
|
|
79
|
+
* 문서 기준 좌표로 재고, 섹션 사이 빈틈에서는 **지나온 마지막 섹션**을 고른다.
|
|
80
|
+
*/
|
|
81
|
+
function pickActiveId(ids: string[]): string | undefined {
|
|
82
|
+
const found = ids
|
|
83
|
+
.map((id) => ({ id, el: document.getElementById(id) }))
|
|
84
|
+
.filter((s): s is { id: string; el: HTMLElement } => s.el !== null);
|
|
85
|
+
if (found.length === 0) return undefined;
|
|
86
|
+
|
|
87
|
+
const y = window.scrollY;
|
|
88
|
+
// 스크롤이 문서 끝에 닿으면 마지막 항목(짧은 마지막 섹션이 영영 활성화되지 못하는 것을 막는다).
|
|
89
|
+
if (y + window.innerHeight >= document.documentElement.scrollHeight - 1) {
|
|
90
|
+
return found[found.length - 1]!.id;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const guard = Math.ceil(window.innerHeight / 5); // 킷과 같은 「윈도우의 20%」
|
|
94
|
+
let active = found[0]!.id;
|
|
95
|
+
for (const { id, el } of found) {
|
|
96
|
+
if (y >= el.getBoundingClientRect().top + y - guard) active = id;
|
|
97
|
+
}
|
|
98
|
+
return active;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function InPageNavigation({
|
|
102
|
+
items,
|
|
103
|
+
caption = '이 페이지의 구성',
|
|
104
|
+
title,
|
|
105
|
+
navLabel,
|
|
106
|
+
action,
|
|
107
|
+
info,
|
|
108
|
+
activeId,
|
|
109
|
+
offsetTop,
|
|
110
|
+
className,
|
|
111
|
+
...rest
|
|
112
|
+
}: InPageNavigationProps) {
|
|
113
|
+
const [spyId, setSpyId] = useState<string | undefined>(undefined);
|
|
114
|
+
const controlled = activeId !== undefined;
|
|
115
|
+
const active = controlled ? activeId : spyId;
|
|
116
|
+
|
|
117
|
+
// 항목 id 를 문자열 하나로 굳혀 effect 의존성에 쓴다(배열이 매번 새로 만들어져도 다시 붙지 않게).
|
|
118
|
+
const idKey = items.map((it) => it.id).join(' ');
|
|
119
|
+
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
if (controlled) return;
|
|
122
|
+
const ids = idKey ? idKey.split(' ') : [];
|
|
123
|
+
if (ids.length === 0) return;
|
|
124
|
+
|
|
125
|
+
let frame = 0;
|
|
126
|
+
const update = () => {
|
|
127
|
+
frame = 0;
|
|
128
|
+
setSpyId(pickActiveId(ids));
|
|
129
|
+
};
|
|
130
|
+
// 스크롤마다 레이아웃을 재는 계산이라 프레임당 한 번으로 묶는다.
|
|
131
|
+
const schedule = () => {
|
|
132
|
+
if (frame === 0) frame = window.requestAnimationFrame(update);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
update();
|
|
136
|
+
window.addEventListener('scroll', schedule, { passive: true });
|
|
137
|
+
window.addEventListener('resize', schedule);
|
|
138
|
+
return () => {
|
|
139
|
+
if (frame !== 0) window.cancelAnimationFrame(frame);
|
|
140
|
+
window.removeEventListener('scroll', schedule);
|
|
141
|
+
window.removeEventListener('resize', schedule);
|
|
142
|
+
};
|
|
143
|
+
}, [controlled, idKey]);
|
|
144
|
+
|
|
145
|
+
const goToSection = useCallback(
|
|
146
|
+
(id: string): boolean => {
|
|
147
|
+
const target = document.getElementById(id);
|
|
148
|
+
if (!target) return false; // 대상이 없으면 브라우저 기본 앵커 이동에 맡긴다
|
|
149
|
+
|
|
150
|
+
const gap = offsetTop ?? headerHeight();
|
|
151
|
+
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
152
|
+
window.scrollTo({
|
|
153
|
+
left: 0,
|
|
154
|
+
top: target.getBoundingClientRect().top + window.scrollY - gap,
|
|
155
|
+
behavior: reduced ? 'auto' : 'smooth',
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// 스크롤만 하고 초점을 두면 규격 위반이다(「스크롤과 함께 목적지 섹션으로 Focus 이벤트」).
|
|
159
|
+
// 킷은 `.sec-tit` 을 찾지만 그 클래스는 킷 CSS 에 없다 — 섹션의 첫 제목 태그를 쓴다.
|
|
160
|
+
const focusTarget = target.querySelector<HTMLElement>('h1, h2, h3, h4, h5, h6') ?? target;
|
|
161
|
+
focusTarget.setAttribute('tabindex', '-1');
|
|
162
|
+
focusTarget.focus({ preventScroll: true });
|
|
163
|
+
return true;
|
|
164
|
+
},
|
|
165
|
+
[offsetTop],
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const handleClick = (event: ReactMouseEvent<HTMLAnchorElement>, id: string) => {
|
|
169
|
+
// 새 탭·새 창으로 여는 조작은 그대로 둔다(킷 JS 는 이것까지 막는다).
|
|
170
|
+
if (
|
|
171
|
+
event.defaultPrevented ||
|
|
172
|
+
event.button !== 0 ||
|
|
173
|
+
event.metaKey ||
|
|
174
|
+
event.ctrlKey ||
|
|
175
|
+
event.shiftKey ||
|
|
176
|
+
event.altKey
|
|
177
|
+
) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (goToSection(id)) {
|
|
181
|
+
event.preventDefault();
|
|
182
|
+
if (!controlled) setSpyId(id);
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
if (items.length === 0) return null;
|
|
187
|
+
|
|
188
|
+
const hasHeader = caption != null || title != null;
|
|
189
|
+
|
|
190
|
+
return (
|
|
191
|
+
<div className={cx('krds-in-page-navigation-area', className)} {...rest}>
|
|
192
|
+
{hasHeader && (
|
|
193
|
+
<div className="in-page-navigation-header">
|
|
194
|
+
{caption != null && <p className="quick-caption">{caption}</p>}
|
|
195
|
+
{/* 제목 태그가 아니라 `<p>` 다 — 본문 제목 계층을 흐리지 않으려는 킷 샘플 그대로. */}
|
|
196
|
+
{title != null && <p className="quick-title">{title}</p>}
|
|
197
|
+
</div>
|
|
198
|
+
)}
|
|
199
|
+
|
|
200
|
+
<nav
|
|
201
|
+
className="in-page-navigation-list"
|
|
202
|
+
aria-label={navLabel ?? (typeof caption === 'string' ? caption : '이 페이지의 구성')}
|
|
203
|
+
>
|
|
204
|
+
<ul>
|
|
205
|
+
{items.map((item) => {
|
|
206
|
+
const isActive = item.id === active;
|
|
207
|
+
return (
|
|
208
|
+
<li key={item.id}>
|
|
209
|
+
<a
|
|
210
|
+
href={`#${item.id}`}
|
|
211
|
+
className={isActive ? 'active' : undefined}
|
|
212
|
+
aria-current={isActive ? 'true' : undefined}
|
|
213
|
+
onClick={(event) => handleClick(event, item.id)}
|
|
214
|
+
>
|
|
215
|
+
{item.label}
|
|
216
|
+
</a>
|
|
217
|
+
</li>
|
|
218
|
+
);
|
|
219
|
+
})}
|
|
220
|
+
</ul>
|
|
221
|
+
</nav>
|
|
222
|
+
|
|
223
|
+
{(action != null || info != null) && (
|
|
224
|
+
// 767px 이하에서 킷이 이 블록을 통째로 `display:none` 한다. 유일한 실행 수단을 여기 두지 마라.
|
|
225
|
+
<div className="in-page-navigation-action">
|
|
226
|
+
{action}
|
|
227
|
+
{info != null && <p className="quick-info">{info}</p>}
|
|
228
|
+
</div>
|
|
229
|
+
)}
|
|
230
|
+
</div>
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* 목차가 있는 화면의 컨테이너 — 킷 `krds-in-page-navigation-type`.
|
|
236
|
+
*
|
|
237
|
+
* **목차가 아니라 컨테이너에 붙인다** — 이 클래스가 본문 오른쪽에 목차 자리를 파 준다.
|
|
238
|
+
* 안쪽은 `.inner`(LNB 가 같이 있으면 `.inner.in-between`) → `.contents` 순서다.
|
|
239
|
+
*
|
|
240
|
+
* @example
|
|
241
|
+
* <InPageNavigationLayout id="container">
|
|
242
|
+
* <div className="inner in-between">
|
|
243
|
+
* <SideNav … />
|
|
244
|
+
* <div className="contents">…<InPageNavigation … />…</div>
|
|
245
|
+
* </div>
|
|
246
|
+
* </InPageNavigationLayout>
|
|
247
|
+
*/
|
|
248
|
+
export function InPageNavigationLayout({
|
|
249
|
+
className,
|
|
250
|
+
children,
|
|
251
|
+
...rest
|
|
252
|
+
}: ComponentPropsWithRef<'div'>) {
|
|
253
|
+
return (
|
|
254
|
+
<div className={cx('krds-in-page-navigation-type', className)} {...rest}>
|
|
255
|
+
{children}
|
|
256
|
+
</div>
|
|
257
|
+
);
|
|
258
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KRDS(디지털정부서비스 UI/UX 가이드라인) React 부품.
|
|
3
|
+
*
|
|
4
|
+
* 두 갈래다.
|
|
5
|
+
* 1. **KRDS 부품** — 킷(`krds-uiux`)의 클래스를 그대로 쓰는 껍데기.
|
|
6
|
+
* 모양·색·크기는 전부 킷에서 오고, 이 층은 마크업 구조와 접근성 속성을 고정한다.
|
|
7
|
+
* 2. **보충 부품** — KRDS 가 다루지 않는 업무시스템(백오피스)용. KRDS 토큰으로 그린다.
|
|
8
|
+
* KRDS 는 국민용 누리집이 대상이라 표 정렬·콤보박스·서랍·토스트 같은 것이 없다.
|
|
9
|
+
*
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/* ── 공통 타입 ─────────────────────────────────────────────── */
|
|
13
|
+
export * from './lib/krds';
|
|
14
|
+
|
|
15
|
+
/* ── 액션 ─────────────────────────────────────────────────── */
|
|
16
|
+
export * from './button';
|
|
17
|
+
|
|
18
|
+
/* ── 폼 ───────────────────────────────────────────────────── */
|
|
19
|
+
export * from './field';
|
|
20
|
+
export * from './text-input';
|
|
21
|
+
export * from './textarea';
|
|
22
|
+
export * from './select';
|
|
23
|
+
export * from './checkbox';
|
|
24
|
+
export * from './radio';
|
|
25
|
+
export * from './file-upload';
|
|
26
|
+
export * from './combobox';
|
|
27
|
+
export * from './input-group';
|
|
28
|
+
|
|
29
|
+
/* ── 표현 ─────────────────────────────────────────────────── */
|
|
30
|
+
export * from './badge';
|
|
31
|
+
export * from './tag';
|
|
32
|
+
export * from './table';
|
|
33
|
+
export * from './data-table';
|
|
34
|
+
export * from './structured-list';
|
|
35
|
+
export * from './text-list';
|
|
36
|
+
export * from './critical-alert';
|
|
37
|
+
export * from './alert';
|
|
38
|
+
export * from './empty-state';
|
|
39
|
+
|
|
40
|
+
/* ── 탐색 ─────────────────────────────────────────────────── */
|
|
41
|
+
export * from './skip-link';
|
|
42
|
+
export * from './masthead';
|
|
43
|
+
export * from './header';
|
|
44
|
+
export * from './main-menu';
|
|
45
|
+
export * from './breadcrumb';
|
|
46
|
+
export * from './side-navigation';
|
|
47
|
+
export * from './in-page-navigation';
|
|
48
|
+
export * from './pagination';
|
|
49
|
+
export * from './footer';
|
|
50
|
+
export * from './identifier';
|
|
51
|
+
|
|
52
|
+
/* ── 열고 닫기 ─────────────────────────────────────────────── */
|
|
53
|
+
export * from './accordion';
|
|
54
|
+
export * from './disclosure';
|
|
55
|
+
export * from './tab';
|
|
56
|
+
export * from './modal';
|
|
57
|
+
export * from './drawer';
|
|
58
|
+
export * from './tooltip';
|
|
59
|
+
export * from './context-menu';
|
|
60
|
+
|
|
61
|
+
/* ── 피드백 ───────────────────────────────────────────────── */
|
|
62
|
+
export * from './spinner';
|
|
63
|
+
export * from './step-indicator';
|
|
64
|
+
export * from './progress';
|
|
65
|
+
export * from './skeleton';
|
|
66
|
+
export * from './toast';
|
|
67
|
+
|
|
68
|
+
/* ── 그 밖 ────────────────────────────────────────────────── */
|
|
69
|
+
export * from './avatar';
|
|
70
|
+
export * from './separator';
|
|
71
|
+
export * from './scroll-area';
|
|
72
|
+
export * from './resize';
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { Children, cloneElement, Fragment, isValidElement } from 'react';
|
|
2
|
+
import type { ComponentPropsWithRef, ReactElement, ReactNode } from 'react';
|
|
3
|
+
import { cx, type KrdsFieldSize } from '../lib/krds';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 입력 묶음 — 킷 `input-group`.
|
|
7
|
+
*
|
|
8
|
+
* 컨트롤 하나와 그에 딸린 버튼·기호를 한 줄로 묶고 **크기를 한 번만 정해 자식에게 내려보낸다.**
|
|
9
|
+
* 그래서 입력칸과 옆 버튼의 높이가 어긋나지 않는다(기본 medium · 48px).
|
|
10
|
+
* 레이블까지 갖춘 필드 여럿을 가로로 늘어놓는 것은 `ui/field` 의 `FieldRow` 다.
|
|
11
|
+
* 기간(시작~끝)은 {@link InputGroupRange} 를 쓴다.
|
|
12
|
+
* 크기는 `TextInput`·`Select`·`Button` 같은 **부품**에만 내려간다 — 맨 `<input>` 은 그대로 남는다.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* <InputGroup size="medium">
|
|
16
|
+
* <InputGroupItem>
|
|
17
|
+
* <TextField id="q" label="검색어" hideLabel placeholder="검색어를 입력하세요" />
|
|
18
|
+
* </InputGroupItem>
|
|
19
|
+
* <Button variant="primary" type="submit">검색</Button>
|
|
20
|
+
* </InputGroup>
|
|
21
|
+
* <InputGroup>
|
|
22
|
+
* <InputGroupItem><TextInput id="amount" inputMode="numeric" /></InputGroupItem>
|
|
23
|
+
* <InputGroupText>원</InputGroupText>
|
|
24
|
+
* </InputGroup>
|
|
25
|
+
*
|
|
26
|
+
* 자세히: docs/krds/09-부품-노트.md#입력묶음
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/* ────────────────────────────── 크기 전파 ────────────────────────────── */
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 자식 부품에 `size` 를 내려보낸다. DOM 태그와 Fragment 에는 넘기지 않고(HTML `size` 속성과 뜻이 다르다),
|
|
33
|
+
* 자식이 이미 `size` 를 갖고 있으면 그대로 둔다.
|
|
34
|
+
*/
|
|
35
|
+
function withSize(children: ReactNode, size: KrdsFieldSize): ReactNode {
|
|
36
|
+
return Children.map(children, (child) => {
|
|
37
|
+
if (!isValidElement(child)) return child;
|
|
38
|
+
if (typeof child.type === 'string' || child.type === Fragment) return child;
|
|
39
|
+
// React 19 타입에서 `props` 는 unknown 이라 좁혀서 본다.
|
|
40
|
+
const props = child.props as { size?: unknown };
|
|
41
|
+
if (props.size !== undefined) return child;
|
|
42
|
+
return cloneElement(child as ReactElement<{ size?: KrdsFieldSize }>, { size });
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/* ────────────────────────────── 기본형 ────────────────────────────── */
|
|
47
|
+
|
|
48
|
+
export type InputGroupProps = Omit<ComponentPropsWithRef<'div'>, 'children'> & {
|
|
49
|
+
/** 묶음 전체의 크기. 기본 medium(48px). **자식이 저마다 적지 않고 여기서 한 번만 정한다.** */
|
|
50
|
+
size?: KrdsFieldSize;
|
|
51
|
+
/** 세로 정렬. 기본 `center` — 자식이 레이블까지 달고 있으면 `end` 로 밑선을 맞춘다. */
|
|
52
|
+
align?: 'center' | 'end';
|
|
53
|
+
/** 간격을 16px 에서 8px 로 좁힌다. 입력칸과 버튼을 한 덩어리처럼 보이게 할 때. */
|
|
54
|
+
tight?: boolean;
|
|
55
|
+
children?: ReactNode;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* `<div class="input-group">` — 컨트롤 하나와 그에 딸린 버튼·기호를 한 줄로 묶는다.
|
|
60
|
+
* `size` 를 하나 바꾸면 입력칸·버튼이 함께 움직인다.
|
|
61
|
+
*
|
|
62
|
+
* **`className="row"` 안에서는 세로로 쌓인다** — 킷에 그런 규칙이 있다.
|
|
63
|
+
*/
|
|
64
|
+
export function InputGroup({
|
|
65
|
+
size = 'medium',
|
|
66
|
+
align = 'center',
|
|
67
|
+
tight,
|
|
68
|
+
className,
|
|
69
|
+
children,
|
|
70
|
+
...rest
|
|
71
|
+
}: InputGroupProps) {
|
|
72
|
+
return (
|
|
73
|
+
<div
|
|
74
|
+
className={cx('input-group', align === 'end' && 'items-end', tight && 'gap-2', className)}
|
|
75
|
+
{...rest}
|
|
76
|
+
>
|
|
77
|
+
{withSize(children, size)}
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/* ────────────────────────────── 폭을 차지하는 칸 ────────────────────────────── */
|
|
83
|
+
|
|
84
|
+
export type InputGroupItemProps = ComponentPropsWithRef<'div'> & {
|
|
85
|
+
/** 안쪽 자식에 내려보낸다. 보통 {@link InputGroup} 이 알아서 넣어 준다. */
|
|
86
|
+
size?: KrdsFieldSize;
|
|
87
|
+
/** 남는 폭을 차지한다. 기본 true — 이 상자에 넣는 것이 대개 입력칸이라서다. */
|
|
88
|
+
grow?: boolean;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 늘어나는 칸. **입력칸은 이 상자로 감싼다** — 감싸지 않으면 기준 폭이 컨테이너 전체가 되어
|
|
93
|
+
* 옆 버튼을 찌그러뜨린다. 늘어나지 않아야 하면 `grow={false}`.
|
|
94
|
+
*/
|
|
95
|
+
export function InputGroupItem({
|
|
96
|
+
size = 'medium',
|
|
97
|
+
grow = true,
|
|
98
|
+
className,
|
|
99
|
+
children,
|
|
100
|
+
...rest
|
|
101
|
+
}: InputGroupItemProps) {
|
|
102
|
+
return (
|
|
103
|
+
<div className={cx(grow ? 'min-w-0 flex-1' : 'shrink-0', className)} {...rest}>
|
|
104
|
+
{withSize(children, size)}
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/* ────────────────────────────── 접두사·접미사 ────────────────────────────── */
|
|
110
|
+
|
|
111
|
+
/** 크기별 상자 높이와 글자 크기. 킷 입력칸 토큰과 같은 값이다(40 / 48 / 56 / 80px). */
|
|
112
|
+
const ADDON_BOX: Record<KrdsFieldSize, string> = {
|
|
113
|
+
small: 'h-10 text-label-sm',
|
|
114
|
+
medium: 'h-12 text-label-md',
|
|
115
|
+
large: 'h-14 text-label-lg',
|
|
116
|
+
xlarge: 'h-20 text-heading-md',
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export type InputGroupTextProps = ComponentPropsWithRef<'span'> & {
|
|
120
|
+
/** 보통 {@link InputGroup} 이 넣어 준다. 기본 medium(48px). */
|
|
121
|
+
size?: KrdsFieldSize;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* 입력칸에 붙는 글자 — 접두사(`https://`), 단위(`원`·`건`), 구분 기호(`~`).
|
|
126
|
+
* 킷에 클래스가 없어 보충했다. 입력칸과 같은 높이로 서서 밑선이 맞는다.
|
|
127
|
+
*
|
|
128
|
+
* 「원」처럼 **뜻이 있는 단위는 읽혀야 하고**, `~` 처럼 시각적 기호는 `aria-hidden` 을 직접 붙여
|
|
129
|
+
* 소리에서 뺀다 — 어느 쪽인지는 부품이 판단할 수 없어 기본값을 두지 않았다.
|
|
130
|
+
*/
|
|
131
|
+
export function InputGroupText({
|
|
132
|
+
size = 'medium',
|
|
133
|
+
className,
|
|
134
|
+
children,
|
|
135
|
+
...rest
|
|
136
|
+
}: InputGroupTextProps) {
|
|
137
|
+
return (
|
|
138
|
+
<span
|
|
139
|
+
className={cx(
|
|
140
|
+
'inline-flex shrink-0 items-center whitespace-nowrap text-fg-subtle',
|
|
141
|
+
ADDON_BOX[size],
|
|
142
|
+
className,
|
|
143
|
+
)}
|
|
144
|
+
{...rest}
|
|
145
|
+
>
|
|
146
|
+
{children}
|
|
147
|
+
</span>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/* ────────────────────────────── 기간 입력 ────────────────────────────── */
|
|
152
|
+
|
|
153
|
+
/** 기간 입력이 쓰는 크기. 킷에 구분 기호의 xlarge 규칙이 없어 덜어냈다. */
|
|
154
|
+
export type InputGroupRangeSize = Exclude<KrdsFieldSize, 'xlarge'>;
|
|
155
|
+
|
|
156
|
+
export type InputGroupRangeProps = Omit<ComponentPropsWithRef<'ul'>, 'children'> & {
|
|
157
|
+
/** 기본 medium(48px). 두 칸과 가운데 기호에 함께 내려간다. */
|
|
158
|
+
size?: InputGroupRangeSize;
|
|
159
|
+
/** 시작 칸. 레이블까지 갖춘 필드를 그대로 넣어도 된다. */
|
|
160
|
+
from: ReactNode;
|
|
161
|
+
/** 끝 칸. */
|
|
162
|
+
to: ReactNode;
|
|
163
|
+
/** 가운데 구분 기호. 기본 `-`. 장식이라 소리에서는 빠진다. */
|
|
164
|
+
separator?: ReactNode;
|
|
165
|
+
/** 킷 달력 스크립트가 잡는 손잡이 클래스(`set`)를 붙인다. 킷 달력을 물릴 때만 켠다. */
|
|
166
|
+
set?: boolean;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* 기간 입력 — 킷 `input-group range`. 두 칸이 폭을 반씩 나눠 갖고 입력칸 밑선이 맞는다.
|
|
171
|
+
*
|
|
172
|
+
* @example
|
|
173
|
+
* <InputGroupRange
|
|
174
|
+
* from={<TextField id="from" label="시작일" type="date" />}
|
|
175
|
+
* to={<TextField id="to" label="종료일" type="date" />}
|
|
176
|
+
* />
|
|
177
|
+
*/
|
|
178
|
+
export function InputGroupRange({
|
|
179
|
+
size = 'medium',
|
|
180
|
+
from,
|
|
181
|
+
to,
|
|
182
|
+
separator = '-',
|
|
183
|
+
set,
|
|
184
|
+
className,
|
|
185
|
+
...rest
|
|
186
|
+
}: InputGroupRangeProps) {
|
|
187
|
+
return (
|
|
188
|
+
<ul className={cx('input-group range', set && 'set', className)} {...rest}>
|
|
189
|
+
<li>{withSize(from, size)}</li>
|
|
190
|
+
{/* 크기 클래스를 반드시 붙인다 — 빼면 킷 기본 56px 이라 medium 입력 사이에서 혼자 커진다. */}
|
|
191
|
+
<li className={cx('mark', size)} aria-hidden="true">
|
|
192
|
+
{separator}
|
|
193
|
+
</li>
|
|
194
|
+
<li>{withSize(to, size)}</li>
|
|
195
|
+
</ul>
|
|
196
|
+
);
|
|
197
|
+
}
|
package/src/lib/krds.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KRDS 공통 타입과 도우미.
|
|
3
|
+
*
|
|
4
|
+
* 여기 있는 값은 전부 킷(`public/krds/krds.min.css`)에서 실측한 것이다. 지어낸 값이 없다.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 크기.
|
|
9
|
+
*
|
|
10
|
+
* ★ 버튼과 입력칸이 **같은 이름에 같은 높이**다. 실측(1rem = 10px):
|
|
11
|
+
*
|
|
12
|
+
* | 이름 | 높이 | 버튼 | 입력·셀렉트 |
|
|
13
|
+
* |---------|-----|------|------------|
|
|
14
|
+
* | xsmall | 32 | ○ | — |
|
|
15
|
+
* | small | 40 | ○ | ○ |
|
|
16
|
+
* | medium | 48 | ○ | ○ |
|
|
17
|
+
* | large | 56 | ○ | ○ |
|
|
18
|
+
* | xlarge | 64 | ○ | 80(예외) |
|
|
19
|
+
*
|
|
20
|
+
* 그래서 한 줄에 놓을 버튼과 입력칸에는 **같은 size 를 준다.**
|
|
21
|
+
* 예전에 입력칸에 크기를 안 줘서 킷 기본값 large(56)로 렌더되고 옆 버튼은 small(40)이라
|
|
22
|
+
* 16px 이 어긋났다 — 부품에서 기본값을 명시해 이 실수를 구조적으로 막는다.
|
|
23
|
+
*/
|
|
24
|
+
export type KrdsSize = 'xsmall' | 'small' | 'medium' | 'large' | 'xlarge';
|
|
25
|
+
|
|
26
|
+
/** 폼 요소가 실제로 지원하는 크기(킷에 xsmall 규칙이 없다). */
|
|
27
|
+
export type KrdsFieldSize = Exclude<KrdsSize, 'xsmall'>;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 의미 색상. 킷의 배지·태그·알림이 공통으로 쓰는 이름이다.
|
|
31
|
+
*/
|
|
32
|
+
export type KrdsTone =
|
|
33
|
+
| 'primary'
|
|
34
|
+
| 'secondary'
|
|
35
|
+
| 'gray'
|
|
36
|
+
| 'point'
|
|
37
|
+
| 'danger'
|
|
38
|
+
| 'warning'
|
|
39
|
+
| 'success'
|
|
40
|
+
| 'information';
|
|
41
|
+
|
|
42
|
+
/** 폼 필드 상태. 킷 클래스는 `.is-error` · `.is-success` · `.is-information`. */
|
|
43
|
+
export type KrdsFieldState = 'error' | 'success' | 'information';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 킷이 제공하는 아이콘 이름(`ico-` 접두사를 뺀 것).
|
|
47
|
+
* `<i className="svg-icon ico-sch" />` 처럼 쓴다.
|
|
48
|
+
*
|
|
49
|
+
* 킷의 아이콘 파일은 `public/krds/img/component/icon/ico_*.svg` 에 있고 CSS 가 mask 로 물린다.
|
|
50
|
+
* **여기 없는 이름을 쓰면 검은 네모가 나온다** — 그래서 타입으로 좁혀 둔다.
|
|
51
|
+
* 아래 목록은 킷 CSS 에서 `\\.ico-([a-z0-9-]+)` 로 뽑은 68종 전부다. 킷을 올릴 때 다시 뽑는다.
|
|
52
|
+
*/
|
|
53
|
+
export type KrdsIcon =
|
|
54
|
+
| 'all' | 'angle' | 'blog' | 'bread-home' | 'cal-move' | 'calendar'
|
|
55
|
+
| 'call' | 'checkbox' | 'complete-fill' | 'del' | 'delete-fill' | 'down'
|
|
56
|
+
| 'ellipsis' | 'email' | 'error-fill' | 'facebook' | 'faq' | 'file'
|
|
57
|
+
| 'filter' | 'flag' | 'fold' | 'func' | 'global' | 'go'
|
|
58
|
+
| 'go-top' | 'help' | 'information-fill' | 'instagram' | 'invalid' | 'join'
|
|
59
|
+
| 'like' | 'link' | 'log' | 'login-go' | 'login-type01' | 'login-type02'
|
|
60
|
+
| 'login-type03' | 'login-type04' | 'login-type05' | 'login-type06' | 'logout' | 'modal-close'
|
|
61
|
+
| 'more' | 'my' | 'page-next' | 'plus' | 'popup-close' | 'print'
|
|
62
|
+
| 'pw-visible' | 'pw-visible-on' | 'refresh' | 'reset' | 'sch' | 'sch-plus'
|
|
63
|
+
| 'scrap' | 'search' | 'setting' | 'share' | 'sns-x' | 'success-fill'
|
|
64
|
+
| 'swiper-play' | 'swiper-stop' | 'toggle' | 'tooltip' | 'upload' | 'urgent-danger'
|
|
65
|
+
| 'view-mode' | 'youtube';
|
|
66
|
+
|
|
67
|
+
/** 클래스 이름을 합친다. falsy 는 버린다. */
|
|
68
|
+
export function cx(...parts: Array<string | false | null | undefined>): string {
|
|
69
|
+
return parts.filter(Boolean).join(' ');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 상태 → 킷 클래스. */
|
|
73
|
+
export function stateClass(state?: KrdsFieldState): string | undefined {
|
|
74
|
+
return state && `is-${state}`;
|
|
75
|
+
}
|
package/src/lib/link.tsx
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 링크 구현 — **이 묶음이 프레임워크에 닿는 유일한 자리다.**
|
|
3
|
+
*
|
|
4
|
+
* 부품 안에서 화면 이동이 필요할 때는 프레임워크의 링크를 직접 부르지 않고 여기 `UiLink` 를 쓴다.
|
|
5
|
+
* 그래서 Next·React Router·순수 React 어디에 올리든 **고칠 파일이 이 하나**다.
|
|
6
|
+
*
|
|
7
|
+
* 기본값은 맨 `<a>` 다 — 아무 프레임워크에도 묶이지 않는다.
|
|
8
|
+
* 클라이언트 라우팅을 쓰고 있다면 아래처럼 이 파일만 바꾼다.
|
|
9
|
+
*
|
|
10
|
+
* ```tsx
|
|
11
|
+
* // Next.js (App Router · Pages Router 공통)
|
|
12
|
+
* import NextLink from 'next/link';
|
|
13
|
+
* export function UiLink({ href, ...rest }: UiLinkProps) {
|
|
14
|
+
* return <NextLink href={href} {...rest} />;
|
|
15
|
+
* }
|
|
16
|
+
*
|
|
17
|
+
* // React Router
|
|
18
|
+
* import { Link } from 'react-router';
|
|
19
|
+
* export function UiLink({ href, ...rest }: UiLinkProps) {
|
|
20
|
+
* return <Link to={href} {...rest} />;
|
|
21
|
+
* }
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* 왜 context 나 전역 등록이 아닌가 — 부품 중 절반이 서버 컴포넌트로도 쓰인다.
|
|
25
|
+
* React context 는 서버 컴포넌트에서 못 쓰고, 모듈 전역 등록은 서버와 클라이언트의
|
|
26
|
+
* 모듈 그래프가 갈라져 있어 한쪽만 설정되는 함정이 있다. 그냥 import 를 한 곳에 모으는 것이
|
|
27
|
+
* 가장 단순하고 양쪽에서 똑같이 동작한다.
|
|
28
|
+
*/
|
|
29
|
+
import type { ComponentPropsWithRef } from 'react';
|
|
30
|
+
|
|
31
|
+
export type UiLinkProps = ComponentPropsWithRef<'a'> & { href: string };
|
|
32
|
+
|
|
33
|
+
export function UiLink({ href, ...rest }: UiLinkProps) {
|
|
34
|
+
return <a href={href} {...rest} />;
|
|
35
|
+
}
|