feat(client): expose the language button to the navbar (#50084)

* feat(client): expose the language button to the navbar

* clean extra logic

Co-authored-by: Huyen Nguyen <25715018+huyenltnguyen@users.noreply.github.com>

* show the language menu

* set keyboard navigation logic

* change the language icon

* sort float values

* fix the test

* remove the menu roles from the languagelist

Co-authored-by: Bruce B <bbsmooth@gmail.com>

* add id to button and polish the attributes

Co-authored-by: Co-authored-by: Bruce B <bbsmooth@gmail.com>

* revert to production focus events

Co-authored-by: Co-authored-by: Bruce B <bbsmooth@gmail.com>

* fix the menu not closing and fix the label

Co-authored-by: Co-authored-by: Bruce B <bbsmooth@gmail.com>

* make event passing more explicit

Co-authored-by: Co-authored-by: Bruce B <bbsmooth@gmail.com>

* Let screenreaders annouce that the expand is closed

Co-authored-by: Bruce Blaser <bbsmooth@gmail.com>

* let screenreader annouce list is closed

Co-authored-by: Bruce Blaser <bbsmooth@gmail.com>

* polish the logic around the styling

Co-authored-by: Bruce Blaser <bbsmooth@gmail.com>

* add comment about accessiblity function

* oh right active doesn't work like that

* hide the menu if the click is outside it

Co-authored-by:  Bruce B <bbsmooth@gmail.com>

* fix the contrast in the check mark

Co-authored-by:  Bruce B <bbsmooth@gmail.com>

* Stop the menu closing when clicking on the searchbar

Co-authored-by: Bruce Blaser <bbsmooth@gmail.com>

* make the list hover and focus consistent

Co-authored-by: Co-authored-by:  Bruce B <bbsmooth@gmail.com>

---------

Co-authored-by: Huyen Nguyen <25715018+huyenltnguyen@users.noreply.github.com>
Co-authored-by: Bruce B <bbsmooth@gmail.com>
This commit is contained in:
Muhammed Mustafa
2023-04-26 22:44:03 +02:00
committed by GitHub
co-authored by Huyen Nguyen Bruce B
parent 5a8228fa52
commit f0aecf173d
10 changed files with 296 additions and 718 deletions
@@ -73,7 +73,6 @@
"click-start-course": "Start the course",
"click-start-project": "Start the project",
"change-language": "Change Language",
"cancel-change": "Cancel Change",
"resume-project": "Resume project",
"start-project": "Start project"
},
+4 -51
View File
@@ -1,56 +1,9 @@
import { faLanguage } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
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>
</>
);
function LanguageGlobe(): JSX.Element {
return <FontAwesomeIcon icon={faLanguage} size='xl' />;
}
LanguageGlobe.displayName = 'LanguageGlobe';
@@ -0,0 +1,181 @@
import React, { useState, useRef, useEffect } from 'react';
import type { TFunction } from 'i18next';
import { connect } from 'react-redux';
import { withTranslation } from 'react-i18next';
import { clientLocale } from '../../../../../config/env.json';
import {
availableLangs,
LangNames,
LangCodes,
hiddenLangs
} from '../../../../../config/i18n';
import { hardGoTo as navigate } from '../../../redux/actions';
import createLanguageRedirect from '../../create-language-redirect';
import LanguageGlobe from '../../../assets/icons/language-globe';
const locales = availableLangs.client.filter(
lang => !hiddenLangs.includes(lang)
);
const mapDispatchToProps = {
navigate
};
interface LanguageListProps {
t: TFunction;
navigate?: (location: string) => void;
}
export const LanguageList = ({
t,
navigate
}: LanguageListProps): JSX.Element => {
const [showList, setShowList] = useState(false);
const listButtonRef = useRef<HTMLButtonElement>(null);
const handleClick = (): void => {
if (showList) {
setShowList(false);
return;
}
setShowList(true);
};
const handleClickOutside = () => {
if (showList) setShowList(false);
};
useEffect(() => {
document.addEventListener('click', handleClickOutside);
return () => {
document.removeEventListener('click', handleClickOutside);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showList]);
// the accessibility tree just needs a little more time to pick up the change.
// This function allows us to set aria-expanded to false and then delay just a bit before setting focus on the button
const closeAndFocus = () => {
listButtonRef.current?.classList.add('force-show');
setShowList(false);
setTimeout(() => {
listButtonRef.current?.focus();
listButtonRef.current?.classList.remove('force-show');
}, 100);
};
const handleLanguageChange = (
event: React.MouseEvent<HTMLButtonElement>
): void => {
const selectedLanguage = event.currentTarget.dataset.value;
event.preventDefault();
if (selectedLanguage === undefined) return;
const path = createLanguageRedirect({
clientLocale,
lang: selectedLanguage
});
if (navigate) {
return navigate(path);
}
closeAndFocus();
};
const handleMenuKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
if (event.key === 'Escape') {
event.preventDefault();
closeAndFocus();
}
};
const handleBlur = (event: React.FocusEvent<HTMLButtonElement>) => {
if (
event.relatedTarget &&
!event.relatedTarget.closest('.nav-list') &&
event.relatedTarget !== listButtonRef.current
) {
setShowList(false);
}
};
const handleLastLangaugeKeys = (
event: React.KeyboardEvent<HTMLButtonElement>
) => {
const DoKeyPress = new Map<string, { select: () => void }>([
[
'Escape',
{
select: () => {
event.preventDefault();
closeAndFocus();
}
}
],
[
'Tab',
{
select: () => {
const camperPressedTheShiftKey = event.shiftKey;
if (!camperPressedTheShiftKey) {
setShowList(false);
}
}
}
]
]);
DoKeyPress.get(event.key)?.select();
};
const getHandleLanguageKeys = (
event: React.KeyboardEvent<HTMLButtonElement>,
languagePosition: number
) => {
const lastLanguage = locales.length - 1;
if (languagePosition === lastLanguage) {
return handleLastLangaugeKeys(event);
} else handleMenuKeyDown(event);
};
return (
<>
<button
id='toggle-lang-button'
className='lang-button-nav'
title={t('buttons.change-language')}
aria-label={t('buttons.change-language')}
aria-controls='nav-lang-list'
aria-expanded={showList}
ref={listButtonRef}
onBlur={handleBlur}
onClick={handleClick}
>
<LanguageGlobe />
</button>
<ul
id='nav-lang-list'
className='nav-list'
aria-labelledby='toggle-lang-button'
>
{locales.map((lang, index) => (
<li key={'lang-' + lang}>
<button
className='nav-link nav-lang-list-option'
data-value={lang}
onClick={handleLanguageChange}
onKeyDown={event => getHandleLanguageKeys(event, index)}
{...(clientLocale === lang && { 'aria-current': true })}
{...(LangCodes[lang] && {
lang: LangCodes[lang]
})}
>
{LangNames[lang]}
</button>
</li>
))}
</ul>
</>
);
};
export default connect(
null,
mapDispatchToProps
)(withTranslation()(LanguageList));
@@ -25,7 +25,8 @@ const MenuButton = ({
const handleBlur = (event: React.FocusEvent<HTMLButtonElement>): void => {
if (
event.relatedTarget &&
!event.relatedTarget.closest('.universal-nav-right') &&
!event.relatedTarget.closest('.nav-list') &&
!event.relatedTarget.closest('.fcc_searchBar') &&
displayMenu
) {
hideMenu();
@@ -5,46 +5,29 @@ import {
faHeart
} from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React, { Fragment, useRef } from 'react';
import React, { Fragment } from 'react';
import Media from 'react-responsive';
import { useTranslation, withTranslation } from 'react-i18next';
import { useFeature } from '@growthbook/growthbook-react';
import { connect } from 'react-redux';
import { clientLocale, radioLocation } from '../../../../../config/env.json';
import { radioLocation } from '../../../../../config/env.json';
import { DONATE_NAV_EXPOSED_WIDTH } from '../../../../../config/misc';
import {
availableLangs,
LangNames,
LangCodes,
hiddenLangs
} from '../../../../../config/i18n';
import { hardGoTo as navigate, openSignoutModal } from '../../../redux/actions';
import { openSignoutModal } from '../../../redux/actions';
import { updateMyTheme } from '../../../redux/settings/actions';
import createLanguageRedirect from '../../create-language-redirect';
import { Link } from '../../helpers';
import { type ThemeProps, Themes } from '../../settings/theme';
import LanguageGlobe from '../../../assets/icons/language-globe';
import { User } from '../../../redux/prop-types';
const locales = availableLangs.client.filter(
lang => !hiddenLangs.includes(lang)
);
export interface NavLinksProps extends Pick<ThemeProps, 'toggleNightMode'> {
displayMenu: boolean;
isLanguageMenuDisplayed: boolean;
showMenu: () => void;
hideMenu: () => void;
user?: User;
navigate?: (location: string) => void;
showLanguageMenu: (elementToFocus: HTMLButtonElement | null) => void;
hideLanguageMenu: () => void;
menuButtonRef: React.RefObject<HTMLButtonElement>;
openSignoutModal: () => void;
}
const mapDispatchToProps = {
navigate,
toggleNightMode: (theme: Themes) => updateMyTheme({ theme }),
openSignoutModal
};
@@ -115,82 +98,36 @@ const toggleTheme = (
function NavLinks({
menuButtonRef,
hideLanguageMenu,
openSignoutModal,
hideMenu,
showLanguageMenu,
isLanguageMenuDisplayed,
displayMenu,
toggleNightMode,
user,
navigate
user
}: NavLinksProps) {
const { t } = useTranslation();
const langButtonRef = useRef<HTMLButtonElement>(null);
const firstLangOptionRef = useRef<HTMLButtonElement>(null);
const lastLangOptionRef = useRef<HTMLButtonElement>(null);
const {
isDonating: isUserDonating,
username: currentUserName,
theme: currentUserTheme
} = user || {};
const isUserDonating = user?.isDonating;
const currentUserName = user?.username;
const currentUserTheme = user?.theme;
const getPreviousMenuItem = (target: HTMLButtonElement | null) => {
const previousSibling =
target?.closest('.nav-list > li')?.previousElementSibling;
const previousButton = previousSibling?.querySelector<
HTMLButtonElement | HTMLAnchorElement
>('a, button');
return previousButton ?? menuButtonRef.current;
};
const handleLanguageChange = (
event: React.MouseEvent<HTMLButtonElement>
): void => {
event.preventDefault();
const newLanguage = event.currentTarget.dataset.value;
// 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.
langButtonRef.current?.focus();
hideLanguageMenu();
return;
}
// Put focus on menu button first so we don't lose focus
// for screen readers.
menuButtonRef.current?.focus();
// the accessibility tree just needs a little more time to pick up the change.
// This function allows us to set aria-expanded to false and then delay just a bit before setting focus on the button
const closeAndFocus = () => {
menuButtonRef.current?.classList.add('force-show');
hideMenu();
// If user selected the current language then we just close the menu
if (newLanguage === clientLocale) {
return;
}
if (newLanguage !== undefined) {
const path = createLanguageRedirect({
clientLocale,
lang: newLanguage
});
if (typeof navigate !== 'undefined') {
return navigate(path);
}
}
setTimeout(() => {
menuButtonRef.current?.focus();
menuButtonRef.current?.classList.remove('force-show');
}, 100);
};
const handleMenuKeyDown = (
event: React.KeyboardEvent<HTMLButtonElement | HTMLAnchorElement>
) => {
if (event.key === 'Escape') {
menuButtonRef.current?.focus();
hideMenu();
event.preventDefault();
}
};
const handleLanguageButtonClick = () => {
if (isLanguageMenuDisplayed) {
hideLanguageMenu();
} else {
showLanguageMenu(firstLangOptionRef.current);
closeAndFocus();
}
};
@@ -202,9 +139,8 @@ function NavLinks({
'Escape',
{
select: () => {
menuButtonRef.current?.focus();
hideMenu();
event.preventDefault();
closeAndFocus();
}
}
],
@@ -223,150 +159,6 @@ function NavLinks({
DoKeyPress.get(event.key)?.select();
};
const handleLanguageButtonKeyDown = (
event: React.KeyboardEvent<HTMLButtonElement>
): void => {
// the strings in map need to start with a Capital latter, because event.key preduce a string that starts with a capital latter
const DoKeyPress = new Map<string, { select: () => void }>([
[
'Escape',
{
select: () => {
menuButtonRef.current?.focus();
hideMenu();
event.preventDefault();
}
}
],
[
'ArrowDown',
{
select: () => {
showLanguageMenu(firstLangOptionRef.current);
event.preventDefault();
}
}
],
[
'ArrowUp',
{
select: () => {
showLanguageMenu(lastLangOptionRef.current);
event.preventDefault();
}
}
]
]);
DoKeyPress.get(event.key)?.select();
};
const handleLanguageMenuKeyDown = (
event: React.KeyboardEvent<HTMLButtonElement>
): void => {
const focusFirstLanguageMenuItem = () => {
firstLangOptionRef.current?.focus();
event.preventDefault();
};
const focusLastLanguageMenuItem = () => {
lastLangOptionRef.current?.focus();
event.preventDefault();
};
const DoKeyPress = new Map<string, { select: () => void }>([
[
'Tab',
{
select: () => {
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.
const currentButton = langButtonRef.current;
getPreviousMenuItem(currentButton)?.focus();
hideLanguageMenu();
event.preventDefault();
}
}
],
[
'Escape',
{
select: () => {
langButtonRef.current?.focus();
hideLanguageMenu();
event.preventDefault();
}
}
],
[
'ArrowDown',
{
select: () => {
const isFocusOnLastLanguageOption =
event.target === lastLangOptionRef.current;
const selectCancelButton = firstLangOptionRef.current?.focus();
const selectNextLanguage = (
event.currentTarget.parentNode?.nextSibling
?.firstChild as HTMLButtonElement
)?.focus();
isFocusOnLastLanguageOption
? selectCancelButton
: selectNextLanguage;
event.preventDefault();
}
}
],
[
'ArrowUp',
{
select: () => {
const isFocusOnCancelButton =
event.target === firstLangOptionRef.current;
const selectLastLanguage = lastLangOptionRef.current?.focus();
// selectPreviousLanguage is a childNode and doesn't have focus property but it still works somehow,
// IDK how it works, and how to please TypeScript, for now I am lying to TypeScript
const selectPreviousLanguage = (
event.currentTarget.parentNode?.previousSibling
?.firstChild as HTMLButtonElement
)?.focus();
isFocusOnCancelButton ? selectLastLanguage : selectPreviousLanguage;
event.preventDefault();
}
}
],
['Home', { select: focusFirstLanguageMenuItem }],
['PageUp', { select: focusFirstLanguageMenuItem }],
['End', { select: focusLastLanguageMenuItem }],
['PageDown', { select: focusLastLanguageMenuItem }]
]);
DoKeyPress.get(event.key)?.select();
};
// Added to the last item in the nav menu. Will close the menu if
// the user Tabs out of the menu.
const handleBlur = (event: React.FocusEvent<HTMLButtonElement>) => {
if (
event.relatedTarget &&
!event.relatedTarget.closest('.nav-list') &&
event.relatedTarget !== menuButtonRef.current
) {
hideMenu();
}
};
const handleSignOutClick = (): void => {
hideMenu();
openSignoutModal();
@@ -375,9 +167,7 @@ function NavLinks({
return (
<ul
aria-labelledby='toggle-button-nav'
className={`nav-list${displayMenu ? ' display-menu' : ''}${
isLanguageMenuDisplayed ? ' display-lang-menu' : ''
}`}
className={`nav-list${displayMenu ? ' display-menu' : ''}`}
>
<DonateButton
isUserDonating={isUserDonating}
@@ -462,7 +252,7 @@ function NavLinks({
toggleTheme(currentUserTheme, toggleNightMode);
}
}}
onKeyDown={handleMenuKeyDown}
onKeyDown={currentUserName ? handleMenuKeyDown : handleSignOutKeys}
>
{currentUserName ? (
<>
@@ -487,72 +277,6 @@ function NavLinks({
)}
</button>
</li>
<li key='lang-menu'>
{/*
The div existences create edge case in which camper skips the change language,
when they press "shift+tab" on signout button whenever signout focus events uses `getPreviousMenuItem`.
To fix this we need to remove `div`, but this creates a bug which close the menu when someone interact with it any other way except the keyboard.
This is a complexy and footgun that can break the site without notices and we shouldn't carry,
to sort this we need to remove the div and make focus events simpler, but that's a ToDo for later.
*/}
<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={handleBlur}
onClick={handleLanguageButtonClick}
onKeyDown={handleLanguageButtonKeyDown}
ref={langButtonRef}
>
<span>{t('buttons.change-language')}</span>
<LanguageGlobe />
</button>
<ul
aria-labelledby='nav-lang-button'
className={'nav-lang-menu' + (currentUserName ? ' 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={handleLanguageChange}
onKeyDown={handleLanguageMenuKeyDown}
ref={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]
})}
onClick={handleLanguageChange}
onKeyDown={handleLanguageMenuKeyDown}
{...(index === locales.length - 1 && {
ref: lastLangOptionRef
})}
role='menuitem'
tabIndex={-1}
>
{LangNames[lang]}
</button>
</li>
))}
</ul>
</div>
</li>
{currentUserName && (
<li className='nav-line' key='sign-out'>
<button
@@ -61,7 +61,6 @@
display: none;
position: absolute;
background-color: var(--theme-color);
top: calc(var(--header-height) * 2);
right: 0;
flex-wrap: wrap;
justify-content: flex-end;
@@ -72,6 +71,24 @@
max-width: 15rem;
}
.lang-button-nav[aria-expanded='true'] + .nav-list,
.lang-button-nav[aria-expanded='false'].force-show + .nav-list {
display: block;
top: 100%;
}
.lang-button-nav:is(
:hover,
:hover:focus,
[aria-expanded='true'],
[aria-expanded='false'].force-show
) {
background-color: var(--gray-10);
color: var(--theme-color);
border: 1px solid var(--gray-00);
fill: var(--gray-10);
}
.nav-list li {
width: 100%;
height: 2.5rem;
@@ -92,12 +109,6 @@
height: 100%;
}
.nav-lang {
padding: 0;
width: 100%;
height: 100%;
}
.nav-link:focus {
outline-offset: -4px;
}
@@ -107,27 +118,27 @@
color: inherit;
}
.nav-link:not([aria-disabled='true']):hover {
color: var(--theme-color);
text-decoration: none;
.nav-link:not([aria-disabled='true']):hover,
.nav-link:focus-visible {
color: var(--gray-90);
background-color: var(--gray-10);
}
.nav-link:not([aria-disabled='true']):hover {
text-decoration: none;
cursor: pointer;
outline-color: transparent !important;
}
button.nav-link[aria-disabled='true'] {
background-color: inherit;
}
li > button.nav-link-signout:not([aria-disabled='true']):is(:hover, :focus) {
background-color: var(--danger-background);
color: var(--danger-color);
}
.nav-link-header,
.nav-link-header:not([aria-disabled='true']):hover,
.nav-link-header:active {
color: var(--gray-00);
background-color: var(--gray-90);
cursor: default;
}
.nav-link .fa-external-link-alt {
color: var(--gray-45);
}
@@ -138,116 +149,12 @@ li > button.nav-link-signout:not([aria-disabled='true']):is(:hover, :focus) {
height: auto !important;
}
.nav-link:hover .nav-lang-menu,
.nav-link:active .nav-lang-menu {
background-color: var(--gray-00);
color: var(--gray-90);
cursor: pointer;
}
#nav-lang-menu li:first-child {
border-bottom: 0.1rem solid var(--gray-45) !important;
}
button.nav-link:focus-visible {
color: var(--tertiary-color);
background-color: var(--tertiary-background);
outline-offset: -4px;
}
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;
}
.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%;
position: absolute;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
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;
/* Ensure lang menu scrolls if there is not enough height to display all of it (this ensures keyboard accessibility).*/
height: min(
var(--lang-menu-height),
calc(100vh - var(--search-box-form) - var(--header-height))
);
}
@media (min-width: 601px) {
/* Search form is no longer between header and main menu. */
.nav-lang-menu {
height: min(var(--lang-menu-height), calc(100vh - var(--header-height)));
}
}
/* 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(var(--lang-menu-height), 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-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'] {
.nav-lang-list-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;
@@ -255,17 +162,8 @@ button.nav-link[aria-disabled='true'] {
background-repeat: no-repeat;
}
.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 {
.nav-lang-list-option[aria-current='true']:hover,
.nav-lang-list-option[aria-current='true']: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='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;
}
@@ -295,7 +193,8 @@ button.nav-link[aria-disabled='true'] {
margin-inline-end: 25px;
}
.exposed-button-nav {
.exposed-button-nav,
.lang-button-nav {
padding: 2px 14px;
border: 1px solid var(--gray-00);
font-size: 18px;
@@ -364,17 +263,13 @@ button.nav-link[aria-disabled='true'] {
.display-menu {
display: inherit;
text-align: left;
margin-top: calc(-1 * var(--header-height));
top: var(--header-height);
max-height: calc(100vh - var(--header-height));
overflow-y: scroll;
-ms-overflow-style: none;
scrollbar-width: none;
}
.expand-lang-menu .display-menu {
overflow-y: unset;
}
.display-menu::-webkit-scrollbar {
display: none;
}
@@ -393,13 +288,6 @@ button.nav-link[aria-disabled='true'] {
border-top: 0.1rem solid var(--gray-45);
}
.nav-line-2 {
border-color: var(--gray-45);
width: 100%;
margin: 0;
border-top-width: 2px;
}
.signup-btn {
max-height: calc(var(--header-height) - 6px);
padding: 4px 12px;
@@ -496,10 +384,13 @@ button.nav-link[aria-disabled='true'] {
@media (max-width: 600px) {
.nav-list {
min-width: 100%;
top: calc(var(--header-height) * 2);
margin-top: 0;
}
.display-menu {
top: calc(var(--header-height) * 2);
}
.fcc_searchBar,
.fcc_searchBar div {
width: 100%;
@@ -14,6 +14,7 @@ import NavLinks, { type NavLinksProps } from './nav-links';
import NavLogo from './nav-logo';
import './universal-nav.css';
import AuthOrProfile from './auth-or-profile';
import { LanguageList } from './language-list';
const SearchBar = Loadable(() => import('../../search/searchBar/search-bar'));
const SearchBarOptimized = Loadable(
@@ -22,18 +23,15 @@ const SearchBarOptimized = Loadable(
type UniversalNavProps = Omit<
NavLinksProps,
'navigate' | 'toggleNightMode' | 'openSignoutModal'
'toggleNightMode' | 'openSignoutModal'
> & {
fetchState: { pending: boolean };
searchBarRef?: React.RefObject<HTMLDivElement>;
};
export const UniversalNav = ({
displayMenu,
isLanguageMenuDisplayed,
showMenu,
hideMenu,
showLanguageMenu,
hideLanguageMenu,
menuButtonRef,
searchBarRef,
user,
@@ -88,6 +86,7 @@ export const UniversalNav = ({
</Link>
</Media>
)}
<LanguageList t={t} />
<MenuButton
displayMenu={displayMenu}
hideMenu={hideMenu}
@@ -98,11 +97,8 @@ export const UniversalNav = ({
<Media maxWidth={SEARCH_EXPOSED_WIDTH}>{search}</Media>
<NavLinks
displayMenu={displayMenu}
isLanguageMenuDisplayed={isLanguageMenuDisplayed}
hideLanguageMenu={hideLanguageMenu}
hideMenu={hideMenu}
menuButtonRef={menuButtonRef}
showLanguageMenu={showLanguageMenu}
showMenu={showMenu}
user={user}
/>
+3 -23
View File
@@ -16,7 +16,7 @@ interface HeaderProps {
}
export class Header extends React.Component<
HeaderProps,
{ displayMenu: boolean; isLanguageMenuDisplayed: boolean }
{ displayMenu: boolean }
> {
menuButtonRef: React.RefObject<HTMLButtonElement>;
searchBarRef: React.RefObject<any>;
@@ -24,16 +24,13 @@ export class Header extends React.Component<
constructor(props: HeaderProps) {
super(props);
this.state = {
displayMenu: false,
isLanguageMenuDisplayed: false
displayMenu: false
};
this.menuButtonRef = React.createRef();
this.searchBarRef = React.createRef();
this.handleClickOutside = this.handleClickOutside.bind(this);
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 {
@@ -48,8 +45,6 @@ export class Header extends React.Component<
!this.searchBarRef.current.contains(eventTarget) &&
// don't count clicks on searcn bar inputs reset button
!eventTarget.closest('.ais-SearchBox-reset') &&
// don't count clicks on language button/menu
!eventTarget.closest('.nav-lang') &&
// don't count clicks on disabled elements
!eventTarget.closest('[aria-disabled="true"]')
) {
@@ -66,23 +61,11 @@ export class Header extends React.Component<
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 | null): void {
this.setState({ isLanguageMenuDisplayed: true }, () =>
elementToFocus?.focus()
);
}
hideLanguageMenu(): void {
this.setState({ isLanguageMenuDisplayed: false });
}
render(): JSX.Element {
const { displayMenu, isLanguageMenuDisplayed } = this.state;
const { displayMenu } = this.state;
const { fetchState, user, skipButtonText } = this.props;
return (
<header>
@@ -92,13 +75,10 @@ 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}
showMenu={this.showMenu}
showLanguageMenu={this.showLanguageMenu}
user={user}
/>
</header>
@@ -54,6 +54,10 @@ universal-nav-bar
and menu secion
*/
[dir='rtl'] .nav-lang-list-option[aria-current='true'] {
background-position: 10px center;
}
@media (min-width: 601px) {
[dir='rtl'] .nav-list {
right: auto;
@@ -1,18 +1,6 @@
import {
availableLangs,
hiddenLangs,
LangNames
} from '../../../../../config/i18n';
import envData from '../../../../../config/env.json';
const { clientLocale } = envData;
const selectors: { [key: string]: string } = {
const navigationItems: { [key: string]: string } = {
'navigation-list': '.nav-list',
'toggle-button': '#toggle-button-nav',
'language-menu': '.nav-lang-menu',
'exit-lang-menu': "[data-value='exit-lang-menu']",
'lang-menu-option': 'button.nav-lang-menu-option',
'sign-in-button': "[data-test-label='landing-small-cta']",
'avatar-link': '.avatar-nav-link',
'avatar-container': '.avatar-container',
@@ -21,7 +9,7 @@ const selectors: { [key: string]: string } = {
'cancel-signout': "[data-test-label='cancel-signout']"
};
const links: { [key: string]: string } = {
const navigationLinks: { [key: string]: string } = {
'sign-in': '/signin',
donate: '/donate',
curriculum: '/learn',
@@ -36,9 +24,10 @@ describe('Default Navigation Menu', () => {
it('should render the expected nav items.', () => {
cy.visit('/learn');
testLink('Sign in', 'sign-in-button', true);
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.');
cy.get(navigationItems['toggle-button']).should('be.visible').click();
cy.get(navigationItems['navigation-list']).contains(
'Sign in to change theme.'
);
testLink('Donate');
testLink('Curriculum');
testLink('Forum');
@@ -47,112 +36,10 @@ describe('Default Navigation Menu', () => {
});
it('should close the menu and focus on the Menu button when the Esc key is pressed while the navigation menu is expanded and an item in the menu is focused', () => {
cy.get(selectors['navigation-list']).contains('Curriculum').focus();
cy.get(navigationItems['navigation-list']).contains('Curriculum').focus();
cy.focused().type('{esc}');
cy.get(selectors['navigation-list']).should('not.be.visible');
cy.get(selectors['toggle-button']).should('be.focused');
});
});
describe('Language menu', () => {
it('should render all used languages.', () => {
cy.get(selectors['toggle-button']).should('be.visible').click();
cy.get(selectors['navigation-list']).contains('Change Language').click();
testAllLanguages();
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('Language menu keyboard accessibility', () => {
before(() => {
cy.get(selectors['exit-lang-menu']).click();
cy.get(selectors['navigation-list']).contains('Change Language').blur();
});
const langMenuOptionSelector =
selectors['language-menu'] + ' ' + selectors['lang-menu-option'];
it('should open the language menu with the ENTER key', () => {
cy.get(selectors['navigation-list'])
.contains('Change Language')
.type('{enter}');
cy.get(selectors['language-menu']).should('be.visible');
});
it('should open the language menu with the Up arrow key and place focus on the last item in the menu', () => {
cy.get(selectors['exit-lang-menu']).click();
cy.focused().type('{upArrow}');
cy.get(selectors['language-menu']).should('be.visible');
cy.get(langMenuOptionSelector).last().should('be.focused');
});
it('should open the language menu with the Down arrow key and place focus on the first item in the menu', () => {
cy.get(selectors['exit-lang-menu']).click();
cy.focused().type('{downArrow}');
cy.get(selectors['language-menu']).should('be.visible');
cy.get(langMenuOptionSelector).first().should('be.focused');
});
it('should be possible to move keyboard focus with the Up and Down arrow keys when the language menu is open', () => {
navigateLanguagesWithKey('downArrow');
navigateLanguagesWithKey('upArrow');
});
it('should move focus to the last menu item when the first menu item is focused and the Up arrow key is pressed', () => {
cy.get(langMenuOptionSelector).first().focus();
cy.focused().type('{upArrow}');
cy.get(langMenuOptionSelector).last().should('be.focused');
});
it('should move focus to the first menu item when the last menu item is focused and the Down arrow key is pressed', () => {
cy.get(langMenuOptionSelector).last().focus();
cy.focused().type('{downArrow}');
cy.get(langMenuOptionSelector).first().should('be.focused');
});
it('should close the language menu and focus on the Change Language button when focus is on a language menu item and the Esc key is pressed', () => {
cy.focused().type('{esc}');
cy.get(selectors['language-menu']).should('not.be.visible');
cy.get(selectors['navigation-list'])
.contains('Change Language')
.should('be.focused');
});
it('should close the language options menu and put focus on the item above the Choose Language button when focus is on a language menu item and the Shift + Tab keys are pressed', () => {
cy.focused().click();
// cy.tab() doesn't accept tabindex="-1" elements as focusable
// https://github.com/kuceb/cypress-plugin-tab/issues/18
// It is better to use .tab() on focused language option
// cy.focused().tab({ shift: true }); // uncomment this line when the issue is fixed
cy.get('body').tab({ shift: true }); // remove this line when the above issue is fixed
cy.get(selectors['language-menu']).should('not.be.visible');
cy.get(selectors['navigation-list'])
.contains('Sign in to change theme.')
.should('be.focused');
});
// cy.tab() doesn't accept tabindex="-1" elements as focusable
// https://github.com/kuceb/cypress-plugin-tab/issues/18
// Also, doing .tab() from element other than language option doesn't yield the expected result
it.skip('should focus on the next tabbable element when focus is on a language menu item and the Tab key is pressed', () => {
cy.get(selectors['navigation-list']).contains('Change Language').click();
cy.focused().tab();
cy.get(selectors['language-menu']).should('not.be.visible');
cy.get(selectors['sign-in-button']).should('be.focused');
cy.get(navigationItems['navigation-list']).should('not.be.visible');
cy.get(navigationItems['toggle-button']).should('be.focused');
});
});
@@ -161,19 +48,19 @@ describe('Authenticated Navigation Menu', () => {
cy.clearCookies();
cy.exec('pnpm run seed');
cy.login();
cy.get(selectors['toggle-button']).should('be.visible').click();
cy.get(navigationItems['toggle-button']).should('be.visible').click();
});
it('should show default avatar.', () => {
testLink('Settings');
cy.get(selectors['sign-in-button']).should('not.exist');
cy.get(selectors['avatar-link'])
cy.get(navigationItems['sign-in-button']).should('not.exist');
cy.get(navigationItems['avatar-link'])
.should('have.attr', 'href')
.and('contain', links['avatar-link']);
cy.get(selectors['avatar-container']).should(
.and('contain', navigationLinks['avatar-link']);
cy.get(navigationItems['avatar-container']).should(
'have.class',
'default-border'
);
cy.get(selectors['navigation-list']).contains('Night Mode').click();
cy.get(navigationItems['navigation-list']).contains('Night Mode').click();
cy.get('body').should('have.class', 'dark-palette');
});
});
@@ -185,22 +72,22 @@ describe('Authenticated User Sign Out', () => {
});
beforeEach(() => {
cy.login();
cy.get(selectors['toggle-button']).should('be.visible').click();
cy.get(navigationItems['toggle-button']).should('be.visible').click();
});
it('should sign out user', () => {
cy.get(selectors['sign-out-button']).click();
cy.get(selectors['signout']).click();
cy.get(selectors['sign-in-button']).should('be.visible');
cy.get(selectors['sign-out-button']).should('not.exist');
cy.get(selectors['avatar-link']).should('not.exist');
cy.get(selectors['avatar-container']).should('not.exist');
cy.get(navigationItems['sign-out-button']).click();
cy.get(navigationItems['signout']).click();
cy.get(navigationItems['sign-in-button']).should('be.visible');
cy.get(navigationItems['sign-out-button']).should('not.exist');
cy.get(navigationItems['avatar-link']).should('not.exist');
cy.get(navigationItems['avatar-container']).should('not.exist');
});
it('should cancel the sign out', () => {
cy.get(selectors['sign-out-button']).click();
cy.get(selectors['cancel-signout']).click();
cy.get(selectors['sign-in-button']).should('not.exist');
cy.get(selectors['avatar-link']).should('be.visible');
cy.get(selectors['avatar-container']).should('be.visible');
cy.get(navigationItems['sign-out-button']).click();
cy.get(navigationItems['cancel-signout']).click();
cy.get(navigationItems['sign-in-button']).should('not.exist');
cy.get(navigationItems['avatar-link']).should('be.visible');
cy.get(navigationItems['avatar-container']).should('be.visible');
});
});
@@ -212,23 +99,16 @@ describe('Donor Navigation Menu', () => {
cy.visit('/donate');
});
it('should show donor avatar border.', () => {
cy.get(selectors['avatar-container']).should('have.class', 'gold-border');
cy.get(navigationItems['avatar-container']).should(
'have.class',
'gold-border'
);
});
it('should show thank you message.', () => {
cy.get(selectors['navigation-list']).contains('Thanks for donating');
cy.get(navigationItems['navigation-list']).contains('Thanks for donating');
});
});
const testAllLanguages = () => {
const availableLangNames = availableLangs.client
.filter(lang => !hiddenLangs.includes(lang))
.map(lang => LangNames[lang]);
for (let i = 0; i < availableLangNames.length; i++) {
const langName = availableLangNames[i];
cy.get(selectors['language-menu']).contains(langName);
}
};
const testLink = (
item: string,
selector = 'navigation-list',
@@ -236,45 +116,14 @@ const testLink = (
) => {
if (checkParent) {
return cy
.get(selectors[selector])
.get(navigationItems[selector])
.should('contain.text', item)
.should('have.attr', 'href')
.and('contain', links[item.replace(/\s+/g, '-').toLowerCase()]);
//.and('contain', links[item.replaceAll(' ', '-').toLowerCase()]);
.and('contain', navigationLinks[item.replace(/\s+/g, '-').toLowerCase()]);
}
return (
cy
.get(selectors[selector])
.contains(item)
.should('have.attr', 'href')
//.and('contain', links[item.replaceAll(' ', '-').toLowerCase()]);
.and('contain', links[item.replace(/\s+/g, '-').toLowerCase()])
);
};
const navigateLanguagesWithKey = (direction: string) => {
const directions = ['downArrow', 'upArrow'];
if (!directions.includes(direction)) {
throw new Error(
'Invalid direction: ' +
direction +
'\nMust be one of: ' +
directions.join(',')
);
}
const availableLangNames = availableLangs.client
.filter(lang => !hiddenLangs.includes(lang))
.map(lang => LangNames[lang]);
if (direction === 'upArrow') {
availableLangNames.reverse();
// remove the first element, as it will be the focused language
availableLangNames.shift();
}
availableLangNames.forEach(langName => {
cy.focused().type(`{${direction}}`);
cy.focused().contains(langName);
});
return cy
.get(navigationItems[selector])
.contains(item)
.should('have.attr', 'href')
.and('contain', navigationLinks[item.replace(/\s+/g, '-').toLowerCase()]);
};