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