@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.12.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.12.0",
25
- "@myst-theme/diagrams": "^0.12.0",
26
- "@myst-theme/frontmatter": "^0.12.0",
27
- "@myst-theme/jupyter": "^0.12.0",
28
- "@myst-theme/providers": "^0.12.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.6.0",
33
- "myst-config": "^1.6.0",
34
- "myst-demo": "^0.12.0",
35
- "myst-spec-ext": "^1.6.0",
36
- "myst-to-react": "^0.12.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, highlight, selector }: Props) => (
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
- getHeaders(selector).forEach((h) => {
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
- const onScreen = useRef<Set<HTMLHeadingElement>>(new Set());
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
- const highlight = useCallback(() => {
115
- const current = [...onScreen.current];
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 = [...onScreen.current].sort((a, b) => a.offsetTop - b.offsetTop)[0];
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 render = throttle(() => setElements(getHeaders(selector)), 500);
222
+ const [headings, setHeadings] = useState<Heading[]>([]);
132
223
  useEffect(() => {
133
- // We have to look at the document changes for reloads/mutations
134
- const main = document.querySelector('main');
135
- const mutations = new MutationObserver(render);
136
- // Fire when added to the dom
137
- render();
138
- if (main) {
139
- mutations.observe(main, { attributes: true, childList: true, subtree: true });
140
- }
141
- return () => mutations.disconnect();
142
- }, []);
143
-
144
- useEffect(() => {
145
- // Re-observe all elements when the observer changes
146
- Array.from(elements).map((e) => observer.current?.observe(e));
147
- }, [observer]);
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
- let minLevel = 10;
150
- const headings: Heading[] = elements
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, highlight, headings };
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, highlight } = useHeaders(selector, maxdepth);
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
- <nav
251
- ref={outlineRef}
252
- aria-label="Document Outline"
253
- className={classNames(
254
- 'not-prose overflow-y-auto',
255
- 'transition-opacity duration-700', // Animation on load
256
- className,
257
- )}
258
- style={{
259
- top: top,
260
- maxHeight: `calc(100vh - ${top + 20}px)`,
261
- }}
262
- >
263
- <div className="mb-4 text-sm leading-6 uppercase text-slate-900 dark:text-slate-100">
264
- In this article
265
- </div>
266
- <Headings headings={headings} activeId={activeId} highlight={highlight} selector={selector} />
267
- {children}
268
- </nav>
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
- useLinkProvider,
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} />