@andesine/ui 0.4.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 +19 -0
- package/LICENSE +21 -0
- package/README.md +43 -0
- package/dist/solid/client/index.js +1354 -0
- package/dist/solid/server/index.js +1244 -0
- package/dist/solid/source/index.jsx +1166 -0
- package/dist/solid/types/index.d.ts +432 -0
- package/package.json +65 -0
|
@@ -0,0 +1,1166 @@
|
|
|
1
|
+
import { createContext, createEffect, createSignal, createUniqueId, on, onCleanup, onMount, splitProps, useContext } from "solid-js";
|
|
2
|
+
import { isServer } from "solid-js/web";
|
|
3
|
+
import { Tabs as Tabs$1 } from "@ark-ui/solid/tabs";
|
|
4
|
+
//#region src/core/storage.ts
|
|
5
|
+
/** Reads a JSON value from session storage. Storage can be unavailable, so errors give `undefined`. */
|
|
6
|
+
const readSession = (key) => {
|
|
7
|
+
try {
|
|
8
|
+
return JSON.parse(sessionStorage.getItem(key) ?? "null") ?? void 0;
|
|
9
|
+
} catch {
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
const writeSession = (key, value) => {
|
|
14
|
+
try {
|
|
15
|
+
sessionStorage.setItem(key, JSON.stringify(value));
|
|
16
|
+
} catch {}
|
|
17
|
+
};
|
|
18
|
+
//#endregion
|
|
19
|
+
//#region src/solid/navigation-tree/create-navigation-tree.ts
|
|
20
|
+
const syncEvent$1 = "andesine:navigation-tree";
|
|
21
|
+
const getActiveGroups = (items) => {
|
|
22
|
+
return items.flatMap((item) => {
|
|
23
|
+
return item.children?.length && item.active ? [item.id, ...getActiveGroups(item.children)] : [];
|
|
24
|
+
});
|
|
25
|
+
};
|
|
26
|
+
const getDefaultGroups = (items, depth) => {
|
|
27
|
+
return depth > 0 ? items.flatMap((item) => {
|
|
28
|
+
return item.children?.length ? [item.id, ...getDefaultGroups(item.children, depth - 1)] : [];
|
|
29
|
+
}) : [];
|
|
30
|
+
};
|
|
31
|
+
/** The saved groups replace the defaults; the current page's groups are always expanded. */
|
|
32
|
+
const getInitialGroups = (options) => {
|
|
33
|
+
const items = options.items();
|
|
34
|
+
const saved = !isServer && options.storageKey ? readSession(options.storageKey) : void 0;
|
|
35
|
+
const base = Array.isArray(saved) ? saved.map(String) : getDefaultGroups(items, options.defaultDepth ?? 0);
|
|
36
|
+
return /* @__PURE__ */ new Set([...base, ...getActiveGroups(items)]);
|
|
37
|
+
};
|
|
38
|
+
const createNavigationTree = (options) => {
|
|
39
|
+
const [expanded, setExpanded] = createSignal(getInitialGroups(options));
|
|
40
|
+
const save = (ids) => {
|
|
41
|
+
if (!options.storageKey) return;
|
|
42
|
+
writeSession(options.storageKey, [...ids]);
|
|
43
|
+
window.dispatchEvent(new CustomEvent(syncEvent$1, { detail: {
|
|
44
|
+
key: options.storageKey,
|
|
45
|
+
ids: [...ids]
|
|
46
|
+
} }));
|
|
47
|
+
};
|
|
48
|
+
onMount(() => {
|
|
49
|
+
if (!options.storageKey) return;
|
|
50
|
+
const sync = (event) => {
|
|
51
|
+
const { key, ids } = event.detail;
|
|
52
|
+
if (key === options.storageKey) setExpanded(new Set(ids));
|
|
53
|
+
};
|
|
54
|
+
writeSession(options.storageKey, [...expanded()]);
|
|
55
|
+
window.addEventListener(syncEvent$1, sync);
|
|
56
|
+
onCleanup(() => window.removeEventListener(syncEvent$1, sync));
|
|
57
|
+
});
|
|
58
|
+
return {
|
|
59
|
+
isExpanded: (id) => expanded().has(id),
|
|
60
|
+
setExpanded: (id, open) => {
|
|
61
|
+
const ids = new Set(expanded());
|
|
62
|
+
if (open) ids.add(id);
|
|
63
|
+
else ids.delete(id);
|
|
64
|
+
setExpanded(ids);
|
|
65
|
+
save(ids);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
//#endregion
|
|
70
|
+
//#region src/solid/navigation-tree/navigation-tree.tsx
|
|
71
|
+
const TreeContext = createContext();
|
|
72
|
+
const ItemContext = createContext();
|
|
73
|
+
const useTree = () => {
|
|
74
|
+
const tree = useContext(TreeContext);
|
|
75
|
+
if (!tree) throw new Error("NavigationTree parts must be inside NavigationTree.Root.");
|
|
76
|
+
return tree;
|
|
77
|
+
};
|
|
78
|
+
const useItem = () => {
|
|
79
|
+
const item = useContext(ItemContext);
|
|
80
|
+
if (!item) throw new Error("NavigationTree item parts must be inside NavigationTree.Item.");
|
|
81
|
+
return item;
|
|
82
|
+
};
|
|
83
|
+
const Root$5 = (props) => {
|
|
84
|
+
const [local, rest] = splitProps(props, [
|
|
85
|
+
"items",
|
|
86
|
+
"storageKey",
|
|
87
|
+
"defaultDepth"
|
|
88
|
+
]);
|
|
89
|
+
const tree = createNavigationTree({
|
|
90
|
+
items: () => local.items,
|
|
91
|
+
storageKey: local.storageKey,
|
|
92
|
+
defaultDepth: local.defaultDepth
|
|
93
|
+
});
|
|
94
|
+
return <TreeContext.Provider value={tree}>
|
|
95
|
+
<nav aria-label="Documentation" {...rest} data-scope="navigation-tree" data-part="root" data-storage-key={local.storageKey} />
|
|
96
|
+
</TreeContext.Provider>;
|
|
97
|
+
};
|
|
98
|
+
const List$2 = (props) => {
|
|
99
|
+
return <ul {...props} data-scope="navigation-tree" data-part="list" />;
|
|
100
|
+
};
|
|
101
|
+
const Item$2 = (props) => {
|
|
102
|
+
const [local, rest] = splitProps(props, ["item"]);
|
|
103
|
+
const tree = useTree();
|
|
104
|
+
const contentID = createUniqueId();
|
|
105
|
+
const isGroup = () => Boolean(local.item.children?.length);
|
|
106
|
+
return <ItemContext.Provider value={{
|
|
107
|
+
item: local.item,
|
|
108
|
+
contentID
|
|
109
|
+
}}>
|
|
110
|
+
<li {...rest} data-scope="navigation-tree" data-part="item" data-id={local.item.id} data-current={local.item.current || void 0} data-active={local.item.active || void 0} data-state={isGroup() ? tree.isExpanded(local.item.id) ? "open" : "closed" : void 0} />
|
|
111
|
+
</ItemContext.Provider>;
|
|
112
|
+
};
|
|
113
|
+
/** The item's page link. Activation never expands or collapses a group. */
|
|
114
|
+
const Link$3 = (props) => {
|
|
115
|
+
const { item } = useItem();
|
|
116
|
+
return <a href={item.href} aria-current={item.current ? "page" : void 0} {...props} data-scope="navigation-tree" data-part="link" />;
|
|
117
|
+
};
|
|
118
|
+
const GroupTrigger = (props) => {
|
|
119
|
+
const tree = useTree();
|
|
120
|
+
const { item, contentID } = useItem();
|
|
121
|
+
const expanded = () => tree.isExpanded(item.id);
|
|
122
|
+
return <button type="button" {...props} aria-expanded={expanded()} aria-controls={contentID} data-scope="navigation-tree" data-part="group-trigger" data-state={expanded() ? "open" : "closed"} onClick={() => tree.setExpanded(item.id, !expanded())} />;
|
|
123
|
+
};
|
|
124
|
+
const GroupContent = (props) => {
|
|
125
|
+
const tree = useTree();
|
|
126
|
+
const { item, contentID } = useItem();
|
|
127
|
+
const expanded = () => tree.isExpanded(item.id);
|
|
128
|
+
return <div {...props} id={contentID} hidden={!expanded()} data-scope="navigation-tree" data-part="group-content" data-state={expanded() ? "open" : "closed"} />;
|
|
129
|
+
};
|
|
130
|
+
const NavigationTree = {
|
|
131
|
+
Root: Root$5,
|
|
132
|
+
List: List$2,
|
|
133
|
+
Item: Item$2,
|
|
134
|
+
Link: Link$3,
|
|
135
|
+
GroupTrigger,
|
|
136
|
+
GroupContent
|
|
137
|
+
};
|
|
138
|
+
//#endregion
|
|
139
|
+
//#region src/core/focus.ts
|
|
140
|
+
const focusableSelector = "a[href], button, input, select, textarea, summary, [tabindex]";
|
|
141
|
+
/** Moves keyboard focus to an element without a second scroll, e.g. after anchor navigation. */
|
|
142
|
+
const focusTarget = (target) => {
|
|
143
|
+
const temporary = !target.matches(focusableSelector);
|
|
144
|
+
if (temporary) target.setAttribute("tabindex", "-1");
|
|
145
|
+
target.focus({ preventScroll: true });
|
|
146
|
+
if (temporary) target.addEventListener("blur", () => target.removeAttribute("tabindex"), { once: true });
|
|
147
|
+
};
|
|
148
|
+
/** Returns the visible, enabled elements that take keyboard focus. */
|
|
149
|
+
const getFocusable = (root) => {
|
|
150
|
+
return Array.from(root.querySelectorAll(focusableSelector)).filter((element) => {
|
|
151
|
+
return element.tabIndex >= 0 && !element.matches(":disabled, [aria-disabled='true']") && !element.closest("[inert], [hidden]") && element.getClientRects().length > 0;
|
|
152
|
+
});
|
|
153
|
+
};
|
|
154
|
+
//#endregion
|
|
155
|
+
//#region src/core/scroll.ts
|
|
156
|
+
/** Returns the document's `scroll-padding-top` in pixels, the height of sticky headers. */
|
|
157
|
+
const getScrollOffset = () => {
|
|
158
|
+
return parseFloat(getComputedStyle(document.documentElement).scrollPaddingTop) || 0;
|
|
159
|
+
};
|
|
160
|
+
/** Scrolls a container just enough to show an element, with a margin at the edges. */
|
|
161
|
+
const scrollIntoContainer = (container, element, margin = 8) => {
|
|
162
|
+
const bounds = container.getBoundingClientRect();
|
|
163
|
+
const target = element.getBoundingClientRect();
|
|
164
|
+
if (target.top < bounds.top + margin) container.scrollTop += target.top - bounds.top - margin;
|
|
165
|
+
if (target.bottom > bounds.bottom - margin) container.scrollTop += target.bottom - bounds.bottom + margin;
|
|
166
|
+
};
|
|
167
|
+
//#endregion
|
|
168
|
+
//#region src/solid/table-of-contents/create-table-of-contents.ts
|
|
169
|
+
const scrollKeys = /* @__PURE__ */ new Set([
|
|
170
|
+
"ArrowUp",
|
|
171
|
+
"ArrowDown",
|
|
172
|
+
"PageUp",
|
|
173
|
+
"PageDown",
|
|
174
|
+
"Home",
|
|
175
|
+
"End",
|
|
176
|
+
" "
|
|
177
|
+
]);
|
|
178
|
+
const intentEvents = [
|
|
179
|
+
"wheel",
|
|
180
|
+
"touchmove",
|
|
181
|
+
"keydown",
|
|
182
|
+
"pointerdown"
|
|
183
|
+
];
|
|
184
|
+
const createTableOfContents = (options) => {
|
|
185
|
+
const [activeID, setActiveID] = createSignal(options.headingIDs()[0]);
|
|
186
|
+
const [visibleIDs, setVisibleIDs] = createSignal([]);
|
|
187
|
+
let locked = false;
|
|
188
|
+
let userScroll = false;
|
|
189
|
+
onMount(() => {
|
|
190
|
+
let headings = [];
|
|
191
|
+
let frame = 0;
|
|
192
|
+
const update = () => {
|
|
193
|
+
const offset = getScrollOffset() + 1;
|
|
194
|
+
const atBottom = window.scrollY > 0 && window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 2;
|
|
195
|
+
const tops = headings.map((heading) => heading.getBoundingClientRect().top);
|
|
196
|
+
const passed = headings.filter((_, index) => tops[index] <= offset);
|
|
197
|
+
const visible = headings.filter((heading, index) => {
|
|
198
|
+
const end = tops[index + 1] ?? heading.parentElement.getBoundingClientRect().bottom;
|
|
199
|
+
return tops[index] < window.innerHeight && end > offset;
|
|
200
|
+
});
|
|
201
|
+
frame = 0;
|
|
202
|
+
setVisibleIDs(visible.map((heading) => heading.id));
|
|
203
|
+
if (locked) return;
|
|
204
|
+
setActiveID((atBottom ? headings.at(-1) : passed.at(-1) ?? headings[0])?.id);
|
|
205
|
+
};
|
|
206
|
+
const schedule = () => {
|
|
207
|
+
frame ||= requestAnimationFrame(update);
|
|
208
|
+
};
|
|
209
|
+
const onIntent = (event) => {
|
|
210
|
+
if (!(event instanceof KeyboardEvent) || scrollKeys.has(event.key)) userScroll = true;
|
|
211
|
+
};
|
|
212
|
+
const onScroll = () => {
|
|
213
|
+
if (userScroll) locked = false;
|
|
214
|
+
userScroll = false;
|
|
215
|
+
schedule();
|
|
216
|
+
};
|
|
217
|
+
const observer = new ResizeObserver(schedule);
|
|
218
|
+
createEffect(() => {
|
|
219
|
+
headings = options.headingIDs().flatMap((id) => document.getElementById(id) ?? []);
|
|
220
|
+
schedule();
|
|
221
|
+
});
|
|
222
|
+
observer.observe(document.body);
|
|
223
|
+
intentEvents.forEach((name) => window.addEventListener(name, onIntent, { passive: true }));
|
|
224
|
+
window.addEventListener("scroll", onScroll, { passive: true });
|
|
225
|
+
window.addEventListener("hashchange", schedule);
|
|
226
|
+
onCleanup(() => {
|
|
227
|
+
cancelAnimationFrame(frame);
|
|
228
|
+
observer.disconnect();
|
|
229
|
+
intentEvents.forEach((name) => window.removeEventListener(name, onIntent));
|
|
230
|
+
window.removeEventListener("scroll", onScroll);
|
|
231
|
+
window.removeEventListener("hashchange", schedule);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
return {
|
|
235
|
+
activeID,
|
|
236
|
+
visibleIDs,
|
|
237
|
+
select: (id) => {
|
|
238
|
+
locked = true;
|
|
239
|
+
userScroll = false;
|
|
240
|
+
setActiveID(id);
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
};
|
|
244
|
+
//#endregion
|
|
245
|
+
//#region src/solid/table-of-contents/table-of-contents.tsx
|
|
246
|
+
const Context = createContext();
|
|
247
|
+
const useTableOfContents = () => {
|
|
248
|
+
const state = useContext(Context);
|
|
249
|
+
if (!state) throw new Error("TableOfContents parts must be inside TableOfContents.Root.");
|
|
250
|
+
return state;
|
|
251
|
+
};
|
|
252
|
+
const Root$4 = (props) => {
|
|
253
|
+
const [local, rest] = splitProps(props, ["headingIDs"]);
|
|
254
|
+
const state = createTableOfContents({ headingIDs: () => local.headingIDs });
|
|
255
|
+
return <Context.Provider value={state}>
|
|
256
|
+
<nav aria-label="On this page" {...rest} data-scope="table-of-contents" data-part="root" />
|
|
257
|
+
</Context.Provider>;
|
|
258
|
+
};
|
|
259
|
+
const List$1 = (props) => {
|
|
260
|
+
return <ul {...props} data-scope="table-of-contents" data-part="list" />;
|
|
261
|
+
};
|
|
262
|
+
const Item$1 = (props) => {
|
|
263
|
+
return <li {...props} data-scope="table-of-contents" data-part="item" />;
|
|
264
|
+
};
|
|
265
|
+
/** Links to a heading, marks it as current, and moves focus to it after navigation. */
|
|
266
|
+
const Link$2 = (props) => {
|
|
267
|
+
const [local, rest] = splitProps(props, ["headingID"]);
|
|
268
|
+
const state = useTableOfContents();
|
|
269
|
+
const onClick = (event) => {
|
|
270
|
+
if (!(event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey)) return;
|
|
271
|
+
state.select(local.headingID);
|
|
272
|
+
requestAnimationFrame(() => {
|
|
273
|
+
const heading = document.getElementById(local.headingID);
|
|
274
|
+
if (heading) focusTarget(heading);
|
|
275
|
+
});
|
|
276
|
+
};
|
|
277
|
+
return <a {...rest} href={`#${encodeURIComponent(local.headingID)}`} aria-current={state.activeID() === local.headingID ? "location" : void 0} data-visible={state.visibleIDs().includes(local.headingID) || void 0} data-scope="table-of-contents" data-part="link" onClick={onClick} />;
|
|
278
|
+
};
|
|
279
|
+
const TableOfContents = {
|
|
280
|
+
Root: Root$4,
|
|
281
|
+
List: List$1,
|
|
282
|
+
Item: Item$1,
|
|
283
|
+
Link: Link$2
|
|
284
|
+
};
|
|
285
|
+
//#endregion
|
|
286
|
+
//#region src/solid/table-of-contents/create-table-of-contents-guide.ts
|
|
287
|
+
/**
|
|
288
|
+
* Measures one guide path through all table of contents links, with curves between depths, the
|
|
289
|
+
* dash that highlights a range of links, and a backdrop for that range.
|
|
290
|
+
*/
|
|
291
|
+
const createTableOfContentsGuide = (options) => {
|
|
292
|
+
const [shape, setShape] = createSignal();
|
|
293
|
+
const [stroke, setStroke] = createSignal();
|
|
294
|
+
const links = /* @__PURE__ */ new Map();
|
|
295
|
+
const inset = options.inset ?? 6;
|
|
296
|
+
const radius = options.radius ?? 8;
|
|
297
|
+
const edgePoints = options.edgePoints ?? 16;
|
|
298
|
+
let track;
|
|
299
|
+
let marker;
|
|
300
|
+
onMount(() => {
|
|
301
|
+
let frame = 0;
|
|
302
|
+
const measure = () => {
|
|
303
|
+
const bounds = track?.getBoundingClientRect();
|
|
304
|
+
const segments = options.ids().flatMap((id) => {
|
|
305
|
+
const rect = links.get(id)?.getBoundingClientRect();
|
|
306
|
+
const x = options.position(id);
|
|
307
|
+
return rect && bounds ? [{
|
|
308
|
+
id,
|
|
309
|
+
x,
|
|
310
|
+
top: rect.top - bounds.top,
|
|
311
|
+
bottom: rect.bottom - bounds.top
|
|
312
|
+
}] : [];
|
|
313
|
+
});
|
|
314
|
+
const highlighted = segments.filter((segment) => {
|
|
315
|
+
return options.highlightedIDs().includes(segment.id);
|
|
316
|
+
});
|
|
317
|
+
const first = highlighted[0];
|
|
318
|
+
const last = highlighted.at(-1);
|
|
319
|
+
const width = bounds?.width ?? 0;
|
|
320
|
+
const path = segments.map((segment, index) => {
|
|
321
|
+
const previous = segments[index - 1];
|
|
322
|
+
const line = `L ${segment.x} ${segment.bottom - inset}`;
|
|
323
|
+
if (!previous) return `M ${segment.x} ${segment.top + inset} ${line}`;
|
|
324
|
+
if (previous.x === segment.x) return line;
|
|
325
|
+
const middle = (previous.bottom + segment.top) / 2;
|
|
326
|
+
return `C ${previous.x} ${middle} ${segment.x} ${middle} ${segment.x} ${segment.top + inset} ${line}`;
|
|
327
|
+
}).join(" ");
|
|
328
|
+
frame = 0;
|
|
329
|
+
setShape(segments.length ? {
|
|
330
|
+
path,
|
|
331
|
+
width,
|
|
332
|
+
range: first && last && {
|
|
333
|
+
top: first.top,
|
|
334
|
+
height: last.bottom - first.top
|
|
335
|
+
}
|
|
336
|
+
} : void 0);
|
|
337
|
+
};
|
|
338
|
+
const schedule = () => {
|
|
339
|
+
frame ||= requestAnimationFrame(measure);
|
|
340
|
+
};
|
|
341
|
+
const observer = new ResizeObserver(schedule);
|
|
342
|
+
createEffect(() => {
|
|
343
|
+
options.highlightedIDs();
|
|
344
|
+
schedule();
|
|
345
|
+
});
|
|
346
|
+
if (track) observer.observe(track);
|
|
347
|
+
onCleanup(() => {
|
|
348
|
+
cancelAnimationFrame(frame);
|
|
349
|
+
observer.disconnect();
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
createEffect(() => {
|
|
353
|
+
const current = shape();
|
|
354
|
+
if (!current || !marker) return setStroke(void 0);
|
|
355
|
+
marker.setAttribute("d", current.path);
|
|
356
|
+
const total = marker.getTotalLength();
|
|
357
|
+
const lengthAt = (y) => {
|
|
358
|
+
let low = 0;
|
|
359
|
+
let high = total;
|
|
360
|
+
for (let step = 0; step < 20; step += 1) {
|
|
361
|
+
const middle = (low + high) / 2;
|
|
362
|
+
if (marker.getPointAtLength(middle).y < y) low = middle;
|
|
363
|
+
else high = middle;
|
|
364
|
+
}
|
|
365
|
+
return (low + high) / 2;
|
|
366
|
+
};
|
|
367
|
+
if (!current.range) return setStroke({
|
|
368
|
+
...current,
|
|
369
|
+
offset: 0,
|
|
370
|
+
length: 0,
|
|
371
|
+
total
|
|
372
|
+
});
|
|
373
|
+
const { top, height } = current.range;
|
|
374
|
+
const bottom = top + height;
|
|
375
|
+
const r = Math.min(radius, height / 2);
|
|
376
|
+
const xAt = (y) => marker.getPointAtLength(lengthAt(y)).x;
|
|
377
|
+
const edge = Array.from({ length: edgePoints }, (_, index) => {
|
|
378
|
+
const y = bottom - r - (height - 2 * r) * index / (edgePoints - 1);
|
|
379
|
+
return `L ${xAt(y)} ${y}`;
|
|
380
|
+
});
|
|
381
|
+
const [xTop, xBottom] = [xAt(top + r), xAt(bottom - r)];
|
|
382
|
+
const backdrop = [
|
|
383
|
+
`M ${xTop + r} ${top} L ${current.width - r} ${top}`,
|
|
384
|
+
`Q ${current.width} ${top} ${current.width} ${top + r} L ${current.width} ${bottom - r}`,
|
|
385
|
+
`Q ${current.width} ${bottom} ${current.width - r} ${bottom} L ${xBottom + r} ${bottom}`,
|
|
386
|
+
`Q ${xBottom} ${bottom} ${xBottom} ${bottom - r}`,
|
|
387
|
+
...edge,
|
|
388
|
+
`Q ${xTop} ${top} ${xTop + r} ${top} Z`
|
|
389
|
+
].join(" ");
|
|
390
|
+
const start = lengthAt(top + inset);
|
|
391
|
+
const end = lengthAt(bottom - inset);
|
|
392
|
+
setStroke({
|
|
393
|
+
...current,
|
|
394
|
+
offset: -start,
|
|
395
|
+
length: end - start,
|
|
396
|
+
total,
|
|
397
|
+
backdrop
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
createEffect(() => {
|
|
401
|
+
const viewport = options.viewport?.();
|
|
402
|
+
const first = links.get(options.highlightedIDs()[0] ?? "");
|
|
403
|
+
if (viewport && first) scrollIntoContainer(viewport, first, 12);
|
|
404
|
+
});
|
|
405
|
+
return {
|
|
406
|
+
stroke,
|
|
407
|
+
trackRef: (element) => track = element,
|
|
408
|
+
linkRef: (id, element) => links.set(id, element),
|
|
409
|
+
pathRef: (element) => marker = element
|
|
410
|
+
};
|
|
411
|
+
};
|
|
412
|
+
//#endregion
|
|
413
|
+
//#region src/solid/mobile-navigation/enhance-mobile-navigation.ts
|
|
414
|
+
/**
|
|
415
|
+
* Makes a server-rendered `<details>` menu modal: it traps focus, makes the rest of the page
|
|
416
|
+
* inert, locks scrolling, closes on Escape and on navigation, and restores focus.
|
|
417
|
+
* Returns a cleanup function.
|
|
418
|
+
*/
|
|
419
|
+
const enhanceMobileNavigation = (root, options = {}) => {
|
|
420
|
+
const trigger = root.querySelector(":scope > summary");
|
|
421
|
+
const content = root.querySelector(":scope > :not(summary)");
|
|
422
|
+
const media = options.mediaQuery ? window.matchMedia(options.mediaQuery) : void 0;
|
|
423
|
+
const inertElements = /* @__PURE__ */ new Map();
|
|
424
|
+
let opened = false;
|
|
425
|
+
let previousOverflow = "";
|
|
426
|
+
if (!trigger || !content) throw new Error("Mobile navigation needs a summary and content.");
|
|
427
|
+
const setModal = (modal) => {
|
|
428
|
+
if (!modal) {
|
|
429
|
+
inertElements.forEach((inert, element) => element.inert = inert);
|
|
430
|
+
inertElements.clear();
|
|
431
|
+
document.body.style.overflow = previousOverflow;
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
for (let branch = root; branch.parentElement; branch = branch.parentElement) for (const sibling of branch.parentElement.children) {
|
|
435
|
+
if (sibling === branch || !(sibling instanceof HTMLElement)) continue;
|
|
436
|
+
inertElements.set(sibling, sibling.inert);
|
|
437
|
+
sibling.inert = true;
|
|
438
|
+
}
|
|
439
|
+
previousOverflow = document.body.style.overflow;
|
|
440
|
+
document.body.style.overflow = "hidden";
|
|
441
|
+
};
|
|
442
|
+
const sync = () => {
|
|
443
|
+
const open = root.open && (media?.matches ?? true);
|
|
444
|
+
if (root.open && !open) root.open = false;
|
|
445
|
+
if (opened === open) return;
|
|
446
|
+
opened = open;
|
|
447
|
+
setModal(open);
|
|
448
|
+
if (open) (getFocusable(content)[0] ?? trigger).focus({ preventScroll: true });
|
|
449
|
+
options.onOpenChange?.(open);
|
|
450
|
+
};
|
|
451
|
+
const close = () => {
|
|
452
|
+
root.open = false;
|
|
453
|
+
sync();
|
|
454
|
+
};
|
|
455
|
+
const onToggle = (event) => {
|
|
456
|
+
if (event.target !== root) return;
|
|
457
|
+
const hadFocus = content.contains(document.activeElement);
|
|
458
|
+
sync();
|
|
459
|
+
if (!opened && hadFocus) trigger.focus({ preventScroll: true });
|
|
460
|
+
};
|
|
461
|
+
const onKeyDown = (event) => {
|
|
462
|
+
if (!opened || event.defaultPrevented) return;
|
|
463
|
+
if (event.key === "Escape") {
|
|
464
|
+
event.preventDefault();
|
|
465
|
+
close();
|
|
466
|
+
trigger.focus({ preventScroll: true });
|
|
467
|
+
}
|
|
468
|
+
if (event.key !== "Tab") return;
|
|
469
|
+
const items = [trigger, ...getFocusable(content)];
|
|
470
|
+
const edge = event.shiftKey ? items[0] : items.at(-1);
|
|
471
|
+
if (document.activeElement !== edge) return;
|
|
472
|
+
event.preventDefault();
|
|
473
|
+
(event.shiftKey ? items.at(-1) : items[0])?.focus();
|
|
474
|
+
};
|
|
475
|
+
const onClick = (event) => {
|
|
476
|
+
const link = event.target.closest("a[href]");
|
|
477
|
+
const isPlainClick = event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey;
|
|
478
|
+
if (!(opened && isPlainClick && link !== null && content.contains(link))) return;
|
|
479
|
+
const url = new URL(link.href);
|
|
480
|
+
const samePage = url.origin === location.origin && url.pathname === location.pathname;
|
|
481
|
+
close();
|
|
482
|
+
if (samePage) requestAnimationFrame(() => {
|
|
483
|
+
const target = url.hash && document.getElementById(decodeURIComponent(url.hash.slice(1))) || options.navigationTarget?.();
|
|
484
|
+
if (target) focusTarget(target);
|
|
485
|
+
else trigger.focus({ preventScroll: true });
|
|
486
|
+
});
|
|
487
|
+
};
|
|
488
|
+
root.addEventListener("toggle", onToggle);
|
|
489
|
+
root.addEventListener("keydown", onKeyDown);
|
|
490
|
+
root.addEventListener("click", onClick);
|
|
491
|
+
media?.addEventListener("change", sync);
|
|
492
|
+
sync();
|
|
493
|
+
return () => {
|
|
494
|
+
root.removeEventListener("toggle", onToggle);
|
|
495
|
+
root.removeEventListener("keydown", onKeyDown);
|
|
496
|
+
root.removeEventListener("click", onClick);
|
|
497
|
+
media?.removeEventListener("change", sync);
|
|
498
|
+
if (opened) setModal(false);
|
|
499
|
+
};
|
|
500
|
+
};
|
|
501
|
+
//#endregion
|
|
502
|
+
//#region src/solid/mobile-navigation/mobile-navigation.tsx
|
|
503
|
+
/** A `<details>` menu; without JavaScript, it still opens and closes. */
|
|
504
|
+
const Root$3 = (props) => {
|
|
505
|
+
const [local, rest] = splitProps(props, [
|
|
506
|
+
"mediaQuery",
|
|
507
|
+
"navigationTarget",
|
|
508
|
+
"onOpenChange"
|
|
509
|
+
]);
|
|
510
|
+
let root;
|
|
511
|
+
onMount(() => onCleanup(enhanceMobileNavigation(root, local)));
|
|
512
|
+
return <details {...rest} ref={root} data-scope="mobile-navigation" data-part="root" />;
|
|
513
|
+
};
|
|
514
|
+
const Trigger = (props) => {
|
|
515
|
+
return <summary {...props} data-scope="mobile-navigation" data-part="trigger" />;
|
|
516
|
+
};
|
|
517
|
+
const Content = (props) => {
|
|
518
|
+
return <div {...props} data-scope="mobile-navigation" data-part="content" />;
|
|
519
|
+
};
|
|
520
|
+
const MobileNavigation = {
|
|
521
|
+
Root: Root$3,
|
|
522
|
+
Trigger,
|
|
523
|
+
Content
|
|
524
|
+
};
|
|
525
|
+
//#endregion
|
|
526
|
+
//#region src/solid/breadcrumbs/index.tsx
|
|
527
|
+
const Root$2 = (props) => {
|
|
528
|
+
return <nav aria-label="Breadcrumbs" {...props} data-scope="breadcrumbs" data-part="root" />;
|
|
529
|
+
};
|
|
530
|
+
const List = (props) => {
|
|
531
|
+
return <ol {...props} data-scope="breadcrumbs" data-part="list" />;
|
|
532
|
+
};
|
|
533
|
+
const Item = (props) => {
|
|
534
|
+
return <li {...props} data-scope="breadcrumbs" data-part="item" />;
|
|
535
|
+
};
|
|
536
|
+
const Link$1 = (props) => {
|
|
537
|
+
return <a {...props} data-scope="breadcrumbs" data-part="link" />;
|
|
538
|
+
};
|
|
539
|
+
/** An ancestor without a page. */
|
|
540
|
+
const Label = (props) => {
|
|
541
|
+
return <span {...props} data-scope="breadcrumbs" data-part="label" />;
|
|
542
|
+
};
|
|
543
|
+
const Separator = (props) => {
|
|
544
|
+
return <span aria-hidden="true" {...props} data-scope="breadcrumbs" data-part="separator" />;
|
|
545
|
+
};
|
|
546
|
+
const Breadcrumbs = {
|
|
547
|
+
Root: Root$2,
|
|
548
|
+
List,
|
|
549
|
+
Item,
|
|
550
|
+
Link: Link$1,
|
|
551
|
+
Label,
|
|
552
|
+
Separator
|
|
553
|
+
};
|
|
554
|
+
//#endregion
|
|
555
|
+
//#region src/solid/pagination/index.tsx
|
|
556
|
+
const Root$1 = (props) => {
|
|
557
|
+
return <nav aria-label="Pages" {...props} data-scope="pagination" data-part="root" />;
|
|
558
|
+
};
|
|
559
|
+
const Link = (props) => {
|
|
560
|
+
const [local, rest] = splitProps(props, ["direction"]);
|
|
561
|
+
return <a {...rest} rel={local.direction === "previous" ? "prev" : "next"} data-scope="pagination" data-part="link" data-direction={local.direction} />;
|
|
562
|
+
};
|
|
563
|
+
const Pagination = {
|
|
564
|
+
Root: Root$1,
|
|
565
|
+
Link
|
|
566
|
+
};
|
|
567
|
+
//#endregion
|
|
568
|
+
//#region src/solid/tabs/tabs.tsx
|
|
569
|
+
const syncEvent = "andesine:tabs";
|
|
570
|
+
const Root = (props) => {
|
|
571
|
+
const [local, rest] = splitProps(props, [
|
|
572
|
+
"values",
|
|
573
|
+
"defaultValue",
|
|
574
|
+
"syncKey",
|
|
575
|
+
"onValueChange"
|
|
576
|
+
]);
|
|
577
|
+
const [value, setValue] = createSignal(local.defaultValue ?? local.values[0] ?? "");
|
|
578
|
+
const storageKey = () => `andesine-tabs:${local.syncKey}`;
|
|
579
|
+
const select = (next) => {
|
|
580
|
+
if (typeof next === "string" && local.values.includes(next)) setValue(next);
|
|
581
|
+
};
|
|
582
|
+
onMount(() => {
|
|
583
|
+
if (!local.syncKey) return;
|
|
584
|
+
const sync = (event) => {
|
|
585
|
+
const { key, value } = event.detail;
|
|
586
|
+
if (key === local.syncKey) select(value);
|
|
587
|
+
};
|
|
588
|
+
select(readSession(storageKey()));
|
|
589
|
+
window.addEventListener(syncEvent, sync);
|
|
590
|
+
onCleanup(() => window.removeEventListener(syncEvent, sync));
|
|
591
|
+
});
|
|
592
|
+
return <Tabs$1.Root {...rest} value={value()} onValueChange={(details) => {
|
|
593
|
+
setValue(details.value);
|
|
594
|
+
local.onValueChange?.(details);
|
|
595
|
+
if (!local.syncKey) return;
|
|
596
|
+
writeSession(storageKey(), details.value);
|
|
597
|
+
window.dispatchEvent(new CustomEvent(syncEvent, { detail: {
|
|
598
|
+
key: local.syncKey,
|
|
599
|
+
value: details.value
|
|
600
|
+
} }));
|
|
601
|
+
}} />;
|
|
602
|
+
};
|
|
603
|
+
const Tabs = {
|
|
604
|
+
Root,
|
|
605
|
+
List: Tabs$1.List,
|
|
606
|
+
Trigger: Tabs$1.Trigger,
|
|
607
|
+
Content: Tabs$1.Content,
|
|
608
|
+
Indicator: Tabs$1.Indicator
|
|
609
|
+
};
|
|
610
|
+
//#endregion
|
|
611
|
+
//#region src/solid/clipboard/index.ts
|
|
612
|
+
/** Copies text, and reports the result for a short time. */
|
|
613
|
+
const createClipboard = (timeout = 2e3) => {
|
|
614
|
+
const [status, setStatus] = createSignal();
|
|
615
|
+
let timer;
|
|
616
|
+
onCleanup(() => clearTimeout(timer));
|
|
617
|
+
return {
|
|
618
|
+
status,
|
|
619
|
+
copy: async (text) => {
|
|
620
|
+
clearTimeout(timer);
|
|
621
|
+
try {
|
|
622
|
+
if (typeof text !== "string" && typeof ClipboardItem !== "undefined") {
|
|
623
|
+
const blob = text.then((value) => new Blob([value], { type: "text/plain" }));
|
|
624
|
+
await navigator.clipboard.write([new ClipboardItem({ "text/plain": blob })]);
|
|
625
|
+
} else await navigator.clipboard.writeText(await text);
|
|
626
|
+
setStatus(true);
|
|
627
|
+
} catch {
|
|
628
|
+
setStatus(false);
|
|
629
|
+
}
|
|
630
|
+
timer = setTimeout(() => setStatus(void 0), timeout);
|
|
631
|
+
}
|
|
632
|
+
};
|
|
633
|
+
};
|
|
634
|
+
//#endregion
|
|
635
|
+
//#region src/solid/search/create-search.ts
|
|
636
|
+
/**
|
|
637
|
+
* Runs a search after input stops. A new query cancels the previous request, and only the
|
|
638
|
+
* latest response updates the results.
|
|
639
|
+
*/
|
|
640
|
+
const createSearch = (options) => {
|
|
641
|
+
const [query, setQueryValue] = createSignal("");
|
|
642
|
+
const [results, setResults] = createSignal();
|
|
643
|
+
const [status, setStatus] = createSignal("idle");
|
|
644
|
+
const [error, setError] = createSignal();
|
|
645
|
+
let timer;
|
|
646
|
+
let controller;
|
|
647
|
+
const cancel = () => {
|
|
648
|
+
clearTimeout(timer);
|
|
649
|
+
controller?.abort();
|
|
650
|
+
controller = void 0;
|
|
651
|
+
if (status() === "loading") setStatus(results() ? "done" : "idle");
|
|
652
|
+
};
|
|
653
|
+
const run = async (value) => {
|
|
654
|
+
const current = new AbortController();
|
|
655
|
+
controller = current;
|
|
656
|
+
try {
|
|
657
|
+
const found = await options.search(value, current.signal);
|
|
658
|
+
if (controller !== current) return;
|
|
659
|
+
setResults(() => found);
|
|
660
|
+
setStatus("done");
|
|
661
|
+
} catch (reason) {
|
|
662
|
+
if (controller !== current || current.signal.aborted) return;
|
|
663
|
+
setError(reason);
|
|
664
|
+
setStatus("error");
|
|
665
|
+
}
|
|
666
|
+
};
|
|
667
|
+
onCleanup(cancel);
|
|
668
|
+
return {
|
|
669
|
+
query,
|
|
670
|
+
results,
|
|
671
|
+
status,
|
|
672
|
+
error,
|
|
673
|
+
cancel,
|
|
674
|
+
setQuery: (value) => {
|
|
675
|
+
cancel();
|
|
676
|
+
setQueryValue(value);
|
|
677
|
+
if (!value.trim()) {
|
|
678
|
+
setResults(void 0);
|
|
679
|
+
setStatus("idle");
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
setStatus("loading");
|
|
683
|
+
timer = setTimeout(() => run(value.trim()), options.delay ?? 200);
|
|
684
|
+
}
|
|
685
|
+
};
|
|
686
|
+
};
|
|
687
|
+
//#endregion
|
|
688
|
+
//#region src/solid/search/create-list-navigation.ts
|
|
689
|
+
/** Keyboard navigation for a list whose focus stays on another element. */
|
|
690
|
+
const createListNavigation = (options) => {
|
|
691
|
+
const [active, setActive] = createSignal(0);
|
|
692
|
+
const moves = {
|
|
693
|
+
ArrowDown: (index, count) => (index + 1) % count,
|
|
694
|
+
ArrowUp: (index, count) => (index - 1 + count) % count,
|
|
695
|
+
Home: () => 0,
|
|
696
|
+
End: (_, count) => count - 1
|
|
697
|
+
};
|
|
698
|
+
return {
|
|
699
|
+
active,
|
|
700
|
+
setActive,
|
|
701
|
+
onKeyDown: (event) => {
|
|
702
|
+
const count = options.count();
|
|
703
|
+
const move = moves[event.key];
|
|
704
|
+
if (!count || event.isComposing) return;
|
|
705
|
+
if (event.key === "Enter") {
|
|
706
|
+
event.preventDefault();
|
|
707
|
+
options.onSelect(Math.min(active(), count - 1));
|
|
708
|
+
}
|
|
709
|
+
if (!move) return;
|
|
710
|
+
event.preventDefault();
|
|
711
|
+
setActive(move(Math.min(active(), count - 1), count));
|
|
712
|
+
}
|
|
713
|
+
};
|
|
714
|
+
};
|
|
715
|
+
//#endregion
|
|
716
|
+
//#region src/solid/search/create-answer.ts
|
|
717
|
+
/** A conversation of streamed answers, with cancellation and follow-up questions. */
|
|
718
|
+
const createAnswer = (options) => {
|
|
719
|
+
const [turns, setTurns] = createSignal([]);
|
|
720
|
+
let controller;
|
|
721
|
+
const pending = () => turns().at(-1)?.status === "loading";
|
|
722
|
+
const updateLast = (current, turn) => {
|
|
723
|
+
if (controller !== current) return;
|
|
724
|
+
setTurns((list) => [...list.slice(0, -1), {
|
|
725
|
+
...list.at(-1),
|
|
726
|
+
...turn
|
|
727
|
+
}]);
|
|
728
|
+
};
|
|
729
|
+
const cancel = () => {
|
|
730
|
+
controller?.abort();
|
|
731
|
+
controller = void 0;
|
|
732
|
+
if (pending()) setTurns((list) => list.slice(0, -1));
|
|
733
|
+
};
|
|
734
|
+
const run = async (question) => {
|
|
735
|
+
const current = new AbortController();
|
|
736
|
+
cancel();
|
|
737
|
+
controller = current;
|
|
738
|
+
const history = turns().filter((turn) => turn.status === "done");
|
|
739
|
+
setTurns([...history, {
|
|
740
|
+
question,
|
|
741
|
+
status: "loading"
|
|
742
|
+
}]);
|
|
743
|
+
try {
|
|
744
|
+
const answer = await options.ask(question, history, current.signal, (partial) => {
|
|
745
|
+
return updateLast(current, { answer: partial });
|
|
746
|
+
});
|
|
747
|
+
updateLast(current, {
|
|
748
|
+
answer,
|
|
749
|
+
status: "done"
|
|
750
|
+
});
|
|
751
|
+
} catch (error) {
|
|
752
|
+
if (current.signal.aborted) return;
|
|
753
|
+
updateLast(current, {
|
|
754
|
+
status: "error",
|
|
755
|
+
error
|
|
756
|
+
});
|
|
757
|
+
} finally {
|
|
758
|
+
if (controller === current) controller = void 0;
|
|
759
|
+
}
|
|
760
|
+
};
|
|
761
|
+
onCleanup(cancel);
|
|
762
|
+
return {
|
|
763
|
+
turns,
|
|
764
|
+
pending,
|
|
765
|
+
cancel,
|
|
766
|
+
ask: (question) => {
|
|
767
|
+
run(question).catch(() => {});
|
|
768
|
+
},
|
|
769
|
+
reset: () => {
|
|
770
|
+
cancel();
|
|
771
|
+
setTurns([]);
|
|
772
|
+
}
|
|
773
|
+
};
|
|
774
|
+
};
|
|
775
|
+
//#endregion
|
|
776
|
+
//#region src/core/platform.ts
|
|
777
|
+
/** Apple devices use Command in place of Ctrl for shortcuts. */
|
|
778
|
+
const isApplePlatform = () => {
|
|
779
|
+
return typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent);
|
|
780
|
+
};
|
|
781
|
+
//#endregion
|
|
782
|
+
//#region src/solid/search/create-search-palette.ts
|
|
783
|
+
/**
|
|
784
|
+
* The state of a search dialog: search as you type, AI answers with follow-ups, keyboard
|
|
785
|
+
* navigation, the ⌘K or Ctrl+K shortcut, trigger elements, and a query in the URL.
|
|
786
|
+
*/
|
|
787
|
+
const createSearchPalette = (options) => {
|
|
788
|
+
const [open, setOpen] = createSignal(false);
|
|
789
|
+
const [isApple, setIsApple] = createSignal(true);
|
|
790
|
+
const [input, setInput] = createSignal();
|
|
791
|
+
const search = createSearch({
|
|
792
|
+
search: options.search,
|
|
793
|
+
delay: options.delay ?? 250
|
|
794
|
+
});
|
|
795
|
+
const answer = options.ask && createAnswer({ ask: options.ask });
|
|
796
|
+
const items = () => {
|
|
797
|
+
const results = search.results();
|
|
798
|
+
return results === void 0 ? [] : options.items(results);
|
|
799
|
+
};
|
|
800
|
+
const canAsk = () => Boolean(answer && search.query().trim());
|
|
801
|
+
const showingAnswer = () => {
|
|
802
|
+
return !search.query().trim() && Boolean(answer && answer.turns().length);
|
|
803
|
+
};
|
|
804
|
+
const openItem = (item) => {
|
|
805
|
+
setOpen(false);
|
|
806
|
+
window.location.assign(item.href);
|
|
807
|
+
};
|
|
808
|
+
const askQuery = () => {
|
|
809
|
+
const question = search.query().trim();
|
|
810
|
+
if (!question || !answer) return;
|
|
811
|
+
follow = true;
|
|
812
|
+
answer.ask(question);
|
|
813
|
+
search.setQuery("");
|
|
814
|
+
};
|
|
815
|
+
const navigation = createListNavigation({
|
|
816
|
+
count: () => search.status() === "loading" ? 0 : items().length + (canAsk() ? 1 : 0),
|
|
817
|
+
onSelect: (index) => {
|
|
818
|
+
const item = items()[index];
|
|
819
|
+
if (item) openItem(item);
|
|
820
|
+
else askQuery();
|
|
821
|
+
}
|
|
822
|
+
});
|
|
823
|
+
let results;
|
|
824
|
+
let resumeSearch = false;
|
|
825
|
+
let follow = true;
|
|
826
|
+
createEffect(() => {
|
|
827
|
+
search.results();
|
|
828
|
+
navigation.setActive(0);
|
|
829
|
+
});
|
|
830
|
+
createEffect(on(open, (isOpen) => {
|
|
831
|
+
if (isOpen) {
|
|
832
|
+
if (resumeSearch) search.setQuery(search.query());
|
|
833
|
+
resumeSearch = false;
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
resumeSearch = search.status() === "loading";
|
|
837
|
+
search.cancel();
|
|
838
|
+
answer?.cancel();
|
|
839
|
+
}));
|
|
840
|
+
createEffect(() => {
|
|
841
|
+
answer?.turns();
|
|
842
|
+
if (follow && results) queueMicrotask(() => results.scrollTo({ top: results.scrollHeight }));
|
|
843
|
+
});
|
|
844
|
+
onMount(() => {
|
|
845
|
+
const url = new URL(window.location.href);
|
|
846
|
+
const param = options.queryParam ?? "q";
|
|
847
|
+
const query = url.searchParams.get(param)?.trim();
|
|
848
|
+
const onShortcut = (event) => {
|
|
849
|
+
if (!(event.key.toLowerCase() === "k" && (event.metaKey || event.ctrlKey))) return;
|
|
850
|
+
event.preventDefault();
|
|
851
|
+
setOpen(true);
|
|
852
|
+
};
|
|
853
|
+
const onClick = (event) => {
|
|
854
|
+
if (event.target.closest(options.triggerSelector ?? "[data-search-trigger]")) setOpen(true);
|
|
855
|
+
};
|
|
856
|
+
setIsApple(isApplePlatform());
|
|
857
|
+
if (query) {
|
|
858
|
+
url.searchParams.delete(param);
|
|
859
|
+
window.history.replaceState(window.history.state, "", url);
|
|
860
|
+
search.setQuery(query.slice(0, options.maxLength ?? 500));
|
|
861
|
+
setOpen(true);
|
|
862
|
+
}
|
|
863
|
+
document.addEventListener("keydown", onShortcut);
|
|
864
|
+
document.addEventListener("click", onClick);
|
|
865
|
+
onCleanup(() => {
|
|
866
|
+
document.removeEventListener("keydown", onShortcut);
|
|
867
|
+
document.removeEventListener("click", onClick);
|
|
868
|
+
});
|
|
869
|
+
});
|
|
870
|
+
return {
|
|
871
|
+
open,
|
|
872
|
+
setOpen,
|
|
873
|
+
search,
|
|
874
|
+
answer,
|
|
875
|
+
items,
|
|
876
|
+
canAsk,
|
|
877
|
+
showingAnswer,
|
|
878
|
+
navigation,
|
|
879
|
+
isApple,
|
|
880
|
+
askQuery,
|
|
881
|
+
openItem,
|
|
882
|
+
input,
|
|
883
|
+
inputRef: setInput,
|
|
884
|
+
resultsRef: (element) => {
|
|
885
|
+
results = element;
|
|
886
|
+
element.addEventListener("scroll", () => {
|
|
887
|
+
follow = element.scrollHeight - element.scrollTop - element.clientHeight < 48;
|
|
888
|
+
});
|
|
889
|
+
},
|
|
890
|
+
onInputKeyDown: (event) => {
|
|
891
|
+
const isTouch = window.matchMedia("(hover: none) and (pointer: coarse)").matches;
|
|
892
|
+
if (event.key === "Enter" && isTouch) {
|
|
893
|
+
event.preventDefault();
|
|
894
|
+
input()?.blur();
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
if (showingAnswer()) return;
|
|
898
|
+
navigation.onKeyDown(event);
|
|
899
|
+
queueMicrotask(() => {
|
|
900
|
+
results?.querySelector(`[data-search-result="${navigation.active()}"]`)?.scrollIntoView({ block: "nearest" });
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
};
|
|
904
|
+
};
|
|
905
|
+
//#endregion
|
|
906
|
+
//#region src/solid/search/highlight.ts
|
|
907
|
+
const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
908
|
+
/** The whole query and its words of two or more characters, longest first. */
|
|
909
|
+
const getMatchTerms = (query) => {
|
|
910
|
+
const phrase = query.trim().toLowerCase();
|
|
911
|
+
if (!phrase) return [];
|
|
912
|
+
return [.../* @__PURE__ */ new Set([phrase, ...phrase.split(/\s+/).filter((term) => term.length >= 2)])].sort((a, b) => b.length - a.length);
|
|
913
|
+
};
|
|
914
|
+
const matchesQuery = (text, query) => {
|
|
915
|
+
return getMatchTerms(query).some((term) => text.toLowerCase().includes(term));
|
|
916
|
+
};
|
|
917
|
+
/** Splits text into parts that match the query and parts that do not, for highlighting. */
|
|
918
|
+
const splitMatches = (text, query) => {
|
|
919
|
+
const terms = getMatchTerms(query);
|
|
920
|
+
if (!terms.length) return [{
|
|
921
|
+
text,
|
|
922
|
+
match: false
|
|
923
|
+
}];
|
|
924
|
+
return text.split(new RegExp(`(${terms.map(escapeRegExp).join("|")})`, "giu")).filter(Boolean).map((part) => ({
|
|
925
|
+
text: part,
|
|
926
|
+
match: terms.includes(part.toLowerCase())
|
|
927
|
+
}));
|
|
928
|
+
};
|
|
929
|
+
/** Cuts the text to start just before the first match of the query. */
|
|
930
|
+
const getMatchPreview = (text, query, options = {}) => {
|
|
931
|
+
const lower = text.toLowerCase();
|
|
932
|
+
const matches = getMatchTerms(query).map((term) => lower.indexOf(term)).filter((index) => index >= 0);
|
|
933
|
+
const start = matches.length ? Math.max(Math.min(...matches) - (options.prefix ?? 24), 0) : 0;
|
|
934
|
+
const end = Math.min(start + (options.length ?? 180), text.length);
|
|
935
|
+
return `${start > 0 ? "…" : ""}${text.slice(start, end).trim()}${end < text.length ? "…" : ""}`;
|
|
936
|
+
};
|
|
937
|
+
//#endregion
|
|
938
|
+
//#region src/solid/page-actions/create-page-actions.ts
|
|
939
|
+
/** Copies the page URL or its Markdown alternative. */
|
|
940
|
+
const createPageActions = (options) => {
|
|
941
|
+
const [item, setItem] = createSignal("link");
|
|
942
|
+
const clipboard = createClipboard(options.timeout);
|
|
943
|
+
let markdown;
|
|
944
|
+
const loadMarkdown = () => {
|
|
945
|
+
markdown ??= fetch(options.markdown).then((response) => {
|
|
946
|
+
if (!response.ok) throw new Error(`Markdown request failed (${response.status}).`);
|
|
947
|
+
return response.text();
|
|
948
|
+
});
|
|
949
|
+
markdown.catch(() => markdown = void 0);
|
|
950
|
+
return markdown;
|
|
951
|
+
};
|
|
952
|
+
const result = () => {
|
|
953
|
+
const success = clipboard.status();
|
|
954
|
+
return success === void 0 ? void 0 : {
|
|
955
|
+
item: item(),
|
|
956
|
+
success
|
|
957
|
+
};
|
|
958
|
+
};
|
|
959
|
+
return {
|
|
960
|
+
result,
|
|
961
|
+
copyLink: () => {
|
|
962
|
+
setItem("link");
|
|
963
|
+
return clipboard.copy(window.location.href);
|
|
964
|
+
},
|
|
965
|
+
copyMarkdown: () => {
|
|
966
|
+
setItem("markdown");
|
|
967
|
+
return clipboard.copy(loadMarkdown());
|
|
968
|
+
}
|
|
969
|
+
};
|
|
970
|
+
};
|
|
971
|
+
//#endregion
|
|
972
|
+
//#region src/solid/scroll/create-scroll-memory.ts
|
|
973
|
+
/** Keeps the scroll position of a container between pages, and shows the current item. */
|
|
974
|
+
const createScrollMemory = (options) => {
|
|
975
|
+
onMount(() => {
|
|
976
|
+
const container = options.container();
|
|
977
|
+
const current = options.current?.();
|
|
978
|
+
if (!container) return;
|
|
979
|
+
const save = () => writeSession(options.key, container.scrollTop);
|
|
980
|
+
container.scrollTop = Number(readSession(options.key)) || 0;
|
|
981
|
+
if (current) scrollIntoContainer(container, current);
|
|
982
|
+
container.addEventListener("scroll", save, { passive: true });
|
|
983
|
+
onCleanup(() => container.removeEventListener("scroll", save));
|
|
984
|
+
});
|
|
985
|
+
};
|
|
986
|
+
//#endregion
|
|
987
|
+
//#region src/solid/scroll/create-scroll-edges.ts
|
|
988
|
+
/** Tracks whether the page has content past the top and bottom of the viewport, e.g. for fades. */
|
|
989
|
+
const createScrollEdges = () => {
|
|
990
|
+
const [start, setStart] = createSignal(false);
|
|
991
|
+
const [end, setEnd] = createSignal(false);
|
|
992
|
+
onMount(() => {
|
|
993
|
+
const root = document.documentElement;
|
|
994
|
+
const update = () => {
|
|
995
|
+
setStart(root.scrollTop > 0);
|
|
996
|
+
setEnd(root.scrollHeight - root.clientHeight - root.scrollTop >= 10);
|
|
997
|
+
};
|
|
998
|
+
const observer = new ResizeObserver(update);
|
|
999
|
+
observer.observe(document.body);
|
|
1000
|
+
window.addEventListener("scroll", update, { passive: true });
|
|
1001
|
+
update();
|
|
1002
|
+
onCleanup(() => {
|
|
1003
|
+
observer.disconnect();
|
|
1004
|
+
window.removeEventListener("scroll", update);
|
|
1005
|
+
});
|
|
1006
|
+
});
|
|
1007
|
+
return {
|
|
1008
|
+
start,
|
|
1009
|
+
end
|
|
1010
|
+
};
|
|
1011
|
+
};
|
|
1012
|
+
//#endregion
|
|
1013
|
+
//#region src/solid/scroll/create-page-scrollbar.ts
|
|
1014
|
+
/**
|
|
1015
|
+
* An overlay scrollbar for the page, for layouts that hide the native one so pages with and
|
|
1016
|
+
* without overflow have the same width. It shows while the page scrolls, or when the pointer is
|
|
1017
|
+
* near the right edge, and supports dragging and track clicks.
|
|
1018
|
+
*/
|
|
1019
|
+
const createPageScrollbar = (options = {}) => {
|
|
1020
|
+
const [metrics, setMetrics] = createSignal();
|
|
1021
|
+
const [scrolling, setScrolling] = createSignal(false);
|
|
1022
|
+
const [near, setNear] = createSignal(false);
|
|
1023
|
+
const [dragging, setDragging] = createSignal(false);
|
|
1024
|
+
const inset = options.inset ?? 4;
|
|
1025
|
+
const interactive = () => near() || dragging();
|
|
1026
|
+
let dragStart = 0;
|
|
1027
|
+
let scrollStart = 0;
|
|
1028
|
+
const scrollTo = (top) => window.scrollTo({
|
|
1029
|
+
top,
|
|
1030
|
+
behavior: "instant"
|
|
1031
|
+
});
|
|
1032
|
+
const stopDragging = () => {
|
|
1033
|
+
setDragging(false);
|
|
1034
|
+
};
|
|
1035
|
+
onMount(() => {
|
|
1036
|
+
const edgeZone = options.edgeZone ?? 16;
|
|
1037
|
+
const minThumbHeight = options.minThumbHeight ?? 24;
|
|
1038
|
+
let frame = 0;
|
|
1039
|
+
let timer;
|
|
1040
|
+
const measure = () => {
|
|
1041
|
+
const root = document.documentElement;
|
|
1042
|
+
const viewport = window.innerHeight;
|
|
1043
|
+
const maximum = root.scrollHeight - viewport;
|
|
1044
|
+
const locked = getComputedStyle(document.body).overflow === "hidden";
|
|
1045
|
+
const track = viewport - inset * 2;
|
|
1046
|
+
const height = Math.max(minThumbHeight, track * viewport / root.scrollHeight);
|
|
1047
|
+
frame = 0;
|
|
1048
|
+
setMetrics(maximum > 0 && !locked ? {
|
|
1049
|
+
top: inset + window.scrollY / maximum * (track - height),
|
|
1050
|
+
height,
|
|
1051
|
+
ratio: maximum / (track - height)
|
|
1052
|
+
} : void 0);
|
|
1053
|
+
};
|
|
1054
|
+
const schedule = () => {
|
|
1055
|
+
frame ||= requestAnimationFrame(measure);
|
|
1056
|
+
};
|
|
1057
|
+
const onScroll = () => {
|
|
1058
|
+
schedule();
|
|
1059
|
+
setScrolling(true);
|
|
1060
|
+
clearTimeout(timer);
|
|
1061
|
+
timer = setTimeout(() => setScrolling(false), options.hideDelay ?? 1e3);
|
|
1062
|
+
};
|
|
1063
|
+
const onPointerMove = (event) => {
|
|
1064
|
+
setNear(event.pointerType === "mouse" && event.clientX >= window.innerWidth - edgeZone);
|
|
1065
|
+
};
|
|
1066
|
+
const observer = new ResizeObserver(schedule);
|
|
1067
|
+
const styleObserver = new MutationObserver(schedule);
|
|
1068
|
+
observer.observe(document.body);
|
|
1069
|
+
styleObserver.observe(document.body, {
|
|
1070
|
+
attributes: true,
|
|
1071
|
+
attributeFilter: ["style"]
|
|
1072
|
+
});
|
|
1073
|
+
window.addEventListener("scroll", onScroll, { passive: true });
|
|
1074
|
+
window.addEventListener("resize", schedule);
|
|
1075
|
+
window.addEventListener("pointermove", onPointerMove, { passive: true });
|
|
1076
|
+
measure();
|
|
1077
|
+
onCleanup(() => {
|
|
1078
|
+
cancelAnimationFrame(frame);
|
|
1079
|
+
clearTimeout(timer);
|
|
1080
|
+
observer.disconnect();
|
|
1081
|
+
styleObserver.disconnect();
|
|
1082
|
+
window.removeEventListener("scroll", onScroll);
|
|
1083
|
+
window.removeEventListener("resize", schedule);
|
|
1084
|
+
window.removeEventListener("pointermove", onPointerMove);
|
|
1085
|
+
});
|
|
1086
|
+
});
|
|
1087
|
+
return {
|
|
1088
|
+
thumb: () => {
|
|
1089
|
+
const current = metrics();
|
|
1090
|
+
return current && {
|
|
1091
|
+
top: current.top,
|
|
1092
|
+
height: current.height
|
|
1093
|
+
};
|
|
1094
|
+
},
|
|
1095
|
+
visible: () => Boolean(metrics() && (scrolling() || interactive())),
|
|
1096
|
+
interactive,
|
|
1097
|
+
onTrackPointerDown: (event) => {
|
|
1098
|
+
const current = metrics();
|
|
1099
|
+
if (event.button !== 0 || !current) return;
|
|
1100
|
+
scrollTo((event.clientY - inset - current.height / 2) * current.ratio);
|
|
1101
|
+
},
|
|
1102
|
+
thumbHandlers: {
|
|
1103
|
+
onPointerDown: (event) => {
|
|
1104
|
+
if (event.button !== 0) return;
|
|
1105
|
+
event.preventDefault();
|
|
1106
|
+
event.stopPropagation();
|
|
1107
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
1108
|
+
dragStart = event.clientY;
|
|
1109
|
+
scrollStart = window.scrollY;
|
|
1110
|
+
setDragging(true);
|
|
1111
|
+
},
|
|
1112
|
+
onPointerMove: (event) => {
|
|
1113
|
+
const current = metrics();
|
|
1114
|
+
if (dragging() && current) scrollTo(scrollStart + (event.clientY - dragStart) * current.ratio);
|
|
1115
|
+
},
|
|
1116
|
+
onPointerUp: stopDragging,
|
|
1117
|
+
onPointerCancel: stopDragging,
|
|
1118
|
+
onLostPointerCapture: stopDragging
|
|
1119
|
+
}
|
|
1120
|
+
};
|
|
1121
|
+
};
|
|
1122
|
+
//#endregion
|
|
1123
|
+
//#region src/solid/tab-indicator/create-tab-indicator.ts
|
|
1124
|
+
/** Measures an underline for the current tab of a scrollable row, and keeps that tab in view. */
|
|
1125
|
+
const createTabIndicator = (options) => {
|
|
1126
|
+
const [position, setPosition] = createSignal();
|
|
1127
|
+
const tabs = /* @__PURE__ */ new Map();
|
|
1128
|
+
let root;
|
|
1129
|
+
let scroll;
|
|
1130
|
+
onMount(() => {
|
|
1131
|
+
let frame = 0;
|
|
1132
|
+
const measure = () => {
|
|
1133
|
+
const rootBounds = root?.getBoundingClientRect();
|
|
1134
|
+
const tabBounds = tabs.get(options.current())?.getBoundingClientRect();
|
|
1135
|
+
frame = 0;
|
|
1136
|
+
setPosition(rootBounds && tabBounds && {
|
|
1137
|
+
left: tabBounds.left - rootBounds.left,
|
|
1138
|
+
width: tabBounds.width
|
|
1139
|
+
});
|
|
1140
|
+
};
|
|
1141
|
+
const schedule = () => {
|
|
1142
|
+
frame ||= requestAnimationFrame(measure);
|
|
1143
|
+
};
|
|
1144
|
+
const observer = new ResizeObserver(schedule);
|
|
1145
|
+
tabs.get(options.current())?.scrollIntoView({
|
|
1146
|
+
block: "nearest",
|
|
1147
|
+
inline: "nearest"
|
|
1148
|
+
});
|
|
1149
|
+
if (root) observer.observe(root);
|
|
1150
|
+
scroll?.addEventListener("scroll", schedule, { passive: true });
|
|
1151
|
+
schedule();
|
|
1152
|
+
onCleanup(() => {
|
|
1153
|
+
cancelAnimationFrame(frame);
|
|
1154
|
+
observer.disconnect();
|
|
1155
|
+
scroll?.removeEventListener("scroll", schedule);
|
|
1156
|
+
});
|
|
1157
|
+
});
|
|
1158
|
+
return {
|
|
1159
|
+
position,
|
|
1160
|
+
rootRef: (element) => root = element,
|
|
1161
|
+
scrollRef: (element) => scroll = element,
|
|
1162
|
+
tabRef: (index, element) => tabs.set(index, element)
|
|
1163
|
+
};
|
|
1164
|
+
};
|
|
1165
|
+
//#endregion
|
|
1166
|
+
export { Breadcrumbs, MobileNavigation, NavigationTree, Pagination, TableOfContents, Tabs, createAnswer, createClipboard, createListNavigation, createNavigationTree, createPageActions, createPageScrollbar, createScrollEdges, createScrollMemory, createSearch, createSearchPalette, createTabIndicator, createTableOfContents, createTableOfContentsGuide, enhanceMobileNavigation, getMatchPreview, getMatchTerms, matchesQuery, splitMatches, useTableOfContents };
|