diff --git a/client/i18n/locales/english/translations.json b/client/i18n/locales/english/translations.json index 885c3792304..a17748e5dd3 100644 --- a/client/i18n/locales/english/translations.json +++ b/client/i18n/locales/english/translations.json @@ -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" }, diff --git a/client/src/assets/icons/language-globe.tsx b/client/src/assets/icons/language-globe.tsx index af82eabc374..ac0b59301f8 100644 --- a/client/src/assets/icons/language-globe.tsx +++ b/client/src/assets/icons/language-globe.tsx @@ -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 -): JSX.Element { - return ( - <> - - - - - - - - - ); +function LanguageGlobe(): JSX.Element { + return ; } LanguageGlobe.displayName = 'LanguageGlobe'; diff --git a/client/src/components/Header/components/language-list.tsx b/client/src/components/Header/components/language-list.tsx new file mode 100644 index 00000000000..04e2eed1f3b --- /dev/null +++ b/client/src/components/Header/components/language-list.tsx @@ -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(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 + ): 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) => { + if (event.key === 'Escape') { + event.preventDefault(); + closeAndFocus(); + } + }; + + const handleBlur = (event: React.FocusEvent) => { + if ( + event.relatedTarget && + !event.relatedTarget.closest('.nav-list') && + event.relatedTarget !== listButtonRef.current + ) { + setShowList(false); + } + }; + + const handleLastLangaugeKeys = ( + event: React.KeyboardEvent + ) => { + const DoKeyPress = new Map 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, + languagePosition: number + ) => { + const lastLanguage = locales.length - 1; + if (languagePosition === lastLanguage) { + return handleLastLangaugeKeys(event); + } else handleMenuKeyDown(event); + }; + return ( + <> + + + + ); +}; + +export default connect( + null, + mapDispatchToProps +)(withTranslation()(LanguageList)); diff --git a/client/src/components/Header/components/menu-button.tsx b/client/src/components/Header/components/menu-button.tsx index 0b3371046a2..4fd54695a72 100644 --- a/client/src/components/Header/components/menu-button.tsx +++ b/client/src/components/Header/components/menu-button.tsx @@ -25,7 +25,8 @@ const MenuButton = ({ const handleBlur = (event: React.FocusEvent): void => { if ( event.relatedTarget && - !event.relatedTarget.closest('.universal-nav-right') && + !event.relatedTarget.closest('.nav-list') && + !event.relatedTarget.closest('.fcc_searchBar') && displayMenu ) { hideMenu(); diff --git a/client/src/components/Header/components/nav-links.tsx b/client/src/components/Header/components/nav-links.tsx index 4a9ce92a34c..d95fac40780 100644 --- a/client/src/components/Header/components/nav-links.tsx +++ b/client/src/components/Header/components/nav-links.tsx @@ -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 { displayMenu: boolean; - isLanguageMenuDisplayed: boolean; showMenu: () => void; hideMenu: () => void; user?: User; - navigate?: (location: string) => void; - showLanguageMenu: (elementToFocus: HTMLButtonElement | null) => void; - hideLanguageMenu: () => void; menuButtonRef: React.RefObject; 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(null); - const firstLangOptionRef = useRef(null); - const lastLangOptionRef = useRef(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 - ): 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 ) => { 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 - ): 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 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 - ): void => { - const focusFirstLanguageMenuItem = () => { - firstLangOptionRef.current?.focus(); - event.preventDefault(); - }; - const focusLastLanguageMenuItem = () => { - lastLangOptionRef.current?.focus(); - event.preventDefault(); - }; - const DoKeyPress = new Map 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) => { - 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 (
    {currentUserName ? ( <> @@ -487,72 +277,6 @@ function NavLinks({ )} -
  • - {/* - 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. - */} -
    - - -
    -
  • {currentUserName && (