@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import cx from 'classnames'
|
||||
import { useRouter } from 'next/router'
|
||||
import { AnchoredOverlay, Button, Dialog, IconButton } from '@primer/react'
|
||||
import { ActionList, ActionMenu, Dialog, IconButton } from '@primer/react'
|
||||
import {
|
||||
KebabHorizontalIcon,
|
||||
LinkExternalIcon,
|
||||
@@ -30,16 +30,13 @@ import styles from './Header.module.scss'
|
||||
export const Header = () => {
|
||||
const router = useRouter()
|
||||
const { error } = useMainContext()
|
||||
const { isHomepageVersion, currentProduct, allVersions } = useMainContext()
|
||||
const { isHomepageVersion, currentProduct } = useMainContext()
|
||||
const { currentVersion } = useVersion()
|
||||
const { t } = useTranslation(['header'])
|
||||
const isRestPage = currentProduct && currentProduct.id === 'rest'
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false)
|
||||
const [scroll, setScroll] = useState(false)
|
||||
const { hasAccount } = useHasAccount()
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false)
|
||||
const openMenuOverlay = useCallback(() => setIsMenuOpen(true), [setIsMenuOpen])
|
||||
const closeMenuOverlay = useCallback(() => setIsMenuOpen(false), [setIsMenuOpen])
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
|
||||
const openSidebar = useCallback(() => setIsSidebarOpen(true), [isSidebarOpen])
|
||||
const closeSidebar = useCallback(() => setIsSidebarOpen(false), [isSidebarOpen])
|
||||
@@ -230,15 +227,12 @@ export const Header = () => {
|
||||
|
||||
{/* The ... navigation menu at medium and smaller widths */}
|
||||
<div>
|
||||
<AnchoredOverlay
|
||||
anchorRef={menuButtonRef}
|
||||
renderAnchor={(anchorProps) => (
|
||||
<Button
|
||||
<ActionMenu aria-labelledby="menu-title">
|
||||
<ActionMenu.Anchor>
|
||||
<IconButton
|
||||
data-testid="mobile-menu"
|
||||
className="px-2"
|
||||
{...anchorProps}
|
||||
icon={KebabHorizontalIcon}
|
||||
aria-label="Open Menu Bar"
|
||||
aria-label="Open Menu"
|
||||
sx={
|
||||
isSearchOpen
|
||||
? // The ... menu button when the smaller width search UI is open. Since the search
|
||||
@@ -271,47 +265,56 @@ export const Header = () => {
|
||||
},
|
||||
}
|
||||
}
|
||||
/>
|
||||
</ActionMenu.Anchor>
|
||||
<ActionMenu.Overlay align="start">
|
||||
{/* Mobile Menu at XS browser width */}
|
||||
<ActionList
|
||||
sx={{
|
||||
'@media (min-width: 544px)': {
|
||||
display: 'none',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{}
|
||||
</Button>
|
||||
)}
|
||||
open={isMenuOpen}
|
||||
onOpen={openMenuOverlay}
|
||||
onClose={closeMenuOverlay}
|
||||
aria-labelledby="menu-title"
|
||||
>
|
||||
<div
|
||||
data-testid="open-mobile-menu"
|
||||
className={cx('pt-2', !signupCTAVisible && 'pb-2', styles.menuOverlay)}
|
||||
>
|
||||
<span id="menu-title" className="f6 px-3 py-2 mb-1 d-block h6 color-fg-muted">
|
||||
{t('menu')}
|
||||
</span>
|
||||
<span className="px-2 pb-2 m-2 d-block d-sm-none">
|
||||
<VersionPicker mediumOrLower={true} />
|
||||
</span>
|
||||
<span className="px-2 pb-2 m-2 d-block">
|
||||
<LanguagePicker mediumOrLower={true} />
|
||||
</span>
|
||||
{isRestPage && allVersions[currentVersion].apiVersions.length > 0 && (
|
||||
<span className="px-2 pb-2 m-2 d-block">
|
||||
<ApiVersionPicker />
|
||||
</span>
|
||||
)}
|
||||
<ActionList.Group data-testid="open-xs-mobile-menu">
|
||||
<LanguagePicker xs={true} />
|
||||
<ActionList.Divider />
|
||||
<VersionPicker xs={true} />
|
||||
{signupCTAVisible && (
|
||||
<>
|
||||
<ActionList.Divider />
|
||||
<ActionList.LinkItem
|
||||
href="https://github.com/signup?ref_cta=Sign+up&ref_loc=docs+header&ref_page=docs"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
data-testid="xs-mobile-signup"
|
||||
className="d-flex color-fg-muted"
|
||||
>
|
||||
{t`sign_up_cta`}
|
||||
<LinkExternalIcon
|
||||
className="height-full float-right"
|
||||
aria-label="(external site)"
|
||||
/>
|
||||
</ActionList.LinkItem>
|
||||
</>
|
||||
)}{' '}
|
||||
</ActionList.Group>
|
||||
</ActionList>
|
||||
<LanguagePicker mediumOrLower={true} />
|
||||
{signupCTAVisible && (
|
||||
<Link
|
||||
href="https://github.com/signup?ref_cta=Sign+up&ref_loc=docs+header&ref_page=docs"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
data-testid="mobile-signup"
|
||||
className="d-flex flex-justify-between flex-items-center color-fg-muted border-top px-3 py-3"
|
||||
className="hide-sm d-flex flex-justify-between flex-items-center color-fg-muted border-top px-3 py-3"
|
||||
>
|
||||
{t`sign_up_cta`}
|
||||
<LinkExternalIcon aria-label="(external site)" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</AnchoredOverlay>
|
||||
)}{' '}
|
||||
</ActionMenu.Overlay>
|
||||
</ActionMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { useRouter } from 'next/router'
|
||||
import { GlobeIcon } from '@primer/octicons-react'
|
||||
import { GlobeIcon, KebabHorizontalIcon } from '@primer/octicons-react'
|
||||
|
||||
import { useLanguages } from 'components/context/LanguagesContext'
|
||||
import { useTranslation } from 'components/hooks/useTranslation'
|
||||
import { useUserLanguage } from 'components/hooks/useUserLanguage'
|
||||
import { Picker } from 'src/tools/components/Picker'
|
||||
import { ActionList, ActionMenu, IconButton, Link } from '@primer/react'
|
||||
|
||||
type Props = {
|
||||
xs?: boolean
|
||||
mediumOrLower?: boolean
|
||||
}
|
||||
|
||||
export const LanguagePicker = ({ mediumOrLower }: Props) => {
|
||||
export const LanguagePicker = ({ xs, mediumOrLower }: Props) => {
|
||||
const router = useRouter()
|
||||
const { languages } = useLanguages()
|
||||
const { setUserLanguageCookie } = useUserLanguage()
|
||||
@@ -37,39 +38,80 @@ export const LanguagePicker = ({ mediumOrLower }: Props) => {
|
||||
// in a "denormalized" way.
|
||||
const routerPath = router.asPath.split('#')[0]
|
||||
|
||||
return (
|
||||
<div data-testid="language-picker">
|
||||
<Picker
|
||||
defaultText={t('language_picker_default_text')}
|
||||
items={langs.map((lang) => ({
|
||||
text: lang.nativeName || lang.name,
|
||||
selected: lang === selectedLang,
|
||||
href: `/${lang.code}${routerPath}`,
|
||||
extra: {
|
||||
locale: lang.code,
|
||||
},
|
||||
}))}
|
||||
pickerLabel={mediumOrLower ? 'Language' : ''}
|
||||
iconButton={mediumOrLower ? undefined : GlobeIcon}
|
||||
onSelect={(item) => {
|
||||
if (item.extra?.locale) {
|
||||
try {
|
||||
setUserLanguageCookie(item.extra.locale)
|
||||
} catch (err) {
|
||||
// You can never be too careful because setting a cookie
|
||||
// can fail. For example, some browser
|
||||
// extensions disallow all setting of cookies and attempts
|
||||
// at the `document.cookie` setter could throw. Just swallow
|
||||
// and move on.
|
||||
console.warn('Unable to set preferred language cookie', err)
|
||||
}
|
||||
// languageList is specifically <ActionList.Item>'s which are reused
|
||||
// for menus that behave differently at the breakpoints.
|
||||
const languageList = langs.map((lang) => (
|
||||
<ActionList.Item
|
||||
key={`/${lang.code}${routerPath}`}
|
||||
selected={lang === selectedLang}
|
||||
as={Link}
|
||||
href={`/${lang.code}${routerPath}`}
|
||||
onSelect={() => {
|
||||
if (lang.code) {
|
||||
try {
|
||||
setUserLanguageCookie(lang.code)
|
||||
} catch (err) {
|
||||
// You can never be too careful because setting a cookie
|
||||
// can fail. For example, some browser
|
||||
// extensions disallow all setting of cookies and attempts
|
||||
// at the `document.cookie` setter could throw. Just swallow
|
||||
// and move on.
|
||||
console.warn('Unable to set preferred language cookie', err)
|
||||
}
|
||||
}}
|
||||
buttonBorder={mediumOrLower}
|
||||
dataTestId="default-language"
|
||||
ariaLabel={`Select language: current language is ${selectedLang.name}`}
|
||||
alignment={mediumOrLower ? 'start' : 'end'}
|
||||
/>
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span data-testid="default-language">{lang.nativeName || lang.name}</span>
|
||||
</ActionList.Item>
|
||||
))
|
||||
|
||||
// At large breakpoints, we return the full <ActionMenu> with just the languages,
|
||||
// at smaller breakpoints, we return just the <ActionList> with its items so that
|
||||
// the <Header> component can place it inside its own <ActionMenu> with multiple
|
||||
// groups, language being just one of those groups.
|
||||
return (
|
||||
<div data-testid="language-picker" className="d-flex">
|
||||
{xs ? (
|
||||
<>
|
||||
{/* XS Mobile Menu */}
|
||||
<ActionMenu>
|
||||
<ActionMenu.Anchor>
|
||||
<ActionMenu.Button
|
||||
variant="invisible"
|
||||
className="color-fg-default width-full"
|
||||
aria-label={`Select language: current language is ${selectedLang.name}`}
|
||||
sx={{
|
||||
height: 'auto',
|
||||
textAlign: 'left',
|
||||
'span:first-child': { display: 'inline' },
|
||||
}}
|
||||
>
|
||||
<span style={{ whiteSpace: 'pre-wrap' }}>{t('language_picker_label') + '\n'}</span>
|
||||
<span className="color-fg-muted text-normal f6">{selectedLang.name}</span>
|
||||
</ActionMenu.Button>
|
||||
</ActionMenu.Anchor>
|
||||
<ActionMenu.Overlay align="start">
|
||||
<ActionList selectionVariant="single">{languageList}</ActionList>
|
||||
</ActionMenu.Overlay>
|
||||
</ActionMenu>
|
||||
</>
|
||||
) : mediumOrLower ? (
|
||||
<ActionList className="hide-sm" selectionVariant="single">
|
||||
<ActionList.Group title={t('language_picker_label')}>{languageList}</ActionList.Group>
|
||||
</ActionList>
|
||||
) : (
|
||||
<ActionMenu>
|
||||
<ActionMenu.Anchor>
|
||||
<IconButton
|
||||
icon={mediumOrLower ? KebabHorizontalIcon : GlobeIcon}
|
||||
aria-label={`Select language: current language is ${selectedLang.name}`}
|
||||
/>
|
||||
</ActionMenu.Anchor>
|
||||
<ActionMenu.Overlay align="end">
|
||||
<ActionList selectionVariant="single">{languageList}</ActionList>
|
||||
</ActionMenu.Overlay>
|
||||
</ActionMenu>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,10 +9,10 @@ import { Picker } from 'src/tools/components/Picker'
|
||||
import styles from './VersionPicker.module.scss'
|
||||
|
||||
type Props = {
|
||||
mediumOrLower?: boolean
|
||||
xs?: boolean
|
||||
}
|
||||
|
||||
export const VersionPicker = ({ mediumOrLower }: Props) => {
|
||||
export const VersionPicker = ({ xs }: Props) => {
|
||||
const router = useRouter()
|
||||
const { currentVersion } = useVersion()
|
||||
const { allVersions, page, enterpriseServerVersions } = useMainContext()
|
||||
@@ -81,14 +81,14 @@ export const VersionPicker = ({ mediumOrLower }: Props) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="version-picker">
|
||||
<div data-testid="version-picker" className={xs ? 'd-flex' : ''}>
|
||||
<Picker
|
||||
defaultText={t('version_picker_default_text')}
|
||||
items={allLinks}
|
||||
alignment="start"
|
||||
pickerLabel="Version"
|
||||
alignment="end"
|
||||
pickerLabel={xs ? `Version\n` : `Version: `}
|
||||
dataTestId="field"
|
||||
buttonBorder={mediumOrLower}
|
||||
descriptionFontSize={xs ? 6 : 5}
|
||||
ariaLabel={`Select GitHub product version: current version is ${currentVersion}`}
|
||||
renderItem={(item) => {
|
||||
return (
|
||||
|
||||
@@ -17,7 +17,7 @@ header:
|
||||
sign_up_cta: Sign up
|
||||
menu: Menu
|
||||
picker:
|
||||
language_picker_default_text: Choose a language
|
||||
language_picker_label: Language
|
||||
product_picker_default_text: All products
|
||||
version_picker_default_text: Choose a version
|
||||
release_notes:
|
||||
|
||||
@@ -79,11 +79,11 @@ export const ApiVersionPicker = () => {
|
||||
// This only shows the REST Version picker if it's calendar date versioned
|
||||
return allVersions[currentVersion].apiVersions.length > 0 ? (
|
||||
<div className="mb-3">
|
||||
<div data-testid="api-version-picker" className="width-full">
|
||||
<div data-testid="api-version-picker">
|
||||
<Picker
|
||||
defaultText={currentDateDisplayText}
|
||||
items={apiVersionLinks}
|
||||
pickerLabel="API Version"
|
||||
pickerLabel="API Version: "
|
||||
alignment="start"
|
||||
buttonBorder={true}
|
||||
dataTestId="version"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ReactNode, useState } from 'react'
|
||||
import { ActionMenu, IconButton } from '@primer/react'
|
||||
import { Icon } from '@primer/octicons-react'
|
||||
import { ActionMenu } from '@primer/react'
|
||||
|
||||
import { AnchorAlignment } from '@primer/behaviors'
|
||||
|
||||
@@ -8,7 +7,6 @@ import { Fields } from './Fields'
|
||||
|
||||
interface Props {
|
||||
items: PickerItem[]
|
||||
iconButton?: Icon
|
||||
onSelect?: (item: PickerItem) => void
|
||||
buttonBorder?: boolean
|
||||
pickerLabel?: string
|
||||
@@ -16,6 +14,7 @@ interface Props {
|
||||
defaultText: string
|
||||
ariaLabel: string
|
||||
alignment: AnchorAlignment
|
||||
descriptionFontSize?: number
|
||||
renderItem?: (item: PickerItem) => ReactNode | string
|
||||
}
|
||||
|
||||
@@ -31,39 +30,38 @@ export interface PickerItem {
|
||||
|
||||
export const Picker = ({
|
||||
items,
|
||||
iconButton,
|
||||
ariaLabel,
|
||||
pickerLabel,
|
||||
buttonBorder,
|
||||
dataTestId,
|
||||
defaultText,
|
||||
onSelect,
|
||||
buttonBorder,
|
||||
alignment,
|
||||
descriptionFontSize,
|
||||
renderItem,
|
||||
}: Props) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const selectedOption = items.find((item) => item.selected === true)
|
||||
return (
|
||||
<ActionMenu open={open} onOpenChange={setOpen}>
|
||||
{iconButton ? (
|
||||
<ActionMenu.Anchor>
|
||||
<IconButton icon={iconButton} aria-label={ariaLabel} />
|
||||
</ActionMenu.Anchor>
|
||||
) : (
|
||||
<ActionMenu.Button
|
||||
aria-label={ariaLabel}
|
||||
variant={buttonBorder ? 'default' : 'invisible'}
|
||||
sx={{
|
||||
color: `var(--color-fg-default)`,
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
<ActionMenu.Button
|
||||
aria-label={ariaLabel}
|
||||
variant={buttonBorder ? 'default' : 'invisible'}
|
||||
className="color-fg-default width-full p-1 pl-2 pr-2"
|
||||
sx={{
|
||||
height: 'auto',
|
||||
textAlign: 'left',
|
||||
'span:first-child': { display: 'inline' },
|
||||
}}
|
||||
>
|
||||
{pickerLabel && <span style={{ whiteSpace: 'pre-wrap' }}>{`${pickerLabel}`}</span>}
|
||||
<span
|
||||
className={`f${descriptionFontSize} color-fg-muted text-normal`}
|
||||
data-testid={dataTestId}
|
||||
>
|
||||
{pickerLabel && <span className="color-fg-muted text-normal">{`${pickerLabel}: `}</span>}
|
||||
<span data-testid={dataTestId}>{selectedOption?.text || defaultText}</span>
|
||||
</ActionMenu.Button>
|
||||
)}
|
||||
{selectedOption?.text || defaultText}
|
||||
</span>
|
||||
</ActionMenu.Button>
|
||||
<ActionMenu.Overlay width="auto" align={alignment}>
|
||||
<Fields
|
||||
open={open}
|
||||
|
||||
2
tests/fixtures/data/ui.yml
vendored
2
tests/fixtures/data/ui.yml
vendored
@@ -17,7 +17,7 @@ header:
|
||||
sign_up_cta: Sign up
|
||||
menu: Menu
|
||||
picker:
|
||||
language_picker_default_text: Choose a language
|
||||
language_picker_label: Language
|
||||
product_picker_default_text: All products
|
||||
version_picker_default_text: Choose a version
|
||||
release_notes:
|
||||
|
||||
@@ -332,7 +332,7 @@ test.describe('test nav at different viewports', () => {
|
||||
|
||||
// language picker is in mobile menu
|
||||
await page.getByTestId('mobile-menu').click()
|
||||
await page.getByRole('button', { name: 'Select language: current language is English' }).click()
|
||||
await page.getByTestId('language-picker')
|
||||
await expect(page.getByRole('menuitemradio', { name: 'English' })).toBeVisible()
|
||||
|
||||
// sign up button is in mobile menu
|
||||
@@ -346,7 +346,41 @@ test.describe('test nav at different viewports', () => {
|
||||
|
||||
test('small viewports - 544-767', async ({ page }) => {
|
||||
page.setViewportSize({
|
||||
width: 500,
|
||||
width: 555,
|
||||
height: 700,
|
||||
})
|
||||
await page.goto('/get-started/foo/bar')
|
||||
|
||||
// header sign-up button is not visible
|
||||
await expect(page.getByTestId('header-signup')).not.toBeVisible()
|
||||
|
||||
// language picker is not visible
|
||||
await expect(page.getByTestId('language-picker')).not.toBeVisible()
|
||||
|
||||
// version picker is visible
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: 'Select GitHub product version: current version is free-pro-team@latest',
|
||||
}),
|
||||
).toBeVisible()
|
||||
|
||||
// language picker is in mobile menu
|
||||
await page.getByTestId('mobile-menu').click()
|
||||
await page.getByTestId('language-picker')
|
||||
await expect(page.getByRole('menuitemradio', { name: 'English' })).toBeVisible()
|
||||
|
||||
// sign up button is in mobile menu
|
||||
await expect(page.getByTestId('mobile-signup')).toBeVisible()
|
||||
|
||||
// hamburger button for sidebar overlay is visible
|
||||
await expect(page.getByTestId('sidebar-hamburger')).toBeVisible()
|
||||
await page.getByTestId('sidebar-hamburger').click()
|
||||
await expect(page.getByTestId('sidebar-product-dialog')).toBeVisible()
|
||||
})
|
||||
|
||||
test('x-small viewports - 0-544', async ({ page }) => {
|
||||
page.setViewportSize({
|
||||
width: 345,
|
||||
height: 700,
|
||||
})
|
||||
await page.goto('/get-started/foo/bar')
|
||||
@@ -367,13 +401,17 @@ test.describe('test nav at different viewports', () => {
|
||||
// version picker is in mobile menu
|
||||
await expect(page.getByTestId('version-picker')).not.toBeVisible()
|
||||
await page.getByTestId('mobile-menu').click()
|
||||
await expect(page.getByTestId('open-mobile-menu').getByTestId('version-picker')).toBeVisible()
|
||||
await expect(
|
||||
page.getByTestId('open-xs-mobile-menu').getByTestId('version-picker'),
|
||||
).toBeVisible()
|
||||
|
||||
// language picker is in mobile menu
|
||||
await expect(page.getByTestId('open-mobile-menu').getByTestId('language-picker')).toBeVisible()
|
||||
await expect(
|
||||
page.getByTestId('open-xs-mobile-menu').getByTestId('language-picker'),
|
||||
).toBeVisible()
|
||||
|
||||
// sign up button is in mobile menu
|
||||
await expect(page.getByTestId('open-mobile-menu').getByTestId('version-picker')).toBeVisible()
|
||||
await expect(page.getByTestId('xs-mobile-signup')).toBeVisible()
|
||||
|
||||
// hamburger button for sidebar overlay is visible
|
||||
await expect(page.getByTestId('sidebar-hamburger')).toBeVisible()
|
||||
|
||||
Reference in New Issue
Block a user