-
1
-
2
-
3
-
4
-
5
-
6
-
7
-
8
-
9
-
10
-
11
-
12
-
13
-
14
-
15
-
16
-
17
-
18
-
19
-
20
-
21
-
22
-
23
-
24
-
25
-
26
-
27
-
28
-
29
-
30
-
31
-
32
-
33
-
34
-
35
-
36
-
37
-
38
-
39
-
40
-
41
-
42
-
43
-
44
-
45
-
46
-
47
-
48
-
49
-
50
-
51
-
52
-
53
-
54
-
55
-
56
-
57
-
58
-
59
-
60
-
61
-
62
-
63
-
64
-
65
-
66
-
67
-
68
-
69
-
70
-
71
-
72
-
73
-
74
-
75
-
76
-
77
-
78
-
79
-
80
-
81
-
82
-
83
-
84
-
85
-
86
-
87
-
88
-
89
-
90
-
91
-
92
-
93
-
94
-
95
-
96
-
97
-
98
-
99
-
100
-
101
-
102
-
103
-
104
-
105
-
106
-
107
-
108
-
109
-
110
-
111
-
112
-
113
-
114
-
115
-
116
-
117
-
118
-
119
-
120
-
121
-
122
-
123
-
124
-
125
-
126
-
127
-
128
-
129
-
130
-
131
-
132
-
133
-
134
-
135
-
136
-
137
-
138
-
139
-
140
-
141
-
142
-
143
-
144
-
145
-
146
-
147
-
148
-
149
-
150
-
151
-
152
-
153
-
154
-
155
-
156
-
157
-
158
-
159
-
160
-
161
-
162
-
163
-
164
/** @jsxImportSource ../jsx */
import { effect } from "../ef.js";
import { type JSXChildren } from "../jsx/jsx";
import { cx } from "./nav.module.css";
const EL_KIND = "nav_link";
const idToOffsetTopMap = new Map<string, number>();
function onScrollEnd() {
const { scrollTop } = document.body;
// This margin works like scroll-padding-top
const topmostY = Math.min(...idToOffsetTopMap.values());
let closest: readonly [number, string] | null = null;
for (const [id, top] of idToOffsetTopMap) {
const dy = top - scrollTop - topmostY;
if (
!closest ||
(closest[0] > 0 && dy < closest[0]) ||
(dy < 0 && dy > closest[0])
) {
closest = [dy, id];
continue;
}
}
const currents = document.querySelectorAll(
`[data-kind="${EL_KIND}"][data-current]`,
);
currents.forEach((el) => {
el.removeAttribute("data-current");
});
if (!closest) {
return;
}
const [, id] = closest;
const target = document.querySelector(
`[data-kind="${EL_KIND}"][href="#${id}"]`,
);
if (!target) {
return;
}
target.setAttribute("data-current", "");
}
interface NavProps {
class?: string;
ulClass?: string;
children: JSXChildren;
onESC?(): void;
}
export function Nav({
children,
class: className,
ulClass,
onESC,
...rest
}: NavProps) {
effect(() => {
requestAnimationFrame(() => {
onScrollEnd();
});
document.body.addEventListener("scrollend", onScrollEnd, { passive: true });
return () => {
document.body.removeEventListener("scrollend", onScrollEnd);
};
});
return (
<nav
{...rest}
class={className ?? false}
onKeyDown={(ev: Event) => {
if (!(ev instanceof KeyboardEvent) || ev.key !== "Escape") {
return;
}
onESC?.();
}}
>
<ul class={[cx.list, ulClass].filter(Boolean).join(" ")}>{children}</ul>
</nav>
);
}
interface NavItemProps {
href: string;
children: JSXChildren;
}
const HASH_PATTERN = /^#(\S+)$/;
function register(href: string) {
effect(() => {
if (HASH_PATTERN.test(href)) {
const [, id] = href.match(HASH_PATTERN)!;
requestAnimationFrame(() => {
const target = document.getElementById(id);
if (!target) {
return;
}
idToOffsetTopMap.set(id, target.offsetTop);
});
return () => {
idToOffsetTopMap.delete(id);
};
}
});
}
export function NavItem({ href, children }: NavItemProps) {
register(href);
return (
<li class={cx.item}>
<a class={cx.link} href={href} data-kind={EL_KIND}>
{children}
</a>
</li>
);
}
interface NavGroupProps {
label: JSXChildren;
href?: string;
children: JSXChildren;
}
export function NavGroup({ label, children, href }: NavGroupProps) {
if (href) {
register(href);
}
return (
<li class={cx.item}>
{href ? (
<a href={href} class={cx.link} data-kind={EL_KIND}>
{label}
</a>
) : (
<span class={cx.groupLabel}>{label}</span>
)}
<ul class={cx.list}>{children}</ul>
</li>
);
}