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,496 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
useCallback,
|
|
5
|
+
useEffect,
|
|
6
|
+
useId,
|
|
7
|
+
useLayoutEffect,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
type ComponentPropsWithRef,
|
|
11
|
+
type FocusEvent as ReactFocusEvent,
|
|
12
|
+
type MouseEvent as ReactMouseEvent,
|
|
13
|
+
type ReactNode,
|
|
14
|
+
type Ref,
|
|
15
|
+
} from 'react';
|
|
16
|
+
import { cx, type KrdsIcon, type KrdsSize } from '../lib/krds';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 툴팁 — KRDS `krds-tooltip` · 맥락적 도움말 — KRDS `krds-contextual-help`.
|
|
20
|
+
*
|
|
21
|
+
* 갈림길은 하나다 — **팝오버 안에 링크·버튼이 필요하면 툴팁이 아니라 맥락적 도움말이다.**
|
|
22
|
+
* 툴팁은 hover/focus 로 뜨고 맥락적 도움말은 click 으로 연다.
|
|
23
|
+
* **한 화면에 둘을 섞어 쓰지 마라**(가이드 원문) — 버튼이 똑같이 생겼는데 상호작용이 달라진다.
|
|
24
|
+
* 본문은 150자 내외로 쓰고, 그보다 길면 도움 패널이나 별도 도움말 페이지로 보낸다.
|
|
25
|
+
* `title` 속성과 겹쳐 쓰지 않는다 — 같은 말이 두 번 읽힌다.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* <Tooltip iconOnly icon="help" label="처리 기간 도움말" content="접수일로부터 10일 이내" />
|
|
29
|
+
* <Tooltip variant="text" icon="angle" iconDirection="right" content="예상 처리 기간입니다.">
|
|
30
|
+
* 처리 기간
|
|
31
|
+
* </Tooltip>
|
|
32
|
+
* <ContextualHelp label="신청 자격 도움말" heading="신청 자격" text="신청 자격">
|
|
33
|
+
* <p>만 18세 이상이면 신청할 수 있습니다.</p>
|
|
34
|
+
* <div className="btn-wrap">
|
|
35
|
+
* <LinkButton size="xsmall" href="/help/eligibility">자세히 보기</LinkButton>
|
|
36
|
+
* </div>
|
|
37
|
+
* </ContextualHelp>
|
|
38
|
+
*
|
|
39
|
+
* 자세히: docs/krds/09-부품-노트.md#툴팁
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/** 킷 JS 의 `tooltipGap`. CSS 토큰이 아니라 코드 상수다. */
|
|
43
|
+
const TOOLTIP_GAP = 12;
|
|
44
|
+
|
|
45
|
+
/** 킷 CSS 의 좁은 화면 경계. */
|
|
46
|
+
const MOBILE_MAX = 767;
|
|
47
|
+
|
|
48
|
+
/** 킷 CSS 가 팝오버를 화면 폭에 맞춰 버리는 경계. 이때는 좌표를 계산하지 않는다. */
|
|
49
|
+
const NARROW_MAX = 420;
|
|
50
|
+
|
|
51
|
+
/** 팝오버를 열 때 초점을 줄 첫 대화형 요소. 킷 JS 의 선택자 그대로다. */
|
|
52
|
+
const CONTEXTUAL_FOCUSABLE = 'a, button, [tabindex="0"], input, textarea, select';
|
|
53
|
+
|
|
54
|
+
/** SSR 경고를 피하려고 갈아 끼운다. 위치 계산은 그리기 전에 끝나야 좌상단에 번쩍이지 않는다. */
|
|
55
|
+
const useIsoLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;
|
|
56
|
+
|
|
57
|
+
/** 버튼 위계. `ui/button` 의 `ButtonVariant` 와 같은 값이다(부품 간 의존을 만들지 않으려고 따로 적었다). */
|
|
58
|
+
export type TooltipVariant = 'primary' | 'secondary' | 'tertiary' | 'text' | 'link';
|
|
59
|
+
|
|
60
|
+
/** 계산된 팝오버 위치. `cls` 는 킷이 말풍선 꼬리를 붙일 때 쓰는 방향 클래스다. */
|
|
61
|
+
type TooltipPosition = { top: number; left: number; centered: boolean; cls: string };
|
|
62
|
+
|
|
63
|
+
/** 팝오버 좌표. 킷 JS 의 `calculateTooltipPosition()` 이식. 좌표가 뷰포트 기준이라 스크롤에 닫는다. */
|
|
64
|
+
function computeTooltipPosition(
|
|
65
|
+
trigger: HTMLElement,
|
|
66
|
+
popover: HTMLElement,
|
|
67
|
+
vertical: boolean,
|
|
68
|
+
): TooltipPosition {
|
|
69
|
+
const { clientHeight: popH, clientWidth: popW } = popover;
|
|
70
|
+
const rect = trigger.getBoundingClientRect();
|
|
71
|
+
const halfW = window.innerWidth / 2;
|
|
72
|
+
const halfH = window.innerHeight / 2;
|
|
73
|
+
// 좁은 화면에서는 방향 지정과 무관하게 세로형으로 떨어뜨린다(킷 JS 의 isMobile 분기).
|
|
74
|
+
const isVertical = vertical || window.innerWidth <= MOBILE_MAX;
|
|
75
|
+
const cls: string[] = [];
|
|
76
|
+
let top: number;
|
|
77
|
+
let left: number;
|
|
78
|
+
|
|
79
|
+
if (isVertical) {
|
|
80
|
+
if (rect.top + rect.height > halfH) {
|
|
81
|
+
top = rect.top - popH - TOOLTIP_GAP; // 화면 아래쪽 요소 → 위로 띄운다
|
|
82
|
+
cls.push('top');
|
|
83
|
+
} else {
|
|
84
|
+
top = rect.top + rect.height + TOOLTIP_GAP;
|
|
85
|
+
cls.push('bottom');
|
|
86
|
+
}
|
|
87
|
+
if (rect.left + rect.width > halfW) {
|
|
88
|
+
left = rect.right - popW; // 오른쪽 정렬
|
|
89
|
+
// 오른쪽에 여유가 있으면 가운데로 되돌린다
|
|
90
|
+
if (window.innerWidth - (rect.left + rect.width) > popW / 2) {
|
|
91
|
+
left = rect.left + (rect.width - popW) / 2;
|
|
92
|
+
} else {
|
|
93
|
+
cls.push('right');
|
|
94
|
+
}
|
|
95
|
+
} else {
|
|
96
|
+
left = rect.left + (rect.width - popW) / 2;
|
|
97
|
+
if (left < 0) {
|
|
98
|
+
left = rect.left; // 왼쪽 공간 부족 보정
|
|
99
|
+
cls.push('left');
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
} else {
|
|
103
|
+
// 가로형: 트리거 옆에 세로 가운데를 맞춰 붙인다
|
|
104
|
+
top = rect.top + (rect.height - popH) / 2;
|
|
105
|
+
if (rect.left + rect.width > halfW) {
|
|
106
|
+
left = rect.left - popW - TOOLTIP_GAP; // 왼쪽에 붙인다
|
|
107
|
+
cls.push('right'); // 꼬리는 팝오버의 오른쪽에 난다
|
|
108
|
+
} else {
|
|
109
|
+
left = rect.right + TOOLTIP_GAP;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { top, left, centered: window.innerWidth <= NARROW_MAX, cls: cls.join(' ') };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
type TooltipBaseProps = {
|
|
117
|
+
/** 팝오버 본문. **150자 내외**로 쓴다 — 더 길면 맥락적 도움말·도움 패널·별도 페이지다. */
|
|
118
|
+
content: ReactNode;
|
|
119
|
+
/** 기본 medium(48px). 같은 줄의 입력칸·버튼과 같은 값을 준다. */
|
|
120
|
+
size?: KrdsSize;
|
|
121
|
+
/** 트리거 버튼의 위계. */
|
|
122
|
+
variant?: TooltipVariant;
|
|
123
|
+
/** 킷 아이콘 이름(`ico-` 뺀 것). 예: `help` · `information` */
|
|
124
|
+
icon?: KrdsIcon;
|
|
125
|
+
/** 아이콘 회전. 방향은 이름의 일부가 아니라 별도 클래스다 — `icon="angle right"` 로 쓰지 마라. */
|
|
126
|
+
iconDirection?: 'up' | 'down' | 'left' | 'right';
|
|
127
|
+
/** 아이콘 위치. 기본 `end`. */
|
|
128
|
+
iconPosition?: 'start' | 'end';
|
|
129
|
+
/** 테두리 있는 아이콘 버튼. `iconOnly` 일 때만 뜻이 있다. */
|
|
130
|
+
bordered?: boolean;
|
|
131
|
+
/** 박스형 — 흰 바탕에 테두리, 줄바꿈 허용. 세로 방향으로 뜬다. */
|
|
132
|
+
box?: boolean;
|
|
133
|
+
/** 인라인형을 세로(위/아래) 방향으로 띄운다. */
|
|
134
|
+
vertical?: boolean;
|
|
135
|
+
/** 팝오버 맨 앞에 숨겨 넣을 트리거 이름. 생략하면 트리거의 글자에서 읽어 온다. */
|
|
136
|
+
name?: string;
|
|
137
|
+
/** 트리거 버튼에 붙일 클래스. */
|
|
138
|
+
className?: string;
|
|
139
|
+
/** 트리거 글자. `iconOnly` 가 아닐 때. */
|
|
140
|
+
children?: ReactNode;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
export type TooltipProps = Omit<ComponentPropsWithRef<'button'>, 'children' | 'content'> &
|
|
144
|
+
TooltipBaseProps & {
|
|
145
|
+
iconOnly?: false;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
export type IconTooltipProps = Omit<ComponentPropsWithRef<'button'>, 'children' | 'content'> &
|
|
149
|
+
TooltipBaseProps & {
|
|
150
|
+
iconOnly: true;
|
|
151
|
+
/** 스크린리더가 읽을 버튼 이름. **툴팁 글월은 이름의 대체물이 아니다**(가이드 원문). */
|
|
152
|
+
label: string;
|
|
153
|
+
icon: KrdsIcon;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* 툴팁 — KRDS `krds-tooltip`. hover/focus 로 뜨는 부가 설명이다.
|
|
158
|
+
*
|
|
159
|
+
* **팝오버 안에 링크·버튼을 넣지 마라** — hover 로만 뜨는 영역은 마우스를 옮기는 동안 사라진다
|
|
160
|
+
* (대화형 요소가 필요하면 {@link ContextualHelp}).
|
|
161
|
+
* **필수 정보를 툴팁에만 담지 마라** — 터치 기기에는 hover 가 없다.
|
|
162
|
+
* `disabled` 트리거에는 붙지 않는다(마우스·초점 이벤트가 오지 않는다).
|
|
163
|
+
*/
|
|
164
|
+
export function Tooltip(props: TooltipProps | IconTooltipProps) {
|
|
165
|
+
const {
|
|
166
|
+
content,
|
|
167
|
+
size = 'medium',
|
|
168
|
+
variant,
|
|
169
|
+
icon,
|
|
170
|
+
iconDirection,
|
|
171
|
+
iconPosition = 'end',
|
|
172
|
+
bordered,
|
|
173
|
+
box,
|
|
174
|
+
vertical,
|
|
175
|
+
name,
|
|
176
|
+
className,
|
|
177
|
+
children,
|
|
178
|
+
type = 'button',
|
|
179
|
+
disabled,
|
|
180
|
+
ref,
|
|
181
|
+
onMouseEnter,
|
|
182
|
+
onMouseLeave,
|
|
183
|
+
onFocus,
|
|
184
|
+
onBlur,
|
|
185
|
+
...rest
|
|
186
|
+
} = props as TooltipProps & Partial<IconTooltipProps>;
|
|
187
|
+
|
|
188
|
+
const iconOnly = 'iconOnly' in props && props.iconOnly === true;
|
|
189
|
+
const label = 'label' in props ? props.label : undefined;
|
|
190
|
+
|
|
191
|
+
const popoverId = useId();
|
|
192
|
+
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
|
193
|
+
const popoverRef = useRef<HTMLDivElement | null>(null);
|
|
194
|
+
const [open, setOpen] = useState(false);
|
|
195
|
+
const [pos, setPos] = useState<TooltipPosition | null>(null);
|
|
196
|
+
// 트리거 글자 사본. `name` 을 주면 안 읽는다.
|
|
197
|
+
const [autoName, setAutoName] = useState(label ?? '');
|
|
198
|
+
|
|
199
|
+
// 밖에서 온 ref 와 부품 ref 를 한 요소에 함께 건다.
|
|
200
|
+
const setTriggerRef = useCallback(
|
|
201
|
+
(el: HTMLButtonElement | null) => {
|
|
202
|
+
triggerRef.current = el;
|
|
203
|
+
if (typeof ref === 'function') ref(el);
|
|
204
|
+
else if (ref) (ref as { current: HTMLButtonElement | null }).current = el;
|
|
205
|
+
},
|
|
206
|
+
[ref],
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
// 트리거 글자 읽기(팝오버가 이름을 갈아 끼우므로 사본이 필요하다). 값이 같으면 아무것도 하지 않는다.
|
|
210
|
+
useEffect(() => {
|
|
211
|
+
if (name !== undefined) return;
|
|
212
|
+
const text = (triggerRef.current?.innerText ?? '').trim();
|
|
213
|
+
if (text && text !== autoName) setAutoName(text);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// 열린 뒤 그리기 전에 좌표를 잡는다. 팝오버는 `.active` 라야 크기를 잴 수 있다.
|
|
217
|
+
useIsoLayoutEffect(() => {
|
|
218
|
+
if (!open) {
|
|
219
|
+
setPos(null);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const trigger = triggerRef.current;
|
|
223
|
+
const popover = popoverRef.current;
|
|
224
|
+
if (!trigger || !popover) return;
|
|
225
|
+
setPos(computeTooltipPosition(trigger, popover, Boolean(box || vertical)));
|
|
226
|
+
}, [open, box, vertical]);
|
|
227
|
+
|
|
228
|
+
// 닫는 사건들 — Esc · 스크롤 · 크기 변경.
|
|
229
|
+
useEffect(() => {
|
|
230
|
+
if (!open) return;
|
|
231
|
+
const close = () => setOpen(false);
|
|
232
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
233
|
+
if (event.key === 'Escape' || event.key === 'Esc') close();
|
|
234
|
+
};
|
|
235
|
+
document.addEventListener('keydown', onKeyDown);
|
|
236
|
+
// 안쪽 스크롤 상자에서도 좌표가 낡으므로 캡처로 넓혀 잡았다.
|
|
237
|
+
window.addEventListener('scroll', close, true);
|
|
238
|
+
window.addEventListener('resize', close);
|
|
239
|
+
return () => {
|
|
240
|
+
document.removeEventListener('keydown', onKeyDown);
|
|
241
|
+
window.removeEventListener('scroll', close, true);
|
|
242
|
+
window.removeEventListener('resize', close);
|
|
243
|
+
};
|
|
244
|
+
}, [open]);
|
|
245
|
+
|
|
246
|
+
const show = () => !disabled && setOpen(true);
|
|
247
|
+
const hide = () => setOpen(false);
|
|
248
|
+
|
|
249
|
+
const iconNode = icon ? (
|
|
250
|
+
<i className={cx(`svg-icon ico-${icon}`, iconDirection)} aria-hidden="true" />
|
|
251
|
+
) : null;
|
|
252
|
+
|
|
253
|
+
return (
|
|
254
|
+
<>
|
|
255
|
+
<button
|
|
256
|
+
type={type}
|
|
257
|
+
ref={setTriggerRef}
|
|
258
|
+
disabled={disabled}
|
|
259
|
+
className={cx(
|
|
260
|
+
'krds-btn',
|
|
261
|
+
size,
|
|
262
|
+
variant,
|
|
263
|
+
iconOnly && 'icon',
|
|
264
|
+
iconOnly && bordered && 'border',
|
|
265
|
+
'krds-tooltip',
|
|
266
|
+
box && 'tooltip-box',
|
|
267
|
+
vertical && 'tooltip-vertical',
|
|
268
|
+
className,
|
|
269
|
+
)}
|
|
270
|
+
// 이름이 팝오버로 갈린다. 팝오버가 없는 disabled 일 때는 걸지 않는다.
|
|
271
|
+
aria-labelledby={disabled ? undefined : popoverId}
|
|
272
|
+
onMouseEnter={(event: ReactMouseEvent<HTMLButtonElement>) => {
|
|
273
|
+
show();
|
|
274
|
+
onMouseEnter?.(event);
|
|
275
|
+
}}
|
|
276
|
+
onMouseLeave={(event: ReactMouseEvent<HTMLButtonElement>) => {
|
|
277
|
+
hide();
|
|
278
|
+
onMouseLeave?.(event);
|
|
279
|
+
}}
|
|
280
|
+
onFocus={(event: ReactFocusEvent<HTMLButtonElement>) => {
|
|
281
|
+
show();
|
|
282
|
+
onFocus?.(event);
|
|
283
|
+
}}
|
|
284
|
+
onBlur={(event: ReactFocusEvent<HTMLButtonElement>) => {
|
|
285
|
+
hide();
|
|
286
|
+
onBlur?.(event);
|
|
287
|
+
}}
|
|
288
|
+
{...rest}
|
|
289
|
+
>
|
|
290
|
+
{iconOnly ? (
|
|
291
|
+
<>
|
|
292
|
+
<span className="sr-only">{label}</span>
|
|
293
|
+
{iconNode}
|
|
294
|
+
</>
|
|
295
|
+
) : (
|
|
296
|
+
<>
|
|
297
|
+
{iconPosition === 'start' && iconNode}
|
|
298
|
+
{children}
|
|
299
|
+
{iconPosition === 'end' && iconNode}
|
|
300
|
+
</>
|
|
301
|
+
)}
|
|
302
|
+
</button>
|
|
303
|
+
{!disabled && (
|
|
304
|
+
<div
|
|
305
|
+
id={popoverId}
|
|
306
|
+
ref={popoverRef}
|
|
307
|
+
className={cx(
|
|
308
|
+
'krds-tooltip-popover',
|
|
309
|
+
open && 'active',
|
|
310
|
+
box && 'tooltip-box',
|
|
311
|
+
vertical && 'tooltip-vertical',
|
|
312
|
+
open && pos?.cls,
|
|
313
|
+
)}
|
|
314
|
+
// 킷이 만드는 팝오버는 늘 aria-hidden 이다 — 소리로는 위 aria-labelledby 로 한 번만 전해진다.
|
|
315
|
+
aria-hidden="true"
|
|
316
|
+
style={
|
|
317
|
+
open && pos
|
|
318
|
+
? { top: `${pos.top}px`, left: pos.centered ? '50%' : `${pos.left}px` }
|
|
319
|
+
: undefined
|
|
320
|
+
}
|
|
321
|
+
>
|
|
322
|
+
<span className="sr-only">{name ?? autoName}</span> {content}
|
|
323
|
+
</div>
|
|
324
|
+
)}
|
|
325
|
+
</>
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** 팝오버가 붙는 세로 방향. */
|
|
330
|
+
export type ContextualHelpSide = 'top' | 'bottom';
|
|
331
|
+
|
|
332
|
+
/** 팝오버가 붙는 가로 정렬. */
|
|
333
|
+
export type ContextualHelpAlign = 'left' | 'center' | 'right';
|
|
334
|
+
|
|
335
|
+
export type ContextualHelpProps = Omit<ComponentPropsWithRef<'div'>, 'title'> & {
|
|
336
|
+
/** 아이콘 버튼의 이름. **고유하고 구체적으로** — 「도움말」 여러 개보다 「신청 자격 도움말」이 맞다. */
|
|
337
|
+
label: string;
|
|
338
|
+
/** 팝오버 제목. 한 줄로 잘린다. */
|
|
339
|
+
heading?: ReactNode;
|
|
340
|
+
/** 아이콘 버튼 옆에 놓이는 글자. 대개 그 도움말이 설명하는 항목의 이름이다. */
|
|
341
|
+
text?: ReactNode;
|
|
342
|
+
/** 킷 아이콘 이름. 기본 `tooltip` — 정보 제공이면 `information`, 과업 도움이면 `help`. */
|
|
343
|
+
icon?: KrdsIcon;
|
|
344
|
+
/** 팝오버가 붙는 세로 방향. 기본 `top`. */
|
|
345
|
+
side?: ContextualHelpSide;
|
|
346
|
+
/** 팝오버가 붙는 가로 정렬. 기본 `left`. */
|
|
347
|
+
align?: ContextualHelpAlign;
|
|
348
|
+
/** 아이콘 버튼 크기. 기본 medium(48px). */
|
|
349
|
+
buttonSize?: KrdsSize;
|
|
350
|
+
/** 닫기 버튼 이름. */
|
|
351
|
+
closeLabel?: string;
|
|
352
|
+
/** 제목 태그 단계. 기본 4 — 문서 구조에 맞춰 고른다. */
|
|
353
|
+
headingLevel?: 2 | 3 | 4 | 5 | 6;
|
|
354
|
+
/** 팝오버 본문. 링크는 `<div className="btn-wrap">` 안에 넣는다. */
|
|
355
|
+
children?: ReactNode;
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* 좁은 화면에서 팝오버를 본문 폭에 맞춘다. 킷 JS 이식이되 넓은 화면에서는 인라인 폭을 지우기만 한다
|
|
360
|
+
* (킷 CSS 의 rem 폭이 글자 크기 조정을 따라오기 때문이다).
|
|
361
|
+
*/
|
|
362
|
+
function adjustContextualPopover(popover: HTMLElement, action: HTMLElement) {
|
|
363
|
+
// 킷 JS 는 768 로 재서 CSS 의 767 과 1px 어긋난다 — 여기서는 CSS 에 맞춰 767 로 잰다.
|
|
364
|
+
if (window.innerWidth > MOBILE_MAX) {
|
|
365
|
+
popover.style.left = '';
|
|
366
|
+
popover.style.width = '';
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
const raw = getComputedStyle(document.documentElement).getPropertyValue(
|
|
370
|
+
'--krds-contents-padding-x',
|
|
371
|
+
);
|
|
372
|
+
const pad = Number.parseFloat(raw) || 0;
|
|
373
|
+
const offsetLeft = action.getBoundingClientRect().left - pad;
|
|
374
|
+
popover.style.left = `${-offsetLeft}px`;
|
|
375
|
+
popover.style.width = `${document.body.clientWidth - pad * 2}px`;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* 맥락적 도움말 — KRDS `krds-contextual-help`. click 으로 열리고 **팝오버 안에 링크를 둘 수 있다.**
|
|
380
|
+
*
|
|
381
|
+
* **스스로 열리면 안 된다**(규격 금지) — 그래서 `open`·`defaultOpen` 이 일부러 없다.
|
|
382
|
+
* 닫는 방법은 셋(닫기 버튼 · Esc · 바깥 누르기)이고, 열면 초점이 팝오버 안으로 들어간다.
|
|
383
|
+
* 제목은 한 줄로 잘린다. 한 화면에서 {@link Tooltip} 과 섞어 쓰지 마라.
|
|
384
|
+
*/
|
|
385
|
+
export function ContextualHelp({
|
|
386
|
+
label,
|
|
387
|
+
heading,
|
|
388
|
+
text,
|
|
389
|
+
icon = 'tooltip',
|
|
390
|
+
side = 'top',
|
|
391
|
+
align = 'left',
|
|
392
|
+
buttonSize = 'medium',
|
|
393
|
+
closeLabel = '닫기',
|
|
394
|
+
headingLevel = 4,
|
|
395
|
+
className,
|
|
396
|
+
children,
|
|
397
|
+
...rest
|
|
398
|
+
}: ContextualHelpProps) {
|
|
399
|
+
const actionRef = useRef<HTMLDivElement | null>(null);
|
|
400
|
+
const buttonRef = useRef<HTMLButtonElement | null>(null);
|
|
401
|
+
const popoverRef = useRef<HTMLDivElement | null>(null);
|
|
402
|
+
const [open, setOpen] = useState(false);
|
|
403
|
+
|
|
404
|
+
/** 규격이 초점을 아이콘 버튼으로 되돌리라고 못 박은 경우에만 `refocus` 를 켠다. */
|
|
405
|
+
const close = useCallback((refocus: boolean) => {
|
|
406
|
+
setOpen(false);
|
|
407
|
+
if (refocus) buttonRef.current?.focus();
|
|
408
|
+
}, []);
|
|
409
|
+
|
|
410
|
+
// 좁은 화면 좌표·폭. 그리기 전에 넣어야 어긋난 자리에 번쩍이지 않는다.
|
|
411
|
+
useIsoLayoutEffect(() => {
|
|
412
|
+
const popover = popoverRef.current;
|
|
413
|
+
const action = actionRef.current;
|
|
414
|
+
if (!popover || !action) return;
|
|
415
|
+
if (!open) {
|
|
416
|
+
popover.style.left = '';
|
|
417
|
+
popover.style.width = '';
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
adjustContextualPopover(popover, action);
|
|
421
|
+
}, [open]);
|
|
422
|
+
|
|
423
|
+
// 열리면 초점을 팝오버 안 첫 대화형 요소로(닫기 버튼도 그 대상에 든다).
|
|
424
|
+
useEffect(() => {
|
|
425
|
+
if (!open) return;
|
|
426
|
+
popoverRef.current?.querySelector<HTMLElement>(CONTEXTUAL_FOCUSABLE)?.focus();
|
|
427
|
+
}, [open]);
|
|
428
|
+
|
|
429
|
+
useEffect(() => {
|
|
430
|
+
if (!open) return;
|
|
431
|
+
const action = actionRef.current;
|
|
432
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
433
|
+
// 초점이 어디 있든 Esc 로 닫고 아이콘 버튼으로 돌아간다(규격).
|
|
434
|
+
if (event.key === 'Escape' || event.key === 'Esc') close(true);
|
|
435
|
+
};
|
|
436
|
+
const onDocumentClick = (event: MouseEvent) => {
|
|
437
|
+
if (!action?.contains(event.target as Node)) setOpen(false);
|
|
438
|
+
};
|
|
439
|
+
const onFocusOut = (event: FocusEvent) => {
|
|
440
|
+
const next = event.relatedTarget as Node | null;
|
|
441
|
+
if (next && action?.contains(next)) return;
|
|
442
|
+
setOpen(false); // 초점은 사용자가 간 곳에 둔다 — 킷은 버튼으로 끌어와 키보드 함정이 된다
|
|
443
|
+
};
|
|
444
|
+
const onResize = () => {
|
|
445
|
+
if (popoverRef.current && action) adjustContextualPopover(popoverRef.current, action);
|
|
446
|
+
};
|
|
447
|
+
document.addEventListener('keydown', onKeyDown);
|
|
448
|
+
document.addEventListener('click', onDocumentClick);
|
|
449
|
+
action?.addEventListener('focusout', onFocusOut);
|
|
450
|
+
window.addEventListener('resize', onResize);
|
|
451
|
+
return () => {
|
|
452
|
+
document.removeEventListener('keydown', onKeyDown);
|
|
453
|
+
document.removeEventListener('click', onDocumentClick);
|
|
454
|
+
action?.removeEventListener('focusout', onFocusOut);
|
|
455
|
+
window.removeEventListener('resize', onResize);
|
|
456
|
+
};
|
|
457
|
+
}, [open, close]);
|
|
458
|
+
|
|
459
|
+
const Heading = `h${headingLevel}` as 'h4';
|
|
460
|
+
|
|
461
|
+
return (
|
|
462
|
+
<div className={cx('krds-contextual-help', side, align, className)} {...rest}>
|
|
463
|
+
{text != null && <p className="tooltip-txt">{text}</p>}
|
|
464
|
+
<div className="tooltip-action" ref={actionRef}>
|
|
465
|
+
<button
|
|
466
|
+
type="button"
|
|
467
|
+
ref={buttonRef}
|
|
468
|
+
className={cx('krds-btn', buttonSize, 'icon', 'tooltip-btn')}
|
|
469
|
+
aria-expanded={open}
|
|
470
|
+
onClick={() => (open ? close(true) : setOpen(true))}
|
|
471
|
+
>
|
|
472
|
+
<span className="sr-only">{label}</span>
|
|
473
|
+
<i className={`svg-icon ico-${icon}`} aria-hidden="true" />
|
|
474
|
+
</button>
|
|
475
|
+
{/* 킷은 팝오버를 인라인 display 로 여닫는다. */}
|
|
476
|
+
<div
|
|
477
|
+
className="tooltip-popover"
|
|
478
|
+
role="tooltip"
|
|
479
|
+
ref={popoverRef}
|
|
480
|
+
style={{ display: open ? 'block' : undefined }}
|
|
481
|
+
>
|
|
482
|
+
{heading != null && <Heading className="tooltip-title">{heading}</Heading>}
|
|
483
|
+
<div className="tooltip-contents">{children}</div>
|
|
484
|
+
<button
|
|
485
|
+
type="button"
|
|
486
|
+
className="krds-btn xsmall icon tooltip-close"
|
|
487
|
+
onClick={() => close(true)}
|
|
488
|
+
>
|
|
489
|
+
<span className="sr-only">{closeLabel}</span>
|
|
490
|
+
<i className="svg-icon ico-modal-close" aria-hidden="true" />
|
|
491
|
+
</button>
|
|
492
|
+
</div>
|
|
493
|
+
</div>
|
|
494
|
+
</div>
|
|
495
|
+
);
|
|
496
|
+
}
|