@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.
- package/package.json +20 -12
- package/src/actions/index.ts +1 -0
- package/src/actions/theme.ts +8 -0
- package/src/components/DocumentOutline.tsx +254 -101
- package/src/components/Navigation/PrimarySidebar.tsx +7 -1
- package/src/components/Navigation/Search.tsx +630 -0
- package/src/components/Navigation/ThemeButton.tsx +7 -10
- package/src/components/Navigation/TopNav.tsx +4 -7
- package/src/components/index.ts +1 -0
- package/src/components/theme.tsx +19 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/theme.tsx +84 -0
- package/src/index.ts +2 -0
- package/src/loaders/theme.server.ts +3 -2
- package/src/pages/Root.tsx +75 -10
|
@@ -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 {
|
|
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 {
|
|
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={`
|
|
15
|
-
aria-label={`
|
|
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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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} />
|
package/src/components/index.ts
CHANGED
|
@@ -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';
|