@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 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 &quot;{searchTerm}&quot;
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: 768px) {
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: 100%;
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, {useCallback, useEffect, useMemo, useRef, useState} from 'react'
1
+ import React, {useEffect, useRef, useState} from 'react'
2
2
  import {MarkGithubIcon, MoonIcon, SearchIcon, SunIcon, ThreeBarsIcon, XIcon} from '@primer/octicons-react'
3
- import {Box, FormControl, IconButton, TextInput} from '@primer/react'
4
- import {Heading, Stack, Text} from '@primer/react-brand'
3
+ import {IconButton} from '@primer/react'
4
+ import {Stack, Text} from '@primer/react-brand'
5
5
  import {clsx} from 'clsx'
6
- import {MdxFile, PageMapItem} from 'nextra'
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 inputRef = useRef<HTMLInputElement | null>(null)
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 setSearchResultsDebounced = debounce((data: SearchResults[] | undefined) => setSearchResults(data), 1000)
77
- const searchData = useMemo(
78
- () =>
79
- flatDocsDirectories
80
- .map(item => {
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
- <FormControl>
178
- <FormControl.Label visuallyHidden>Search</FormControl.Label>
179
- <TextInput
180
- contrast
181
- className={styles.Header__searchInput}
182
- leadingVisual={<SearchIcon />}
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
- </FormControl>
207
- {searchTerm && (
208
- <Box
209
- ref={searchResultsRef}
210
- sx={{
211
- display: isSearchResultOpen ? 'block' : 'none',
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 &quot;{searchTerm}&quot;
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
- </Box>
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
- sx={{display: ['flex', null, 'none']}}
308
- onClick={handleSearchButtonOpenClick}
85
+ onClick={() => setIsSearchOpen(true)}
309
86
  />
310
- <Box sx={{display: ['flex', null, 'none']}}>
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
- </Box>
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 = /index\.mdx?$/.test(filePath) && !isHomePage && activeMetadata && !activeMetadata['show-tabs']
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@primer/doctocat-nextjs",
3
- "version": "0.4.0",
3
+ "version": "0.4.1-rc.afcbafc",
4
4
  "description": "A Next.js theme for building Primer documentation sites",
5
5
  "main": "index.js",
6
6
  "type": "module",