for-humanity 0.4.3 → 0.5.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/CHANGELOG.md +23 -0
- package/README.md +5 -5
- package/dist/browser.js +39 -0
- package/dist/cli.css +372 -299
- package/dist/cli.js +1125 -301
- package/package.json +8 -11
- package/src/component/widget/{shell → cursor-face}/_constant/cursor-face.ts +5 -0
- package/src/component/widget/cursor-face/wg-cursor-face.css +15 -0
- package/src/component/widget/cursor-face/wg-cursor-face.tsx +153 -0
- package/src/component/widget/navigation/_constant/navigation-data-schema.ts +35 -0
- package/src/component/widget/navigation/_constant/navigation.ts +9 -0
- package/src/component/widget/navigation/_function/restore-navigation-scroll.ts +59 -0
- package/src/{content → component/widget/navigation/_function}/to-doc-groups/_to-doc-branches.ts +2 -2
- package/src/{content → component/widget/navigation/_function}/to-doc-groups/_to-doc-group-branches.ts +3 -3
- package/src/{content → component/widget/navigation/_function}/to-doc-groups/to-doc-groups.ts +1 -1
- package/src/component/widget/navigation/_function/to-navigation-branch-id.ts +6 -0
- package/src/component/widget/navigation/_function/to-navigation-data/_to-navigation-document.ts +14 -0
- package/src/component/widget/navigation/_function/to-navigation-data/_to-navigation-group.ts +14 -0
- package/src/component/widget/navigation/_function/to-navigation-data/to-navigation-data.ts +25 -0
- package/src/component/widget/navigation/_function/to-navigation-json.ts +8 -0
- package/src/component/widget/{shell → navigation}/_function/to-toc-groups.ts +2 -2
- package/src/{type → component/widget/navigation/_type}/doc-group.ts +1 -1
- package/src/component/widget/navigation/_type/navigation-data.ts +83 -0
- package/src/component/widget/navigation/_type/navigation-focus-snapshot.ts +21 -0
- package/src/component/widget/navigation/_wg-navigation-branch-toggle.css +28 -0
- package/src/component/widget/navigation/_wg-navigation-branch-toggle.tsx +48 -0
- package/src/component/widget/{shell/_wg-shell-nav-list.css → navigation/_wg-navigation-nav-list.css} +24 -16
- package/src/component/widget/navigation/_wg-navigation-nav-list.tsx +106 -0
- package/src/component/widget/{shell/_wg-shell-nav.css → navigation/_wg-navigation-nav.css} +9 -3
- package/src/component/widget/navigation/_wg-navigation-nav.tsx +43 -0
- package/src/component/widget/navigation/_wg-navigation-scroll.tsx +117 -0
- package/src/component/widget/{shell/_wg-shell-toc.css → navigation/_wg-navigation-toc.css} +43 -26
- package/src/component/widget/navigation/_wg-navigation-toc.tsx +155 -0
- package/src/component/widget/navigation/wg-navigation.css +282 -0
- package/src/component/widget/navigation/wg-navigation.tsx +479 -0
- package/src/component/widget/prose/_function/rehype-sections/rehype-sections.ts +1 -1
- package/src/component/widget/shell/wg-shell.css +13 -309
- package/src/component/widget/shell/wg-shell.tsx +16 -75
- package/src/component/widget/shell-controls/wg-shell-controls.css +4 -0
- package/src/component/widget/shell-controls/wg-shell-controls.tsx +170 -0
- package/src/constant/asset.ts +0 -5
- package/src/entry/browser/browser.tsx +22 -0
- package/src/{app.tsx → entry/cli/_function/create-app.tsx} +6 -6
- package/src/{content → entry/cli/_function/create-processor}/create-processor.ts +3 -3
- package/src/{content → entry/cli/_function}/read-docs.ts +1 -1
- package/src/{content → entry/cli/_function}/read-home.ts +1 -1
- package/src/{content/read-docs.test.ts → entry/cli/cli.test.ts} +105 -30
- package/src/{cli.ts → entry/cli/cli.ts} +10 -11
- package/src/store/navigation/_constant/tree.ts +6 -0
- package/src/store/navigation/create-navigation-stores.test.ts +92 -0
- package/src/store/navigation/create-navigation-stores.ts +73 -0
- package/src/store/navigation/navigation-state.ts +46 -0
- package/src/store/navigation/navigation-storage.ts +14 -0
- package/src/store/navigation/to-navigation-scroll-state.ts +42 -0
- package/src/store/navigation/to-navigation-tree-state.ts +19 -0
- package/src/store/navigation/use-navigation-tree-store.ts +12 -0
- package/src/style/token.css +4 -2
- package/src/type/doc-content.ts +1 -1
- package/src/type/doc-file-data.ts +1 -1
- package/src/{component/widget/shell/_type → type}/doc-outline.ts +1 -1
- package/src/type/site-assets.ts +2 -2
- package/dist/client.js +0 -6
- package/src/client.ts +0 -335
- package/src/component/widget/shell/_constant/navigation.ts +0 -9
- package/src/component/widget/shell/_function/bind-mobile-navigation.ts +0 -140
- package/src/component/widget/shell/_function/bind-navigation-scroll.ts +0 -123
- package/src/component/widget/shell/_wg-shell-nav-list.tsx +0 -87
- package/src/component/widget/shell/_wg-shell-nav.tsx +0 -49
- package/src/component/widget/shell/_wg-shell-toc.tsx +0 -57
- /package/src/{constant → component/widget/navigation/_constant}/locale.ts +0 -0
- /package/src/component/widget/{shell → navigation}/_constant/reading-line.ts +0 -0
- /package/src/{type → component/widget/navigation/_type}/doc-branch.ts +0 -0
- /package/src/component/widget/{shell → navigation}/_type/toc-group.ts +0 -0
- /package/src/{constant → entry/cli/_constant}/cli.ts +0 -0
- /package/src/{dev.ts → entry/cli/_function/create-dev-app.ts} +0 -0
- /package/src/{content/rehype-assets.ts → entry/cli/_function/create-processor/_rehype-assets.ts} +0 -0
- /package/src/{content/rehype-heading-ids.ts → entry/cli/_function/create-processor/_rehype-heading-ids.ts} +0 -0
- /package/src/{content/remark-report.ts → entry/cli/_function/create-processor/_remark-report.ts} +0 -0
- /package/src/{component/widget/prose/_type → type}/doc-section.ts +0 -0
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
import clsx from "clsx";
|
|
2
|
+
import {type DOMAttributes, Fragment, useLayoutEffect, useRef, useState} from "hono/jsx";
|
|
3
|
+
import {flushSync} from "hono/jsx/dom";
|
|
4
|
+
import {navigation_mobile_query, navigation_wide_query} from "@/component/widget/navigation/_constant/navigation";
|
|
5
|
+
import {restoreNavigationScroll} from "@/component/widget/navigation/_function/restore-navigation-scroll";
|
|
6
|
+
import type {NavigationData} from "@/component/widget/navigation/_type/navigation-data";
|
|
7
|
+
import type {NavigationFocusSnapshot} from "@/component/widget/navigation/_type/navigation-focus-snapshot";
|
|
8
|
+
import {WgNavigationNav} from "@/component/widget/navigation/_wg-navigation-nav";
|
|
9
|
+
import {WgNavigationScroll} from "@/component/widget/navigation/_wg-navigation-scroll";
|
|
10
|
+
import {WgNavigationToc} from "@/component/widget/navigation/_wg-navigation-toc";
|
|
11
|
+
import {copy_nav_close, copy_nav_open, copy_nav_title} from "@/constant/copy";
|
|
12
|
+
import type {createNavigationStores} from "@/store/navigation/create-navigation-stores";
|
|
13
|
+
import type {NavigationLayout, NavigationPosition, NavigationTreeState} from "@/store/navigation/navigation-state";
|
|
14
|
+
import {navigation_scroll_storage_key, navigation_storage_version, navigation_tree_storage_key} from "@/store/navigation/navigation-storage";
|
|
15
|
+
import {toNavigationScrollState} from "@/store/navigation/to-navigation-scroll-state";
|
|
16
|
+
import {useNavigationTreeStore} from "@/store/navigation/use-navigation-tree-store";
|
|
17
|
+
import {revealHashTarget} from "@/util/dom/reveal-hash-target";
|
|
18
|
+
import "./wg-navigation.css";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 같은 Hono 탐색을 서버에서는 기본 링크로, 브라우저에서는 사용자 저장 상태로 렌더
|
|
22
|
+
*/
|
|
23
|
+
export interface WgNavigationProps {
|
|
24
|
+
/**
|
|
25
|
+
* 서버가 정리한 문서 목록과 현재 페이지 목차
|
|
26
|
+
*/
|
|
27
|
+
data: NavigationData;
|
|
28
|
+
/**
|
|
29
|
+
* 브라우저 진입점만 제공 · 없으면 저장소와 전역 DOM을 읽지 않음
|
|
30
|
+
*/
|
|
31
|
+
stores?: ReturnType<typeof createNavigationStores>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const WgNavigation = (props: WgNavigationProps) => {
|
|
35
|
+
const navigationTreeStore = useNavigationTreeStore(props.stores?.tree);
|
|
36
|
+
const [layout, setLayout] = useState<NavigationLayout>(() => {
|
|
37
|
+
if (props.stores === undefined) return "desktop";
|
|
38
|
+
if (matchMedia(navigation_mobile_query).matches) return "drawer";
|
|
39
|
+
return matchMedia(navigation_wide_query).matches ? "wide" : "desktop";
|
|
40
|
+
});
|
|
41
|
+
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
42
|
+
const navigationRef = useRef<HTMLDivElement | null>(null);
|
|
43
|
+
const documentsRef = useRef<HTMLDivElement | null>(null);
|
|
44
|
+
const drawerNavigationRef = useRef<HTMLDivElement | null>(null);
|
|
45
|
+
const drawerDocumentsRef = useRef<HTMLDivElement | null>(null);
|
|
46
|
+
const outlineRef = useRef<HTMLDivElement | null>(null);
|
|
47
|
+
const dialogRef = useRef<HTMLDialogElement | null>(null);
|
|
48
|
+
const openButtonRef = useRef<HTMLButtonElement | null>(null);
|
|
49
|
+
const brandRef = useRef<HTMLAnchorElement | null>(null);
|
|
50
|
+
const mobileRef = useRef<MediaQueryList | null>(null);
|
|
51
|
+
const wideRef = useRef<MediaQueryList | null>(null);
|
|
52
|
+
const layoutRef = useRef(layout);
|
|
53
|
+
const focusRef = useRef<NavigationFocusSnapshot | null>(null);
|
|
54
|
+
const restorePendingRef = useRef(true);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 저장된 배치와 현재 CSS 조건을 구분 · 조건 변경 직후 잘린 좌표를 이전 배치에 저장하지 않음
|
|
58
|
+
*/
|
|
59
|
+
const getNavigationLayout = (): NavigationLayout => {
|
|
60
|
+
if (mobileRef.current === null || wideRef.current === null) return layoutRef.current;
|
|
61
|
+
if (mobileRef.current.matches) return "drawer";
|
|
62
|
+
return wideRef.current.matches ? "wide" : "desktop";
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Hono의 이전 노드 정리가 새 배치 참조를 비우지 않도록 두 배치의 DOM 참조를 별도로 유지
|
|
67
|
+
*/
|
|
68
|
+
const getNavigationPort = () => {
|
|
69
|
+
return layoutRef.current === "drawer" ? drawerNavigationRef.current : navigationRef.current;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 배치별 DOM은 분리하되 좌표 저장과 복원은 현재 표시되는 한 영역을 소비
|
|
74
|
+
*/
|
|
75
|
+
const getDocumentsPort = () => {
|
|
76
|
+
return layoutRef.current === "drawer" ? drawerDocumentsRef.current : documentsRef.current;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 접힘과 무관한 모든 링크의 URL·표시명으로 사이트 공통 문서 목록의 동일성 판정
|
|
81
|
+
*/
|
|
82
|
+
const getNavigationSignature = () => {
|
|
83
|
+
const navigation = getNavigationPort();
|
|
84
|
+
if (navigation === null) return undefined;
|
|
85
|
+
return [...navigation.querySelectorAll<HTMLAnchorElement>(".wg_navigationNav__root a[href]")].map((link) => `${link.pathname}:${link.textContent}`).join("\n");
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 닫기·이동보다 먼저 실제 좌표 보존 · 숨긴 모달과 아직 복원하지 않은 배치는 저장하지 않음
|
|
90
|
+
*/
|
|
91
|
+
const saveScroll = () => {
|
|
92
|
+
const documents = getDocumentsPort();
|
|
93
|
+
if (
|
|
94
|
+
props.stores === undefined ||
|
|
95
|
+
documents === null ||
|
|
96
|
+
outlineRef.current === null ||
|
|
97
|
+
restorePendingRef.current ||
|
|
98
|
+
layoutRef.current !== getNavigationLayout() ||
|
|
99
|
+
(layoutRef.current === "drawer" && dialogRef.current?.open !== true)
|
|
100
|
+
) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const signature = getNavigationSignature();
|
|
104
|
+
if (signature === undefined) return;
|
|
105
|
+
const navigationPosition: NavigationPosition = {
|
|
106
|
+
navigation: signature,
|
|
107
|
+
pathname: location.pathname,
|
|
108
|
+
documents: documents.scrollTop,
|
|
109
|
+
outline: outlineRef.current.scrollTop,
|
|
110
|
+
};
|
|
111
|
+
props.stores.scroll.setState((state) => ({
|
|
112
|
+
positions: {
|
|
113
|
+
...state.positions,
|
|
114
|
+
[layoutRef.current]: navigationPosition,
|
|
115
|
+
},
|
|
116
|
+
}));
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 연결된 영역에서 첫 paint 전 복원 · 문서는 페이지 간 공유하고 목차는 같은 경로에서만 재사용
|
|
121
|
+
*/
|
|
122
|
+
const restoreScroll = () => {
|
|
123
|
+
layoutRef.current = getNavigationLayout();
|
|
124
|
+
const documents = getDocumentsPort();
|
|
125
|
+
const navigation = getNavigationPort();
|
|
126
|
+
if (props.stores === undefined || documents === null || outlineRef.current === null || navigation === null) return;
|
|
127
|
+
if (layoutRef.current === "drawer" && dialogRef.current?.open !== true) return;
|
|
128
|
+
const signature = getNavigationSignature();
|
|
129
|
+
if (signature === undefined) return;
|
|
130
|
+
const position = props.stores.scroll.getState().positions[layoutRef.current];
|
|
131
|
+
documents.scrollTop = 0;
|
|
132
|
+
outlineRef.current.scrollTop = 0;
|
|
133
|
+
if (position === undefined) {
|
|
134
|
+
restoreNavigationScroll({key: navigation_scroll_storage_key, version: navigation_storage_version, layout: layoutRef.current, toState: toNavigationScrollState});
|
|
135
|
+
} else if (position.navigation === signature) {
|
|
136
|
+
documents.scrollTop = position.documents;
|
|
137
|
+
if (position.pathname === location.pathname) outlineRef.current.scrollTop = position.outline;
|
|
138
|
+
}
|
|
139
|
+
navigation.dataset.navigationLayout = layoutRef.current;
|
|
140
|
+
restorePendingRef.current = false;
|
|
141
|
+
saveScroll();
|
|
142
|
+
// 좌표 설정의 비동기 scroll 이벤트를 기다리지 않고 첫 paint의 넘침 표시를 맞춘다
|
|
143
|
+
documents.dispatchEvent(new Event("scroll"));
|
|
144
|
+
outlineRef.current.dispatchEvent(new Event("scroll"));
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* 선택 순간의 최신 스토어로 반전 · 직접 클릭과 다른 탭의 선택을 같은 자료로 유지
|
|
149
|
+
*/
|
|
150
|
+
const handleBranchToggle = (key: string) => {
|
|
151
|
+
props.stores?.tree.setState((state) => ({collapsed: {...state.collapsed, [key]: state.collapsed[key] !== true}}));
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 네이티브 close가 이전 열기 이벤트보다 늦게 도착해도 새 모달을 잠금 해제하지 않음
|
|
156
|
+
*/
|
|
157
|
+
const handleDrawerClose = (_event: Event) => {
|
|
158
|
+
if (dialogRef.current?.open === true) return;
|
|
159
|
+
document.body.classList.remove("wg_shell__root--drawerOpen");
|
|
160
|
+
setDrawerOpen(false);
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* 네이티브 포커스 복원을 유지하면서 링크의 기본 이동보다 먼저 배경 잠금 해제
|
|
165
|
+
*/
|
|
166
|
+
const closeNavigation = () => {
|
|
167
|
+
saveScroll();
|
|
168
|
+
dialogRef.current?.close();
|
|
169
|
+
document.body.classList.remove("wg_shell__root--drawerOpen");
|
|
170
|
+
setDrawerOpen(false);
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* 모바일에서만 네이티브 모달을 열고 독립된 드로어 좌표를 즉시 복원
|
|
175
|
+
*/
|
|
176
|
+
const handleOpenButtonClick: DOMAttributes["onClick"] = (_event) => {
|
|
177
|
+
if (getNavigationLayout() !== "drawer" || dialogRef.current === null || dialogRef.current.open) return;
|
|
178
|
+
dialogRef.current.showModal();
|
|
179
|
+
document.body.classList.add("wg_shell__root--drawerOpen");
|
|
180
|
+
setDrawerOpen(true);
|
|
181
|
+
restoreScroll();
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const handleCloseButtonClick: DOMAttributes["onClick"] = (_event) => {
|
|
185
|
+
closeNavigation();
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Escape의 네이티브 포커스 복원을 쓰되 좌표는 닫힌 모달이 되기 전에 보존
|
|
190
|
+
*/
|
|
191
|
+
const handleDrawerCancel = (event: Event) => {
|
|
192
|
+
event.preventDefault();
|
|
193
|
+
closeNavigation();
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* 대화상자 내부 빈 공간은 유지하고 경계 밖 배경만 닫기
|
|
198
|
+
*/
|
|
199
|
+
const handleDrawerClick: DOMAttributes["onClick"] = (event) => {
|
|
200
|
+
if (dialogRef.current === null || event.target !== dialogRef.current) return;
|
|
201
|
+
const bounds = dialogRef.current.getBoundingClientRect();
|
|
202
|
+
if (event.clientX < bounds.left || event.clientX > bounds.right || event.clientY < bounds.top || event.clientY > bounds.bottom) closeNavigation();
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* 보조키·새 탭 링크는 모달 유지 · 기본 문서 이동만 닫고 같은 문서 제목에 키보드 위치 전달
|
|
207
|
+
*/
|
|
208
|
+
const handleNavigationClick: DOMAttributes["onClick"] = (event) => {
|
|
209
|
+
saveScroll();
|
|
210
|
+
if (
|
|
211
|
+
dialogRef.current?.open !== true ||
|
|
212
|
+
event.defaultPrevented ||
|
|
213
|
+
event.button !== 0 ||
|
|
214
|
+
event.metaKey ||
|
|
215
|
+
event.ctrlKey ||
|
|
216
|
+
event.shiftKey ||
|
|
217
|
+
event.altKey ||
|
|
218
|
+
!(event.target instanceof Element)
|
|
219
|
+
) {
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const link = event.target.closest("a[href]");
|
|
223
|
+
if (!(link instanceof HTMLAnchorElement) || (link.target !== "" && link.target !== "_self") || link.hasAttribute("download")) return;
|
|
224
|
+
closeNavigation();
|
|
225
|
+
if (link.origin === location.origin && link.pathname === location.pathname && link.search === location.search && link.hash) {
|
|
226
|
+
const target = revealHashTarget(link.hash);
|
|
227
|
+
if (target !== null) {
|
|
228
|
+
target.tabIndex = -1;
|
|
229
|
+
target.focus({preventScroll: true});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* 같은 목록을 한 배치에만 렌더 · 전환 전 포커스를 기억하고 새 배치가 연결된 뒤 좌표 복원
|
|
236
|
+
*/
|
|
237
|
+
const handleLayoutChange = () => {
|
|
238
|
+
const nextLayout = getNavigationLayout();
|
|
239
|
+
if (nextLayout === layoutRef.current) return;
|
|
240
|
+
if (document.activeElement instanceof HTMLElement) {
|
|
241
|
+
focusRef.current = {
|
|
242
|
+
navigation: document.activeElement.closest("[data-navigation]") !== null,
|
|
243
|
+
drawer: document.activeElement === openButtonRef.current || document.activeElement.closest("[data-navigation-drawer]") !== null,
|
|
244
|
+
branch: document.activeElement.getAttribute("data-navigation-toggle"),
|
|
245
|
+
href: document.activeElement.getAttribute("href"),
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
if (nextLayout !== "drawer") closeNavigation();
|
|
249
|
+
restorePendingRef.current = true;
|
|
250
|
+
layoutRef.current = nextLayout;
|
|
251
|
+
flushSync(() => setLayout(nextLayout));
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* BFCache의 이전 메모리가 다음 문서의 선택·좌표를 덮지 않도록 렌더까지 동기 갱신 후 복원
|
|
256
|
+
*/
|
|
257
|
+
const handlePageShow = (event: PageTransitionEvent) => {
|
|
258
|
+
if (!event.persisted || props.stores === undefined) return;
|
|
259
|
+
restorePendingRef.current = true;
|
|
260
|
+
flushSync(() => {
|
|
261
|
+
props.stores?.tree.persist.rehydrate();
|
|
262
|
+
props.stores?.scroll.persist.rehydrate();
|
|
263
|
+
});
|
|
264
|
+
handleLayoutChange();
|
|
265
|
+
restoreScroll();
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* 같은 origin의 다른 탭에서 바꾼 접힘 선택과 저장소 초기화 반영
|
|
270
|
+
*/
|
|
271
|
+
const handleStorageChange = (event: StorageEvent) => {
|
|
272
|
+
if (event.key === navigation_tree_storage_key || event.key === null) {
|
|
273
|
+
flushSync(() => props.stores?.tree.persist.rehydrate());
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* 다른 탭·BFCache의 선택이 현재 키보드 위치를 숨기기 전에 가장 바깥 접힘 버튼으로 복원
|
|
279
|
+
*/
|
|
280
|
+
const handleTreeChange = (state: NavigationTreeState) => {
|
|
281
|
+
if (!(document.activeElement instanceof HTMLElement)) return;
|
|
282
|
+
for (const button of document.querySelectorAll<HTMLButtonElement>("[data-navigation-toggle]")) {
|
|
283
|
+
const key = button.dataset.navigationToggle;
|
|
284
|
+
const controls = button.getAttribute("aria-controls");
|
|
285
|
+
if (key === undefined || controls === null || state.collapsed[key] !== true) continue;
|
|
286
|
+
const branch = document.getElementById(controls);
|
|
287
|
+
if (branch?.contains(document.activeElement)) button.focus({preventScroll: true});
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Hono 탐색이 전역 구독의 설치·정리를 소유 · 서버 렌더에서는 실행하지 않음
|
|
293
|
+
*/
|
|
294
|
+
useLayoutEffect(() => {
|
|
295
|
+
if (props.stores === undefined) return;
|
|
296
|
+
mobileRef.current = matchMedia(navigation_mobile_query);
|
|
297
|
+
wideRef.current = matchMedia(navigation_wide_query);
|
|
298
|
+
mobileRef.current.addEventListener("change", handleLayoutChange);
|
|
299
|
+
wideRef.current.addEventListener("change", handleLayoutChange);
|
|
300
|
+
addEventListener("pagehide", saveScroll);
|
|
301
|
+
addEventListener("pageshow", handlePageShow);
|
|
302
|
+
addEventListener("storage", handleStorageChange);
|
|
303
|
+
const unsubscribeTree = props.stores.tree.subscribe(handleTreeChange);
|
|
304
|
+
return () => {
|
|
305
|
+
mobileRef.current?.removeEventListener("change", handleLayoutChange);
|
|
306
|
+
wideRef.current?.removeEventListener("change", handleLayoutChange);
|
|
307
|
+
mobileRef.current = null;
|
|
308
|
+
wideRef.current = null;
|
|
309
|
+
removeEventListener("pagehide", saveScroll);
|
|
310
|
+
removeEventListener("pageshow", handlePageShow);
|
|
311
|
+
removeEventListener("storage", handleStorageChange);
|
|
312
|
+
unsubscribeTree();
|
|
313
|
+
document.body.classList.remove("wg_shell__root--drawerOpen");
|
|
314
|
+
};
|
|
315
|
+
}, [props.stores]);
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* 최초 mount와 반응형 재연결의 paint 전 좌표·포커스 복원 · Hono의 fragment 연결 시점 보정
|
|
319
|
+
*/
|
|
320
|
+
useLayoutEffect(() => {
|
|
321
|
+
if (props.stores === undefined) return;
|
|
322
|
+
let active = true;
|
|
323
|
+
const restoreNavigation = () => {
|
|
324
|
+
if (!active) return;
|
|
325
|
+
restoreScroll();
|
|
326
|
+
if (focusRef.current === null) return;
|
|
327
|
+
const navigation = getNavigationPort();
|
|
328
|
+
if (layout === "drawer") {
|
|
329
|
+
if (focusRef.current.navigation) openButtonRef.current?.focus({preventScroll: true});
|
|
330
|
+
} else if (focusRef.current.navigation && navigation !== null) {
|
|
331
|
+
const target = [...navigation.querySelectorAll<HTMLElement>("a[href], button")].find((element) => {
|
|
332
|
+
if (focusRef.current === null) return false;
|
|
333
|
+
return focusRef.current.branch !== null
|
|
334
|
+
? element.getAttribute("data-navigation-toggle") === focusRef.current.branch
|
|
335
|
+
: focusRef.current.href !== null && element.getAttribute("href") === focusRef.current.href;
|
|
336
|
+
});
|
|
337
|
+
target?.focus({preventScroll: true});
|
|
338
|
+
} else if (focusRef.current.drawer) {
|
|
339
|
+
brandRef.current?.focus({preventScroll: true});
|
|
340
|
+
}
|
|
341
|
+
focusRef.current = null;
|
|
342
|
+
};
|
|
343
|
+
if (getDocumentsPort()?.isConnected) {
|
|
344
|
+
restoreNavigation();
|
|
345
|
+
} else {
|
|
346
|
+
// Hono는 최초 layout effect에서 아직 fragment를 사용하므로 연결 후 microtask도 같은 첫 paint 앞에 둔다
|
|
347
|
+
queueMicrotask(restoreNavigation);
|
|
348
|
+
}
|
|
349
|
+
return () => {
|
|
350
|
+
active = false;
|
|
351
|
+
};
|
|
352
|
+
}, [layout, props.stores]);
|
|
353
|
+
|
|
354
|
+
return (
|
|
355
|
+
<Fragment>
|
|
356
|
+
{/**
|
|
357
|
+
* 데스크톱 문서 사이드바 · 모바일에서는 고정 브랜드와 연결된 메뉴 버튼
|
|
358
|
+
*/}
|
|
359
|
+
<div className={clsx("wg_navigation__sidebar", props.stores !== undefined && "wg_navigation__sidebar--navigationReady")}>
|
|
360
|
+
{/**
|
|
361
|
+
* 어느 문서에서도 홈으로 이동 · 모달 진입은 브라우저 동작이 연결된 뒤 표시
|
|
362
|
+
*/}
|
|
363
|
+
<div className={clsx("wg_navigation__brandRow")}>
|
|
364
|
+
<a
|
|
365
|
+
ref={props.stores === undefined ? undefined : brandRef}
|
|
366
|
+
className={clsx("wg_navigation__brand")}
|
|
367
|
+
href="/"
|
|
368
|
+
aria-current={props.data.home ? "page" : undefined}
|
|
369
|
+
>
|
|
370
|
+
{props.data.title}
|
|
371
|
+
</a>
|
|
372
|
+
{/**
|
|
373
|
+
* 네이티브 모달의 열림 상태와 접근 가능한 진입 이름
|
|
374
|
+
*/}
|
|
375
|
+
<button
|
|
376
|
+
ref={props.stores === undefined ? undefined : openButtonRef}
|
|
377
|
+
className={clsx("wg_navigation__menuToggle", props.stores !== undefined && layout === "drawer" && "wg_navigation__menuToggle--visible")}
|
|
378
|
+
type="button"
|
|
379
|
+
aria-label={copy_nav_open}
|
|
380
|
+
aria-controls="fh-navigation-drawer"
|
|
381
|
+
aria-expanded={String(drawerOpen)}
|
|
382
|
+
data-navigation-open=""
|
|
383
|
+
onClick={handleOpenButtonClick}
|
|
384
|
+
>
|
|
385
|
+
<svg className={clsx("wg_navigation__menuIcon")} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
|
386
|
+
<path d="M4 6h16M4 12h16M4 18h16" />
|
|
387
|
+
</svg>
|
|
388
|
+
</button>
|
|
389
|
+
</div>
|
|
390
|
+
{/**
|
|
391
|
+
* JavaScript 없이도 전체 문서 링크를 표시 · 브라우저 모바일에서는 드로어가 같은 목록을 소유
|
|
392
|
+
*/}
|
|
393
|
+
{layout !== "drawer" && (
|
|
394
|
+
<div ref={props.stores === undefined ? undefined : navigationRef} className={clsx("wg_navigation__navigation")} data-navigation="">
|
|
395
|
+
<WgNavigationScroll portRef={documentsRef} kind="documents" ready={props.stores !== undefined} onScroll={saveScroll} onClick={handleNavigationClick}>
|
|
396
|
+
<WgNavigationNav
|
|
397
|
+
groups={props.data.groups}
|
|
398
|
+
collapsed={navigationTreeStore.collapsed}
|
|
399
|
+
onToggle={props.stores === undefined ? undefined : handleBranchToggle}
|
|
400
|
+
/>
|
|
401
|
+
</WgNavigationScroll>
|
|
402
|
+
</div>
|
|
403
|
+
)}
|
|
404
|
+
</div>
|
|
405
|
+
{/**
|
|
406
|
+
* 와이드 화면 전용 목차 · 드로어에는 문서 목록만 표시
|
|
407
|
+
*/}
|
|
408
|
+
<aside className={clsx("wg_navigation__tocRail")} data-navigation-rail="">
|
|
409
|
+
<div className={clsx("wg_navigation__outline")} data-navigation-outline="">
|
|
410
|
+
<WgNavigationScroll portRef={outlineRef} kind="outline" ready={props.stores !== undefined} onScroll={saveScroll} onClick={handleNavigationClick}>
|
|
411
|
+
{props.data.outline.length > 0 && (
|
|
412
|
+
<WgNavigationToc
|
|
413
|
+
groups={props.data.outline}
|
|
414
|
+
pageId={props.data.pageId}
|
|
415
|
+
collapsed={navigationTreeStore.collapsed}
|
|
416
|
+
onToggle={props.stores === undefined ? undefined : handleBranchToggle}
|
|
417
|
+
/>
|
|
418
|
+
)}
|
|
419
|
+
</WgNavigationScroll>
|
|
420
|
+
</div>
|
|
421
|
+
</aside>
|
|
422
|
+
{/**
|
|
423
|
+
* 브라우저 top layer의 포커스·Escape·배경 클릭 동작과 모바일 문서 목록
|
|
424
|
+
*/}
|
|
425
|
+
{/**
|
|
426
|
+
* biome-ignore lint/a11y/useKeyWithClickEvents: 배경 클릭의 키보드 대응은 네이티브 dialog의 cancel과 별도 닫기 버튼이 제공한다
|
|
427
|
+
*/}
|
|
428
|
+
<dialog
|
|
429
|
+
ref={props.stores === undefined ? undefined : dialogRef}
|
|
430
|
+
className={clsx("wg_navigation__drawer")}
|
|
431
|
+
id="fh-navigation-drawer"
|
|
432
|
+
aria-labelledby="fh-navigation-title"
|
|
433
|
+
data-navigation-drawer=""
|
|
434
|
+
onClose={handleDrawerClose}
|
|
435
|
+
onCancel={handleDrawerCancel}
|
|
436
|
+
onClick={handleDrawerClick}
|
|
437
|
+
>
|
|
438
|
+
{/**
|
|
439
|
+
* 목록 스크롤과 독립된 대화상자 이름·닫기 동작
|
|
440
|
+
*/}
|
|
441
|
+
<div className={clsx("wg_navigation__drawerHeader")}>
|
|
442
|
+
<h2 className={clsx("wg_navigation__drawerTitle")} id="fh-navigation-title">
|
|
443
|
+
{copy_nav_title}
|
|
444
|
+
</h2>
|
|
445
|
+
{/**
|
|
446
|
+
* 네이티브 close로 열기 버튼에 포커스 복원
|
|
447
|
+
*/}
|
|
448
|
+
<button className={clsx("wg_navigation__drawerClose")} type="button" aria-label={copy_nav_close} data-navigation-close="" onClick={handleCloseButtonClick}>
|
|
449
|
+
<svg className={clsx("wg_navigation__menuIcon")} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
|
450
|
+
<path d="m6 6 12 12M18 6 6 18" />
|
|
451
|
+
</svg>
|
|
452
|
+
</button>
|
|
453
|
+
</div>
|
|
454
|
+
{/**
|
|
455
|
+
* 모바일에만 한 번 렌더하는 문서 탐색 · 다른 배치와 저장 좌표 분리
|
|
456
|
+
*/}
|
|
457
|
+
<div className={clsx("wg_navigation__drawerContent")} data-navigation-content="">
|
|
458
|
+
{layout === "drawer" && (
|
|
459
|
+
<div ref={props.stores === undefined ? undefined : drawerNavigationRef} className={clsx("wg_navigation__navigation")} data-navigation="">
|
|
460
|
+
<WgNavigationScroll
|
|
461
|
+
portRef={drawerDocumentsRef}
|
|
462
|
+
kind="documents"
|
|
463
|
+
ready={props.stores !== undefined}
|
|
464
|
+
onScroll={saveScroll}
|
|
465
|
+
onClick={handleNavigationClick}
|
|
466
|
+
>
|
|
467
|
+
<WgNavigationNav
|
|
468
|
+
groups={props.data.groups}
|
|
469
|
+
collapsed={navigationTreeStore.collapsed}
|
|
470
|
+
onToggle={props.stores === undefined ? undefined : handleBranchToggle}
|
|
471
|
+
/>
|
|
472
|
+
</WgNavigationScroll>
|
|
473
|
+
</div>
|
|
474
|
+
)}
|
|
475
|
+
</div>
|
|
476
|
+
</dialog>
|
|
477
|
+
</Fragment>
|
|
478
|
+
);
|
|
479
|
+
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type {Root} from "hast";
|
|
2
2
|
import type {VFile} from "vfile";
|
|
3
3
|
import {toSectionHeadings} from "@/component/widget/prose/_function/rehype-sections/_to-section-headings";
|
|
4
|
-
import type {DocSection} from "@/
|
|
4
|
+
import type {DocSection} from "@/type/doc-section";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* h2·h3의 가름 메타데이터 수집 · 작성한 제목은 그대로 유지
|