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,50 @@
|
|
|
1
|
+
import type { ComponentPropsWithRef, ReactNode } from 'react';
|
|
2
|
+
import { cx } from '../lib/krds';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 공식 배너(마스트헤드) — KRDS `#krds-masthead`.
|
|
6
|
+
*
|
|
7
|
+
* 「이 누리집은 대한민국 공식 전자정부 누리집입니다」 띠. 모든 화면 최상단,
|
|
8
|
+
* `SkipLink` 뒤·`Header` 앞에 같은 자리로 둔다(순서는 셸이 지켜야 한다).
|
|
9
|
+
* 문구와 구조는 규격이 변형을 금지한다 — `children` 은 번역을 넣는 자리다.
|
|
10
|
+
* 기관 식별은 푸터의 `Identifier` 가 맡는다.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* <Masthead />
|
|
14
|
+
* <Masthead label="공식 누리집 안내">{t('masthead')}</Masthead>
|
|
15
|
+
*
|
|
16
|
+
* 자세히: docs/krds/09-부품-노트.md#마스트헤드
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** KRDS 가 규정한 배너 문구. 변형 금지 — 번역 외에 손대지 않는다. */
|
|
20
|
+
export const MASTHEAD_TEXT = '이 누리집은 대한민국 공식 전자정부 누리집입니다.';
|
|
21
|
+
|
|
22
|
+
export type MastheadProps = Omit<ComponentPropsWithRef<'div'>, 'id' | 'children'> & {
|
|
23
|
+
/** 배너 문구. 생략하면 KRDS 원문. 번역본 말고 다른 문구를 넣지 마라. */
|
|
24
|
+
children?: ReactNode;
|
|
25
|
+
/** 주면 `role="region" aria-label` 로 랜드마크가 된다. 본문과 같은 문구를 넣지 마라(두 번 읽힌다). */
|
|
26
|
+
label?: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function Masthead({ children, label, className, ...rest }: MastheadProps) {
|
|
30
|
+
return (
|
|
31
|
+
<div
|
|
32
|
+
id="krds-masthead"
|
|
33
|
+
role={label ? 'region' : undefined}
|
|
34
|
+
aria-label={label}
|
|
35
|
+
// 뿌리에는 킷 클래스가 없다(모양이 `#krds-masthead` 아이디에 걸려 있다).
|
|
36
|
+
// 합칠 게 className 뿐이라 비면 속성을 아예 빼서 `class=""` 를 남기지 않는다.
|
|
37
|
+
className={cx(className) || undefined}
|
|
38
|
+
{...rest}
|
|
39
|
+
>
|
|
40
|
+
{/* `.toggle-wrap` 은 킷 CSS 에 규칙이 없지만 규격이 구조 변형을 금지해 샘플 그대로 둔다. */}
|
|
41
|
+
<div className="toggle-wrap">
|
|
42
|
+
<div className="toggle-head">
|
|
43
|
+
<div className="inner">
|
|
44
|
+
<span className="nuri-txt">{children ?? MASTHEAD_TEXT}</span>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
useContext,
|
|
6
|
+
useEffect,
|
|
7
|
+
useId,
|
|
8
|
+
useMemo,
|
|
9
|
+
useRef,
|
|
10
|
+
useState,
|
|
11
|
+
type ComponentPropsWithRef,
|
|
12
|
+
type MouseEvent as ReactMouseEvent,
|
|
13
|
+
type KeyboardEvent as ReactKeyboardEvent,
|
|
14
|
+
type ReactNode,
|
|
15
|
+
} from 'react';
|
|
16
|
+
import { createPortal } from 'react-dom';
|
|
17
|
+
import { cx, type KrdsSize } from '../lib/krds';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 모달(대화 상자) — KRDS `krds-modal`.
|
|
21
|
+
*
|
|
22
|
+
* **최후의 수단이다.** 폼 오류·성공은 `Field` 의 인라인 문구, 저장 결과는 `Toast` 로 낸다.
|
|
23
|
+
* 폭은 `size` 3단에서 고르고 픽셀을 손으로 넘기지 않는다.
|
|
24
|
+
* 확정·취소는 `Modal.Footer` 에 두고 제출이 가장 오른쪽이다.
|
|
25
|
+
* 승인이 필요한 모달에는 `Modal.Close` 를 두지 않고 `dismissible={false}` 로 Esc 도 막는다.
|
|
26
|
+
* 옆에서 미끄러져 나오는 패널은 `ui/drawer` 다.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* <Modal.Root open={open} onClose={close} size="medium">
|
|
30
|
+
* <Modal.Header>정말 삭제할까요?</Modal.Header>
|
|
31
|
+
* <Modal.Body>지운 글은 되살릴 수 없습니다.</Modal.Body>
|
|
32
|
+
* <Modal.Footer>
|
|
33
|
+
* <Button variant="tertiary" onClick={close}>아니요</Button>
|
|
34
|
+
* <Button variant="primary" onClick={remove}>예</Button>
|
|
35
|
+
* </Modal.Footer>
|
|
36
|
+
* <Modal.Close />
|
|
37
|
+
* </Modal.Root>
|
|
38
|
+
*
|
|
39
|
+
* 자세히: docs/krds/09-부품-노트.md#모달
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/** 폭 3단 — 400 / 560 / 760px. 픽셀을 손으로 넘기지 말고 여기서 고른다. */
|
|
43
|
+
export type ModalSize = Extract<KrdsSize, 'small' | 'medium' | 'large'>;
|
|
44
|
+
|
|
45
|
+
const SIZE_CLASS: Record<ModalSize, string> = {
|
|
46
|
+
small: 'modal-sm',
|
|
47
|
+
medium: 'modal-md',
|
|
48
|
+
large: 'modal-lg',
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** 유형. `bottom-sheet` 는 킷 안에서 모바일 주메뉴가 쓰던 형태다 — 범용 서랍 대용으로 쓰지 마라. */
|
|
52
|
+
export type ModalType = 'dialog' | 'full' | 'bottom-sheet';
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 킷 JS 가 쓰는 지연값. 킷 CSS transition 과 짝이라 임의로 줄이면 어긋난다.
|
|
56
|
+
* `.in` 전까지 `visibility:hidden` 이라 초점 이동도 기다려야 한다.
|
|
57
|
+
*/
|
|
58
|
+
const ENTER_DELAY = 150;
|
|
59
|
+
const FOCUS_DELAY = 350;
|
|
60
|
+
const LEAVE_DELAY = 350;
|
|
61
|
+
|
|
62
|
+
/** 초점 가둠 대상. 킷 JS 보다 넓게 잡았다. */
|
|
63
|
+
const FOCUSABLE_SELECTOR = [
|
|
64
|
+
'a[href]',
|
|
65
|
+
'area[href]',
|
|
66
|
+
'button:not([disabled])',
|
|
67
|
+
'input:not([disabled]):not([type="hidden"])',
|
|
68
|
+
'select:not([disabled])',
|
|
69
|
+
'textarea:not([disabled])',
|
|
70
|
+
'details > summary',
|
|
71
|
+
'audio[controls]',
|
|
72
|
+
'video[controls]',
|
|
73
|
+
'[contenteditable]:not([contenteditable="false"])',
|
|
74
|
+
'[tabindex]:not([tabindex="-1"])',
|
|
75
|
+
].join(',');
|
|
76
|
+
|
|
77
|
+
function focusables(root: HTMLElement): HTMLElement[] {
|
|
78
|
+
return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
|
|
79
|
+
(el) =>
|
|
80
|
+
!el.hasAttribute('inert') &&
|
|
81
|
+
(el.offsetWidth > 0 || el.offsetHeight > 0 || el.getClientRects().length > 0),
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 열려 있는 모달 수. 겹쳐 열릴 때 z-index 와 딤 중복을 킷 JS 와 같은 방식으로 처리한다. */
|
|
86
|
+
let openModals = 0;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 뒤 문서 스크롤 잠금. 킷의 `body.scroll-no` 는 CSS 규칙이 없어 `body.style.overflow` 를 직접 다룬다.
|
|
90
|
+
* 겹쳐 열릴 수 있으니 셈해서 마지막 하나가 닫힐 때만 되돌린다.
|
|
91
|
+
*/
|
|
92
|
+
let scrollLocks = 0;
|
|
93
|
+
let savedOverflow = '';
|
|
94
|
+
|
|
95
|
+
function lockScroll() {
|
|
96
|
+
if (scrollLocks++ > 0) return;
|
|
97
|
+
savedOverflow = document.body.style.overflow;
|
|
98
|
+
document.body.style.overflow = 'hidden';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function unlockScroll() {
|
|
102
|
+
scrollLocks = Math.max(0, scrollLocks - 1);
|
|
103
|
+
if (scrollLocks > 0) return;
|
|
104
|
+
document.body.style.overflow = savedOverflow;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
type ModalContextValue = {
|
|
108
|
+
/** 제목 요소의 id. `Modal.Header` 가 붙이고 `Modal.Root` 가 `aria-labelledby` 로 가리킨다. */
|
|
109
|
+
titleId: string;
|
|
110
|
+
close: () => void;
|
|
111
|
+
/** 제목이 실제로 있는지 알린다 — 없으면 `aria-labelledby` 가 허공을 가리키게 되므로 붙이지 않는다. */
|
|
112
|
+
registerTitle: (has: boolean) => void;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const ModalContext = createContext<ModalContextValue | null>(null);
|
|
116
|
+
|
|
117
|
+
function useModalContext(name: string): ModalContextValue {
|
|
118
|
+
const ctx = useContext(ModalContext);
|
|
119
|
+
if (!ctx) throw new Error(`<${name}> 은 <Modal.Root> 안에서만 쓸 수 있다.`);
|
|
120
|
+
return ctx;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export type ModalRootProps = ComponentPropsWithRef<'section'> & {
|
|
124
|
+
/** 열림 여부. 상태는 화면 쪽이 쥔다. */
|
|
125
|
+
open: boolean;
|
|
126
|
+
/** 닫아 달라는 요청 — Esc·`Modal.Close`·(켰다면) 딤 클릭에서 온다. */
|
|
127
|
+
onClose: () => void;
|
|
128
|
+
/** 기본 medium(560px). 3단에서 고른다. */
|
|
129
|
+
size?: ModalSize;
|
|
130
|
+
/** 유형. 기본 `dialog`. */
|
|
131
|
+
type?: ModalType;
|
|
132
|
+
/** Esc 로 닫을 수 있는가. 승인이 필요한 모달에서는 false. */
|
|
133
|
+
dismissible?: boolean;
|
|
134
|
+
/** 딤을 눌렀을 때 닫는가. 기본 false 가 킷 동작이다(초점만 되돌린다). */
|
|
135
|
+
closeOnBackdrop?: boolean;
|
|
136
|
+
/** 열릴 때 첫 초점. 기본 `dialog`(보조기술이 이름부터 읽는다) · `first` 는 첫 초점 요소로 보낸다. */
|
|
137
|
+
initialFocus?: 'dialog' | 'first';
|
|
138
|
+
/** 포털 대상. 기본 `document.body`. */
|
|
139
|
+
container?: Element | null;
|
|
140
|
+
/** `.modal-dialog` 에 붙일 클래스. */
|
|
141
|
+
dialogClassName?: string;
|
|
142
|
+
/** `.modal-content` 에 붙일 클래스. */
|
|
143
|
+
contentClassName?: string;
|
|
144
|
+
children?: ReactNode;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* 모달 바깥틀. 포털·초점 가둠·Esc·스크롤 잠금·초점 복귀를 모두 여기서 한다.
|
|
149
|
+
*
|
|
150
|
+
* `Modal.Header` 를 두면 `aria-labelledby` 가 자동으로 붙는다. 헤더 없이 쓸 거면
|
|
151
|
+
* `aria-label` 을 직접 넘겨라 — 대화상자에는 반드시 이름이 있어야 한다.
|
|
152
|
+
*/
|
|
153
|
+
export function ModalRoot({
|
|
154
|
+
open,
|
|
155
|
+
onClose,
|
|
156
|
+
size = 'medium',
|
|
157
|
+
type = 'dialog',
|
|
158
|
+
dismissible = true,
|
|
159
|
+
closeOnBackdrop = false,
|
|
160
|
+
initialFocus = 'dialog',
|
|
161
|
+
container,
|
|
162
|
+
dialogClassName,
|
|
163
|
+
contentClassName,
|
|
164
|
+
className,
|
|
165
|
+
style,
|
|
166
|
+
children,
|
|
167
|
+
onKeyDown,
|
|
168
|
+
onMouseDown,
|
|
169
|
+
...rest
|
|
170
|
+
}: ModalRootProps) {
|
|
171
|
+
const reactId = useId();
|
|
172
|
+
const titleId = `${reactId}-title`;
|
|
173
|
+
const contentRef = useRef<HTMLDivElement | null>(null);
|
|
174
|
+
const [mounted, setMounted] = useState(false);
|
|
175
|
+
/** `.shown` — display:block. 닫힌 뒤에도 사라지는 동안 유지한다. */
|
|
176
|
+
const [shown, setShown] = useState(false);
|
|
177
|
+
/** `.in` — opacity·visibility·z-index. */
|
|
178
|
+
const [entered, setEntered] = useState(false);
|
|
179
|
+
const [hasTitle, setHasTitle] = useState(false);
|
|
180
|
+
/** 몇 번째로 열린 모달인가(1부터). */
|
|
181
|
+
const [stack, setStack] = useState(1);
|
|
182
|
+
|
|
183
|
+
// 초점 정책이 도중에 바뀌어도 열림 효과가 다시 돌지 않게 ref 로 읽는다.
|
|
184
|
+
const initialFocusRef = useRef(initialFocus);
|
|
185
|
+
initialFocusRef.current = initialFocus;
|
|
186
|
+
|
|
187
|
+
// 서버에서는 포털을 만들지 않는다(hydration 어긋남 방지).
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
setMounted(true);
|
|
190
|
+
}, []);
|
|
191
|
+
|
|
192
|
+
// 열림 — 스크롤 잠금 · 상태 클래스 · 초점 이동. 정리 단계에서 잠금 해제와 초점 복귀.
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
if (!open) return;
|
|
195
|
+
const restore = document.activeElement as HTMLElement | null;
|
|
196
|
+
openModals += 1;
|
|
197
|
+
setStack(openModals);
|
|
198
|
+
setShown(true);
|
|
199
|
+
lockScroll();
|
|
200
|
+
|
|
201
|
+
const enterTimer = window.setTimeout(() => setEntered(true), ENTER_DELAY);
|
|
202
|
+
const focusTimer = window.setTimeout(() => {
|
|
203
|
+
const content = contentRef.current;
|
|
204
|
+
if (!content) return;
|
|
205
|
+
if (initialFocusRef.current === 'first') {
|
|
206
|
+
const first = focusables(content)[0];
|
|
207
|
+
if (first) {
|
|
208
|
+
first.focus();
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
content.focus();
|
|
213
|
+
}, FOCUS_DELAY);
|
|
214
|
+
|
|
215
|
+
return () => {
|
|
216
|
+
window.clearTimeout(enterTimer);
|
|
217
|
+
window.clearTimeout(focusTimer);
|
|
218
|
+
openModals = Math.max(0, openModals - 1);
|
|
219
|
+
setEntered(false);
|
|
220
|
+
unlockScroll();
|
|
221
|
+
// 모달을 열었던 요소로 초점 복귀 — 킷 `returnFocusToTrigger` 에 해당한다.
|
|
222
|
+
restore?.focus?.();
|
|
223
|
+
};
|
|
224
|
+
}, [open]);
|
|
225
|
+
|
|
226
|
+
// 닫힘 — `.in` 을 뗀 뒤 transition 만큼 기다렸다가 `.shown`(=DOM)을 뗀다.
|
|
227
|
+
useEffect(() => {
|
|
228
|
+
if (open || !shown) return;
|
|
229
|
+
const timer = window.setTimeout(() => setShown(false), LEAVE_DELAY);
|
|
230
|
+
return () => window.clearTimeout(timer);
|
|
231
|
+
}, [open, shown]);
|
|
232
|
+
|
|
233
|
+
const ctx = useMemo<ModalContextValue>(
|
|
234
|
+
() => ({ titleId, close: onClose, registerTitle: setHasTitle }),
|
|
235
|
+
[titleId, onClose],
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
function handleKeyDown(event: ReactKeyboardEvent<HTMLElement>) {
|
|
239
|
+
onKeyDown?.(event);
|
|
240
|
+
if (event.defaultPrevented) return;
|
|
241
|
+
|
|
242
|
+
if (event.key === 'Escape' || event.key === 'Esc') {
|
|
243
|
+
if (!dismissible) return;
|
|
244
|
+
// 겹쳐 열린 경우 바깥 모달까지 닫히지 않도록 여기서 끊는다.
|
|
245
|
+
event.stopPropagation();
|
|
246
|
+
onClose();
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (event.key !== 'Tab') return;
|
|
251
|
+
const content = contentRef.current;
|
|
252
|
+
if (!content) return;
|
|
253
|
+
const nodes = focusables(content);
|
|
254
|
+
if (nodes.length === 0) {
|
|
255
|
+
event.preventDefault();
|
|
256
|
+
content.focus();
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const first = nodes[0];
|
|
260
|
+
const last = nodes[nodes.length - 1];
|
|
261
|
+
const active = document.activeElement;
|
|
262
|
+
if (event.shiftKey && (active === first || active === content)) {
|
|
263
|
+
event.preventDefault();
|
|
264
|
+
last.focus();
|
|
265
|
+
} else if (!event.shiftKey && active === last) {
|
|
266
|
+
event.preventDefault();
|
|
267
|
+
first.focus();
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function handleMouseDown(event: ReactMouseEvent<HTMLElement>) {
|
|
272
|
+
onMouseDown?.(event);
|
|
273
|
+
if (event.defaultPrevented) return;
|
|
274
|
+
const target = event.target as HTMLElement | null;
|
|
275
|
+
if (target?.closest('.modal-content')) return;
|
|
276
|
+
if (closeOnBackdrop && dismissible) {
|
|
277
|
+
onClose();
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
// 킷 기본 동작 — 바깥을 눌러도 닫지 않고 초점만 대화상자로 되돌린다.
|
|
281
|
+
contentRef.current?.focus();
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (!mounted || !shown) return null;
|
|
285
|
+
|
|
286
|
+
return createPortal(
|
|
287
|
+
<ModalContext.Provider value={ctx}>
|
|
288
|
+
<section
|
|
289
|
+
// `fade` 는 샘플이 늘 붙이지만 킷 CSS 에 규칙이 없다(실측 0건). 샘플 충실도로 남긴다.
|
|
290
|
+
className={cx('krds-modal', 'fade', shown && 'shown', entered && 'in', className)}
|
|
291
|
+
role="dialog"
|
|
292
|
+
// 킷 샘플에는 없다. 대화상자 밖을 보조기술이 읽지 않게 하는 표준 속성이라 더했다.
|
|
293
|
+
// (킷 JS 는 대신 `#wrap` 에 inert 를 건다 — 겹쳐 열릴 때 어긋나므로 쓰지 않았다.)
|
|
294
|
+
aria-modal="true"
|
|
295
|
+
aria-labelledby={hasTitle ? titleId : undefined}
|
|
296
|
+
data-type={type === 'dialog' ? undefined : type}
|
|
297
|
+
// 겹쳐 열릴 때만 킷 `updateZIndex` 와 같은 값을 준다(기본은 킷 CSS 의 1010).
|
|
298
|
+
style={stack > 1 ? { zIndex: 1010 + stack, ...style } : style}
|
|
299
|
+
onKeyDown={handleKeyDown}
|
|
300
|
+
onMouseDown={handleMouseDown}
|
|
301
|
+
{...rest}
|
|
302
|
+
>
|
|
303
|
+
<div className={cx('modal-dialog', SIZE_CLASS[size], dialogClassName)}>
|
|
304
|
+
{/* tabIndex -1 — 초점을 받되 Tab 순서에는 들어가지 않는다. 킷 CSS 에 `:focus` 테두리가 있다. */}
|
|
305
|
+
<div className={cx('modal-content', contentClassName)} ref={contentRef} tabIndex={-1}>
|
|
306
|
+
{children}
|
|
307
|
+
</div>
|
|
308
|
+
</div>
|
|
309
|
+
{/* 딤. 겹쳐 열린 두 번째부터는 킷과 같이 켜지 않는다(딤이 겹쳐 어두워지는 것을 막는다). */}
|
|
310
|
+
<div className={cx('modal-back', entered && stack === 1 && 'in')} />
|
|
311
|
+
</section>
|
|
312
|
+
</ModalContext.Provider>,
|
|
313
|
+
container ?? document.body,
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export type ModalHeaderProps = ComponentPropsWithRef<'div'> & {
|
|
318
|
+
/** 제목 태그 단계. 기본 h2 — 화면의 제목 위계에 맞춘다. */
|
|
319
|
+
level?: 1 | 2 | 3 | 4 | 5 | 6;
|
|
320
|
+
/** 제목을 눈에서만 감춘다. 이름 자체는 남는다. */
|
|
321
|
+
hideTitle?: boolean;
|
|
322
|
+
/** 제목 요소에 붙일 클래스. */
|
|
323
|
+
titleClassName?: string;
|
|
324
|
+
/** 제목 오른쪽에 놓을 것. 바텀시트 헤더가 space-between 이라 쓸모 있다. */
|
|
325
|
+
extra?: ReactNode;
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
/** 제목 줄 — 킷 `modal-header > .modal-title`. **한 줄로 잘리므로 긴 제목을 넣지 마라.** */
|
|
329
|
+
export function ModalHeader({
|
|
330
|
+
level = 2,
|
|
331
|
+
hideTitle,
|
|
332
|
+
titleClassName,
|
|
333
|
+
extra,
|
|
334
|
+
className,
|
|
335
|
+
children,
|
|
336
|
+
...rest
|
|
337
|
+
}: ModalHeaderProps) {
|
|
338
|
+
const { titleId, registerTitle } = useModalContext('Modal.Header');
|
|
339
|
+
|
|
340
|
+
useEffect(() => {
|
|
341
|
+
registerTitle(true);
|
|
342
|
+
return () => registerTitle(false);
|
|
343
|
+
}, [registerTitle]);
|
|
344
|
+
|
|
345
|
+
const Heading = `h${level}` as 'h2';
|
|
346
|
+
|
|
347
|
+
return (
|
|
348
|
+
<div className={cx('modal-header', className)} {...rest}>
|
|
349
|
+
<Heading id={titleId} className={cx(hideTitle ? 'sr-only' : 'modal-title', titleClassName)}>
|
|
350
|
+
{children}
|
|
351
|
+
</Heading>
|
|
352
|
+
{extra}
|
|
353
|
+
</div>
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export type ModalBodyProps = ComponentPropsWithRef<'div'> & {
|
|
358
|
+
/** 안쪽 `conts-area` 에 붙일 클래스. 바깥 `modal-conts` 는 `className` 이 받는다. */
|
|
359
|
+
areaClassName?: string;
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
/** 본문 — 킷 `modal-conts > .conts-area`. 스크롤이 생기면 `tabindex="0"` 을 붙인다. */
|
|
363
|
+
export function ModalBody({ className, areaClassName, children, ref, ...rest }: ModalBodyProps) {
|
|
364
|
+
const innerRef = useRef<HTMLDivElement | null>(null);
|
|
365
|
+
|
|
366
|
+
useEffect(() => {
|
|
367
|
+
const node = innerRef.current;
|
|
368
|
+
if (!node) return;
|
|
369
|
+
const sync = () => {
|
|
370
|
+
if (node.scrollHeight > node.clientHeight) node.setAttribute('tabindex', '0');
|
|
371
|
+
else node.removeAttribute('tabindex');
|
|
372
|
+
};
|
|
373
|
+
sync();
|
|
374
|
+
if (typeof ResizeObserver === 'undefined') return;
|
|
375
|
+
const observer = new ResizeObserver(sync);
|
|
376
|
+
observer.observe(node);
|
|
377
|
+
for (const child of Array.from(node.children)) observer.observe(child);
|
|
378
|
+
return () => observer.disconnect();
|
|
379
|
+
}, []);
|
|
380
|
+
|
|
381
|
+
return (
|
|
382
|
+
<div
|
|
383
|
+
className={cx('modal-conts', className)}
|
|
384
|
+
ref={(node) => {
|
|
385
|
+
innerRef.current = node;
|
|
386
|
+
if (typeof ref === 'function') ref(node);
|
|
387
|
+
else if (ref) (ref as { current: HTMLDivElement | null }).current = node;
|
|
388
|
+
}}
|
|
389
|
+
{...rest}
|
|
390
|
+
>
|
|
391
|
+
<div className={cx('conts-area', areaClassName)}>{children}</div>
|
|
392
|
+
</div>
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export type ModalFooterProps = ComponentPropsWithRef<'div'> & {
|
|
397
|
+
/** 좌우로 갈라 놓는다(킷 `multi-conts`). 왼쪽 보조 액션, 오른쪽 확정 액션. */
|
|
398
|
+
split?: boolean;
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* 푸터 — 확정·취소 액션 자리. 제출(primary)이 가장 오른쪽이다.
|
|
403
|
+
* 정렬·간격·버튼 최소폭은 킷이 잡는다 — 여백을 손으로 붙이지 마라.
|
|
404
|
+
*/
|
|
405
|
+
export function ModalFooter({ split, className, children, ...rest }: ModalFooterProps) {
|
|
406
|
+
return (
|
|
407
|
+
<div className={cx('modal-btn', 'btn-wrap', split && 'multi-conts', className)} {...rest}>
|
|
408
|
+
{children}
|
|
409
|
+
</div>
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export type ModalCloseProps = Omit<ComponentPropsWithRef<'button'>, 'children'> & {
|
|
414
|
+
/** 스크린리더가 읽을 이름. 아이콘만 있는 버튼이라 반드시 있어야 한다. */
|
|
415
|
+
label?: string;
|
|
416
|
+
size?: KrdsSize;
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* 오른쪽 위 닫기 버튼. **`Modal.Root` 의 마지막 자식으로 두어라** —
|
|
421
|
+
* 그래야 Tab 순서에서 본문·푸터를 지난 뒤 마지막에 닿는다.
|
|
422
|
+
* 승인이 필요한 모달에는 이 버튼을 두지 않는다.
|
|
423
|
+
*/
|
|
424
|
+
export function ModalClose({
|
|
425
|
+
label = '닫기',
|
|
426
|
+
size = 'medium',
|
|
427
|
+
className,
|
|
428
|
+
onClick,
|
|
429
|
+
type = 'button',
|
|
430
|
+
...rest
|
|
431
|
+
}: ModalCloseProps) {
|
|
432
|
+
const { close } = useModalContext('Modal.Close');
|
|
433
|
+
|
|
434
|
+
return (
|
|
435
|
+
<button
|
|
436
|
+
type={type}
|
|
437
|
+
className={cx('krds-btn', size, 'icon', 'btn-close', className)}
|
|
438
|
+
onClick={(event) => {
|
|
439
|
+
onClick?.(event);
|
|
440
|
+
if (!event.defaultPrevented) close();
|
|
441
|
+
}}
|
|
442
|
+
{...rest}
|
|
443
|
+
>
|
|
444
|
+
<span className="sr-only">{label}</span>
|
|
445
|
+
<i className="svg-icon ico-popup-close" aria-hidden="true" />
|
|
446
|
+
</button>
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** 합성용 묶음. `Root` · `Header` · `Body` · `Footer` · `Close`. */
|
|
451
|
+
export const Modal = {
|
|
452
|
+
Root: ModalRoot,
|
|
453
|
+
Header: ModalHeader,
|
|
454
|
+
Body: ModalBody,
|
|
455
|
+
Footer: ModalFooter,
|
|
456
|
+
Close: ModalClose,
|
|
457
|
+
};
|