fix(a11y): main menu a11y updates (#45137)

* fix: main menu a11y updates

* update font menu

* fix: sign in button text alignment

* fix: disabled button font

* fix: language menu font

* remove sign-in from main menu

* fix: add section dividers to menu

* fix: height on language selector menu

* fix: alignment of Menu button text

* fix: language globe icon

* refactor: remove dup selector

* update language menu colors

* refactor: clearer name for language menu display state

* fix: don't close onBlur if Menu button is clicked

* refactor: move globe icon styling to CSS

* refactor: get rid of switch statements

* refactor: remove try catch block

* fix: translate Change language button

* fix: move search into nav menu for mobile layout

* fix: forgot a merge

* refactor: updates for changes in i18n/all-langs

* fix: prevent menu from collapsing when focus is on change language button and user clicks into search

* fix: translate cancel change option in language picker

* feat: add cypress tests

* feat: display the complete language list

* fix: fix TS typing

* fix: force scrollbar on lang menu

* fix: remove scroll bar from lang menu

* fix: close menu when user tabs away from last menu item

* add list role to navigation list to appease Safari

* chore: capitalize Change Language/remove CSS comment

* fix: right side search box alignment in narrow view

* remove extraneous list role

* fix: cypress

Co-authored-by: ahmad abdolsaheb <ahmad.abdolsaheb@gmail.com>
Co-authored-by: moT01 <20648924+moT01@users.noreply.github.com>
pull/46267/head
Bruce Blaser 2022-06-01 00:39:26 -07:00 committed by GitHub
parent 6d89576b6c
commit d2332093f6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 912 additions and 545 deletions

View File

@ -70,7 +70,9 @@
"start-coding": "Start coding!",
"go-to-settings": "Go to settings to claim your certification",
"click-start-course": "Start the course",
"click-start-project": "Start the project"
"click-start-project": "Start the project",
"change-language": "Change Language",
"cancel-change": "Cancel Change"
},
"landing": {
"big-heading-1": "Learn to code — for free.",

View File

@ -0,0 +1,58 @@
import React from 'react';
function LanguageGlobe(
props: JSX.IntrinsicAttributes & React.SVGProps<SVGSVGElement>
): JSX.Element {
return (
<>
<svg
aria-hidden={true}
height={22}
viewBox='0 0 24 24'
width={24}
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<path
d='M2 12C2 17.5228 6.47715 22 12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12Z'
stroke='currentColor'
strokeWidth='1.5'
strokeLinecap='round'
strokeLinejoin='round'
/>
<path
d='M13 2.04932C13 2.04932 16 5.99994 16 11.9999C16 17.9999 13 21.9506 13 21.9506'
stroke='currentColor'
strokeWidth='1.5'
strokeLinecap='round'
strokeLinejoin='round'
/>
<path
d='M11 21.9506C11 21.9506 8 17.9999 8 11.9999C8 5.99994 11 2.04932 11 2.04932'
stroke='currentColor'
strokeWidth='1.5'
strokeLinecap='round'
strokeLinejoin='round'
/>
<path
d='M2.62964 15.5H21.3704'
stroke='currentColor'
strokeWidth='1.5'
strokeLinecap='round'
strokeLinejoin='round'
/>
<path
d='M2.62964 8.5H21.3704'
stroke='currentColor'
strokeWidth='1.5'
strokeLinecap='round'
strokeLinejoin='round'
/>
</svg>
</>
);
}
LanguageGlobe.displayName = 'LanguageGlobe';
export default LanguageGlobe;

View File

@ -1,39 +1,54 @@
import React, { RefObject } from 'react';
import { useTranslation } from 'react-i18next';
import AuthOrProfile from './auth-or-profile';
export interface MenuButtonProps {
className?: string;
displayMenu?: boolean;
innerRef?: RefObject<HTMLButtonElement>;
onClick?: React.MouseEventHandler<HTMLButtonElement> | undefined;
user?: Record<string, unknown>;
showMenu: () => void;
hideMenu: () => void;
}
const MenuButton = ({
displayMenu,
innerRef,
onClick,
user
showMenu,
hideMenu
}: MenuButtonProps): JSX.Element => {
const { t } = useTranslation();
// Will close the menu if the user Shift+Tabs from the menu button.
const handleBlur = (event: React.FocusEvent<HTMLButtonElement>): void => {
if (
event.relatedTarget &&
!event.relatedTarget.closest('.universal-nav-right') &&
displayMenu
) {
hideMenu();
}
};
const handleClick = (): void => {
if (displayMenu) {
hideMenu();
return;
}
showMenu();
};
return (
<>
<button
aria-expanded={displayMenu}
className={
'toggle-button-nav' + (displayMenu ? ' reverse-toggle-color' : '')
}
onClick={onClick}
ref={innerRef}
>
{t('buttons.menu')}
</button>
<span className='navatar'>
<AuthOrProfile user={user} />
</span>
</>
<button
aria-expanded={displayMenu}
className={`toggle-button-nav${
displayMenu ? ' reverse-toggle-color' : ''
}`}
id='toggle-button-nav'
onBlur={handleBlur}
onClick={handleClick}
ref={innerRef}
>
{t('buttons.menu')}
</button>
);
};

View File

@ -16,19 +16,21 @@ import {
faExternalLinkAlt
} from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React, { Component, Fragment } from 'react';
import React, { Component, Fragment, createRef, Ref } from 'react';
import { TFunction, withTranslation } from 'react-i18next';
import { connect } from 'react-redux';
import envData from '../../../../../config/env.json';
import {
availableLangs,
getLangName
LangNames,
LangCodes
} from '../../../../../config/i18n/all-langs';
import { hardGoTo as navigate } from '../../../redux';
import { updateUserFlag } from '../../../redux/settings';
import createLanguageRedirect from '../../create-language-redirect';
import { Link } from '../../helpers';
import { Themes } from '../../settings/theme';
import LanguageGlobe from '../../../assets/icons/language-globe';
const { clientLocale, radioLocation, apiLocation } = envData;
@ -36,13 +38,17 @@ const locales = availableLangs.client;
export interface NavLinksProps {
displayMenu?: boolean;
isLanguageMenuDisplayed?: boolean;
fetchState?: { pending: boolean };
i18n: Object;
t: TFunction;
toggleDisplayMenu?: React.MouseEventHandler<HTMLButtonElement>;
hideMenu: () => void;
toggleNightMode: (x: any) => any;
user?: Record<string, unknown>;
navigate?: (location: string) => void;
showLanguageMenu?: (elementToFocus: HTMLButtonElement) => void;
hideLanguageMenu?: () => void;
menuButtonRef: Ref<HTMLButtonElement>;
}
const mapDispatchToProps = {
@ -52,10 +58,22 @@ const mapDispatchToProps = {
export class NavLinks extends Component<NavLinksProps, {}> {
static displayName: string;
langButtonRef: React.RefObject<HTMLButtonElement>;
firstLangOptionRef: React.RefObject<HTMLElement>;
lastLangOptionRef: React.RefObject<HTMLElement>;
constructor(props: NavLinksProps) {
super(props);
this.langButtonRef = createRef();
this.firstLangOptionRef = createRef();
this.lastLangOptionRef = createRef();
this.handleLanguageChange = this.handleLanguageChange.bind(this);
this.handleLanguageMenuKeyDown = this.handleLanguageMenuKeyDown.bind(this);
this.handleLanguageButtonClick = this.handleLanguageButtonClick.bind(this);
this.handleLanguageButtonKeyDown =
this.handleLanguageButtonKeyDown.bind(this);
this.handleMenuKeyDown = this.handleMenuKeyDown.bind(this);
this.handleBlur = this.handleBlur.bind(this);
}
toggleTheme(currentTheme = Themes.Default, toggleNightMode: any) {
@ -64,23 +82,172 @@ export class NavLinks extends Component<NavLinksProps, {}> {
);
}
handleLanguageChange = (
event: React.ChangeEvent<HTMLSelectElement>
): void => {
const { toggleDisplayMenu, navigate } = this.props;
toggleDisplayMenu();
getPreviousMenuItem(target: HTMLElement): HTMLElement {
const { menuButtonRef } = this.props;
const previousSibling =
target.closest('.nav-list > li')?.previousElementSibling;
return previousSibling?.querySelector('a, button') ?? menuButtonRef.current;
}
handleLanguageChange = (event: React.MouseEvent<HTMLButtonElement>): void => {
event.preventDefault();
const { hideMenu, hideLanguageMenu, menuButtonRef, navigate } = this.props;
const newLanguage = event.target.dataset.value as string;
// If user selected cancel then close menu and put focus on button
if (newLanguage === 'exit-lang-menu') {
// Set focus to language button first so we don't lose focus
// for screen readers.
this.langButtonRef.current.focus();
hideLanguageMenu();
return;
}
// Put focus on menu button first so we don't lose focus
// for screen readers.
menuButtonRef.current.focus();
hideMenu();
// If user selected the current language then we just close the menu
if (newLanguage === clientLocale) {
return;
}
const path = createLanguageRedirect({
clientLocale,
lang: event.target.value
lang: newLanguage
});
return navigate(path);
};
handleMenuKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>): void => {
const { menuButtonRef, hideMenu } = this.props;
if (event.key === 'Escape') {
menuButtonRef.current.focus();
hideMenu();
event.preventDefault();
}
};
handleLanguageButtonClick = (): void => {
const { isLanguageMenuDisplayed, hideLanguageMenu, showLanguageMenu } =
this.props;
if (isLanguageMenuDisplayed) {
hideLanguageMenu();
} else {
showLanguageMenu(this.firstLangOptionRef.current);
}
};
handleLanguageButtonKeyDown = (
event: React.KeyboardEvent<HTMLButtonElement>
): void => {
const { menuButtonRef, showLanguageMenu, hideMenu } = this.props;
/* eslint-disable @typescript-eslint/naming-convention */
const doKeyPress = {
Escape: () => {
menuButtonRef.current.focus();
hideMenu();
event.preventDefault();
},
ArrowDown: () => {
showLanguageMenu(this.firstLangOptionRef.current);
event.preventDefault();
},
ArrowUp: () => {
showLanguageMenu(this.lastLangOptionRef.current);
event.preventDefault();
}
};
/* eslint-enable @typescript-eslint/naming-convention */
doKeyPress[event.key]?.();
};
handleLanguageMenuKeyDown = (
event: React.KeyboardEvent<HTMLButtonElement>
): void => {
const { hideLanguageMenu, hideMenu } = this.props;
const focusFirstLanguageMenuItem = () => {
this.firstLangOptionRef.current.focus();
event.preventDefault();
};
const focusLastLanguageMenuItem = () => {
this.lastLangOptionRef.current.focus();
event.preventDefault();
};
/* eslint-disable @typescript-eslint/naming-convention */
const doKeyPress = {
Tab: () => {
if (!event.shiftKey) {
// Let the Tab work as normal.
hideLanguageMenu();
// Close the menu if focus is now outside of the menu. This will
// happen when there is no Sign Out menu item.
setTimeout(() => {
const currentlyFocusedElement = document.activeElement;
if (
currentlyFocusedElement &&
!currentlyFocusedElement.closest('.nav-list')
) {
hideMenu();
}
}, 200);
return;
}
// Because FF adds an extra tab stop to the lang menu (because it
// is scrollable) we need to manually focus the previous menu item.
this.getPreviousMenuItem(this.langButtonRef.current).focus();
hideLanguageMenu();
event.preventDefault();
},
ArrowUp: () => {
const arrowUpItemToFocus =
event.target === this.firstLangOptionRef.current
? this.lastLangOptionRef.current
: (event.target.parentNode.previousSibling
.firstChild as HTMLElement);
arrowUpItemToFocus.focus();
event.preventDefault();
},
ArrowDown: () => {
const arrowDownItemToFocus =
event.target === this.lastLangOptionRef.current
? this.firstLangOptionRef.current
: (event.target.parentNode.nextSibling.firstChild as HTMLElement);
arrowDownItemToFocus.focus();
event.preventDefault();
},
Escape: () => {
// Set focus to language button first so we don't lose focus
// for screen readers.
this.langButtonRef.current.focus();
hideLanguageMenu();
event.preventDefault();
},
Home: focusFirstLanguageMenuItem,
PageUp: focusFirstLanguageMenuItem,
End: focusLastLanguageMenuItem,
PageDown: focusLastLanguageMenuItem
};
/* eslint-enable @typescript-eslint/naming-convention */
doKeyPress[event.key]?.();
};
// Added to the last item in the nav menu. Will close the menu if
// the user Tabs out of the menu.
handleBlur = (event: React.FocusEvent<HTMLButtonElement>): void => {
const { hideMenu, menuButtonRef } = this.props;
console.log('handleBlur: relatedTarget = ', event.relatedTarget);
if (
event.relatedTarget &&
!event.relatedTarget.closest('.nav-list') &&
event.relatedTarget !== menuButtonRef.current
) {
hideMenu();
}
};
render() {
const {
displayMenu,
isLanguageMenuDisplayed,
fetchState,
t,
toggleNightMode,
@ -92,132 +259,214 @@ export class NavLinks extends Component<NavLinksProps, {}> {
return pending ? (
<div className='nav-skeleton' />
) : (
<div className={'nav-list' + (displayMenu ? ' display-menu' : '')}>
<ul
aria-labelledby='toggle-button-nav'
className={`nav-list${displayMenu ? ' display-menu' : ''}${
isLanguageMenuDisplayed ? ' display-lang-menu' : ''
}`}
>
{isDonating ? (
<div className='nav-link nav-link-flex nav-link-header' key='donate'>
<span>{t('donate.thanks')}</span>
<FontAwesomeIcon icon={faHeart} />
</div>
<li key='donate'>
<div className='nav-link nav-link-flex nav-link-header'>
<span>{t('donate.thanks')}</span>
<FontAwesomeIcon icon={faHeart} />
</div>
</li>
) : (
<Link className='nav-link' key='donate' sameTab={false} to='/donate'>
{t('buttons.donate')}
</Link>
<li key='donate'>
<Link
className='nav-link'
onKeyDown={this.handleMenuKeyDown}
sameTab={false}
to='/donate'
>
{t('buttons.donate')}
</Link>
</li>
)}
{!username && (
<a
className='nav-link nav-link-sign-in'
href={`${apiLocation}/signin`}
key='signin'
<li key='learn'>
<Link
className='nav-link'
onKeyDown={this.handleMenuKeyDown}
to='/learn'
>
{t('buttons.sign-in')}
</a>
)}
<Link className='nav-link' key='learn' to='/learn'>
{t('buttons.curriculum')}
</Link>
{t('buttons.curriculum')}
</Link>
</li>
{username && (
<Fragment key='profile-settings'>
<Link
className='nav-link'
key='profile'
sameTab={false}
to={`/${username}`}
>
{t('buttons.profile')}
</Link>
<Link
className='nav-link'
key='settings'
sameTab={false}
to={`/settings`}
>
{t('buttons.settings')}
</Link>
<li key='profile'>
<Link
className='nav-link'
onKeyDown={this.handleMenuKeyDown}
sameTab={false}
to={`/${username}`}
>
{t('buttons.profile')}
</Link>
</li>
<li key='settings'>
<Link
className='nav-link'
onKeyDown={this.handleMenuKeyDown}
sameTab={false}
to={`/settings`}
>
{t('buttons.settings')}
</Link>
</li>
</Fragment>
)}
<hr className='nav-line' />
<Link
className='nav-link nav-link-flex'
external={true}
key='forum'
sameTab={false}
to={t('links:nav.forum')}
>
<span>{t('buttons.forum')}</span>
<FontAwesomeIcon icon={faExternalLinkAlt} />
</Link>
<Link
className='nav-link nav-link-flex'
external={true}
key='news'
sameTab={false}
to={t('links:nav.news')}
>
<span>{t('buttons.news')}</span>
<FontAwesomeIcon icon={faExternalLinkAlt} />
</Link>
<Link
className='nav-link nav-link-flex'
external={true}
key='radio'
sameTab={false}
to={radioLocation}
>
<span>{t('buttons.radio')}</span>
<FontAwesomeIcon icon={faExternalLinkAlt} />
</Link>
<hr className='nav-line' />
<button
className={
'nav-link nav-link-flex' + (!username ? ' nav-link-header' : '')
}
disabled={!username}
key='theme'
onClick={() => this.toggleTheme(String(theme), toggleNightMode)}
>
{username ? (
<>
<span>{t('settings.labels.night-mode')}</span>
{theme === Themes.Night ? (
<FontAwesomeIcon icon={faCheckSquare} />
) : (
<FontAwesomeIcon icon={faSquare} />
)}
</>
) : (
<span className='nav-link-dull'>{t('misc.change-theme')}</span>
)}
</button>
<div className='nav-link nav-link-header' key='lang-header'>
{t('footer.language')}
</div>
<div className='nav-link dropdown-nav-link' key='language-dropdown'>
<select
className='nav-link-lang-dropdown'
onChange={this.handleLanguageChange}
value={clientLocale}
<li key='forum' className='nav-line'>
<Link
className='nav-link nav-link-flex'
external={true}
onKeyDown={this.handleMenuKeyDown}
sameTab={false}
to={t('links:nav.forum')}
>
{locales.map(lang => (
<option key={'lang-' + lang} value={lang}>
{getLangName(lang)}
</option>
))}
</select>
</div>
<span>{t('buttons.forum')}</span>
<FontAwesomeIcon icon={faExternalLinkAlt} />
</Link>
</li>
<li key='news'>
<Link
className='nav-link nav-link-flex'
external={true}
onKeyDown={this.handleMenuKeyDown}
sameTab={false}
to={t('links:nav.news')}
>
<span>{t('buttons.news')}</span>
<FontAwesomeIcon icon={faExternalLinkAlt} />
</Link>
</li>
<li key='radio'>
<Link
className='nav-link nav-link-flex'
external={true}
onKeyDown={this.handleMenuKeyDown}
sameTab={false}
to={radioLocation}
>
<span>{t('buttons.radio')}</span>
<FontAwesomeIcon icon={faExternalLinkAlt} />
</Link>
</li>
<li className='nav-line' key='theme'>
<button
{...(!username && { 'aria-describedby': 'theme-sign-in' })}
aria-disabled={!username}
aria-pressed={theme === Themes.Night ? 'true' : 'false'}
className={
'nav-link nav-link-flex' + (!username ? ' nav-link-header' : '')
}
onClick={() => {
if (username) {
this.toggleTheme(String(theme), toggleNightMode);
}
}}
onKeyDown={this.handleMenuKeyDown}
>
{username ? (
<>
<span>{t('settings.labels.night-mode')}</span>
{theme === Themes.Night ? (
<FontAwesomeIcon icon={faCheckSquare} />
) : (
<FontAwesomeIcon icon={faSquare} />
)}
</>
) : (
<Fragment key='night-mode'>
<span className='sr-only'>
{t('settings.labels.night-mode')}
</span>
<span
aria-hidden='true'
className='nav-link-dull'
id='theme-sign-in'
>
{t('misc.change-theme')}
</span>
</Fragment>
)}
</button>
</li>
<li key='lang-menu'>
<div className='nav-lang' key='language-dropdown'>
<button
aria-controls='nav-lang-menu'
{...(isLanguageMenuDisplayed && { 'aria-expanded': true })}
aria-haspopup='true'
className='nav-link nav-lang-button'
id='nav-lang-button'
onBlur={this.handleBlur}
onClick={this.handleLanguageButtonClick}
onKeyDown={this.handleLanguageButtonKeyDown}
ref={this.langButtonRef}
>
<span>{t('buttons.change-language')}</span>
<LanguageGlobe />
</button>
<ul
aria-labelledby='nav-lang-button'
className={'nav-lang-menu' + (username ? ' logged-in' : '')}
id='nav-lang-menu'
role='menu'
>
<li key='lang-menu-exit' role='none'>
<button
className='nav-link nav-lang-menu-option'
data-value='exit-lang-menu'
onClick={this.handleLanguageChange}
onKeyDown={this.handleLanguageMenuKeyDown}
ref={this.firstLangOptionRef}
role='menuitem'
tabIndex='-1'
>
{t('buttons.cancel-change')}
</button>
</li>
{locales.map((lang, index) => (
<li key={'lang-' + lang} role='none'>
<button
{...(clientLocale === lang && { 'aria-current': true })}
className='nav-link nav-lang-menu-option'
data-value={lang}
{...(LangCodes[lang] && {
lang: LangCodes[lang] as string
})}
onClick={this.handleLanguageChange}
onKeyDown={this.handleLanguageMenuKeyDown}
{...(index === locales.length - 1 && {
ref: this.lastLangOptionRef
})}
role='menuitem'
tabIndex='-1'
>
{LangNames[lang]}
</button>
</li>
))}
</ul>
</div>
</li>
{username && (
<Fragment key='signout-frag'>
<hr className='nav-line-2' />
<a
className='nav-link'
href={`${apiLocation}/signout`}
key='sign-out'
>
{t('buttons.sign-out')}
</a>
<li className='nav-line' key='sign-out'>
<a
className='nav-link nav-link-signout'
href={`${apiLocation}/signout`}
onBlur={this.handleBlur}
onKeyDown={this.handleMenuKeyDown}
>
{t('buttons.sign-out')}
</a>
</li>
</Fragment>
)}
</div>
</ul>
);
}
}

View File

@ -3,7 +3,7 @@
justify-content: space-between;
align-items: flex-start;
font-size: 18px;
font-family: 'Roboto-Mono', sans-serif;
font-family: 'Lato', sans-serif;
height: var(--header-height);
background: var(--theme-color);
position: fixed;
@ -18,10 +18,6 @@
text-decoration: none;
}
.universal-nav .universal-nav-right a {
font-family: 'Roboto Mono', monospace;
}
.universal-nav-left {
display: flex;
flex: 1 0 33%;
@ -89,7 +85,12 @@
margin: 0 0 0 -12px;
padding: 0;
list-style: none;
max-width: 250px;
max-width: 15rem;
}
.nav-list li {
width: 100%;
height: 2.2rem;
}
.nav-link {
@ -104,28 +105,35 @@
min-height: var(--header-height);
width: 100%;
border: none;
height: 100%;
}
.dropdown-nav-link {
padding-left: 0;
padding-right: 0;
.nav-lang {
padding: 0;
width: 100%;
height: 100%;
}
.dropdown-nav-link:hover .nav-link-lang-dropdown,
.dropdown-nav-link:active .nav-link-lang-dropdown {
background-image: url('data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2212%22%20height%3D%2212%22%20viewBox%3D%220%200%2012%2012%22%3E%3Ctitle%3Edown-arrow%3C%2Ftitle%3E%3Cg%20fill%3D%22%23000000%22%3E%3Cpath%20d%3D%22M10.293%2C3.293%2C6%2C7.586%2C1.707%2C3.293A1%2C1%2C0%2C0%2C0%2C.293%2C4.707l5%2C5a1%2C1%2C0%2C0%2C0%2C1.414%2C0l5-5a1%2C1%2C0%2C1%2C0-1.414-1.414Z%22%20fill%3D%22%23000000%22%3E%3C%2Fpath%3E%3C%2Fg%3E%3C%2Fsvg%3E');
.nav-link:focus {
outline: 3px solid var(--blue-mid);
outline-offset: -4px;
}
.nav-link:hover,
.nav-link:active {
color: var(--theme-color);
.nav-link:focus:not(:focus-visible) {
background-color: inherit;
color: inherit;
}
.nav-link:not([aria-disabled='true']):hover {
color: var(--theme-color) !important;
text-decoration: none;
background: white;
background-color: var(--gray-10) !important;
cursor: pointer;
outline: none !important;
}
.nav-link-header,
.nav-link-header:hover,
.nav-link-header:not([aria-disabled='true']):hover,
.nav-link-header:active {
color: var(--gray-00);
background-color: var(--gray-90);
@ -142,31 +150,146 @@
height: auto !important;
}
.nav-link:hover .nav-link-lang-dropdown,
.nav-link:active .nav-link-lang-dropdown {
.nav-link:hover .nav-lang-menu,
.nav-link:active .nav-lang-menu {
background-color: var(--gray-00);
color: var(--gray-90);
cursor: pointer;
}
.nav-link-lang-dropdown {
padding: 0 15px;
color: var(--gray-00);
background-color: var(--gray-90);
button.nav-link:focus {
color: var(--tertiary-color);
background-color: var(--tertiary-background);
outline: 3px solid var(--blue-mid);
outline-offset: -4px;
}
button.nav-link:focus:not(:focus-visible) {
background-color: inherit;
color: #fff;
outline: none;
}
button.nav-link:not([aria-disabled='true']):hover {
background-color: var(--gray-10);
color: var(--theme-color);
}
button.nav-link[aria-disabled='true'] {
cursor: default;
background-color: inherit;
}
.display-menu .theme-disabled {
height: 4.2rem;
}
.nav-link-header label {
font-weight: normal;
margin-bottom: 0;
}
.nav-lang-menu {
display: none;
padding: 0;
color: var(--gray-90);
background-color: var(--gray-80);
width: 100%;
border: none;
position: relative;
position: absolute;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
background-image: url('data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2212%22%20height%3D%2212%22%20viewBox%3D%220%200%2012%2012%22%3E%3Ctitle%3Edown-arrow%3C%2Ftitle%3E%3Cg%20fill%3D%22%23ffffff%22%3E%3Cpath%20d%3D%22M10.293%2C3.293%2C6%2C7.586%2C1.707%2C3.293A1%2C1%2C0%2C0%2C0%2C.293%2C4.707l5%2C5a1%2C1%2C0%2C0%2C0%2C1.414%2C0l5-5a1%2C1%2C0%2C1%2C0-1.414-1.414Z%22%20fill%3D%22%23ffffff%22%3E%3C%2Fpath%3E%3C%2Fg%3E%3C%2Fsvg%3E');
background-size: 10px;
background-position: calc(100% - 15px) center;
list-style-type: none;
margin: 0;
border: 2px solid var(--gray-90);
border-top: 0;
border-bottom: 0;
top: 0;
overflow-y: auto;
scrollbar-width: auto;
/* lang menu should always be full height */
height: 19.9rem;
}
/* main menu must be at least as tall as lang menu (when displayed)
unless there isn't enough view port height */
.nav-list.display-lang-menu {
min-height: min(19.9rem, calc(100vh - var(--header-height)));
}
.nav-lang-menu > button:hover {
background-image: none !important;
}
.nav-lang-menu .nav-link {
color: #fff;
background-color: inherit;
padding-top: 0;
}
.nav-lang-menu .nav-link:focus {
color: #000;
background-color: var(--gray-10);
outline: none;
}
.nav-lang-menu .nav-link:focus:not(:focus-visible) {
background-color: inherit;
}
.nav-lang-menu .nav-link:hover {
background-color: var(--gray-10) !important;
}
.nav-lang-button {
display: flex;
justify-content: space-between;
background-image: none;
}
.nav-lang-button:hover svg,
.nav-lang-button:focus {
fill: var(--gray-10);
}
.nav-lang-button:focus:not(:focus-visible) {
fill: revert;
}
.nav-lang-button[aria-expanded='true'] + .nav-lang-menu {
display: block;
}
.nav-lang-button svg {
height: 1rem;
width: 1rem;
}
.dark-palette .nav-lang-button:focus {
fill: revert;
}
.nav-lang-menu-option[aria-current='true'] {
/* check mark for current language */
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='32' height='32' preserveAspectRatio='xMidYMid meet' viewBox='0 0 16 16'%3E%3Cg fill='white'%3E%3Cpath d='M12.736 3.97a.733.733 0 0 1 1.047 0c.286.289.29.756.01 1.05L7.88 12.01a.733.733 0 0 1-1.065.02L3.217 8.384a.757.757 0 0 1 0-1.06a.733.733 0 0 1 1.047 0l3.052 3.093l5.4-6.425a.247.247 0 0 1 .02-.022z'%3E%3C/path%3E%3C/g%3E%3C/svg%3E");
background-size: 1.2rem;
background-position: calc(100% - 10px) center;
background-repeat: no-repeat;
}
.nav-link-lang-dropdown:focus {
outline: none;
.nav-lang-menu-option[aria-current='true']:focus {
/* check mark for current language */
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='32' height='32' preserveAspectRatio='xMidYMid meet' viewBox='0 0 16 16'%3E%3Cg fill='currentColor'%3E%3Cpath d='M12.736 3.97a.733.733 0 0 1 1.047 0c.286.289.29.756.01 1.05L7.88 12.01a.733.733 0 0 1-1.065.02L3.217 8.384a.757.757 0 0 1 0-1.06a.733.733 0 0 1 1.047 0l3.052 3.093l5.4-6.425a.247.247 0 0 1 .02-.022z'%3E%3C/path%3E%3C/g%3E%3C/svg%3E");
}
.nav-lang-menu-option[aria-current='true']:focus:not(:focus-visible) {
/* check mark for current language */
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='32' height='32' preserveAspectRatio='xMidYMid meet' viewBox='0 0 16 16'%3E%3Cg fill='white'%3E%3Cpath d='M12.736 3.97a.733.733 0 0 1 1.047 0c.286.289.29.756.01 1.05L7.88 12.01a.733.733 0 0 1-1.065.02L3.217 8.384a.757.757 0 0 1 0-1.06a.733.733 0 0 1 1.047 0l3.052 3.093l5.4-6.425a.247.247 0 0 1 .02-.022z'%3E%3C/path%3E%3C/g%3E%3C/svg%3E");
}
.nav-lang-menu-option[aria-current='true']:hover {
/* check mark for current language */
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='32' height='32' preserveAspectRatio='xMidYMid meet' viewBox='0 0 16 16'%3E%3Cg fill='currentColor'%3E%3Cpath d='M12.736 3.97a.733.733 0 0 1 1.047 0c.286.289.29.756.01 1.05L7.88 12.01a.733.733 0 0 1-1.065.02L3.217 8.384a.757.757 0 0 1 0-1.06a.733.733 0 0 1 1.047 0l3.052 3.093l5.4-6.425a.247.247 0 0 1 .02-.022z'%3E%3C/path%3E%3C/g%3E%3C/svg%3E") !important;
}
.nav-link-flex {
@ -202,8 +325,10 @@
color: var(--gray-00);
background-color: var(--theme-color);
cursor: pointer;
max-height: calc(var(--header-height) - 6px);
max-height: calc(var(--header-height) - 8px);
margin-right: 10px;
display: flex;
align-items: center;
}
.toggle-button-nav:hover {
@ -213,8 +338,12 @@
}
.toggle-button-nav:focus {
outline: 5px auto -webkit-focus-ring-color !important;
outline-offset: -3px;
outline: 3px solid var(--blue-mid);
outline-offset: 0;
}
.toggle-button-nav:focus:not(:focus-visible) {
outline: none;
}
.avatar-nav-link {
@ -269,12 +398,12 @@
scrollbar-width: none;
}
.display-menu::-webkit-scrollbar {
display: none;
.expand-lang-menu .display-menu {
overflow-y: unset;
}
.toggle-button-nav {
display: flex;
.display-menu::-webkit-scrollbar {
display: none;
}
.reverse-toggle-color {
@ -287,14 +416,14 @@
color: var(--theme-color);
}
.nav-line,
.nav-line {
border-top: 0.1rem solid var(--gray-45);
}
.nav-line-2 {
border-color: var(--gray-45);
width: 100%;
margin: 0;
}
.nav-line-2 {
border-top-width: 2px;
}
@ -302,13 +431,71 @@
max-height: calc(var(--header-height) - 6px);
padding: 0 8px;
margin-left: 2px;
font-family: 'Roboto Mono', monospace !important;
display: flex;
align-items: center;
}
.universal-nav-right .fcc_searchBar {
position: absolute;
top: 0;
left: 0;
}
.universal-nav-right .fcc_searchBar .ais-SearchBox-form {
width: 100vw;
height: 38px;
max-width: unset;
padding: 0 15px;
left: 0;
}
/* In mobile layout, prevent search input from hanging around if the
menu is collapsed. */
.universal-nav-right
#toggle-button-nav[aria-expanded='false']
+ .fcc_searchBar
.ais-SearchBox-form {
display: none;
}
/* In mobile layout, prevent search results from hanging around if the
menu is collapsed. */
.universal-nav-right
#toggle-button-nav[aria-expanded='false']
+ .fcc_searchBar
.ais-Hits {
display: none;
}
.universal-nav-right .ais-SearchBox-input {
width: calc(100vw - 17rem);
padding-left: 27px;
}
.universal-nav-right .fcc_searchBar .ais-Hits {
width: calc(100vw - 17rem);
}
.universal-nav-right .fcc_searchBar .ais-SearchBox-submit {
top: unset;
margin-top: 19px;
left: 18px;
}
.ais-SearchBox-input:focus {
outline: 3px solid var(--blue-mid);
}
.ais-SearchBox-submit:focus {
outline: 3px solid var(--blue-mid);
}
.ais-SearchBox-submit:focus:not(:focus-visible) {
outline: none;
}
@media (max-width: 980px) {
.universal-nav-left {
display: none;
}
.universal-nav-middle {
flex: none;
}
@ -350,21 +537,26 @@
margin-top: 0;
}
.fcc_searchBar .ais-SearchBox-form {
max-width: 100%;
.fcc_searchBar,
.fcc_searchBar div {
width: 100%;
}
.ais-SearchBox-input {
min-width: 100%;
}
.ais-Hits {
min-width: calc(100% - 30px);
.universal-nav-right .fcc_searchBar .ais-SearchBox-form {
width: 100%;
}
.display-menu {
max-height: calc(100vh - var(--header-height) * 2);
}
.universal-nav-right .ais-SearchBox-input {
width: calc(100vw - 30px);
}
.universal-nav-right .fcc_searchBar .ais-Hits {
width: calc(100% - 30px);
}
}
@media (max-width: 455px) {

View File

@ -6,29 +6,41 @@
import Loadable from '@loadable/component';
import React, { Ref } from 'react';
import { useTranslation } from 'react-i18next';
import Media from 'react-responsive';
import { isLanding } from '../../../utils/path-parsers';
import { Link, SkeletonSprite } from '../../helpers';
import MenuButton from './menu-button';
import NavLinks from './nav-links';
import NavLogo from './nav-logo';
import './universal-nav.css';
import AuthOrProfile from './auth-or-profile';
const SearchBar = Loadable(() => import('../../search/searchBar/search-bar'));
const SearchBarOptimized = Loadable(
() => import('../../search/searchBar/search-bar-optimized')
);
const MAX_MOBILE_WIDTH = 980;
export interface UniversalNavProps {
displayMenu?: boolean;
isLanguageMenuDisplayed?: boolean;
fetchState?: { pending: boolean };
menuButtonRef?: Ref<HTMLButtonElement> | undefined;
searchBarRef?: unknown;
toggleDisplayMenu?: React.MouseEventHandler<HTMLButtonElement> | undefined;
showMenu?: () => void;
hideMenu?: () => void;
showLanguageMenu?: (elementToFocus: HTMLButtonElement) => void;
hideLanguageMenu?: () => void;
user?: Record<string, unknown>;
}
export const UniversalNav = ({
displayMenu,
toggleDisplayMenu,
isLanguageMenuDisplayed,
showMenu,
hideMenu,
showLanguageMenu,
hideLanguageMenu,
menuButtonRef,
searchBarRef,
user,
@ -47,15 +59,13 @@ export const UniversalNav = ({
return (
<nav
aria-label={t('aria.primary-nav')}
className={'universal-nav' + (displayMenu ? ' expand-nav' : '')}
className={`universal-nav${displayMenu ? ' expand-nav' : ''}`}
id='universal-nav'
>
<div
className={
'universal-nav-left' + (displayMenu ? ' display-search' : '')
}
className={`universal-nav-left${displayMenu ? ' display-search' : ''}`}
>
{search}
<Media minWidth={MAX_MOBILE_WIDTH + 1}>{search}</Media>
</div>
<div className='universal-nav-middle'>
<Link id='universal-nav-logo' to='/learn'>
@ -68,21 +78,32 @@ export const UniversalNav = ({
<SkeletonSprite />
</div>
) : (
<MenuButton
displayMenu={displayMenu}
innerRef={menuButtonRef}
onClick={toggleDisplayMenu}
user={user}
/>
<>
<MenuButton
displayMenu={displayMenu}
hideMenu={hideMenu}
innerRef={menuButtonRef}
showMenu={showMenu}
user={user}
/>
<Media maxWidth={MAX_MOBILE_WIDTH}>{search}</Media>
<NavLinks
displayMenu={displayMenu}
fetchState={fetchState}
isLanguageMenuDisplayed={isLanguageMenuDisplayed}
hideLanguageMenu={hideLanguageMenu}
hideMenu={hideMenu}
menuButtonRef={menuButtonRef}
showLanguageMenu={showLanguageMenu}
showMenu={showMenu}
user={user}
/>
<div className='navatar'>
<AuthOrProfile user={user} />
</div>
</>
)}
</div>
<NavLinks
displayMenu={displayMenu}
fetchState={fetchState}
toggleDisplayMenu={toggleDisplayMenu}
user={user}
/>
</nav>
);
};

View File

@ -2,191 +2,12 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-return */
import React, { Ref } from 'react';
import { useTranslation } from 'react-i18next';
import React from 'react';
import { create, ReactTestRendererJSON } from 'react-test-renderer';
import ShallowRenderer from 'react-test-renderer/shallow';
import envData from '../../../../config/env.json';
import { availableLangs, getLangName } from '../../../../config/i18n/all-langs';
import { Themes } from '../settings/theme';
import AuthOrProfile from './components/auth-or-profile';
import { NavLinks } from './components/nav-links';
import { UniversalNav } from './components/universal-nav';
const { apiLocation, clientLocale } = envData;
jest.mock('../../analytics');
describe('<UniversalNav />', () => {
const UniversalNavProps = {
displayMenu: false,
menuButtonRef: {} as Ref<HTMLButtonElement> | undefined,
searchBarRef: {},
// eslint-disable-next-line @typescript-eslint/no-empty-function
toggleDisplayMenu: function () {},
pathName: '/',
fetchState: {
pending: false
}
};
it('renders to the DOM', () => {
const utils = ShallowRenderer.createRenderer();
utils.render(<UniversalNav {...UniversalNavProps} />);
const view = utils.getRenderOutput();
expect(view).toBeTruthy();
});
});
describe('<NavLinks />', () => {
const { t } = useTranslation();
it('has expected navigation links when not signed in', () => {
const landingPageProps = {
fetchState: {
pending: false
},
user: {
isDonating: false,
username: null,
theme: Themes.Default
},
i18n: {
language: 'en'
},
toggleNightMode: (theme: Themes) => theme,
t: t
};
const utils = ShallowRenderer.createRenderer();
utils.render(<NavLinks {...landingPageProps} />);
const view = utils.getRenderOutput();
expect(
hasDonateNavItem(view) &&
hasSignInNavItem(view) &&
hasCurriculumNavItem(view) &&
hasForumNavItem(view) &&
hasNewsNavItem(view) &&
hasRadioNavItem(view) &&
hasLanguageHeader(view) &&
hasLanguageDropdown(view)
).toBeTruthy();
});
it('has expected navigation links when signed in', () => {
const landingPageProps = {
fetchState: {
pending: false
},
user: {
isDonating: false,
username: 'nhcarrigan',
theme: Themes.Default
},
i18n: {
language: 'en'
},
t: t,
toggleNightMode: (theme: Themes) => theme
};
const utils = ShallowRenderer.createRenderer();
utils.render(<NavLinks {...landingPageProps} />);
const view = utils.getRenderOutput();
expect(
hasDonateNavItem(view) &&
hasCurriculumNavItem(view) &&
hasProfileAndSettingsNavItems(view, landingPageProps.user.username) &&
hasForumNavItem(view) &&
hasNewsNavItem(view) &&
hasRadioNavItem(view) &&
hasSignOutNavItem(view)
).toBeTruthy();
});
it('has expected navigation links when signed in and donating', () => {
const landingPageProps = {
fetchState: {
pending: false
},
user: {
isDonating: true,
username: 'moT01',
theme: Themes.Default
},
i18n: {
language: 'en'
},
t: t,
toggleNightMode: (theme: Themes) => theme
};
const utils = ShallowRenderer.createRenderer();
utils.render(<NavLinks {...landingPageProps} />);
const view = utils.getRenderOutput();
expect(
hasThanksForDonating(view) &&
hasCurriculumNavItem(view) &&
hasProfileAndSettingsNavItems(view, landingPageProps.user.username) &&
hasForumNavItem(view) &&
hasNewsNavItem(view) &&
hasRadioNavItem(view) &&
hasSignOutNavItem(view) &&
hasLanguageHeader(view) &&
hasLanguageDropdown(view)
).toBeTruthy();
});
it('has expected available languages in the language dropdown', () => {
const landingPageProps = {
fetchState: {
pending: false
},
user: {
isDonating: true,
username: 'moT01',
theme: Themes.Default
},
i18n: {
language: 'en'
},
t: t,
toggleNightMode: (theme: Themes) => theme
};
const utils = ShallowRenderer.createRenderer();
utils.render(<NavLinks {...landingPageProps} />);
const view = utils.getRenderOutput();
expect(
hasLanguageHeader(view) &&
hasLanguageDropdown(view) &&
hasAllAvailableLanguagesInDropdown(view)
).toBeTruthy();
});
it('has default language selected in language dropdown based on client config', () => {
const landingPageProps = {
fetchState: {
pending: false
},
user: {
isDonating: true,
username: 'moT01',
theme: Themes.Default
},
i18n: {
language: 'en'
},
t: t,
toggleNightMode: (theme: Themes) => theme
};
const utils = ShallowRenderer.createRenderer();
utils.render(<NavLinks {...landingPageProps} />);
const view = utils.getRenderOutput();
expect(
hasLanguageHeader(view) &&
hasLanguageDropdown(view) &&
hasAllAvailableLanguagesInDropdown(view) &&
hasDefaultLanguageInLanguageDropdown(view, clientLocale)
).toBeTruthy();
});
});
describe('<AuthOrProfile />', () => {
it('has avatar with default border for default users', () => {
const defaultUserProps = {
@ -257,123 +78,9 @@ describe('<AuthOrProfile />', () => {
});
});
const navigationLinks = (component: JSX.Element, key: string) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
const target = component.props.children.find(
(child: { key?: string }) => child && child.key === key
);
return target.props;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const profileNavItem = (component: any) => component.children[0];
const hasDonateNavItem = (component: JSX.Element) => {
const { children, to } = navigationLinks(component, 'donate');
return children === 'buttons.donate' && to === '/donate';
};
const hasThanksForDonating = (component: JSX.Element) => {
const { children } = navigationLinks(component, 'donate');
return children ? children[0].props.children === 'donate.thanks' : null;
};
const hasSignInNavItem = (component: JSX.Element) => {
const { children } = navigationLinks(component, 'signin');
return children === 'buttons.sign-in';
};
const hasCurriculumNavItem = (component: JSX.Element) => {
const { children, to } = navigationLinks(component, 'learn');
return children === 'buttons.curriculum' && to === '/learn';
};
const hasProfileAndSettingsNavItems = (
component: JSX.Element,
username: string
) => {
const fragment = navigationLinks(component, 'profile-settings');
const profile = fragment ? fragment.children[0].props : null;
const settings = fragment.children[1].props;
const hasProfile =
profile.children === 'buttons.profile' && profile.to === `/${username}`;
const hasSettings =
settings.children === 'buttons.settings' && settings.to === '/settings';
return hasProfile && hasSettings;
};
const hasForumNavItem = (component: JSX.Element) => {
const { children, to } = navigationLinks(component, 'forum');
// TODO: test compiled TFunction value
return (
children[0].props.children === 'buttons.forum' && to === 'links:nav.forum'
);
};
const hasNewsNavItem = (component: JSX.Element) => {
const { children, to } = navigationLinks(component, 'news');
return (
children[0].props.children === 'buttons.news' && to === 'links:nav.news'
);
};
const hasRadioNavItem = (component: JSX.Element) => {
const { children, to } = navigationLinks(component, 'radio');
return (
children[0].props.children === 'buttons.radio' &&
to === 'https://coderadio.freecodecamp.org'
);
};
const hasLanguageHeader = (component: JSX.Element) => {
const { children } = navigationLinks(component, 'lang-header');
return children === 'footer.language';
};
const hasLanguageDropdown = (component: JSX.Element) => {
const { children } = navigationLinks(component, 'language-dropdown');
return children.type === 'select';
};
const hasDefaultLanguageInLanguageDropdown = (
component: JSX.Element,
defaultLanguage: string
) => {
const { children } = navigationLinks(component, 'language-dropdown');
return children.props.value === defaultLanguage;
};
const hasAllAvailableLanguagesInDropdown = (component: JSX.Element) => {
const { children }: { children: JSX.Element } = navigationLinks(
component,
'language-dropdown'
);
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
return children.props.children.every(
({ props }: { props: { value: string; children: string } }) =>
availableLangs.client.includes(props.value) &&
getLangName(props.value) === props.children
);
};
const hasSignOutNavItem = (component: JSX.Element) => {
const { children } = navigationLinks(component, 'signout-frag');
const signOutProps = children[1].props;
return (
signOutProps.children === 'buttons.sign-out' &&
signOutProps.href === `${apiLocation}/signout`
);
};
/* TODO: Apply this to Universalnav component
const hasSignInButton = component =>
component.props.children[1].props.children === 'buttons.sign-in';
*/
const avatarHasClass = (
componentTree: ReactTestRendererJSON | ReactTestRendererJSON[] | null,
classes: string

View File

@ -15,52 +15,71 @@ export interface HeaderProps {
}
export class Header extends React.Component<
HeaderProps,
{ displayMenu: boolean }
{ displayMenu: boolean; isLanguageMenuDisplayed: boolean }
> {
menuButtonRef: React.RefObject<any>;
menuButtonRef: React.RefObject<HTMLButtonElement>;
searchBarRef: React.RefObject<any>;
static displayName: string;
constructor(props: HeaderProps) {
super(props);
this.state = {
displayMenu: false
displayMenu: false,
isLanguageMenuDisplayed: false
};
this.menuButtonRef = React.createRef();
this.searchBarRef = React.createRef();
this.handleClickOutside = this.handleClickOutside.bind(this);
this.toggleDisplayMenu = this.toggleDisplayMenu.bind(this);
}
componentDidMount(): void {
document.addEventListener('click', this.handleClickOutside);
}
componentWillUnmount(): void {
document.removeEventListener('click', this.handleClickOutside);
this.showMenu = this.showMenu.bind(this);
this.hideMenu = this.hideMenu.bind(this);
this.showLanguageMenu = this.showLanguageMenu.bind(this);
this.hideLanguageMenu = this.hideLanguageMenu.bind(this);
}
handleClickOutside(event: globalThis.MouseEvent): void {
const eventTarget = event.target as HTMLElement;
if (
this.state.displayMenu &&
this.menuButtonRef.current &&
!this.menuButtonRef.current.contains(event.target) &&
!this.menuButtonRef.current.contains(eventTarget) &&
// since the search bar is part of the menu on small screens, clicks on
// the search bar should not toggle the menu
this.searchBarRef.current &&
!this.searchBarRef.current.contains(event.target) &&
!(event.target instanceof HTMLSelectElement)
!this.searchBarRef.current.contains(eventTarget) &&
// don't count clicks on language button/menu
!eventTarget.closest('.nav-lang') &&
// don't count clicks on disabled elements
!eventTarget.closest('[aria-disabled="true"]')
) {
this.toggleDisplayMenu();
this.hideMenu();
}
}
toggleDisplayMenu(): void {
this.setState(({ displayMenu }: { displayMenu: boolean }) => ({
displayMenu: !displayMenu
}));
showMenu(): void {
this.setState({ displayMenu: true }, () => {
document.addEventListener('click', this.handleClickOutside);
});
}
hideMenu(): void {
this.setState({ displayMenu: false }, () => {
document.removeEventListener('click', this.handleClickOutside);
this.hideLanguageMenu();
});
}
// elementToFocus must be a link in the language menu
showLanguageMenu(elementToFocus: HTMLButtonElement): void {
this.setState({ isLanguageMenuDisplayed: true }, () =>
elementToFocus.focus()
);
}
hideLanguageMenu(): void {
this.setState({ isLanguageMenuDisplayed: false });
}
render(): JSX.Element {
const { displayMenu } = this.state;
const { displayMenu, isLanguageMenuDisplayed } = this.state;
const { fetchState, user } = this.props;
return (
<>
@ -71,9 +90,13 @@ export class Header extends React.Component<
<UniversalNav
displayMenu={displayMenu}
fetchState={fetchState}
isLanguageMenuDisplayed={isLanguageMenuDisplayed}
hideLanguageMenu={this.hideLanguageMenu}
hideMenu={this.hideMenu}
menuButtonRef={this.menuButtonRef}
searchBarRef={this.searchBarRef}
toggleDisplayMenu={this.toggleDisplayMenu}
showMenu={this.showMenu}
showLanguageMenu={this.showLanguageMenu}
user={user}
/>
</header>

View File

@ -1,13 +0,0 @@
describe('Navigation links', () => {
it('should render the expected forum and news links.', () => {
cy.visit('/learn');
cy.get('.toggle-button-nav').should('be.visible');
cy.get('.toggle-button-nav').click();
cy.get('.nav-list')
.contains('Forum')
.should('have.attr', 'href', 'https://forum.freecodecamp.org/');
cy.get('.nav-list')
.contains('News')
.should('have.attr', 'href', 'https://freecodecamp.org/news/');
});
});

View File

@ -0,0 +1,113 @@
import { availableLangs, LangNames } from '../../../../config/i18n/all-langs';
import envData from '../../../../config/env.json';
const { clientLocale } = envData;
const selectors = {
'navigation-list': '.nav-list',
'toggle-button': '.toggle-button-nav',
'language-menu': '.nav-lang-menu',
'sign-in-button': "[data-test-label='landing-small-cta']",
'avatar-link': '.avatar-nav-link',
'avatar-container': '.avatar-container'
};
const links = {
'sign-in': '/signin',
'sign-out': '/signout',
donate: '/donate',
curriculum: '/learn',
forum: 'https://forum.freecodecamp.org/',
news: 'https://freecodecamp.org/news/',
radio: 'https://coderadio.freecodecamp.org',
'avatar-link': '/developmentuser',
settings: '/settings'
};
describe('Default Navigation Menu', () => {
it('should render the expected nav items.', () => {
cy.visit('/learn');
testLink('Sign in', 'sign-in-button');
cy.get(selectors['language-menu']).should('not.be.visible');
cy.get(selectors['toggle-button']).should('be.visible').click();
cy.get(selectors['navigation-list']).contains('Sign in to change theme.');
testLink('Donate');
testLink('Curriculum');
testLink('Forum');
testLink('News');
testLink('Radio');
});
});
describe('Lanuage menu', () => {
it('should render all used languages.', () => {
cy.get(selectors['navigation-list']).contains('Change Language').click();
testAllLanuges();
cy.get(selectors['language-menu'])
.should('be.visible')
.contains('English')
.should('have.attr', 'aria-current', 'true');
cy.get(selectors['language-menu'])
.should('be.visible')
.contains(LangNames[clientLocale])
.should('have.attr', 'aria-current', 'true');
});
it('should have default language selected', () => {
cy.get(selectors['language-menu'])
.should('be.visible')
.contains(LangNames[clientLocale])
.should('have.attr', 'aria-current', 'true');
});
});
describe('Authenticated Navigation Menu', () => {
before(() => {
cy.clearCookies();
cy.exec('npm run seed');
cy.login();
cy.get(selectors['toggle-button']).should('be.visible').click();
});
it('should show default avatar.', () => {
testLink('Settings');
testLink('Sign out');
cy.get(selectors['sign-in-button']).should('not.exist');
cy.get(selectors['avatar-link'])
.should('have.attr', 'href')
.and('contain', links['avatar-link']);
cy.get(selectors['avatar-container']).should(
'have.class',
'default-border'
);
cy.get(selectors['navigation-list']).contains('Night Mode').click();
cy.get('body').should('have.class', 'dark-palette');
});
});
describe('Donor Navigation Menu', () => {
before(() => {
cy.clearCookies();
cy.exec('npm run seed -- --donor');
cy.login();
cy.visit('/donate');
});
it('should show donor avatar border.', () => {
cy.get(selectors['avatar-container']).should('have.class', 'gold-border');
});
it('should show thank you message.', () => {
cy.get(selectors['navigation-list']).contains('Thanks for donating');
});
});
const testAllLanuges = () => {
const availableLangNames = availableLangs.client.map(lang => LangNames[lang]);
availableLangNames.forEach(langName =>
cy.get(selectors['language-menu']).contains(langName)
);
};
const testLink = (item, selector = 'navigation-list') =>
cy
.get(selectors[selector])
.contains(item)
.should('have.attr', 'href')
.and('contain', links[item.replaceAll(' ', '-').toLowerCase()]);