Skip to content

Commit e32f8e7

Browse files
Redesign mobile menu (github#39762)
Co-authored-by: Grace Park <gracepark@github.com>
1 parent 1daafdc commit e32f8e7

8 files changed

Lines changed: 196 additions & 115 deletions

File tree

components/page-header/Header.tsx

Lines changed: 45 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useCallback, useEffect, useRef, useState } from 'react'
22
import cx from 'classnames'
33
import { useRouter } from 'next/router'
4-
import { AnchoredOverlay, Button, Dialog, IconButton } from '@primer/react'
4+
import { ActionList, ActionMenu, Dialog, IconButton } from '@primer/react'
55
import {
66
KebabHorizontalIcon,
77
LinkExternalIcon,
@@ -30,16 +30,13 @@ import styles from './Header.module.scss'
3030
export const Header = () => {
3131
const router = useRouter()
3232
const { error } = useMainContext()
33-
const { isHomepageVersion, currentProduct, allVersions } = useMainContext()
33+
const { isHomepageVersion, currentProduct } = useMainContext()
3434
const { currentVersion } = useVersion()
3535
const { t } = useTranslation(['header'])
3636
const isRestPage = currentProduct && currentProduct.id === 'rest'
3737
const [isSearchOpen, setIsSearchOpen] = useState(false)
3838
const [scroll, setScroll] = useState(false)
3939
const { hasAccount } = useHasAccount()
40-
const [isMenuOpen, setIsMenuOpen] = useState(false)
41-
const openMenuOverlay = useCallback(() => setIsMenuOpen(true), [setIsMenuOpen])
42-
const closeMenuOverlay = useCallback(() => setIsMenuOpen(false), [setIsMenuOpen])
4340
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
4441
const openSidebar = useCallback(() => setIsSidebarOpen(true), [isSidebarOpen])
4542
const closeSidebar = useCallback(() => setIsSidebarOpen(false), [isSidebarOpen])
@@ -230,15 +227,12 @@ export const Header = () => {
230227

231228
{/* The ... navigation menu at medium and smaller widths */}
232229
<div>
233-
<AnchoredOverlay
234-
anchorRef={menuButtonRef}
235-
renderAnchor={(anchorProps) => (
236-
<Button
230+
<ActionMenu aria-labelledby="menu-title">
231+
<ActionMenu.Anchor>
232+
<IconButton
237233
data-testid="mobile-menu"
238-
className="px-2"
239-
{...anchorProps}
240234
icon={KebabHorizontalIcon}
241-
aria-label="Open Menu Bar"
235+
aria-label="Open Menu"
242236
sx={
243237
isSearchOpen
244238
? // The ... menu button when the smaller width search UI is open. Since the search
@@ -271,47 +265,56 @@ export const Header = () => {
271265
},
272266
}
273267
}
268+
/>
269+
</ActionMenu.Anchor>
270+
<ActionMenu.Overlay align="start">
271+
{/* Mobile Menu at XS browser width */}
272+
<ActionList
273+
sx={{
274+
'@media (min-width: 544px)': {
275+
display: 'none',
276+
},
277+
}}
274278
>
275-
{}
276-
</Button>
277-
)}
278-
open={isMenuOpen}
279-
onOpen={openMenuOverlay}
280-
onClose={closeMenuOverlay}
281-
aria-labelledby="menu-title"
282-
>
283-
<div
284-
data-testid="open-mobile-menu"
285-
className={cx('pt-2', !signupCTAVisible && 'pb-2', styles.menuOverlay)}
286-
>
287-
<span id="menu-title" className="f6 px-3 py-2 mb-1 d-block h6 color-fg-muted">
288-
{t('menu')}
289-
</span>
290-
<span className="px-2 pb-2 m-2 d-block d-sm-none">
291-
<VersionPicker mediumOrLower={true} />
292-
</span>
293-
<span className="px-2 pb-2 m-2 d-block">
294-
<LanguagePicker mediumOrLower={true} />
295-
</span>
296-
{isRestPage && allVersions[currentVersion].apiVersions.length > 0 && (
297-
<span className="px-2 pb-2 m-2 d-block">
298-
<ApiVersionPicker />
299-
</span>
300-
)}
279+
<ActionList.Group data-testid="open-xs-mobile-menu">
280+
<LanguagePicker xs={true} />
281+
<ActionList.Divider />
282+
<VersionPicker xs={true} />
283+
{signupCTAVisible && (
284+
<>
285+
<ActionList.Divider />
286+
<ActionList.LinkItem
287+
href="https://github.com/signup?ref_cta=Sign+up&ref_loc=docs+header&ref_page=docs"
288+
target="_blank"
289+
rel="noopener"
290+
data-testid="xs-mobile-signup"
291+
className="d-flex color-fg-muted"
292+
>
293+
{t`sign_up_cta`}
294+
<LinkExternalIcon
295+
className="height-full float-right"
296+
aria-label="(external site)"
297+
/>
298+
</ActionList.LinkItem>
299+
</>
300+
)}{' '}
301+
</ActionList.Group>
302+
</ActionList>
303+
<LanguagePicker mediumOrLower={true} />
301304
{signupCTAVisible && (
302305
<Link
303306
href="https://github.com/signup?ref_cta=Sign+up&ref_loc=docs+header&ref_page=docs"
304307
target="_blank"
305308
rel="noopener"
306309
data-testid="mobile-signup"
307-
className="d-flex flex-justify-between flex-items-center color-fg-muted border-top px-3 py-3"
310+
className="hide-sm d-flex flex-justify-between flex-items-center color-fg-muted border-top px-3 py-3"
308311
>
309312
{t`sign_up_cta`}
310313
<LinkExternalIcon aria-label="(external site)" />
311314
</Link>
312-
)}
313-
</div>
314-
</AnchoredOverlay>
315+
)}{' '}
316+
</ActionMenu.Overlay>
317+
</ActionMenu>
315318
</div>
316319
</div>
317320
</div>
Lines changed: 77 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
import { useRouter } from 'next/router'
2-
import { GlobeIcon } from '@primer/octicons-react'
2+
import { GlobeIcon, KebabHorizontalIcon } from '@primer/octicons-react'
33

44
import { useLanguages } from 'components/context/LanguagesContext'
55
import { useTranslation } from 'components/hooks/useTranslation'
66
import { useUserLanguage } from 'components/hooks/useUserLanguage'
7-
import { Picker } from 'src/tools/components/Picker'
7+
import { ActionList, ActionMenu, IconButton, Link } from '@primer/react'
88

99
type Props = {
10+
xs?: boolean
1011
mediumOrLower?: boolean
1112
}
1213

13-
export const LanguagePicker = ({ mediumOrLower }: Props) => {
14+
export const LanguagePicker = ({ xs, mediumOrLower }: Props) => {
1415
const router = useRouter()
1516
const { languages } = useLanguages()
1617
const { setUserLanguageCookie } = useUserLanguage()
@@ -37,39 +38,80 @@ export const LanguagePicker = ({ mediumOrLower }: Props) => {
3738
// in a "denormalized" way.
3839
const routerPath = router.asPath.split('#')[0]
3940

40-
return (
41-
<div data-testid="language-picker">
42-
<Picker
43-
defaultText={t('language_picker_default_text')}
44-
items={langs.map((lang) => ({
45-
text: lang.nativeName || lang.name,
46-
selected: lang === selectedLang,
47-
href: `/${lang.code}${routerPath}`,
48-
extra: {
49-
locale: lang.code,
50-
},
51-
}))}
52-
pickerLabel={mediumOrLower ? 'Language' : ''}
53-
iconButton={mediumOrLower ? undefined : GlobeIcon}
54-
onSelect={(item) => {
55-
if (item.extra?.locale) {
56-
try {
57-
setUserLanguageCookie(item.extra.locale)
58-
} catch (err) {
59-
// You can never be too careful because setting a cookie
60-
// can fail. For example, some browser
61-
// extensions disallow all setting of cookies and attempts
62-
// at the `document.cookie` setter could throw. Just swallow
63-
// and move on.
64-
console.warn('Unable to set preferred language cookie', err)
65-
}
41+
// languageList is specifically <ActionList.Item>'s which are reused
42+
// for menus that behave differently at the breakpoints.
43+
const languageList = langs.map((lang) => (
44+
<ActionList.Item
45+
key={`/${lang.code}${routerPath}`}
46+
selected={lang === selectedLang}
47+
as={Link}
48+
href={`/${lang.code}${routerPath}`}
49+
onSelect={() => {
50+
if (lang.code) {
51+
try {
52+
setUserLanguageCookie(lang.code)
53+
} catch (err) {
54+
// You can never be too careful because setting a cookie
55+
// can fail. For example, some browser
56+
// extensions disallow all setting of cookies and attempts
57+
// at the `document.cookie` setter could throw. Just swallow
58+
// and move on.
59+
console.warn('Unable to set preferred language cookie', err)
6660
}
67-
}}
68-
buttonBorder={mediumOrLower}
69-
dataTestId="default-language"
70-
ariaLabel={`Select language: current language is ${selectedLang.name}`}
71-
alignment={mediumOrLower ? 'start' : 'end'}
72-
/>
61+
}
62+
}}
63+
>
64+
<span data-testid="default-language">{lang.nativeName || lang.name}</span>
65+
</ActionList.Item>
66+
))
67+
68+
// At large breakpoints, we return the full <ActionMenu> with just the languages,
69+
// at smaller breakpoints, we return just the <ActionList> with its items so that
70+
// the <Header> component can place it inside its own <ActionMenu> with multiple
71+
// groups, language being just one of those groups.
72+
return (
73+
<div data-testid="language-picker" className="d-flex">
74+
{xs ? (
75+
<>
76+
{/* XS Mobile Menu */}
77+
<ActionMenu>
78+
<ActionMenu.Anchor>
79+
<ActionMenu.Button
80+
variant="invisible"
81+
className="color-fg-default width-full"
82+
aria-label={`Select language: current language is ${selectedLang.name}`}
83+
sx={{
84+
height: 'auto',
85+
textAlign: 'left',
86+
'span:first-child': { display: 'inline' },
87+
}}
88+
>
89+
<span style={{ whiteSpace: 'pre-wrap' }}>{t('language_picker_label') + '\n'}</span>
90+
<span className="color-fg-muted text-normal f6">{selectedLang.name}</span>
91+
</ActionMenu.Button>
92+
</ActionMenu.Anchor>
93+
<ActionMenu.Overlay align="start">
94+
<ActionList selectionVariant="single">{languageList}</ActionList>
95+
</ActionMenu.Overlay>
96+
</ActionMenu>
97+
</>
98+
) : mediumOrLower ? (
99+
<ActionList className="hide-sm" selectionVariant="single">
100+
<ActionList.Group title={t('language_picker_label')}>{languageList}</ActionList.Group>
101+
</ActionList>
102+
) : (
103+
<ActionMenu>
104+
<ActionMenu.Anchor>
105+
<IconButton
106+
icon={mediumOrLower ? KebabHorizontalIcon : GlobeIcon}
107+
aria-label={`Select language: current language is ${selectedLang.name}`}
108+
/>
109+
</ActionMenu.Anchor>
110+
<ActionMenu.Overlay align="end">
111+
<ActionList selectionVariant="single">{languageList}</ActionList>
112+
</ActionMenu.Overlay>
113+
</ActionMenu>
114+
)}
73115
</div>
74116
)
75117
}

components/page-header/VersionPicker.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@ import { Picker } from 'src/tools/components/Picker'
99
import styles from './VersionPicker.module.scss'
1010

1111
type Props = {
12-
mediumOrLower?: boolean
12+
xs?: boolean
1313
}
1414

15-
export const VersionPicker = ({ mediumOrLower }: Props) => {
15+
export const VersionPicker = ({ xs }: Props) => {
1616
const router = useRouter()
1717
const { currentVersion } = useVersion()
1818
const { allVersions, page, enterpriseServerVersions } = useMainContext()
@@ -81,14 +81,14 @@ export const VersionPicker = ({ mediumOrLower }: Props) => {
8181
}
8282

8383
return (
84-
<div data-testid="version-picker">
84+
<div data-testid="version-picker" className={xs ? 'd-flex' : ''}>
8585
<Picker
8686
defaultText={t('version_picker_default_text')}
8787
items={allLinks}
88-
alignment="start"
89-
pickerLabel="Version"
88+
alignment="end"
89+
pickerLabel={xs ? `Version\n` : `Version: `}
9090
dataTestId="field"
91-
buttonBorder={mediumOrLower}
91+
descriptionFontSize={xs ? 6 : 5}
9292
ariaLabel={`Select GitHub product version: current version is ${currentVersion}`}
9393
renderItem={(item) => {
9494
return (

data/ui.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ header:
1717
sign_up_cta: Sign up
1818
menu: Menu
1919
picker:
20-
language_picker_default_text: Choose a language
20+
language_picker_label: Language
2121
product_picker_default_text: All products
2222
version_picker_default_text: Choose a version
2323
release_notes:

src/rest/components/ApiVersionPicker.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,11 +79,11 @@ export const ApiVersionPicker = () => {
7979
// This only shows the REST Version picker if it's calendar date versioned
8080
return allVersions[currentVersion].apiVersions.length > 0 ? (
8181
<div className="mb-3">
82-
<div data-testid="api-version-picker" className="width-full">
82+
<div data-testid="api-version-picker">
8383
<Picker
8484
defaultText={currentDateDisplayText}
8585
items={apiVersionLinks}
86-
pickerLabel="API Version"
86+
pickerLabel="API Version: "
8787
alignment="start"
8888
buttonBorder={true}
8989
dataTestId="version"

0 commit comments

Comments
 (0)