Return focus to variant after cross variant navigation
Closes #483
Luke Karrys committed
Oct 21, 2023 at 00:08 UTC
fd531e35399c78442f4dde340c9d3b863fbfe424
12 files changed
+210
-149
cli/lib/build.js
+8
-6
@@ -14,6 +14,7 @@ const updateNav = async (updates, {nav, path}) => {
14
shortName: release.id,
15
url: release.url,
16
default: release.default,
17
+ type: release.type,
18
children: release.nav,
19
}))
20
@@ -43,7 +44,7 @@ const getCurrentVersions = nav => {
44
45
const currentVersions = currentSections
46
.map(v => {
46
- const version = v.title?.match(/^Version\s(.*?)\s/)[1]
47
+ const version = v.title?.match(/^Version\s(.*?)$/)[1]
48
return version
49
})
50
.sort(semver.compare)
@@ -95,16 +96,17 @@ const main = async ({loglevel, releases: rawReleases, useCurrent, navPath, conte
96
97
const releases = releaseVersions.map(release => {
98
const type = release.default
98
- ? 'Latest Release'
99
+ ? 'latest'
100
: release.prerelease
100
- ? 'Prerelease'
101
+ ? 'prerelease'
102
: semver.gt(release.version, latestRelease.version)
102
- ? 'Current Release'
103
- : 'Legacy Release'
103
+ ? 'current'
104
+ : 'legacy'
105
106
return {
107
...release,
107
- title: `Version ${release.version} (${type})`,
108
+ type,
109
+ title: `Version ${release.version}`,
110
url: `/${DOCS_PATH}/${release.id}`,
111
urlPrefix: DOCS_PATH,
112
urlPrefixes: [DOCS_PATH, `${DOCS_PATH}-documentation`],
content/nav.yml
+10
-5
@@ -283,10 +283,11 @@
283
shortName: CLI
284
url: /cli
285
variants:
286
- - title: Version 6.14.18 (Legacy Release)
286
+ - title: Version 6.14.18
287
shortName: v6
288
url: /cli/v6
289
default: false
290
+ type: legacy
291
children:
292
- title: CLI Commands
293
shortName: Commands
@@ -525,10 +526,11 @@
526
- title: Changelog
527
url: /cli/v6/using-npm/changelog
528
description: Changelog notes for each version
528
- - title: Version 7.24.2 (Legacy Release)
529
+ - title: Version 7.24.2
530
shortName: v7
531
url: /cli/v7
532
default: false
533
+ type: legacy
534
children:
535
- title: CLI Commands
536
shortName: Commands
@@ -782,10 +784,11 @@
784
- title: Changelog
785
url: /cli/v7/using-npm/changelog
786
description: Changelog notes for each version
785
- - title: Version 8.19.4 (Legacy Release)
787
+ - title: Version 8.19.4
788
shortName: v8
789
url: /cli/v8
790
default: false
791
+ type: legacy
792
children:
793
- title: CLI Commands
794
shortName: Commands
@@ -1051,10 +1054,11 @@
1054
- title: Changelog
1055
url: /cli/v8/using-npm/changelog
1056
description: Changelog notes for each version
1054
- - title: Version 9.9.0 (Legacy Release)
1057
+ - title: Version 9.9.0
1058
shortName: v9
1059
url: /cli/v9
1060
default: false
1061
+ type: legacy
1062
children:
1063
- title: CLI Commands
1064
shortName: Commands
@@ -1320,10 +1324,11 @@
1324
- title: Changelog
1325
url: /cli/v9/using-npm/changelog
1326
description: Changelog notes for each version
1323
- - title: Version 10.2.1 (Latest Release)
1327
+ - title: Version 10.2.1
1328
shortName: v10
1329
url: /cli/v10
1330
default: true
1331
+ type: latest
1332
children:
1333
- title: CLI Commands
1334
shortName: Commands
src/components/skip-nav.js
new
+50
@@ -0,0 +1,50 @@
1
+import React from 'react'
2
+import {Box} from '@primer/react'
3
+import styled from 'styled-components'
4
+import Link from './link'
5
+import {SCROLL_MARGIN_TOP} from '../constants'
6
+
7
+const ID = 'skip-nav'
8
+
9
+const SkipLinkBase = props => (
10
+ <Link
11
+ {...props}
12
+ href={`#${ID}`}
13
+ sx={{
14
+ p: 3,
15
+ color: 'fg.onEmphasis',
16
+ backgroundColor: 'accent.emphasis',
17
+ fontSize: 1,
18
+ }}
19
+ >
20
+ Skip to content
21
+ </Link>
22
+)
23
+
24
+// The following rules are to ensure that the element is visually hidden, unless
25
+// it has focus. This is the recommended way to hide content from:
26
+// https://webaim.org/techniques/css/invisiblecontent/#techniques
27
+export const SkipLink = styled(SkipLinkBase)`
28
+ z-index: 20;
29
+ width: auto;
30
+ height: auto;
31
+ clip: auto;
32
+ position: absolute;
33
+ overflow: hidden;
34
+ left: 10px;
35
+
36
+ &:not(:focus) {
37
+ clip: rect(1px, 1px, 1px, 1px);
38
+ clip-path: inset(50%);
39
+ height: 1px;
40
+ width: 1px;
41
+ margin: -1px;
42
+ padding: 0;
43
+ }
44
+`
45
+
46
+const SkipNavBase = props => <Box id={ID} {...props} />
47
+
48
+export const SkipNav = styled(SkipNavBase)`
49
+ scroll-margin-top: ${SCROLL_MARGIN_TOP}px;
50
+`
src/components/table-of-contents.js
+5
-4
@@ -2,7 +2,7 @@ import React from 'react'
2
import {Heading, Box, Details, useDetails, Button} from '@primer/react'
3
import {ChevronDownIcon, ChevronRightIcon} from '@primer/octicons-react'
4
import {NavList} from '@primer/react/drafts'
5
-import {FULL_HEADER_HEIGHT} from '../constants'
5
+import {SCROLL_MARGIN_TOP} from '../constants'
6
import usePage from '../hooks/use-page'
7
8
const TableOfContentsItems = ({items, depth}) => (
@@ -54,8 +54,8 @@ export const Desktop = withTableOfContents(({items}) => (
54
marginLeft: [null, 7, 8, 9],
55
display: ['none', null, 'block'],
56
position: 'sticky',
57
- top: FULL_HEADER_HEIGHT + 48,
58
- maxHeight: `calc(100vh - ${FULL_HEADER_HEIGHT + 48}px)`,
57
+ top: SCROLL_MARGIN_TOP,
58
+ maxHeight: `calc(100vh - ${SCROLL_MARGIN_TOP}px)`,
59
}}
60
>
61
<Heading as="h3" sx={{fontSize: 1, display: 'inline-block', fontWeight: 'bold'}} id="toc-heading">
@@ -63,7 +63,8 @@ export const Desktop = withTableOfContents(({items}) => (
63
</Heading>
64
<Box
65
sx={{
66
- maxHeight: `calc(100% - 21px)`,
66
+ // extra pixels to account for table of contents title height
67
+ maxHeight: `calc(100% - 24px)`,
68
overflowY: 'scroll',
69
}}
70
>
src/components/variant-select.js
+99
-61
@@ -1,70 +1,71 @@
1
import React from 'react'
2
import {ActionList, ActionMenu, Box} from '@primer/react'
3
-import {navigate} from 'gatsby'
3
import * as getNav from '../util/get-nav'
4
import usePage from '../hooks/use-page'
5
+import Link from './link'
6
7
-const VariantItem = ({match, active}) => {
8
- const {variant, page} = match
7
+const VariantItem = ({title, shortName, url, active}) => (
8
+ <ActionList.Item
9
+ as={Link}
10
+ to={url}
11
+ id={shortName}
12
+ active={active}
13
+ sx={{
14
+ ':hover': {textDecoration: 'none'},
15
+ }}
16
+ >
17
+ {title}
18
+ </ActionList.Item>
19
+)
20
10
- const navigateToPage = React.useCallback(() => navigate(`${page.url}?v=true`), [page.url])
21
+const useVariantFocus = path => {
22
+ const anchorRef = React.useRef(null)
23
+ const pathRef = React.useRef(null)
24
12
- const handleClick = React.useCallback(
13
- event => {
14
- event.preventDefault()
15
- navigateToPage()
16
- },
17
- [navigateToPage],
18
- )
25
+ React.useEffect(() => {
26
+ const previousPath = pathRef.current
27
+ pathRef.current = path
28
20
- const handleKey = React.useCallback(
21
- event => {
22
- if (event.key === 'Enter') {
23
- navigateToPage()
29
+ if (getNav.didVariantChange(previousPath, path)) {
30
+ const anchor = anchorRef.current
31
+ const onBlur = () => {
32
+ anchor.removeEventListener('blur', onBlur)
33
+ anchor.focus()
34
}
25
- },
26
- [navigateToPage],
27
- )
35
+ anchor.addEventListener('blur', onBlur)
36
+ return () => anchor.removeEventListener('blur', onBlur)
37
+ }
38
+ }, [path])
39
29
- return (
30
- <ActionList.Item onKeyDown={handleKey} onClick={handleClick} id={variant.shortName} active={active}>
31
- {variant.title}
32
- </ActionList.Item>
33
- )
40
+ return anchorRef
41
}
42
36
-const VariantMenu = ({variants, path}) => {
43
+const VariantMenu = ({title, latest, current, prerelease, legacy, path}) => {
44
const [open, setOpen] = React.useState(false)
38
-
39
- const {selected, items} = variants.reduce(
40
- (acc, match, key) => {
41
- const active = match.page.url === path
42
- if (active) {
43
- acc.selected = match
44
- }
45
- acc.items.push({match, key, active})
46
- return acc
47
- },
48
- {selected: variants[0], items: []},
49
- )
45
+ const anchorRef = useVariantFocus(path)
46
+ const labelId = 'label-versions-list-item'
47
48
return (
49
<>
53
- <Box as="p" sx={{m: 0}} id="label-versions-list-item">
50
+ <Box as="p" sx={{m: 0}} id={labelId}>
51
Select CLI Version:
52
</Box>
56
- <ActionMenu open={open} onOpenChange={setOpen}>
57
- {/* Disabling to remove lint warnings. This property was added as "autofocus"
58
- in a previous accessibility audit which did not trigger the lint warning. */
59
- /* eslint-disable-next-line jsx-a11y/no-autofocus */}
60
- <ActionMenu.Button autoFocus aria-describedby="label-versions-list-item">
61
- {selected.variant.title}
62
- </ActionMenu.Button>
63
- <ActionMenu.Overlay width="medium" onEscape={() => setOpen(false)}>
64
- <ActionList id="versions-list-item" aria-labelledby="label-versions-list-item">
65
- {items.map(item => (
66
- <VariantItem key={item.key} {...item} />
67
- ))}
53
+ <ActionMenu anchorRef={anchorRef} open={open} onOpenChange={setOpen}>
54
+ <ActionMenu.Button aria-describedby={labelId}>{title}</ActionMenu.Button>
55
+ <ActionMenu.Overlay width="auto" onEscape={() => setOpen(false)}>
56
+ <ActionList aria-labelledby={labelId}>
57
+ <ActionList.Group title="Current">
58
+ <VariantItem {...latest} />
59
+ {current && <VariantItem {...current} />}
60
+ {prerelease && <VariantItem {...prerelease} />}
61
+ </ActionList.Group>
62
+ {legacy && (
63
+ <ActionList.Group title="Legacy">
64
+ {legacy.map(item => (
65
+ <VariantItem key={item.title} {...item} />
66
+ ))}
67
+ </ActionList.Group>
68
+ )}
69
</ActionList>
70
</ActionMenu.Overlay>
71
</ActionMenu>
@@ -72,22 +73,59 @@ const VariantMenu = ({variants, path}) => {
73
)
74
}
75
75
-const VariantSelect = () => {
76
- const {location} = usePage()
77
- const root = getNav.getVariantRoot(location.pathname)
78
- const path = getNav.getPath(location.pathname)
79
- const vp = getNav.getVariantAndPage(root, path)
80
- const variants = vp ? getNav.getVariantsForPage(root, vp.page) : []
76
+const useVariants = () => {
77
+ const {pathname} = usePage().location
78
82
- if (!variants.length) {
83
- return null
84
- }
79
+ return React.useMemo(() => {
80
+ const root = getNav.getVariantRoot(pathname)
81
+ const path = getNav.getPath(pathname)
82
+ const vp = getNav.getVariantAndPage(root, path)
83
+ const variantPages = vp ? getNav.getVariantsForPage(root, vp.page) : []
84
86
- return (
85
+ if (!variantPages.length) {
86
+ return null
87
+ }
88
+
89
+ const result = {path, latest: null, current: null, prerelease: null, legacy: []}
90
+
91
+ for (const {variant, page} of variantPages) {
92
+ const item = {...variant, url: page.url, active: page.url === path}
93
+ let typeDesc = ''
94
+ switch (variant.type) {
95
+ case 'latest':
96
+ result.latest = item
97
+ typeDesc = ' (Latest)'
98
+ break
99
+ case 'current':
100
+ result.current = item
101
+ typeDesc = ' (Current)'
102
+ break
103
+ case 'prerelease':
104
+ result.prerelease = item
105
+ typeDesc = ' (Prerelease)'
106
+ break
107
+ default:
108
+ result.legacy.push(item)
109
+ typeDesc = ' Legacy'
110
+ }
111
+ if (item.active) {
112
+ result.title = `${item.title}${typeDesc}`
113
+ }
114
+ }
115
+
116
+ result.legacy.sort((a, b) => parseInt(b.shortName.slice(1)) - parseInt(a.shortName.slice(1)))
117
+
118
+ return result
119
+ }, [pathname])
120
+}
121
+
122
+const VariantSelect = () => {
123
+ const variants = useVariants()
124
+ return variants ? (
125
<Box sx={{mt: 2, mb: 3}}>
88
- <VariantMenu variants={variants} path={path} />
126
+ <VariantMenu {...variants} />
127
</Box>
90
- )
128
+ ) : null
129
}
130
131
export default VariantSelect
src/constants.js
+1
-1
@@ -4,4 +4,4 @@ export const HEADER_BAR = 10
4
5
export const FULL_HEADER_HEIGHT = HEADER_HEIGHT + HEADER_BAR
6
7
-export const SKIP_NAV = {id: 'skip-nav', as: 'main'}
7
+export const SCROLL_MARGIN_TOP = FULL_HEADER_HEIGHT + 24
src/layout.js
+6
-4
@@ -6,16 +6,16 @@ import * as TableOfContents from './components/table-of-contents'
6
import VariantSelect from './components/variant-select'
7
import Breadcrumbs from './components/breadcrumbs'
8
import Container from './components/container'
9
-import {SKIP_NAV} from './constants'
9
import useSiteMetadata from './hooks/use-site-metadata'
10
import usePage from './hooks/use-page'
11
import {DarkTheme} from './theme'
12
+import {SkipNav} from './components/skip-nav'
13
14
const HeroLayout = ({children}) => {
15
const {title, description} = useSiteMetadata()
16
17
return (
18
- <Box sx={{width: '100%'}} {...SKIP_NAV}>
18
+ <Box as="main" sx={{width: '100%'}}>
19
<DarkTheme>
20
<Box sx={{bg: 'canvas.inset', py: 6}}>
21
<Container>
@@ -28,6 +28,7 @@ const HeroLayout = ({children}) => {
28
</Container>
29
</Box>
30
</DarkTheme>
31
+ <SkipNav />
32
<Container>{children}</Container>
33
</Box>
34
)
@@ -48,12 +49,13 @@ const DefaultLayout = ({children}) => {
49
}}
50
>
51
<TableOfContents.Desktop />
51
- <Box sx={{width: '100%', maxWidth: '960px'}}>
52
- <Box sx={{mb: 4}} {...SKIP_NAV}>
52
+ <Box as="main" sx={{width: '100%', maxWidth: '960px'}}>
53
+ <Box sx={{mb: 4}}>
54
<Breadcrumbs />
55
<H1 autolink={false}>{title}</H1>
56
{description ? <Box sx={{fontSize: 3, mb: 3}}>{description}</Box> : null}
57
</Box>
58
+ <SkipNav />
59
<VariantSelect />
60
<TableOfContents.Mobile />
61
{children}
src/mdx/index.js
+2
-2
@@ -5,7 +5,7 @@ import styled from 'styled-components'
5
import {variant as styledVariant} from 'styled-system'
6
import {LinkIcon} from '@primer/octicons-react'
7
import textContent from 'react-addons-text-content'
8
-import {FULL_HEADER_HEIGHT} from '../constants'
8
+import {SCROLL_MARGIN_TOP} from '../constants'
9
import usePage from '../hooks/use-page'
10
import SiteLink from '../components/link'
11
@@ -24,7 +24,7 @@ export const Link = props => <SiteLink underline {...props} />
24
const StyledHeading = styled(Heading)`
25
margin-top: ${themeGet('space.4')};
26
margin-bottom: ${themeGet('space.3')};
27
- scroll-margin-top: ${FULL_HEADER_HEIGHT + 24}px;
27
+ scroll-margin-top: ${SCROLL_MARGIN_TOP}px;
28
line-height: ${themeGet('lineHeights.condensed')};
29
30
@media (hover: hover) {
src/mdx/nav-hierarchy.js
+2
-2
@@ -32,10 +32,10 @@ const Hierarchy = ({items, ...props}) => {
32
)
33
}
34
35
-function NavHierarchy({root, depth, ...props}) {
35
+function NavHierarchy({depth, ...props}) {
36
const {location} = usePage()
37
const path = getNav.getLocation(location.pathname)
38
- const navRoot = (root || path).replace(/\/+$/g, '')
38
+ const navRoot = path.replace(/\/+$/g, '')
39
40
const rootItem = getNav.getItem(navRoot)
41
const hierarchy = getNav.getHierarchy(rootItem, props)
src/page.js
+3
-40
@@ -1,51 +1,14 @@
1
import React from 'react'
2
import {BaseStyles, themeGet, Box} from '@primer/react'
3
-import styled, {createGlobalStyle} from 'styled-components'
4
-import {SKIP_NAV} from './constants'
3
+import {createGlobalStyle} from 'styled-components'
4
import Slugger from 'github-slugger'
5
import Header from './components/header'
6
import Sidebar from './components/sidebar'
8
-import Link from './components/link'
7
+import {SkipLink} from './components/skip-nav'
8
+
9
import {PageProvider} from './hooks/use-page'
10
import Layout from './layout'
11
12
-const SkipLinkBase = props => (
13
- <Link
14
- {...props}
15
- href={`#${SKIP_NAV.id}`}
16
- sx={{
17
- p: 3,
18
- color: 'fg.onEmphasis',
19
- backgroundColor: 'accent.emphasis',
20
- fontSize: 1,
21
- }}
22
- >
23
- Skip to content
24
- </Link>
25
-)
26
-
27
-// The following rules are to ensure that the element
28
-// is visually hidden, unless it has focus. This is the recommended
29
-// way to hide content from:
30
-// https://webaim.org/techniques/css/invisiblecontent/#techniques
31
-export const SkipLink = styled(SkipLinkBase)`
32
- z-index: 20;
33
- width: auto;
34
- height: auto;
35
- clip: auto;
36
- position: absolute;
37
- overflow: hidden;
38
-
39
- &:not(:focus) {
40
- clip: rect(1px, 1px, 1px, 1px);
41
- clip-path: inset(50%);
42
- height: 1px;
43
- width: 1px;
44
- margin: -1px;
45
- padding: 0;
46
- }
47
-`
48
-
12
const GlobalStyles = createGlobalStyle`
13
body {
14
color: ${themeGet('colors.fg.default')};
src/theme.js
+1
@@ -13,6 +13,7 @@ export const theme = deepmerge(primerTheme, {
13
colors: {
14
accent: {
15
fg: NPM_RED,
16
+ emphasis: NPM_RED,
17
},
18
},
19
},
src/util/get-nav.js
+23
-24
@@ -143,11 +143,7 @@ export const getHierarchy = (root, props = {}) => {
143
children = root.children
144
}
145
146
- if (children && props.hideVariants === true) {
147
- children = hideVariantsForItems(children, props.path)
148
- }
149
-
150
- return children
146
+ return children && props.hideVariants === true ? hideVariantsForItems(children, props.path) : children
147
}
148
149
export const hideVariantsForItems = (items, path) => {
@@ -159,10 +155,9 @@ export const hideVariantsForItems = (items, path) => {
155
156
for (const item of items) {
157
if (item.variants) {
162
- const {url} = getCurrentOrDefaultVariant(item, path)
158
updated.push({
159
...item,
165
- url,
160
+ url: getCurrentOrDefaultVariant(item, path).url,
161
})
162
} else {
163
updated.push(item)
@@ -173,13 +168,7 @@ export const hideVariantsForItems = (items, path) => {
168
}
169
170
export const getCurrentOrDefaultVariant = (root, path) => {
176
- let variant = path ? getCurrentVariant(root, path) : null
177
-
178
- if (!variant) {
179
- variant = getDefaultVariant(root)
180
- }
181
-
182
- return variant
171
+ return getCurrentVariant(root, path) || getDefaultVariant(root)
172
}
173
174
export const getCurrentVariant = (root, path) => {
@@ -218,6 +207,19 @@ export const getVariantAndPage = (root, path) => {
207
return {variant: match[1], page: match[2]}
208
}
209
210
+export const didVariantChange = (oldPath, newPath) => {
211
+ if (!oldPath || !newPath || oldPath === newPath) {
212
+ return false
213
+ }
214
+
215
+ const oldVariant = getVariantAndPage(getVariantRoot(oldPath), getPath(oldPath))?.variant
216
+ if (!oldVariant) {
217
+ return false
218
+ }
219
+
220
+ return oldVariant !== getVariantAndPage(getVariantRoot(newPath), getPath(newPath))?.variant
221
+}
222
+
223
export const getVariantsForPage = (root, page) => {
224
const pages = []
225
const rootItem = findItem(item => (getPath(item.url) === getPath(root) ? item : null))
@@ -229,16 +231,13 @@ export const getVariantsForPage = (root, page) => {
231
}
232
233
const vp = getVariantAndPage(root, variant.url)
232
- let variantPage
233
-
234
- if (vp.page === page) {
235
- variantPage = variant
236
- } else {
237
- variantPage = findItem(item => {
238
- const itemVp = getVariantAndPage(root, item.url)
239
- return itemVp && itemVp.page === page ? item : null
240
- }, variant.children)
241
- }
234
+ const variantPage =
235
+ vp.page === page
236
+ ? variant
237
+ : findItem(item => {
238
+ const itemVp = getVariantAndPage(root, item.url)
239
+ return itemVp?.page === page ? item : null
240
+ }, variant.children)
241
242
if (!variantPage) {
243
continue