@myst-theme/site 0.11.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.
@@ -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
+ }
@@ -1,25 +1,22 @@
1
- import { useTheme } from '@myst-theme/providers';
1
+ import { useThemeSwitcher } from '@myst-theme/providers';
2
2
  import { MoonIcon } from '@heroicons/react/24/solid';
3
3
  import { SunIcon } from '@heroicons/react/24/outline';
4
4
  import classNames from 'classnames';
5
5
 
6
6
  export function ThemeButton({ className = 'w-8 h-8 mx-3' }: { className?: string }) {
7
- const { isDark, nextTheme } = useTheme();
7
+ const { nextTheme } = useThemeSwitcher();
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
- title={`Change theme to ${isDark ? 'light' : 'dark'} mode.`}
15
- aria-label={`Change theme to ${isDark ? 'light' : 'dark'} mode.`}
14
+ title={`Toggle theme between light and dark mode.`}
15
+ aria-label={`Toggle theme between light and dark mode.`}
16
16
  onClick={nextTheme}
17
17
  >
18
- {isDark ? (
19
- <MoonIcon className="h-full w-full p-0.5" />
20
- ) : (
21
- <SunIcon className="h-full w-full p-0.5" />
22
- )}
18
+ <MoonIcon className="h-full w-full p-0.5 hidden dark:block" />
19
+ <SunIcon className="h-full w-full p-0.5 dark:hidden" />
23
20
  </button>
24
21
  );
25
22
  }
@@ -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} />
@@ -18,3 +18,4 @@ export { ExternalOrInternalLink } from './ExternalOrInternalLink.js';
18
18
  export * from './Navigation/index.js';
19
19
  export { renderers } from './renderers.js';
20
20
  export { SkipToArticle, SkipTo } from './SkipToArticle.js';
21
+ export { BlockingThemeLoader } from './theme.js';