@primer/doctocat-nextjs 0.4.0 → 0.4.1-rc.afcbafc
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/components/highlight-search-term/HighlightSearchTerm.tsx +22 -0
- package/components/layout/global-search/GlobalSearch.module.css +98 -0
- package/components/layout/global-search/GlobalSearch.tsx +254 -0
- package/components/layout/header/Header.module.css +10 -24
- package/components/layout/header/Header.tsx +32 -255
- package/components/layout/root-layout/Theme.tsx +2 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# @primer/doctocat-nextjs
|
|
2
2
|
|
|
3
|
+
## 0.4.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [#28](https://github.com/primer/doctocat-nextjs/pull/28) [`ef80501`](https://github.com/primer/doctocat-nextjs/commit/ef805016a05b059ab3da2f547f89dfc3cc9f0e09) Thanks [@rezrah](https://github.com/rezrah)! - Fixed a bug where a tabs were required in standalone, nested pages using filename `index.mdx`.
|
|
8
|
+
|
|
9
|
+
Use `show-tabs: false` in frontmatter to disable the tabs and present content as normal.
|
|
10
|
+
|
|
3
11
|
## 0.4.0
|
|
4
12
|
|
|
5
13
|
### Minor Changes
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
|
|
3
|
+
export type HighlightSearchTermProps = {
|
|
4
|
+
children: React.ReactNode
|
|
5
|
+
searchTerm: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const HighlightSearchTerm = ({children, searchTerm}: HighlightSearchTermProps) => {
|
|
9
|
+
if (!children || !searchTerm) {
|
|
10
|
+
return <>{children}</>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const escapedSearchTerm = searchTerm.replace(/[.*+?^${}()|[\]\\<>]/g, '\\$&')
|
|
14
|
+
|
|
15
|
+
const parts = children.toString().split(new RegExp(`(${escapedSearchTerm})`, 'gi'))
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<>
|
|
19
|
+
{parts.map((part, i) => (part.toLowerCase() === searchTerm.toLowerCase() ? <mark key={i}>{part}</mark> : part))}
|
|
20
|
+
</>
|
|
21
|
+
)
|
|
22
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
.GlobalSearch__searchInput {
|
|
2
|
+
width: 100%;
|
|
3
|
+
z-index: 1;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
.GlobalSearch__searchResultsContainer {
|
|
7
|
+
display: none;
|
|
8
|
+
margin-top: var(--base-size-4);
|
|
9
|
+
position: absolute;
|
|
10
|
+
z-index: 1;
|
|
11
|
+
background-color: var(--brand-color-canvas-default);
|
|
12
|
+
padding-block-start: var(--base-size-16);
|
|
13
|
+
width: 100%;
|
|
14
|
+
max-width: calc(100% - 46px);
|
|
15
|
+
border: var(--brand-borderWidth-thin) solid var(--brand-color-border-default);
|
|
16
|
+
border-radius: var(--brand-borderRadius-medium);
|
|
17
|
+
max-height: 300px;
|
|
18
|
+
overflow-y: auto;
|
|
19
|
+
overflow-x: hidden;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.GlobalSearch__searchResultsContainer--open {
|
|
23
|
+
display: block;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.GlobalSearch__searchResultsContainer::-webkit-scrollbar {
|
|
27
|
+
width: 8px;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.GlobalSearch__searchResultsContainer::-webkit-scrollbar-track {
|
|
31
|
+
background-color: var(--brand-color-canvas-default);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.GlobalSearch__searchResultsContainer::-webkit-scrollbar-thumb {
|
|
35
|
+
background-color: var(--brand-color-text-muted);
|
|
36
|
+
border-radius: var(--base-size-4);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
@media (min-width: 768px) {
|
|
40
|
+
.GlobalSearch__searchResultsContainer {
|
|
41
|
+
max-width: 350px;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.GlobalSearch__searchResultsEmpty {
|
|
46
|
+
display: flex;
|
|
47
|
+
justify-content: center;
|
|
48
|
+
align-items: center;
|
|
49
|
+
height: 150px;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.GlobalSearch__searchResultsHeading {
|
|
53
|
+
padding-inline-start: var(--base-size-20);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.GlobalSearch__searchResultsList {
|
|
57
|
+
list-style: none;
|
|
58
|
+
padding-inline: var(--base-size-16);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.GlobalSearch__searchResultItem {
|
|
62
|
+
position: relative;
|
|
63
|
+
padding-block: var(--base-size-16);
|
|
64
|
+
border-top: var(--brand-borderWidth-thin) solid var(--brand-color-border-default);
|
|
65
|
+
scroll-margin-block: var(--base-size-8);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.GlobalSearch__searchResultItem[aria-selected='true'] {
|
|
69
|
+
outline: var(--base-size-4) solid var(--brand-color-focus);
|
|
70
|
+
outline-radius: var(--base-size-8);
|
|
71
|
+
padding-inline: var(--base-size-4);
|
|
72
|
+
margin-inline: calc(-1 * var(--base-size-4));
|
|
73
|
+
background-color: var(--brand-color-canvas-default);
|
|
74
|
+
border-radius: var(--brand-borderRadius-small);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.GlobalSearch__searchResultItem mark {
|
|
78
|
+
background-color: var(--brand-color-accent-primary);
|
|
79
|
+
color: var(--brand-color-text-onEmphasis);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.GlobalSearch__searchResultLink {
|
|
83
|
+
color: var(--brand-color-text-default);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.GlobalSearch__searchResultItem:has(mark) .GlobalSearch__searchResultLink {
|
|
87
|
+
text-decoration-color: var(--brand-color-text-onEmphasis);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.GlobalSearch__searchResultLink::before {
|
|
91
|
+
content: '';
|
|
92
|
+
display: block;
|
|
93
|
+
width: 100%;
|
|
94
|
+
height: 100%;
|
|
95
|
+
position: absolute;
|
|
96
|
+
top: 0;
|
|
97
|
+
left: 0;
|
|
98
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import React, {forwardRef, useEffect, useMemo, useRef, useState} from 'react'
|
|
2
|
+
import {SearchIcon} from '@primer/octicons-react'
|
|
3
|
+
import {FormControl, TextInput} from '@primer/react'
|
|
4
|
+
import {Heading, Stack, Text} from '@primer/react-brand'
|
|
5
|
+
import {clsx} from 'clsx'
|
|
6
|
+
import type {MdxFile} from 'nextra'
|
|
7
|
+
import Link from 'next/link'
|
|
8
|
+
import {useRouter} from 'next/navigation'
|
|
9
|
+
|
|
10
|
+
import styles from './GlobalSearch.module.css'
|
|
11
|
+
import type {DocsItem} from '../../../types'
|
|
12
|
+
import {HighlightSearchTerm} from '../../highlight-search-term/HighlightSearchTerm'
|
|
13
|
+
|
|
14
|
+
type GlobalSearchProps = {
|
|
15
|
+
flatDocsDirectories: DocsItem[]
|
|
16
|
+
siteTitle: string
|
|
17
|
+
onNavigate?: () => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type SearchResult = {
|
|
21
|
+
title: string
|
|
22
|
+
description: string
|
|
23
|
+
url: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const GlobalSearch = forwardRef<HTMLInputElement, GlobalSearchProps>(
|
|
27
|
+
({siteTitle, flatDocsDirectories, onNavigate}, forwardedRef) => {
|
|
28
|
+
const router = useRouter()
|
|
29
|
+
const listboxRef = useRef<HTMLUListElement | null>(null)
|
|
30
|
+
const searchResultsRef = useRef<HTMLDivElement | null>(null)
|
|
31
|
+
const [isSearchResultOpen, setIsSearchResultOpen] = useState(false)
|
|
32
|
+
const [searchResults, setSearchResults] = useState<SearchResult[]>([])
|
|
33
|
+
const [searchTerm, setSearchTerm] = useState<string | undefined>('')
|
|
34
|
+
const [activeDescendant, setActiveDescendant] = useState<number>(-1)
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
const handleClickAway = (event: MouseEvent) => {
|
|
38
|
+
if (!searchResultsRef.current?.contains(event.target as Node)) {
|
|
39
|
+
setIsSearchResultOpen(false)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
document.addEventListener('click', handleClickAway)
|
|
44
|
+
|
|
45
|
+
return () => {
|
|
46
|
+
document.removeEventListener('click', handleClickAway)
|
|
47
|
+
}
|
|
48
|
+
}, [])
|
|
49
|
+
|
|
50
|
+
const searchData = useMemo(
|
|
51
|
+
() =>
|
|
52
|
+
flatDocsDirectories.reduce<SearchResult[]>((acc, item) => {
|
|
53
|
+
if (item.route === '/') return acc // remove homepage
|
|
54
|
+
|
|
55
|
+
const {frontMatter, route} = item as MdxFile
|
|
56
|
+
if (!frontMatter) return acc
|
|
57
|
+
const result = {
|
|
58
|
+
title:
|
|
59
|
+
frontMatter['show-tabs'] && frontMatter['tab-label']
|
|
60
|
+
? `${frontMatter.title} | ${frontMatter['tab-label']}`
|
|
61
|
+
: frontMatter.title
|
|
62
|
+
? frontMatter.title
|
|
63
|
+
: '',
|
|
64
|
+
description: frontMatter.description ? frontMatter.description : '',
|
|
65
|
+
url: route,
|
|
66
|
+
}
|
|
67
|
+
return [...acc, result]
|
|
68
|
+
}, []),
|
|
69
|
+
[flatDocsDirectories],
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
73
|
+
const value = e.target.value.toLowerCase()
|
|
74
|
+
|
|
75
|
+
if (value.length === 0) {
|
|
76
|
+
setSearchTerm(undefined)
|
|
77
|
+
setSearchResults([])
|
|
78
|
+
setIsSearchResultOpen(false)
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const filteredData = searchData.filter(data => {
|
|
83
|
+
const title = data.title.toLowerCase()
|
|
84
|
+
const description = data.description.toLowerCase()
|
|
85
|
+
return title.includes(value) || description.includes(value)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const sortedData = filteredData.sort((a, b) => {
|
|
89
|
+
const aTitle = a.title.toLowerCase()
|
|
90
|
+
const bTitle = b.title.toLowerCase()
|
|
91
|
+
const aIncludes = aTitle.includes(value)
|
|
92
|
+
const bIncludes = bTitle.includes(value)
|
|
93
|
+
|
|
94
|
+
if (aIncludes && !bIncludes) {
|
|
95
|
+
return -1
|
|
96
|
+
} else if (!aIncludes && bIncludes) {
|
|
97
|
+
return 1
|
|
98
|
+
} else {
|
|
99
|
+
return 0
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
setSearchResults(sortedData)
|
|
104
|
+
|
|
105
|
+
setSearchTerm(value)
|
|
106
|
+
setIsSearchResultOpen(true)
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const updateActiveDescendant = (offset: number) => {
|
|
111
|
+
if (searchResults.length === 0) {
|
|
112
|
+
setActiveDescendant(-1)
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Wraps from the last item to the first and vice versa
|
|
117
|
+
const nextActiveDescendant = (activeDescendant + offset + searchResults.length) % searchResults.length
|
|
118
|
+
setActiveDescendant(nextActiveDescendant)
|
|
119
|
+
|
|
120
|
+
listboxRef.current
|
|
121
|
+
?.querySelector(`#search-result-${nextActiveDescendant}`)
|
|
122
|
+
// Scroll all the way to the top when the first item is selected
|
|
123
|
+
?.scrollIntoView({block: nextActiveDescendant === 0 ? 'center' : 'nearest'})
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const resetSearch = () => {
|
|
127
|
+
setSearchTerm('')
|
|
128
|
+
setSearchResults([])
|
|
129
|
+
setIsSearchResultOpen(false)
|
|
130
|
+
setActiveDescendant(-1)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
134
|
+
switch (e.key) {
|
|
135
|
+
case 'ArrowDown':
|
|
136
|
+
e.preventDefault()
|
|
137
|
+
updateActiveDescendant(1)
|
|
138
|
+
break
|
|
139
|
+
case 'ArrowUp':
|
|
140
|
+
e.preventDefault()
|
|
141
|
+
updateActiveDescendant(-1)
|
|
142
|
+
break
|
|
143
|
+
case 'Enter':
|
|
144
|
+
e.preventDefault()
|
|
145
|
+
if (activeDescendant !== -1) {
|
|
146
|
+
const selectedResult = searchResults[activeDescendant]
|
|
147
|
+
if (selectedResult.url) {
|
|
148
|
+
router.push(selectedResult.url)
|
|
149
|
+
onNavigate?.()
|
|
150
|
+
resetSearch()
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
break
|
|
154
|
+
case 'Escape':
|
|
155
|
+
if (isSearchResultOpen) {
|
|
156
|
+
e.preventDefault()
|
|
157
|
+
resetSearch()
|
|
158
|
+
}
|
|
159
|
+
break
|
|
160
|
+
case 'Tab':
|
|
161
|
+
resetSearch()
|
|
162
|
+
break
|
|
163
|
+
default:
|
|
164
|
+
break
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return (
|
|
169
|
+
<div ref={searchResultsRef}>
|
|
170
|
+
<FormControl>
|
|
171
|
+
<FormControl.Label visuallyHidden>Search</FormControl.Label>
|
|
172
|
+
<TextInput
|
|
173
|
+
contrast
|
|
174
|
+
type="search"
|
|
175
|
+
className={styles.GlobalSearch__searchInput}
|
|
176
|
+
leadingVisual={<SearchIcon />}
|
|
177
|
+
placeholder={`Search ${siteTitle}`}
|
|
178
|
+
ref={forwardedRef}
|
|
179
|
+
value={searchTerm}
|
|
180
|
+
onChange={handleChange}
|
|
181
|
+
onKeyDown={handleKeyDown}
|
|
182
|
+
role="combobox"
|
|
183
|
+
aria-activedescendant={activeDescendant === -1 ? undefined : `search-result-${activeDescendant}`}
|
|
184
|
+
aria-autocomplete="list"
|
|
185
|
+
aria-controls="search-results-listbox"
|
|
186
|
+
aria-expanded={isSearchResultOpen}
|
|
187
|
+
/>
|
|
188
|
+
</FormControl>
|
|
189
|
+
{searchTerm && (
|
|
190
|
+
<div
|
|
191
|
+
className={clsx(
|
|
192
|
+
styles.GlobalSearch__searchResultsContainer,
|
|
193
|
+
isSearchResultOpen && styles['GlobalSearch__searchResultsContainer--open'],
|
|
194
|
+
)}
|
|
195
|
+
tabIndex={-1}
|
|
196
|
+
>
|
|
197
|
+
<Stack direction="vertical" padding="none" gap="none">
|
|
198
|
+
{searchTerm && (
|
|
199
|
+
<Heading
|
|
200
|
+
as="h3"
|
|
201
|
+
size="subhead-large"
|
|
202
|
+
id="search-results-heading"
|
|
203
|
+
className={styles.GlobalSearch__searchResultsHeading}
|
|
204
|
+
>
|
|
205
|
+
{searchResults.length} Results for "{searchTerm}"
|
|
206
|
+
</Heading>
|
|
207
|
+
)}
|
|
208
|
+
{searchResults.length > 0 ? (
|
|
209
|
+
<ul
|
|
210
|
+
role="listbox"
|
|
211
|
+
ref={listboxRef}
|
|
212
|
+
id="search-results-listbox"
|
|
213
|
+
aria-labelledby="search-results-heading"
|
|
214
|
+
className={clsx(styles.GlobalSearch__searchResultsList)}
|
|
215
|
+
>
|
|
216
|
+
{searchResults.map((result, index) => (
|
|
217
|
+
<li
|
|
218
|
+
key={`${result.title}-${index}`}
|
|
219
|
+
className={clsx(styles.GlobalSearch__searchResultItem)}
|
|
220
|
+
id={`search-result-${index}`}
|
|
221
|
+
aria-selected={index === activeDescendant}
|
|
222
|
+
role="option"
|
|
223
|
+
>
|
|
224
|
+
<Link
|
|
225
|
+
className={styles.GlobalSearch__searchResultLink}
|
|
226
|
+
href={result.url}
|
|
227
|
+
tabIndex={-1}
|
|
228
|
+
onClick={() => {
|
|
229
|
+
onNavigate?.()
|
|
230
|
+
resetSearch()
|
|
231
|
+
}}
|
|
232
|
+
>
|
|
233
|
+
<Text size="200">
|
|
234
|
+
<HighlightSearchTerm searchTerm={searchTerm}>{result.title}</HighlightSearchTerm>
|
|
235
|
+
</Text>
|
|
236
|
+
<Text as="p" size="100" variant="muted">
|
|
237
|
+
<HighlightSearchTerm searchTerm={searchTerm}>{result.description}</HighlightSearchTerm>
|
|
238
|
+
</Text>
|
|
239
|
+
</Link>
|
|
240
|
+
</li>
|
|
241
|
+
))}
|
|
242
|
+
</ul>
|
|
243
|
+
) : (
|
|
244
|
+
<div className={styles.GlobalSearch__searchResultsEmpty}>
|
|
245
|
+
<Text variant="muted">No results found</Text>
|
|
246
|
+
</div>
|
|
247
|
+
)}
|
|
248
|
+
</Stack>
|
|
249
|
+
</div>
|
|
250
|
+
)}
|
|
251
|
+
</div>
|
|
252
|
+
)
|
|
253
|
+
},
|
|
254
|
+
)
|
|
@@ -9,14 +9,14 @@
|
|
|
9
9
|
z-index: 20;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
@media (max-width:
|
|
12
|
+
@media (max-width: 767px) {
|
|
13
13
|
.Header__searchArea {
|
|
14
14
|
display: none;
|
|
15
15
|
position: absolute;
|
|
16
16
|
top: 72px;
|
|
17
17
|
left: 0;
|
|
18
18
|
margin: 0;
|
|
19
|
-
width:
|
|
19
|
+
width: 100vw;
|
|
20
20
|
padding: var(--base-size-24);
|
|
21
21
|
padding-top: 0;
|
|
22
22
|
background: var(--brand-color-canvas-default);
|
|
@@ -52,6 +52,11 @@
|
|
|
52
52
|
background-color: rgba(0, 0, 0, 0.5);
|
|
53
53
|
z-index: -1;
|
|
54
54
|
}
|
|
55
|
+
|
|
56
|
+
.Header__searchButton,
|
|
57
|
+
.Header__navDrawerContainer {
|
|
58
|
+
display: flex;
|
|
59
|
+
}
|
|
55
60
|
}
|
|
56
61
|
|
|
57
62
|
@media (min-width: 768px) {
|
|
@@ -66,15 +71,13 @@
|
|
|
66
71
|
margin-inline-end: var(--base-size-16);
|
|
67
72
|
}
|
|
68
73
|
|
|
69
|
-
.Header__searchHeaderBanner
|
|
74
|
+
.Header__searchHeaderBanner,
|
|
75
|
+
.Header__searchButton,
|
|
76
|
+
.Header__navDrawerContainer {
|
|
70
77
|
display: none;
|
|
71
78
|
}
|
|
72
79
|
}
|
|
73
80
|
|
|
74
|
-
.Header__searchInput {
|
|
75
|
-
width: 100%;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
81
|
.Header__siteTitle {
|
|
79
82
|
display: flex;
|
|
80
83
|
gap: var(--base-size-8);
|
|
@@ -86,20 +89,3 @@
|
|
|
86
89
|
.Header__siteTitle svg {
|
|
87
90
|
fill: var(--brand-color-text-default);
|
|
88
91
|
}
|
|
89
|
-
|
|
90
|
-
.Header__searchResultsList {
|
|
91
|
-
list-style: none;
|
|
92
|
-
padding: 0;
|
|
93
|
-
border-top: var(--brand-borderWidth-thin) solid var(--brand-color-border-default);
|
|
94
|
-
padding-block-start: var(--base-size-16);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
.Header__searchResultsList li:not(:last-child) {
|
|
98
|
-
margin-block-end: var(--base-size-16);
|
|
99
|
-
padding-block-end: var(--base-size-16);
|
|
100
|
-
border-bottom: var(--brand-borderWidth-thin) solid var(--brand-color-border-default);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
.Header__searchResultsList a {
|
|
104
|
-
color: var(--brand-color-text-default);
|
|
105
|
-
}
|
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
import React, {
|
|
1
|
+
import React, {useEffect, useRef, useState} from 'react'
|
|
2
2
|
import {MarkGithubIcon, MoonIcon, SearchIcon, SunIcon, ThreeBarsIcon, XIcon} from '@primer/octicons-react'
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import {IconButton} from '@primer/react'
|
|
4
|
+
import {Stack, Text} from '@primer/react-brand'
|
|
5
5
|
import {clsx} from 'clsx'
|
|
6
|
-
import {
|
|
7
|
-
import {debounce} from 'lodash'
|
|
6
|
+
import {PageMapItem} from 'nextra'
|
|
8
7
|
|
|
9
8
|
import Link from 'next/link'
|
|
10
9
|
import styles from './Header.module.css'
|
|
@@ -12,6 +11,8 @@ import {NavDrawer} from '../nav-drawer/NavDrawer'
|
|
|
12
11
|
import {useNavDrawerState} from '../nav-drawer/useNavDrawerState'
|
|
13
12
|
import {useColorMode} from '../../context/color-modes/useColorMode'
|
|
14
13
|
import {DocsItem} from '../../../types'
|
|
14
|
+
import {GlobalSearch} from '../global-search/GlobalSearch'
|
|
15
|
+
import {FocusOn} from 'react-focus-on'
|
|
15
16
|
|
|
16
17
|
type HeaderProps = {
|
|
17
18
|
pageMap: PageMapItem[]
|
|
@@ -19,148 +20,24 @@ type HeaderProps = {
|
|
|
19
20
|
siteTitle: string
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
type SearchResults = {
|
|
23
|
-
title: string
|
|
24
|
-
description: string
|
|
25
|
-
url: string
|
|
26
|
-
}
|
|
27
|
-
|
|
28
23
|
export function Header({pageMap, siteTitle, flatDocsDirectories}: HeaderProps) {
|
|
24
|
+
const searchRef = useRef<HTMLInputElement | null>(null)
|
|
29
25
|
const {colorMode, setColorMode} = useColorMode()
|
|
30
|
-
const
|
|
31
|
-
const searchResultsRef = useRef<HTMLElement | null>(null)
|
|
26
|
+
const searchTriggerRef = useRef<HTMLButtonElement | null>(null)
|
|
32
27
|
const [isNavDrawerOpen, setIsNavDrawerOpen] = useNavDrawerState('768')
|
|
33
28
|
const [isSearchOpen, setIsSearchOpen] = useState(false)
|
|
34
|
-
const [isSearchResultOpen, setIsSearchResultOpen] = useState(false)
|
|
35
|
-
const [searchResults, setSearchResults] = useState<SearchResults[] | undefined>()
|
|
36
|
-
const [searchTerm, setSearchTerm] = useState<string | undefined>('')
|
|
37
|
-
const [activeDescendant] = useState<number>(-1)
|
|
38
|
-
|
|
39
|
-
useEffect(() => {
|
|
40
|
-
if (isSearchOpen && inputRef.current) {
|
|
41
|
-
inputRef.current.focus()
|
|
42
|
-
}
|
|
43
|
-
}, [isSearchOpen])
|
|
44
|
-
|
|
45
|
-
useEffect(() => {
|
|
46
|
-
const handleKeyDown = (event: KeyboardEvent) => {
|
|
47
|
-
if (event.key === 'Escape') {
|
|
48
|
-
setIsSearchResultOpen(false)
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const handleClickAway = (event: MouseEvent) => {
|
|
53
|
-
if (
|
|
54
|
-
inputRef.current &&
|
|
55
|
-
!inputRef.current.contains(event.target as Node) &&
|
|
56
|
-
!searchResultsRef.current?.contains(event.target as Node)
|
|
57
|
-
) {
|
|
58
|
-
setIsSearchResultOpen(false)
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
document.addEventListener('keydown', handleKeyDown)
|
|
63
|
-
document.addEventListener('click', handleClickAway)
|
|
64
|
-
|
|
65
|
-
// Clean up the event listener when the component unmounts
|
|
66
|
-
return () => {
|
|
67
|
-
document.removeEventListener('keydown', handleKeyDown)
|
|
68
|
-
document.removeEventListener('click', handleClickAway)
|
|
69
|
-
}
|
|
70
|
-
}, [])
|
|
71
29
|
|
|
72
30
|
useEffect(() => {
|
|
73
31
|
document.documentElement.setAttribute('data-color-mode', colorMode)
|
|
74
32
|
}, [colorMode])
|
|
75
33
|
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
() =>
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
if (item.route === '/') return null // remove homepage
|
|
82
|
-
return item
|
|
83
|
-
})
|
|
84
|
-
.filter(Boolean)
|
|
85
|
-
.map(item => {
|
|
86
|
-
const {frontMatter, route} = item as MdxFile
|
|
87
|
-
if (!frontMatter) return null
|
|
88
|
-
const result = {
|
|
89
|
-
title:
|
|
90
|
-
frontMatter['show-tabs'] && frontMatter['tab-label']
|
|
91
|
-
? `${frontMatter.title} | ${frontMatter['tab-label']}`
|
|
92
|
-
: frontMatter.title
|
|
93
|
-
? frontMatter.title
|
|
94
|
-
: '',
|
|
95
|
-
description: frontMatter.description ? frontMatter.description : '',
|
|
96
|
-
url: route,
|
|
97
|
-
}
|
|
98
|
-
return result
|
|
99
|
-
}),
|
|
100
|
-
[flatDocsDirectories],
|
|
101
|
-
)
|
|
102
|
-
|
|
103
|
-
const handleChange = () => {
|
|
104
|
-
if (!inputRef.current) return
|
|
105
|
-
if (inputRef.current.value.length === 0) {
|
|
106
|
-
setSearchTerm(undefined)
|
|
107
|
-
setSearchResults(undefined)
|
|
108
|
-
setIsSearchResultOpen(false)
|
|
109
|
-
return
|
|
110
|
-
}
|
|
111
|
-
if (inputRef.current.value.length > 2) {
|
|
112
|
-
const curSearchTerm = inputRef.current.value.toLowerCase()
|
|
113
|
-
|
|
114
|
-
// filters the frontMatter title and descriptions against the search term
|
|
115
|
-
const filteredData = searchData
|
|
116
|
-
.filter((data): data is SearchResults => data !== null)
|
|
117
|
-
.filter(data => {
|
|
118
|
-
if (!data.title) return false
|
|
119
|
-
const title = data.title.toLowerCase()
|
|
120
|
-
const description = data.description.toLowerCase()
|
|
121
|
-
return title.includes(curSearchTerm) || description.includes(curSearchTerm)
|
|
122
|
-
})
|
|
123
|
-
|
|
124
|
-
// sorts the data to show hits in title first, description second
|
|
125
|
-
const sortedData = filteredData.sort((a, b) => {
|
|
126
|
-
const aTitle = a.title.toLowerCase()
|
|
127
|
-
const bTitle = b.title.toLowerCase()
|
|
128
|
-
const aIncludes = aTitle.includes(curSearchTerm)
|
|
129
|
-
const bIncludes = bTitle.includes(curSearchTerm)
|
|
130
|
-
|
|
131
|
-
if (aIncludes && !bIncludes) {
|
|
132
|
-
return -1
|
|
133
|
-
} else if (!aIncludes && bIncludes) {
|
|
134
|
-
return 1
|
|
135
|
-
} else {
|
|
136
|
-
return 0
|
|
137
|
-
}
|
|
138
|
-
})
|
|
139
|
-
|
|
140
|
-
setSearchResultsDebounced(sortedData)
|
|
141
|
-
|
|
142
|
-
setSearchTerm(inputRef.current.value)
|
|
143
|
-
setIsSearchResultOpen(true)
|
|
144
|
-
return
|
|
145
|
-
}
|
|
34
|
+
const closeSearch = () => {
|
|
35
|
+
setIsSearchOpen(false)
|
|
36
|
+
setTimeout(() => {
|
|
37
|
+
searchTriggerRef.current?.focus()
|
|
38
|
+
}, 0)
|
|
146
39
|
}
|
|
147
40
|
|
|
148
|
-
const handleSubmit = (e: React.FormEvent) => {
|
|
149
|
-
e.preventDefault()
|
|
150
|
-
if (!inputRef.current) return
|
|
151
|
-
if (!inputRef.current.value) {
|
|
152
|
-
// eslint-disable-next-line i18n-text/no-en
|
|
153
|
-
alert(`Enter a value and try again.`)
|
|
154
|
-
return
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
alert(`Name: ${inputRef.current.value}`)
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
const handleSearchButtonOpenClick = useCallback(() => {
|
|
161
|
-
setIsSearchOpen(true)
|
|
162
|
-
}, [])
|
|
163
|
-
|
|
164
41
|
return (
|
|
165
42
|
<nav
|
|
166
43
|
className={clsx(styles.Header, isSearchOpen && styles['Header--searchAreaOpen'])}
|
|
@@ -174,123 +51,22 @@ export function Header({pageMap, siteTitle, flatDocsDirectories}: HeaderProps) {
|
|
|
174
51
|
</Text>
|
|
175
52
|
</Link>
|
|
176
53
|
<div className={clsx(styles.Header__searchArea, isSearchOpen && styles['Header__searchArea--open'])}>
|
|
177
|
-
<
|
|
178
|
-
<
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
placeholder={`Search ${siteTitle}`}
|
|
184
|
-
ref={inputRef}
|
|
185
|
-
onSubmit={handleSubmit}
|
|
186
|
-
onChange={handleChange}
|
|
187
|
-
trailingAction={
|
|
188
|
-
searchTerm ? (
|
|
189
|
-
<TextInput.Action
|
|
190
|
-
onClick={() => {
|
|
191
|
-
if (inputRef.current) {
|
|
192
|
-
inputRef.current.value = ''
|
|
193
|
-
setSearchTerm(undefined)
|
|
194
|
-
setSearchResults(undefined)
|
|
195
|
-
}
|
|
196
|
-
}}
|
|
197
|
-
icon={XIcon}
|
|
198
|
-
aria-label="Clear input"
|
|
199
|
-
tooltipDirection="nw"
|
|
200
|
-
sx={{color: 'fg.subtle'}}
|
|
201
|
-
/>
|
|
202
|
-
) : undefined
|
|
203
|
-
}
|
|
204
|
-
aria-activedescendant={activeDescendant === -1 ? undefined : `search-result-${activeDescendant}`}
|
|
54
|
+
<FocusOn enabled={isSearchOpen} onEscapeKey={closeSearch} onClickOutside={closeSearch}>
|
|
55
|
+
<GlobalSearch
|
|
56
|
+
ref={searchRef}
|
|
57
|
+
siteTitle={siteTitle}
|
|
58
|
+
flatDocsDirectories={flatDocsDirectories}
|
|
59
|
+
onNavigate={() => closeSearch()}
|
|
205
60
|
/>
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
marginTop: 1,
|
|
213
|
-
position: 'absolute',
|
|
214
|
-
zIndex: 1,
|
|
215
|
-
backgroundColor: 'var(--brand-color-canvas-default)',
|
|
216
|
-
padding: 'var(--base-size-16)',
|
|
217
|
-
width: '100%',
|
|
218
|
-
maxWidth: ['calc(100% - 46px)', null, '350px'],
|
|
219
|
-
border: 'var(--brand-borderWidth-thin) solid var(--brand-color-border-default)',
|
|
220
|
-
borderRadius: 'var(--brand-borderRadius-medium)',
|
|
221
|
-
maxHeight: 300,
|
|
222
|
-
overflowY: 'auto',
|
|
223
|
-
overflowX: 'hidden',
|
|
224
|
-
'&::-webkit-scrollbar': {
|
|
225
|
-
width: 8,
|
|
226
|
-
},
|
|
227
|
-
'&::-webkit-scrollbar-track': {
|
|
228
|
-
backgroundColor: 'var(--brand-color-canvas-default)',
|
|
229
|
-
},
|
|
230
|
-
'&::-webkit-scrollbar-thumb': {
|
|
231
|
-
backgroundColor: 'var(--brand-color-text-muted)',
|
|
232
|
-
borderRadius: 'var(--base-size-4)',
|
|
233
|
-
},
|
|
234
|
-
}}
|
|
235
|
-
>
|
|
236
|
-
<Stack direction="vertical" padding="none" gap="none">
|
|
237
|
-
{searchTerm && (
|
|
238
|
-
<Box sx={{pl: 1}}>
|
|
239
|
-
<Heading as="h3" size="subhead-large" id="search-results-heading">
|
|
240
|
-
{searchResults && searchResults.length} Results for "{searchTerm}"
|
|
241
|
-
</Heading>
|
|
242
|
-
</Box>
|
|
243
|
-
)}
|
|
244
|
-
{searchResults && searchResults.length > 0 ? (
|
|
245
|
-
<ul
|
|
246
|
-
role="listbox"
|
|
247
|
-
tabIndex={0}
|
|
248
|
-
aria-labelledby="search-results-heading"
|
|
249
|
-
className={clsx(styles.Header__searchResultsList)}
|
|
250
|
-
>
|
|
251
|
-
{searchResults.map((result, index) => (
|
|
252
|
-
<li
|
|
253
|
-
key={`${result.title}-${index}`}
|
|
254
|
-
id={`search-result-${index}`}
|
|
255
|
-
role="option"
|
|
256
|
-
aria-selected={index === activeDescendant}
|
|
257
|
-
>
|
|
258
|
-
<Text size="200" className={styles.Header__searchResultItemTitle}>
|
|
259
|
-
<Link href={result.url}>{result.title}</Link>
|
|
260
|
-
</Text>
|
|
261
|
-
<Text
|
|
262
|
-
as="p"
|
|
263
|
-
size="100"
|
|
264
|
-
variant="muted"
|
|
265
|
-
id={`search-result-item-desc${index}`}
|
|
266
|
-
className={styles.Header__searchResultItemDesc}
|
|
267
|
-
>
|
|
268
|
-
{result.description}
|
|
269
|
-
</Text>
|
|
270
|
-
</li>
|
|
271
|
-
))}
|
|
272
|
-
</ul>
|
|
273
|
-
) : (
|
|
274
|
-
<Box sx={{p: '100', display: 'flex', justifyContent: 'center', alignItems: 'center', height: 150}}>
|
|
275
|
-
<Text variant="muted">No results found</Text>
|
|
276
|
-
</Box>
|
|
277
|
-
)}
|
|
61
|
+
<div className={styles.Header__searchHeaderBanner}>
|
|
62
|
+
<Stack direction="horizontal" padding="none" gap={4} alignItems="center" justifyContent="space-between">
|
|
63
|
+
<Text as="p" size="300" weight="semibold">
|
|
64
|
+
Search
|
|
65
|
+
</Text>
|
|
66
|
+
<IconButton icon={XIcon} variant="invisible" aria-label="Close search" onClick={closeSearch} />
|
|
278
67
|
</Stack>
|
|
279
|
-
</
|
|
280
|
-
|
|
281
|
-
<div className={styles.Header__searchHeaderBanner}>
|
|
282
|
-
<Stack direction="horizontal" padding="none" gap={4} alignItems="center" justifyContent="space-between">
|
|
283
|
-
<Text as="p" size="300" weight="semibold">
|
|
284
|
-
Search
|
|
285
|
-
</Text>
|
|
286
|
-
<IconButton
|
|
287
|
-
icon={XIcon}
|
|
288
|
-
variant="invisible"
|
|
289
|
-
aria-label="Close search"
|
|
290
|
-
onClick={() => setIsSearchOpen(false)}
|
|
291
|
-
/>
|
|
292
|
-
</Stack>
|
|
293
|
-
</div>
|
|
68
|
+
</div>
|
|
69
|
+
</FocusOn>
|
|
294
70
|
</div>
|
|
295
71
|
<div>
|
|
296
72
|
<Stack direction="horizontal" padding="none" gap={4}>
|
|
@@ -301,13 +77,14 @@ export function Header({pageMap, siteTitle, flatDocsDirectories}: HeaderProps) {
|
|
|
301
77
|
onClick={() => setColorMode(colorMode === 'light' ? 'dark' : 'light')}
|
|
302
78
|
/>
|
|
303
79
|
<IconButton
|
|
80
|
+
ref={searchTriggerRef}
|
|
81
|
+
className={styles.Header__searchButton}
|
|
304
82
|
icon={SearchIcon}
|
|
305
83
|
variant="invisible"
|
|
306
84
|
aria-label={`Open search`}
|
|
307
|
-
|
|
308
|
-
onClick={handleSearchButtonOpenClick}
|
|
85
|
+
onClick={() => setIsSearchOpen(true)}
|
|
309
86
|
/>
|
|
310
|
-
<
|
|
87
|
+
<div className={styles.Header__navDrawerContainer}>
|
|
311
88
|
<IconButton
|
|
312
89
|
icon={ThreeBarsIcon}
|
|
313
90
|
variant="invisible"
|
|
@@ -316,7 +93,7 @@ export function Header({pageMap, siteTitle, flatDocsDirectories}: HeaderProps) {
|
|
|
316
93
|
onClick={() => setIsNavDrawerOpen(true)}
|
|
317
94
|
/>
|
|
318
95
|
<NavDrawer isOpen={isNavDrawerOpen} onDismiss={() => setIsNavDrawerOpen(false)} navItems={pageMap} />
|
|
319
|
-
</
|
|
96
|
+
</div>
|
|
320
97
|
</Stack>
|
|
321
98
|
</div>
|
|
322
99
|
</nav>
|
|
@@ -79,7 +79,8 @@ export function Theme({children, pageMap}: ThemeProps) {
|
|
|
79
79
|
// eslint-disable-next-line i18n-text/no-en
|
|
80
80
|
const siteTitle = process.env.NEXT_PUBLIC_SITE_TITLE || 'Example Site'
|
|
81
81
|
const isHomePage = route === '/'
|
|
82
|
-
const isIndexPage =
|
|
82
|
+
const isIndexPage =
|
|
83
|
+
/index\.mdx?$/.test(filePath) && !isHomePage && activeMetadata && activeMetadata['show-tabs'] === undefined
|
|
83
84
|
const data = !isHomePage && activePath[activePath.length - 2]
|
|
84
85
|
const filteredTabData: MdxFile[] = data && hasChildren(data) ? ((data as Folder).children as MdxFile[]) : []
|
|
85
86
|
|