@myst-theme/site 0.12.0 → 0.13.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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@myst-theme/site",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -21,22 +21,30 @@
|
|
|
21
21
|
"dependencies": {
|
|
22
22
|
"@headlessui/react": "^1.7.15",
|
|
23
23
|
"@heroicons/react": "^2.0.18",
|
|
24
|
-
"@myst-theme/common": "^0.
|
|
25
|
-
"@myst-theme/diagrams": "^0.
|
|
26
|
-
"@myst-theme/frontmatter": "^0.
|
|
27
|
-
"@myst-theme/jupyter": "^0.
|
|
28
|
-
"@myst-theme/providers": "^0.
|
|
24
|
+
"@myst-theme/common": "^0.13.0",
|
|
25
|
+
"@myst-theme/diagrams": "^0.13.0",
|
|
26
|
+
"@myst-theme/frontmatter": "^0.13.0",
|
|
27
|
+
"@myst-theme/jupyter": "^0.13.0",
|
|
28
|
+
"@myst-theme/providers": "^0.13.0",
|
|
29
|
+
"@myst-theme/search": "^0.13.0",
|
|
29
30
|
"@radix-ui/react-collapsible": "^1.0.3",
|
|
31
|
+
"@radix-ui/react-dialog": "^1.0.3",
|
|
32
|
+
"@radix-ui/react-radio-group": "^1.2.0",
|
|
33
|
+
"@radix-ui/react-roving-focus": "^1.1.0",
|
|
34
|
+
"@radix-ui/react-slot": "^1.1.0",
|
|
35
|
+
"@radix-ui/react-visually-hidden": "^1.1.0",
|
|
30
36
|
"classnames": "^2.3.2",
|
|
31
37
|
"lodash.throttle": "^4.1.1",
|
|
32
|
-
"myst-common": "^1.
|
|
33
|
-
"myst-config": "^1.
|
|
34
|
-
"myst-demo": "^0.
|
|
35
|
-
"myst-spec-ext": "^1.
|
|
36
|
-
"myst-to-react": "^0.
|
|
38
|
+
"myst-common": "^1.7.0",
|
|
39
|
+
"myst-config": "^1.7.0",
|
|
40
|
+
"myst-demo": "^0.13.0",
|
|
41
|
+
"myst-spec-ext": "^1.7.0",
|
|
42
|
+
"myst-to-react": "^0.13.0",
|
|
37
43
|
"nbtx": "^0.2.3",
|
|
38
44
|
"node-cache": "^5.1.2",
|
|
39
45
|
"node-fetch": "^2.6.11",
|
|
46
|
+
"react-merge-refs": "^2.1.1",
|
|
47
|
+
"string.prototype.matchall": "^4.0.11",
|
|
40
48
|
"thebe-react": "0.4.10",
|
|
41
49
|
"unist-util-select": "^4.0.1"
|
|
42
50
|
},
|
|
@@ -8,10 +8,12 @@ import { useNavigation } from '@remix-run/react';
|
|
|
8
8
|
import classNames from 'classnames';
|
|
9
9
|
import throttle from 'lodash.throttle';
|
|
10
10
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
11
|
+
import type { RefObject } from 'react';
|
|
11
12
|
import { DocumentChartBarIcon } from '@heroicons/react/24/outline';
|
|
13
|
+
import { ChevronRightIcon } from '@heroicons/react/24/solid';
|
|
14
|
+
import * as Collapsible from '@radix-ui/react-collapsible';
|
|
12
15
|
|
|
13
16
|
const SELECTOR = [1, 2, 3, 4].map((n) => `main h${n}`).join(', ');
|
|
14
|
-
const HIGHLIGHT_CLASS = 'highlight';
|
|
15
17
|
|
|
16
18
|
const onClient = typeof document !== 'undefined';
|
|
17
19
|
|
|
@@ -25,15 +27,13 @@ export type Heading = {
|
|
|
25
27
|
|
|
26
28
|
type Props = {
|
|
27
29
|
headings: Heading[];
|
|
28
|
-
selector: string;
|
|
29
30
|
activeId?: string;
|
|
30
|
-
highlight?: () => void;
|
|
31
31
|
};
|
|
32
32
|
/**
|
|
33
33
|
* This renders an item in the table of contents list.
|
|
34
34
|
* scrollIntoView is used to ensure that when a user clicks on an item, it will smoothly scroll.
|
|
35
35
|
*/
|
|
36
|
-
const Headings = ({ headings, activeId
|
|
36
|
+
const Headings = ({ headings, activeId }: Props) => (
|
|
37
37
|
<ul className="text-sm leading-6 text-slate-400">
|
|
38
38
|
{headings.map((heading) => (
|
|
39
39
|
<li
|
|
@@ -62,11 +62,7 @@ const Headings = ({ headings, activeId, highlight, selector }: Props) => (
|
|
|
62
62
|
e.preventDefault();
|
|
63
63
|
const el = document.querySelector(`#${heading.id}`);
|
|
64
64
|
if (!el) return;
|
|
65
|
-
|
|
66
|
-
h.classList.remove(HIGHLIGHT_CLASS);
|
|
67
|
-
});
|
|
68
|
-
el.classList.add(HIGHLIGHT_CLASS);
|
|
69
|
-
highlight?.();
|
|
65
|
+
|
|
70
66
|
el.scrollIntoView({ behavior: 'smooth' });
|
|
71
67
|
history.replaceState(undefined, '', `#${heading.id}`);
|
|
72
68
|
}}
|
|
@@ -105,15 +101,111 @@ function getHeaders(selector: string): HTMLHeadingElement[] {
|
|
|
105
101
|
return headers as HTMLHeadingElement[];
|
|
106
102
|
}
|
|
107
103
|
|
|
104
|
+
type MutationCallback = (mutations: MutationRecord[], observer: MutationObserver) => void;
|
|
105
|
+
|
|
106
|
+
function useMutationObserver(
|
|
107
|
+
targetRef: RefObject<Element>,
|
|
108
|
+
callback: MutationCallback,
|
|
109
|
+
options: Record<string, any>,
|
|
110
|
+
) {
|
|
111
|
+
const [observer, setObserver] = useState<MutationObserver | null>(null);
|
|
112
|
+
|
|
113
|
+
if (!onClient) return { observer };
|
|
114
|
+
|
|
115
|
+
// Create observer
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
const obs = new MutationObserver(callback);
|
|
118
|
+
setObserver(obs);
|
|
119
|
+
}, [callback, setObserver]);
|
|
120
|
+
|
|
121
|
+
// Setup observer
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
if (!observer || !targetRef.current) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
observer.observe(targetRef.current, options);
|
|
129
|
+
} catch (e) {
|
|
130
|
+
console.error(e);
|
|
131
|
+
}
|
|
132
|
+
return () => {
|
|
133
|
+
if (observer) {
|
|
134
|
+
observer.disconnect();
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
}, [observer]);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const useIntersectionObserver = (elements: Element[], options?: Record<string, any>) => {
|
|
141
|
+
const [observer, setObserver] = useState<IntersectionObserver | null>(null);
|
|
142
|
+
const [intersecting, setIntersecting] = useState<Element[]>([]);
|
|
143
|
+
|
|
144
|
+
if (!onClient) return { observer };
|
|
145
|
+
useEffect(() => {
|
|
146
|
+
const cb: IntersectionObserverCallback = (entries) => {
|
|
147
|
+
setIntersecting(entries.filter((e) => e.isIntersecting).map((e) => e.target));
|
|
148
|
+
};
|
|
149
|
+
const o = new IntersectionObserver(cb, options ?? {});
|
|
150
|
+
setObserver(o);
|
|
151
|
+
return () => o.disconnect();
|
|
152
|
+
}, []);
|
|
153
|
+
|
|
154
|
+
// Changes to the DOM mean we need to update our intersection observer
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
if (!observer) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
// Observe all heading elements
|
|
160
|
+
const toWatch = elements;
|
|
161
|
+
toWatch.map((e) => observer.observe(e));
|
|
162
|
+
// Cleanup afterwards
|
|
163
|
+
return () => {
|
|
164
|
+
toWatch.map((e) => observer.unobserve(e));
|
|
165
|
+
};
|
|
166
|
+
}, [elements]);
|
|
167
|
+
|
|
168
|
+
return { observer, intersecting };
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Keep track of which headers are visible, and which header is active
|
|
173
|
+
*/
|
|
108
174
|
export function useHeaders(selector: string, maxdepth: number) {
|
|
109
175
|
if (!onClient) return { activeId: '', headings: [] };
|
|
110
|
-
|
|
176
|
+
// Keep track of main manually for now
|
|
177
|
+
const mainElementRef = useRef<HTMLElement | null>(null);
|
|
178
|
+
useEffect(() => {
|
|
179
|
+
mainElementRef.current = document.querySelector('main');
|
|
180
|
+
}, []);
|
|
181
|
+
|
|
182
|
+
// Track changes to the DOM
|
|
183
|
+
const [elements, setElements] = useState<HTMLHeadingElement[]>([]);
|
|
184
|
+
const onMutation = useCallback(
|
|
185
|
+
throttle(
|
|
186
|
+
() => {
|
|
187
|
+
setElements(getHeaders(selector));
|
|
188
|
+
},
|
|
189
|
+
500,
|
|
190
|
+
{ trailing: false },
|
|
191
|
+
),
|
|
192
|
+
[selector],
|
|
193
|
+
);
|
|
194
|
+
useMutationObserver(mainElementRef, onMutation, {
|
|
195
|
+
attributes: true,
|
|
196
|
+
childList: true,
|
|
197
|
+
subtree: true,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// Trigger initial update
|
|
201
|
+
useEffect(onMutation, []);
|
|
202
|
+
|
|
203
|
+
// Watch intersections with headings
|
|
204
|
+
const { intersecting } = useIntersectionObserver(elements);
|
|
111
205
|
const [activeId, setActiveId] = useState<string>();
|
|
112
|
-
const headingsSet = useRef<Set<HTMLHeadingElement>>(new Set());
|
|
113
206
|
|
|
114
|
-
|
|
115
|
-
const
|
|
116
|
-
const highlighted = current.reduce(
|
|
207
|
+
useEffect(() => {
|
|
208
|
+
const highlighted = intersecting!.reduce(
|
|
117
209
|
(a, b) => {
|
|
118
210
|
if (a) return a;
|
|
119
211
|
if (b.classList.contains('highlight')) return b.id;
|
|
@@ -121,80 +213,43 @@ export function useHeaders(selector: string, maxdepth: number) {
|
|
|
121
213
|
},
|
|
122
214
|
null as string | null,
|
|
123
215
|
);
|
|
124
|
-
const active = [...
|
|
216
|
+
const active = [...(intersecting as HTMLElement[])].sort(
|
|
217
|
+
(a, b) => a.offsetTop - b.offsetTop,
|
|
218
|
+
)[0];
|
|
125
219
|
if (highlighted || active) setActiveId(highlighted || active.id);
|
|
126
|
-
}, []);
|
|
127
|
-
|
|
128
|
-
const { observer } = useIntersectionObserver(highlight, onScreen.current);
|
|
129
|
-
const [elements, setElements] = useState<HTMLHeadingElement[]>([]);
|
|
220
|
+
}, [intersecting]);
|
|
130
221
|
|
|
131
|
-
const
|
|
222
|
+
const [headings, setHeadings] = useState<Heading[]>([]);
|
|
132
223
|
useEffect(() => {
|
|
133
|
-
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
224
|
+
let minLevel = 10;
|
|
225
|
+
const thisHeadings: Heading[] = elements
|
|
226
|
+
.map((element) => {
|
|
227
|
+
return {
|
|
228
|
+
element,
|
|
229
|
+
level: Number(element.tagName.slice(1)),
|
|
230
|
+
id: element.id,
|
|
231
|
+
text: element.querySelector('.heading-text'),
|
|
232
|
+
};
|
|
233
|
+
})
|
|
234
|
+
.filter((h) => !!h.text)
|
|
235
|
+
.map(({ element, level, text, id }) => {
|
|
236
|
+
const { innerText: title, innerHTML: titleHTML } = cloneHeadingElement(
|
|
237
|
+
text as HTMLSpanElement,
|
|
238
|
+
);
|
|
239
|
+
minLevel = Math.min(minLevel, level);
|
|
240
|
+
return { element, title, titleHTML, id, level };
|
|
241
|
+
})
|
|
242
|
+
.filter((heading) => {
|
|
243
|
+
heading.level = heading.level - minLevel + 1;
|
|
244
|
+
return heading.level < maxdepth + 1;
|
|
245
|
+
});
|
|
148
246
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
.map((element) => {
|
|
152
|
-
return {
|
|
153
|
-
element,
|
|
154
|
-
level: Number(element.tagName.slice(1)),
|
|
155
|
-
id: element.id,
|
|
156
|
-
text: element.querySelector('.heading-text'),
|
|
157
|
-
};
|
|
158
|
-
})
|
|
159
|
-
.filter((h) => !!h.text)
|
|
160
|
-
.map(({ element, level, text, id }) => {
|
|
161
|
-
const { innerText: title, innerHTML: titleHTML } = cloneHeadingElement(
|
|
162
|
-
text as HTMLSpanElement,
|
|
163
|
-
);
|
|
164
|
-
minLevel = Math.min(minLevel, level);
|
|
165
|
-
return { element, title, titleHTML, id, level };
|
|
166
|
-
})
|
|
167
|
-
.filter((heading) => {
|
|
168
|
-
heading.level = heading.level - minLevel + 1;
|
|
169
|
-
return heading.level < maxdepth + 1;
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
headings.forEach(({ element: e }) => {
|
|
173
|
-
if (headingsSet.current.has(e)) return;
|
|
174
|
-
observer.current?.observe(e);
|
|
175
|
-
headingsSet.current.add(e);
|
|
176
|
-
});
|
|
247
|
+
setHeadings(thisHeadings);
|
|
248
|
+
}, [elements]);
|
|
177
249
|
|
|
178
|
-
return { activeId,
|
|
250
|
+
return { activeId, headings };
|
|
179
251
|
}
|
|
180
252
|
|
|
181
|
-
const useIntersectionObserver = (highlight: () => void, onScreen: Set<HTMLHeadingElement>) => {
|
|
182
|
-
const observer = useRef<IntersectionObserver | null>(null);
|
|
183
|
-
if (!onClient) return { observer };
|
|
184
|
-
useEffect(() => {
|
|
185
|
-
const callback: IntersectionObserverCallback = (entries) => {
|
|
186
|
-
entries.forEach((entry) => {
|
|
187
|
-
onScreen[entry.isIntersecting ? 'add' : 'delete'](entry.target as HTMLHeadingElement);
|
|
188
|
-
});
|
|
189
|
-
highlight();
|
|
190
|
-
};
|
|
191
|
-
const o = new IntersectionObserver(callback);
|
|
192
|
-
observer.current = o;
|
|
193
|
-
return () => o.disconnect();
|
|
194
|
-
}, [highlight, onScreen]);
|
|
195
|
-
return { observer };
|
|
196
|
-
};
|
|
197
|
-
|
|
198
253
|
export function useOutlineHeight<T extends HTMLElement = HTMLElement>(
|
|
199
254
|
existingContainer?: React.RefObject<T>,
|
|
200
255
|
) {
|
|
@@ -226,6 +281,71 @@ export function useOutlineHeight<T extends HTMLElement = HTMLElement>(
|
|
|
226
281
|
return { container, outline };
|
|
227
282
|
}
|
|
228
283
|
|
|
284
|
+
/**
|
|
285
|
+
* Determine whether the margin outline should be occluded by margin elements
|
|
286
|
+
*/
|
|
287
|
+
function useMarginOccluder() {
|
|
288
|
+
const [occluded, setOccluded] = useState(false);
|
|
289
|
+
const [elements, setElements] = useState<Element[]>([]);
|
|
290
|
+
|
|
291
|
+
// Keep track of main manually for now
|
|
292
|
+
const mainElementRef = useRef<HTMLElement | null>(null);
|
|
293
|
+
useEffect(() => {
|
|
294
|
+
mainElementRef.current = document.querySelector('main');
|
|
295
|
+
}, []);
|
|
296
|
+
|
|
297
|
+
// Update list of margin elements
|
|
298
|
+
const onMutation = useCallback(
|
|
299
|
+
throttle(
|
|
300
|
+
() => {
|
|
301
|
+
if (!mainElementRef.current) {
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
// Watch margin elements, or their direct descendents (as some margin elements have height set to zero)
|
|
305
|
+
const classes = [
|
|
306
|
+
'col-margin-right',
|
|
307
|
+
'col-margin-right-inset',
|
|
308
|
+
'col-gutter-outset-right',
|
|
309
|
+
'col-screen-right',
|
|
310
|
+
'col-screen-inset-right',
|
|
311
|
+
'col-page-right',
|
|
312
|
+
'col-page-inset-right',
|
|
313
|
+
'col-body-outset-right',
|
|
314
|
+
'col-gutter-page-right',
|
|
315
|
+
// 'col-screen', // This is on everything!
|
|
316
|
+
'col-page',
|
|
317
|
+
'col-page-inset',
|
|
318
|
+
'col-body-outset',
|
|
319
|
+
];
|
|
320
|
+
const selector = classes
|
|
321
|
+
.map((cls) => [`.${cls}`, `.${cls} > *`])
|
|
322
|
+
.flat()
|
|
323
|
+
.join(', ');
|
|
324
|
+
const marginElements = mainElementRef.current.querySelectorAll(selector);
|
|
325
|
+
setElements(Array.from(marginElements));
|
|
326
|
+
},
|
|
327
|
+
500,
|
|
328
|
+
{ trailing: false },
|
|
329
|
+
),
|
|
330
|
+
[],
|
|
331
|
+
);
|
|
332
|
+
useMutationObserver(mainElementRef, onMutation, {
|
|
333
|
+
attributes: true,
|
|
334
|
+
childList: true,
|
|
335
|
+
subtree: true,
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// Trigger initial update
|
|
339
|
+
useEffect(onMutation, []);
|
|
340
|
+
// Keep tabs of margin elements on screen
|
|
341
|
+
const { intersecting } = useIntersectionObserver(elements, { rootMargin: '0px 0px -33% 0px' });
|
|
342
|
+
useEffect(() => {
|
|
343
|
+
setOccluded(intersecting!.length > 0);
|
|
344
|
+
}, [intersecting]);
|
|
345
|
+
|
|
346
|
+
return { occluded };
|
|
347
|
+
}
|
|
348
|
+
|
|
229
349
|
export const DocumentOutline = ({
|
|
230
350
|
outlineRef,
|
|
231
351
|
top = 0,
|
|
@@ -233,6 +353,7 @@ export const DocumentOutline = ({
|
|
|
233
353
|
selector = SELECTOR,
|
|
234
354
|
children,
|
|
235
355
|
maxdepth = 4,
|
|
356
|
+
isMargin,
|
|
236
357
|
}: {
|
|
237
358
|
outlineRef?: React.RefObject<HTMLElement>;
|
|
238
359
|
top?: number;
|
|
@@ -241,31 +362,63 @@ export const DocumentOutline = ({
|
|
|
241
362
|
selector?: string;
|
|
242
363
|
children?: React.ReactNode;
|
|
243
364
|
maxdepth?: number;
|
|
365
|
+
isMargin: boolean;
|
|
244
366
|
}) => {
|
|
245
|
-
const { activeId, headings
|
|
367
|
+
const { activeId, headings } = useHeaders(selector, maxdepth);
|
|
368
|
+
const [open, setOpen] = useState(false);
|
|
369
|
+
|
|
370
|
+
// Keep track of changing occlusion
|
|
371
|
+
const { occluded } = useMarginOccluder();
|
|
372
|
+
|
|
373
|
+
// Handle transition between margin and non-margin
|
|
374
|
+
useEffect(() => {
|
|
375
|
+
setOpen(true);
|
|
376
|
+
}, [isMargin]);
|
|
377
|
+
|
|
378
|
+
// Handle occlusion when outline is in margin
|
|
379
|
+
useEffect(() => {
|
|
380
|
+
if (isMargin) {
|
|
381
|
+
setOpen(!occluded);
|
|
382
|
+
}
|
|
383
|
+
}, [occluded, isMargin]);
|
|
384
|
+
|
|
246
385
|
if (headings.length <= 1 || !onClient) {
|
|
247
386
|
return <nav suppressHydrationWarning>{children}</nav>;
|
|
248
387
|
}
|
|
388
|
+
|
|
249
389
|
return (
|
|
250
|
-
<
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
390
|
+
<Collapsible.Root open={open} onOpenChange={setOpen}>
|
|
391
|
+
<nav
|
|
392
|
+
ref={outlineRef}
|
|
393
|
+
aria-label="Document Outline"
|
|
394
|
+
className={classNames(
|
|
395
|
+
'not-prose overflow-y-auto',
|
|
396
|
+
'transition-opacity duration-700', // Animation on load
|
|
397
|
+
className,
|
|
398
|
+
)}
|
|
399
|
+
style={{
|
|
400
|
+
top: top,
|
|
401
|
+
maxHeight: `calc(100vh - ${top + 20}px)`,
|
|
402
|
+
}}
|
|
403
|
+
>
|
|
404
|
+
<div className="flex flex-row gap-2 mb-4 text-sm leading-6 uppercase rounded-lg text-slate-900 dark:text-slate-100">
|
|
405
|
+
In this article
|
|
406
|
+
<Collapsible.Trigger asChild>
|
|
407
|
+
<button className="self-center flex-none rounded-md group hover:bg-slate-300/30 focus:outline outline-blue-200 outline-2">
|
|
408
|
+
<ChevronRightIcon
|
|
409
|
+
className="transition-transform duration-300 group-data-[state=open]:rotate-90 text-text-slate-700 dark:text-slate-100"
|
|
410
|
+
height="1.5rem"
|
|
411
|
+
width="1.5rem"
|
|
412
|
+
/>
|
|
413
|
+
</button>
|
|
414
|
+
</Collapsible.Trigger>
|
|
415
|
+
</div>
|
|
416
|
+
<Collapsible.Content className="CollapsibleContent">
|
|
417
|
+
<Headings headings={headings} activeId={activeId} />
|
|
418
|
+
{children}
|
|
419
|
+
</Collapsible.Content>
|
|
420
|
+
</nav>
|
|
421
|
+
</Collapsible.Root>
|
|
269
422
|
);
|
|
270
423
|
};
|
|
271
424
|
|
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
import { useEffect, useState, useMemo, useCallback, useRef, forwardRef } from 'react';
|
|
2
|
+
import type { KeyboardEventHandler, Dispatch, SetStateAction, FormEvent, MouseEvent } from 'react';
|
|
3
|
+
import { useNavigate, useFetcher } from '@remix-run/react';
|
|
4
|
+
import {
|
|
5
|
+
ArrowTurnDownLeftIcon,
|
|
6
|
+
MagnifyingGlassIcon,
|
|
7
|
+
HashtagIcon,
|
|
8
|
+
Bars3BottomLeftIcon,
|
|
9
|
+
XCircleIcon,
|
|
10
|
+
} from '@heroicons/react/24/solid';
|
|
11
|
+
import { DocumentIcon } from '@heroicons/react/24/outline';
|
|
12
|
+
import classNames from 'classnames';
|
|
13
|
+
import * as Dialog from '@radix-ui/react-dialog';
|
|
14
|
+
import * as VisuallyHidden from '@radix-ui/react-visually-hidden';
|
|
15
|
+
import type { RankedSearchResult, HeadingLevel, MystSearchIndex } from '@myst-theme/search';
|
|
16
|
+
import { SPACE_OR_PUNCTUATION, rankResults } from '@myst-theme/search';
|
|
17
|
+
import {
|
|
18
|
+
useThemeTop,
|
|
19
|
+
useSearchFactory,
|
|
20
|
+
useLinkProvider,
|
|
21
|
+
withBaseurl,
|
|
22
|
+
useBaseurl,
|
|
23
|
+
} from '@myst-theme/providers';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Shim for string.matchAll
|
|
27
|
+
*
|
|
28
|
+
* @param text - text to repeatedly match with pattern
|
|
29
|
+
* @param pattern - global pattern
|
|
30
|
+
*/
|
|
31
|
+
function matchAll(text: string, pattern: RegExp) {
|
|
32
|
+
const matches = [];
|
|
33
|
+
let match;
|
|
34
|
+
while ((match = pattern.exec(text))) {
|
|
35
|
+
matches.push(match);
|
|
36
|
+
}
|
|
37
|
+
return matches;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Highlight a text string with an array of match words
|
|
42
|
+
*
|
|
43
|
+
* @param text - text to highlight
|
|
44
|
+
* @param result - search result to use for highlighting
|
|
45
|
+
* @param limit - limit to the number of tokens after first match
|
|
46
|
+
*/
|
|
47
|
+
function MarkedText({ text, matches, limit }: { text: string; matches: string[]; limit?: number }) {
|
|
48
|
+
// Split by delimeter, but _keep_ delimeter!
|
|
49
|
+
const splits = matchAll(text, SPACE_OR_PUNCTUATION);
|
|
50
|
+
const tokens: string[] = [];
|
|
51
|
+
let start = 0;
|
|
52
|
+
for (const splitMatch of splits) {
|
|
53
|
+
tokens.push(text.slice(start, splitMatch.index));
|
|
54
|
+
tokens.push(splitMatch[0]);
|
|
55
|
+
start = splitMatch.index + splitMatch[0].length;
|
|
56
|
+
}
|
|
57
|
+
tokens.push(text.slice(start));
|
|
58
|
+
|
|
59
|
+
// Build RegExp matching all highlight matches
|
|
60
|
+
const allTerms = matches.join('|');
|
|
61
|
+
const pattern = new RegExp(`^(${allTerms})`, 'i'); // Match prefix and total pattern, case-insensitively
|
|
62
|
+
const renderToken = (token: string) =>
|
|
63
|
+
pattern.test(token) ? (
|
|
64
|
+
<>
|
|
65
|
+
<mark className="text-blue-600 bg-inherit dark:text-blue-400 group-aria-selected:text-white group-aria-selected:underline">
|
|
66
|
+
{token}
|
|
67
|
+
</mark>
|
|
68
|
+
</>
|
|
69
|
+
) : (
|
|
70
|
+
token
|
|
71
|
+
);
|
|
72
|
+
let firstIndex: number;
|
|
73
|
+
let lastIndex: number;
|
|
74
|
+
const hasLimit = limit !== undefined;
|
|
75
|
+
|
|
76
|
+
if (!hasLimit) {
|
|
77
|
+
firstIndex = 0;
|
|
78
|
+
lastIndex = tokens.length;
|
|
79
|
+
} else {
|
|
80
|
+
firstIndex = tokens.findIndex((token) => pattern.test(token));
|
|
81
|
+
lastIndex = firstIndex + limit;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (tokens.length === 0) {
|
|
85
|
+
return <>{...tokens}</>;
|
|
86
|
+
} else {
|
|
87
|
+
const firstRenderer = renderToken(tokens[firstIndex]);
|
|
88
|
+
const remainingTokens = tokens.slice(firstIndex + 1, lastIndex);
|
|
89
|
+
const remainingRenderers = remainingTokens.map((token) => renderToken(token));
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<>
|
|
93
|
+
{hasLimit && '... '}
|
|
94
|
+
{firstRenderer}
|
|
95
|
+
{...remainingRenderers}
|
|
96
|
+
{hasLimit && ' ...'}
|
|
97
|
+
</>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Return true if the client is a Mac, false if not, or undefined if running on the server
|
|
104
|
+
*/
|
|
105
|
+
function isMac(): boolean | undefined {
|
|
106
|
+
if (typeof window === 'undefined') {
|
|
107
|
+
return undefined;
|
|
108
|
+
} else {
|
|
109
|
+
const hostIsMac = /mac/i.test(
|
|
110
|
+
(window.navigator as any).userAgentData?.platform ?? window.navigator.userAgent,
|
|
111
|
+
);
|
|
112
|
+
return hostIsMac;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Blocking code to ensure that the pre-hydration state on the client matches the post-hydration state
|
|
117
|
+
// The server with SSR cannot determine the client platform
|
|
118
|
+
const clientThemeCode = `
|
|
119
|
+
;(() => {
|
|
120
|
+
const script = document.currentScript;
|
|
121
|
+
const root = script.parentElement;
|
|
122
|
+
|
|
123
|
+
const isMac = /mac/i.test(
|
|
124
|
+
window.navigator.userAgentData?.platform ?? window.navigator.userAgent,
|
|
125
|
+
);
|
|
126
|
+
root.querySelectorAll(".hide-mac").forEach(node => {node.classList.add(isMac ? "hidden" : "block")});
|
|
127
|
+
root.querySelectorAll(".show-mac").forEach(node => {node.classList.add(!isMac ? "hidden" : "block")});
|
|
128
|
+
})()`;
|
|
129
|
+
|
|
130
|
+
function BlockingPlatformLoader() {
|
|
131
|
+
return <script dangerouslySetInnerHTML={{ __html: clientThemeCode }} />;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Component that represents the keyboard shortcut for launching search
|
|
136
|
+
*/
|
|
137
|
+
function SearchShortcut() {
|
|
138
|
+
const hostIsMac = isMac();
|
|
139
|
+
return (
|
|
140
|
+
<div
|
|
141
|
+
aria-hidden
|
|
142
|
+
className="items-center hidden mx-1 font-mono text-sm text-gray-400 sm:flex gap-x-1"
|
|
143
|
+
>
|
|
144
|
+
<kbd
|
|
145
|
+
className={classNames(
|
|
146
|
+
'px-2 py-1 border border-gray-300 dark:border-gray-600 rounded-md',
|
|
147
|
+
'shadow-[0px_2px_0px_0px_rgba(0,0,0,0.08)] dark:shadow-none',
|
|
148
|
+
'hide-mac',
|
|
149
|
+
{ hidden: hostIsMac === true },
|
|
150
|
+
{ block: hostIsMac === false },
|
|
151
|
+
)}
|
|
152
|
+
>
|
|
153
|
+
CTRL
|
|
154
|
+
</kbd>
|
|
155
|
+
<kbd
|
|
156
|
+
className={classNames(
|
|
157
|
+
'px-2 py-1 border border-gray-300 dark:border-gray-600 rounded-md',
|
|
158
|
+
'shadow-[0px_2px_0px_0px_rgba(0,0,0,0.08)] dark:shadow-none',
|
|
159
|
+
'show-mac',
|
|
160
|
+
{ hidden: hostIsMac === false },
|
|
161
|
+
{ block: hostIsMac === true },
|
|
162
|
+
)}
|
|
163
|
+
>
|
|
164
|
+
⌘
|
|
165
|
+
</kbd>
|
|
166
|
+
<kbd className="px-2 py-1 border border-gray-300 dark:border-gray-600 rounded-md shadow-[0px_2px_0px_0px_rgba(0,0,0,0.08)] dark:shadow-none ">
|
|
167
|
+
K
|
|
168
|
+
</kbd>
|
|
169
|
+
<BlockingPlatformLoader />
|
|
170
|
+
</div>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Renderer for a single search result
|
|
176
|
+
*/
|
|
177
|
+
function SearchResultItem({
|
|
178
|
+
result,
|
|
179
|
+
closeSearch,
|
|
180
|
+
}: {
|
|
181
|
+
result: RankedSearchResult;
|
|
182
|
+
closeSearch?: () => void;
|
|
183
|
+
}) {
|
|
184
|
+
const { hierarchy, type, url, queries } = result;
|
|
185
|
+
const Link = useLinkProvider();
|
|
186
|
+
|
|
187
|
+
// Render the icon
|
|
188
|
+
const iconRenderer =
|
|
189
|
+
type === 'lvl1' ? (
|
|
190
|
+
<DocumentIcon className="inline-block w-6 mx-2" />
|
|
191
|
+
) : type === 'content' ? (
|
|
192
|
+
<Bars3BottomLeftIcon className="inline-block w-6 mx-2" />
|
|
193
|
+
) : (
|
|
194
|
+
<HashtagIcon className="inline-block w-6 mx-2" />
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
// Generic "this document matched"
|
|
198
|
+
const title = result.type === 'content' ? result['content'] : hierarchy[type as HeadingLevel]!;
|
|
199
|
+
const matches = useMemo(() => queries.flatMap((query) => Object.keys(query.matches)), [queries]);
|
|
200
|
+
|
|
201
|
+
// Render the title, i.e. content or heading
|
|
202
|
+
const titleRenderer = (
|
|
203
|
+
<MarkedText text={title} matches={matches} limit={type === 'content' ? 16 : undefined} />
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
// Render the subtitle i.e. file name
|
|
207
|
+
let subtitleRenderer;
|
|
208
|
+
if (result.type === 'lvl1') {
|
|
209
|
+
subtitleRenderer = undefined;
|
|
210
|
+
} else {
|
|
211
|
+
const subtitle = result.hierarchy.lvl1!;
|
|
212
|
+
subtitleRenderer = <MarkedText text={subtitle} matches={matches} />;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const enterIconRenderer = (
|
|
216
|
+
<ArrowTurnDownLeftIcon className="invisible w-6 mx-2 group-aria-selected:visible" />
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
return (
|
|
220
|
+
<Link
|
|
221
|
+
className="block px-1 py-2 text-gray-700 rounded shadow-md dark:text-white group-aria-selected:bg-blue-600 group-aria-selected:text-white dark:shadow-none dark:bg-stone-800"
|
|
222
|
+
to={url}
|
|
223
|
+
// Close the main search on click
|
|
224
|
+
onClick={closeSearch}
|
|
225
|
+
>
|
|
226
|
+
<div className="flex flex-row h-11">
|
|
227
|
+
{iconRenderer}
|
|
228
|
+
<div className="flex flex-col justify-center grow">
|
|
229
|
+
<span className="text-sm">{titleRenderer}</span>
|
|
230
|
+
{subtitleRenderer && <span className="text-xs">{subtitleRenderer}</span>}
|
|
231
|
+
</div>
|
|
232
|
+
{enterIconRenderer}
|
|
233
|
+
</div>
|
|
234
|
+
</Link>
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
interface SearchResultsProps {
|
|
239
|
+
searchResults: RankedSearchResult[];
|
|
240
|
+
searchListID: string;
|
|
241
|
+
searchLabelID: string;
|
|
242
|
+
selectedIndex: number;
|
|
243
|
+
onHoverSelect: (index: number) => void;
|
|
244
|
+
className?: string;
|
|
245
|
+
closeSearch?: () => void;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function SearchResults({
|
|
249
|
+
searchResults,
|
|
250
|
+
searchListID,
|
|
251
|
+
searchLabelID,
|
|
252
|
+
className,
|
|
253
|
+
selectedIndex,
|
|
254
|
+
onHoverSelect,
|
|
255
|
+
closeSearch,
|
|
256
|
+
}: SearchResultsProps) {
|
|
257
|
+
// Array of search item refs
|
|
258
|
+
const itemsRef = useRef<(HTMLLIElement | null)[]>([]);
|
|
259
|
+
|
|
260
|
+
// Ref to assign items
|
|
261
|
+
const setItemRef = useCallback(
|
|
262
|
+
(elem: HTMLLIElement) => {
|
|
263
|
+
if (!elem) {
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
const index = parseInt(elem.dataset.index!);
|
|
267
|
+
itemsRef.current[index] = elem;
|
|
268
|
+
},
|
|
269
|
+
[itemsRef],
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
// Keep activeDescendent in sync wth selected index
|
|
273
|
+
const activeDescendent = useMemo(() => {
|
|
274
|
+
const item = itemsRef.current[selectedIndex];
|
|
275
|
+
if (!item) {
|
|
276
|
+
return '';
|
|
277
|
+
} else {
|
|
278
|
+
return item.id;
|
|
279
|
+
}
|
|
280
|
+
}, [selectedIndex, itemsRef]);
|
|
281
|
+
|
|
282
|
+
// If the select item changes, bring it into view
|
|
283
|
+
useEffect(() => {
|
|
284
|
+
const item = itemsRef.current[selectedIndex];
|
|
285
|
+
item?.scrollIntoView({ block: 'nearest' });
|
|
286
|
+
}, [selectedIndex]);
|
|
287
|
+
|
|
288
|
+
// Handle mouse movement events that set the current hovered element
|
|
289
|
+
const handleMouseMove = useCallback(
|
|
290
|
+
(event: MouseEvent<HTMLLIElement>) => {
|
|
291
|
+
const index = parseInt((event.currentTarget as HTMLLIElement).dataset.index!);
|
|
292
|
+
onHoverSelect(index);
|
|
293
|
+
},
|
|
294
|
+
[onHoverSelect],
|
|
295
|
+
);
|
|
296
|
+
return (
|
|
297
|
+
<div className="mt-4 overflow-y-scroll">
|
|
298
|
+
{searchResults.length ? (
|
|
299
|
+
<ul
|
|
300
|
+
// Accessiblity:
|
|
301
|
+
// indicate that this is a selectbox
|
|
302
|
+
role="listbox"
|
|
303
|
+
id={searchListID}
|
|
304
|
+
aria-label="Search results"
|
|
305
|
+
aria-labelledby={searchLabelID}
|
|
306
|
+
aria-orientation="vertical"
|
|
307
|
+
// Track focused item
|
|
308
|
+
aria-activedescendant={activeDescendent}
|
|
309
|
+
className={classNames('flex flex-col gap-y-2 px-1', className)}
|
|
310
|
+
>
|
|
311
|
+
{searchResults.map((result, index) => (
|
|
312
|
+
<li
|
|
313
|
+
key={result.id}
|
|
314
|
+
ref={setItemRef}
|
|
315
|
+
data-index={index}
|
|
316
|
+
// Accessiblity:
|
|
317
|
+
// Indicate that this is an option
|
|
318
|
+
role="option"
|
|
319
|
+
// Indicate whether this is selected
|
|
320
|
+
aria-selected={selectedIndex === index}
|
|
321
|
+
// Allow for nested-highlighting
|
|
322
|
+
className="group"
|
|
323
|
+
// Trigger selection on movement, so that scrolling doesn't trigger handler
|
|
324
|
+
onMouseMove={handleMouseMove}
|
|
325
|
+
>
|
|
326
|
+
<SearchResultItem result={result} closeSearch={closeSearch} />
|
|
327
|
+
</li>
|
|
328
|
+
))}
|
|
329
|
+
</ul>
|
|
330
|
+
) : (
|
|
331
|
+
<span>No results found.</span>
|
|
332
|
+
)}
|
|
333
|
+
</div>
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Build search implementation by requesting search index from server
|
|
339
|
+
*/
|
|
340
|
+
function useSearch() {
|
|
341
|
+
const baseURL = useBaseurl();
|
|
342
|
+
const fetcher = useFetcher();
|
|
343
|
+
const [enabled, setEnabled] = useState(true);
|
|
344
|
+
// Load index when this component is required
|
|
345
|
+
// TODO: this reloads every time the search box is opened.
|
|
346
|
+
// we should lift the state up
|
|
347
|
+
useEffect(() => {
|
|
348
|
+
if (fetcher.state === 'idle' && fetcher.data == null) {
|
|
349
|
+
const searchURL = withBaseurl('/myst.search.json', baseURL);
|
|
350
|
+
fetcher.load(searchURL);
|
|
351
|
+
}
|
|
352
|
+
}, [fetcher, baseURL]);
|
|
353
|
+
|
|
354
|
+
const searchFactory = useSearchFactory();
|
|
355
|
+
const search = useMemo(() => {
|
|
356
|
+
if (!fetcher.data || !searchFactory) {
|
|
357
|
+
return undefined;
|
|
358
|
+
} else {
|
|
359
|
+
if (fetcher.data?.version && fetcher.data?.records) {
|
|
360
|
+
return searchFactory(fetcher.data as MystSearchIndex);
|
|
361
|
+
}
|
|
362
|
+
setEnabled(false);
|
|
363
|
+
return undefined;
|
|
364
|
+
}
|
|
365
|
+
}, [searchFactory, fetcher.data, setEnabled]);
|
|
366
|
+
|
|
367
|
+
// Implement pass-through
|
|
368
|
+
return { search, enabled };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
interface SearchFormProps {
|
|
372
|
+
debounceTime: number;
|
|
373
|
+
searchResults: RankedSearchResult[] | undefined;
|
|
374
|
+
setSearchResults: Dispatch<SetStateAction<RankedSearchResult[] | undefined>>;
|
|
375
|
+
searchInputID: string;
|
|
376
|
+
searchListID: string;
|
|
377
|
+
searchLabelID: string;
|
|
378
|
+
selectedIndex: number;
|
|
379
|
+
setSelectedIndex: Dispatch<SetStateAction<number>>;
|
|
380
|
+
closeSearch?: () => void;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function SearchForm({
|
|
384
|
+
debounceTime,
|
|
385
|
+
searchResults,
|
|
386
|
+
setSearchResults,
|
|
387
|
+
searchInputID,
|
|
388
|
+
searchListID,
|
|
389
|
+
searchLabelID,
|
|
390
|
+
selectedIndex,
|
|
391
|
+
setSelectedIndex,
|
|
392
|
+
closeSearch,
|
|
393
|
+
}: SearchFormProps) {
|
|
394
|
+
const [query, setQuery] = useState<string>('');
|
|
395
|
+
const { search: doSearch, enabled } = useSearch();
|
|
396
|
+
|
|
397
|
+
// Debounce user input
|
|
398
|
+
useEffect(() => {
|
|
399
|
+
const timeoutId = setTimeout(() => {
|
|
400
|
+
if (query != undefined && !!doSearch) {
|
|
401
|
+
doSearch(query).then((rawResults) => {
|
|
402
|
+
setSearchResults(
|
|
403
|
+
rawResults &&
|
|
404
|
+
rankResults(rawResults)
|
|
405
|
+
// Filter duplicates by URL
|
|
406
|
+
.filter((result, index, array) => result.url !== array[index - 1]?.url),
|
|
407
|
+
);
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
}, debounceTime);
|
|
411
|
+
return () => clearTimeout(timeoutId);
|
|
412
|
+
}, [doSearch, query, debounceTime]);
|
|
413
|
+
// Handle user input
|
|
414
|
+
const handleSearchChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
|
415
|
+
setQuery(event.target.value);
|
|
416
|
+
}, []);
|
|
417
|
+
// Handle item selection
|
|
418
|
+
const navigate = useNavigate();
|
|
419
|
+
|
|
420
|
+
// Handle item selection and navigation
|
|
421
|
+
const handleSearchKeyPress = useCallback<KeyboardEventHandler<HTMLInputElement>>(
|
|
422
|
+
(event) => {
|
|
423
|
+
// Ignore modifiers
|
|
424
|
+
if (event.ctrlKey || event.altKey || event.shiftKey) {
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
if (!searchResults) {
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Item selection
|
|
432
|
+
if (event.key === 'Enter') {
|
|
433
|
+
event.preventDefault();
|
|
434
|
+
|
|
435
|
+
const url = searchResults[selectedIndex]?.url;
|
|
436
|
+
if (url) {
|
|
437
|
+
navigate(url);
|
|
438
|
+
closeSearch?.();
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
// Item navigation
|
|
442
|
+
else if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
|
|
443
|
+
event.preventDefault();
|
|
444
|
+
|
|
445
|
+
if (event.key === 'ArrowUp') {
|
|
446
|
+
setSelectedIndex(selectedIndex > 0 ? selectedIndex - 1 : 0);
|
|
447
|
+
} else {
|
|
448
|
+
setSelectedIndex(
|
|
449
|
+
selectedIndex < searchResults.length - 1 ? selectedIndex + 1 : searchResults.length - 1,
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
},
|
|
454
|
+
[searchResults, selectedIndex],
|
|
455
|
+
); // Our form doesn't use the submit function
|
|
456
|
+
const onSubmit = useCallback((event: FormEvent<HTMLFormElement>) => {
|
|
457
|
+
event.preventDefault();
|
|
458
|
+
}, []);
|
|
459
|
+
|
|
460
|
+
return (
|
|
461
|
+
<>
|
|
462
|
+
<form onSubmit={onSubmit}>
|
|
463
|
+
<div className="relative flex w-full h-10 flow-row gap-x-1 ">
|
|
464
|
+
<label id={searchListID} htmlFor={searchInputID}>
|
|
465
|
+
<MagnifyingGlassIcon className="absolute text-gray-400 inset-y-0 start-0 h-10 w-10 p-2.5 aspect-square flex items-center pointer-events-none" />
|
|
466
|
+
</label>
|
|
467
|
+
<input
|
|
468
|
+
autoComplete="off"
|
|
469
|
+
spellCheck="false"
|
|
470
|
+
disabled={!enabled}
|
|
471
|
+
autoCapitalize="false"
|
|
472
|
+
className={classNames(
|
|
473
|
+
'block flex-grow p-2 ps-10 placeholder-gray-400',
|
|
474
|
+
'border border-gray-300 dark:border-gray-600',
|
|
475
|
+
'rounded-lg bg-gray-50 dark:bg-gray-700',
|
|
476
|
+
'focus:ring-blue-500 dark:focus:ring-blue-500',
|
|
477
|
+
'focus:border-blue-500 dark:focus:border-blue-500',
|
|
478
|
+
'dark:placeholder-gray-400',
|
|
479
|
+
{ 'border-red-500': !enabled },
|
|
480
|
+
)}
|
|
481
|
+
id={searchInputID}
|
|
482
|
+
aria-labelledby={searchLabelID}
|
|
483
|
+
aria-controls={searchListID}
|
|
484
|
+
placeholder="Search"
|
|
485
|
+
type="search"
|
|
486
|
+
required
|
|
487
|
+
onChange={handleSearchChange}
|
|
488
|
+
onKeyDown={handleSearchKeyPress}
|
|
489
|
+
/>
|
|
490
|
+
<Dialog.Close asChild className="block grow-0 sm:hidden">
|
|
491
|
+
<button aria-label="Close">
|
|
492
|
+
<XCircleIcon className="flex items-center w-10 h-10 aspect-square" />
|
|
493
|
+
</button>
|
|
494
|
+
</Dialog.Close>
|
|
495
|
+
</div>
|
|
496
|
+
</form>
|
|
497
|
+
{!enabled && (
|
|
498
|
+
<div className="mx-2 mt-4 text-sm text-gray-500">
|
|
499
|
+
Search is not enabled for this site. :(
|
|
500
|
+
</div>
|
|
501
|
+
)}
|
|
502
|
+
</>
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
interface SearchPlaceholderButtonProps {
|
|
507
|
+
className?: string;
|
|
508
|
+
disabled?: boolean;
|
|
509
|
+
}
|
|
510
|
+
const SearchPlaceholderButton = forwardRef<
|
|
511
|
+
HTMLButtonElement,
|
|
512
|
+
SearchPlaceholderButtonProps & Dialog.DialogTriggerProps
|
|
513
|
+
>(({ className, disabled, ...props }, ref) => {
|
|
514
|
+
return (
|
|
515
|
+
<button
|
|
516
|
+
{...props}
|
|
517
|
+
className={classNames(
|
|
518
|
+
className,
|
|
519
|
+
'flex items-center h-10 aspect-square sm:w-64 text-left text-gray-400',
|
|
520
|
+
'border border-gray-300 dark:border-gray-600',
|
|
521
|
+
'rounded-lg bg-gray-50 dark:bg-gray-700',
|
|
522
|
+
{
|
|
523
|
+
'hover:ring-blue-500': !disabled,
|
|
524
|
+
'dark:hover:ring-blue-500': !disabled,
|
|
525
|
+
'hover:border-blue-500': !disabled,
|
|
526
|
+
'dark:hover:border-blue-500': !disabled,
|
|
527
|
+
},
|
|
528
|
+
)}
|
|
529
|
+
disabled={!!disabled}
|
|
530
|
+
ref={ref}
|
|
531
|
+
>
|
|
532
|
+
<MagnifyingGlassIcon className="p-2.5 h-10 w-10 aspect-square" />
|
|
533
|
+
<span className="hidden sm:block grow">Search</span>
|
|
534
|
+
<SearchShortcut />
|
|
535
|
+
</button>
|
|
536
|
+
);
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
export interface SearchProps {
|
|
540
|
+
debounceTime?: number;
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Component that implements a basic search interface
|
|
544
|
+
*/
|
|
545
|
+
export function Search({ debounceTime = 500 }: SearchProps) {
|
|
546
|
+
const [open, setOpen] = useState(false);
|
|
547
|
+
const [searchResults, setSearchResults] = useState<RankedSearchResult[] | undefined>();
|
|
548
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
549
|
+
const top = useThemeTop();
|
|
550
|
+
|
|
551
|
+
// Clear search state on close
|
|
552
|
+
useEffect(() => {
|
|
553
|
+
if (!open) {
|
|
554
|
+
setSearchResults(undefined);
|
|
555
|
+
setSelectedIndex(0);
|
|
556
|
+
}
|
|
557
|
+
}, [open]);
|
|
558
|
+
|
|
559
|
+
// Trigger modal on keypress
|
|
560
|
+
const handleDocumentKeyPress = useCallback((event: KeyboardEvent) => {
|
|
561
|
+
if (event.key === 'k' && (isMac() ? event.metaKey : event.ctrlKey)) {
|
|
562
|
+
setOpen(true);
|
|
563
|
+
event.preventDefault();
|
|
564
|
+
}
|
|
565
|
+
}, []);
|
|
566
|
+
|
|
567
|
+
// Mount the document event handlers
|
|
568
|
+
useEffect(() => {
|
|
569
|
+
// attach the event listener
|
|
570
|
+
document.addEventListener('keydown', handleDocumentKeyPress);
|
|
571
|
+
|
|
572
|
+
// remove the event listener
|
|
573
|
+
return () => {
|
|
574
|
+
document.removeEventListener('keydown', handleDocumentKeyPress);
|
|
575
|
+
};
|
|
576
|
+
}, [handleDocumentKeyPress]);
|
|
577
|
+
|
|
578
|
+
const triggerClose = useCallback(() => setOpen(false), [setOpen]);
|
|
579
|
+
return (
|
|
580
|
+
<Dialog.Root open={open} onOpenChange={setOpen}>
|
|
581
|
+
<Dialog.Trigger asChild>
|
|
582
|
+
<SearchPlaceholderButton />
|
|
583
|
+
</Dialog.Trigger>
|
|
584
|
+
<Dialog.Portal>
|
|
585
|
+
<Dialog.Overlay className="fixed inset-0 bg-[#656c85cc] z-[1000]" />
|
|
586
|
+
<Dialog.Content
|
|
587
|
+
className="fixed flex flex-col top-0 bg-white dark:bg-stone-900 z-[1001] h-screen w-screen sm:left-1/2 sm:-translate-x-1/2 sm:w-[90vw] sm:max-w-screen-sm sm:h-auto sm:max-h-[var(--content-max-height)] sm:top-[var(--content-top)] sm:rounded-md p-4 text-gray-900 dark:text-white"
|
|
588
|
+
// Store state as CSS variables so that we can set the style with tailwind variants
|
|
589
|
+
style={
|
|
590
|
+
{
|
|
591
|
+
'--content-top': `${top}px`,
|
|
592
|
+
'--content-max-height': `calc(90vh - var(--content-top))`,
|
|
593
|
+
} as React.CSSProperties
|
|
594
|
+
}
|
|
595
|
+
>
|
|
596
|
+
<VisuallyHidden.Root asChild>
|
|
597
|
+
<Dialog.Title>Search Website</Dialog.Title>
|
|
598
|
+
</VisuallyHidden.Root>
|
|
599
|
+
<VisuallyHidden.Root asChild>
|
|
600
|
+
<Dialog.Description>
|
|
601
|
+
Search articles and their contents using fuzzy-search and prefix-matching
|
|
602
|
+
</Dialog.Description>
|
|
603
|
+
</VisuallyHidden.Root>
|
|
604
|
+
<SearchForm
|
|
605
|
+
searchListID="search-list"
|
|
606
|
+
searchLabelID="search-label"
|
|
607
|
+
searchInputID="search-input"
|
|
608
|
+
debounceTime={debounceTime}
|
|
609
|
+
searchResults={searchResults}
|
|
610
|
+
setSearchResults={setSearchResults}
|
|
611
|
+
selectedIndex={selectedIndex}
|
|
612
|
+
setSelectedIndex={setSelectedIndex}
|
|
613
|
+
closeSearch={triggerClose}
|
|
614
|
+
/>
|
|
615
|
+
{searchResults && (
|
|
616
|
+
<SearchResults
|
|
617
|
+
searchListID="search-list"
|
|
618
|
+
searchLabelID="search-label"
|
|
619
|
+
className="mt-4"
|
|
620
|
+
searchResults={searchResults}
|
|
621
|
+
selectedIndex={selectedIndex}
|
|
622
|
+
onHoverSelect={setSelectedIndex}
|
|
623
|
+
closeSearch={triggerClose}
|
|
624
|
+
/>
|
|
625
|
+
)}
|
|
626
|
+
</Dialog.Content>
|
|
627
|
+
</Dialog.Portal>
|
|
628
|
+
</Dialog.Root>
|
|
629
|
+
);
|
|
630
|
+
}
|
|
@@ -8,7 +8,7 @@ export function ThemeButton({ className = 'w-8 h-8 mx-3' }: { className?: string
|
|
|
8
8
|
return (
|
|
9
9
|
<button
|
|
10
10
|
className={classNames(
|
|
11
|
-
'theme rounded-full border border-stone-700 dark:border-white hover:bg-neutral-100 border-solid overflow-hidden text-stone-700 dark:text-white hover:text-stone-500 dark:hover:text-neutral-800',
|
|
11
|
+
'theme rounded-full aspect-square border border-stone-700 dark:border-white hover:bg-neutral-100 border-solid overflow-hidden text-stone-700 dark:text-white hover:text-stone-500 dark:hover:text-neutral-800',
|
|
12
12
|
className,
|
|
13
13
|
)}
|
|
14
14
|
title={`Toggle theme between light and dark mode.`}
|
|
@@ -4,12 +4,8 @@ import { Menu, Transition } from '@headlessui/react';
|
|
|
4
4
|
import { ChevronDownIcon, Bars3Icon as MenuIcon } from '@heroicons/react/24/solid';
|
|
5
5
|
import type { SiteManifest, SiteNavItem } from 'myst-config';
|
|
6
6
|
import { ThemeButton } from './ThemeButton.js';
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
useNavLinkProvider,
|
|
10
|
-
useNavOpen,
|
|
11
|
-
useSiteManifest,
|
|
12
|
-
} from '@myst-theme/providers';
|
|
7
|
+
import { Search } from './Search.js';
|
|
8
|
+
import { useNavLinkProvider, useNavOpen, useSiteManifest } from '@myst-theme/providers';
|
|
13
9
|
import { LoadingBar } from './Loading.js';
|
|
14
10
|
import { HomeLink } from './HomeLink.js';
|
|
15
11
|
import { ActionMenu } from './ActionMenu.js';
|
|
@@ -107,7 +103,7 @@ export function NavItems({ nav }: { nav?: SiteManifest['nav'] }) {
|
|
|
107
103
|
);
|
|
108
104
|
}
|
|
109
105
|
|
|
110
|
-
export function TopNav({ hideToc }: { hideToc?: boolean }) {
|
|
106
|
+
export function TopNav({ hideToc, hideSearch }: { hideToc?: boolean; hideSearch?: boolean }) {
|
|
111
107
|
const [open, setOpen] = useNavOpen();
|
|
112
108
|
const config = useSiteManifest();
|
|
113
109
|
const { title, nav, actions } = config ?? {};
|
|
@@ -134,6 +130,7 @@ export function TopNav({ hideToc }: { hideToc?: boolean }) {
|
|
|
134
130
|
<div className="flex items-center flex-grow w-auto">
|
|
135
131
|
<NavItems nav={nav} />
|
|
136
132
|
<div className="flex-grow block"></div>
|
|
133
|
+
{!hideSearch && <Search />}
|
|
137
134
|
<ThemeButton />
|
|
138
135
|
<div className="block sm:hidden">
|
|
139
136
|
<ActionMenu actions={actions} />
|