diff --git a/components/Header/MainMenu/MobileMenu/index.tsx b/components/Header/MainMenu/MobileMenu/index.tsx index 616cd10bb..d9e63fc5b 100644 --- a/components/Header/MainMenu/MobileMenu/index.tsx +++ b/components/Header/MainMenu/MobileMenu/index.tsx @@ -7,6 +7,7 @@ import useDropdownStore from "@/stores/main-menu" import { GiftIcon, SearchIcon, ServiceIcon } from "@/components/Icons" import LanguageSwitcher from "@/components/LanguageSwitcher" +import { useHandleKeyUp } from "@/hooks/useHandleKeyUp" import HeaderLink from "../../HeaderLink" import NavigationMenu from "../NavigationMenu" @@ -22,6 +23,12 @@ export default function MobileMenu({ const intl = useIntl() const { isHamburgerMenuOpen, toggleHamburgerMenu } = useDropdownStore() + useHandleKeyUp((event: KeyboardEvent) => { + if (event.key === "Escape" && isHamburgerMenuOpen) { + toggleHamburgerMenu() + } + }) + return ( <> - + - + - + diff --git a/components/Icons/ChevronLeft.tsx b/components/Icons/ChevronLeft.tsx new file mode 100644 index 000000000..eb14d07dd --- /dev/null +++ b/components/Icons/ChevronLeft.tsx @@ -0,0 +1,40 @@ +import { iconVariants } from "./variants" + +import type { IconProps } from "@/types/components/icon" + +export default function ChevronLeftIcon({ + className, + color, + ...props +}: IconProps) { + const classNames = iconVariants({ className, color }) + return ( + + + + + + + + + ) +} diff --git a/components/Icons/get-icon-by-icon-name.ts b/components/Icons/get-icon-by-icon-name.ts index 79feb4b0c..d7508b83a 100644 --- a/components/Icons/get-icon-by-icon-name.ts +++ b/components/Icons/get-icon-by-icon-name.ts @@ -15,6 +15,7 @@ import { CheckCircleIcon, CheckIcon, ChevronDownIcon, + ChevronLeftIcon, ChevronRightIcon, CloseIcon, CloseLarge, @@ -75,6 +76,8 @@ export function getIconByIconName(icon?: IconName): FC | null { return CheckCircleIcon case IconName.ChevronDown: return ChevronDownIcon + case IconName.ChevronLeft: + return ChevronLeftIcon case IconName.ChevronRight: return ChevronRightIcon case IconName.Close: diff --git a/components/Icons/index.tsx b/components/Icons/index.tsx index 31abcd5ca..9ed34a098 100644 --- a/components/Icons/index.tsx +++ b/components/Icons/index.tsx @@ -9,6 +9,7 @@ export { default as CellphoneIcon } from "./Cellphone" export { default as CheckIcon } from "./Check" export { default as CheckCircleIcon } from "./CheckCircle" export { default as ChevronDownIcon } from "./ChevronDown" +export { default as ChevronLeftIcon } from "./ChevronLeft" export { default as ChevronRightIcon } from "./ChevronRight" export { default as CloseIcon } from "./Close" export { default as CloseLarge } from "./CloseLarge" diff --git a/components/LanguageSwitcher/index.tsx b/components/LanguageSwitcher/index.tsx index e5ab88c5e..229920f01 100644 --- a/components/LanguageSwitcher/index.tsx +++ b/components/LanguageSwitcher/index.tsx @@ -1,28 +1,54 @@ "use client" import Link from "next/link" +import { useIntl } from "react-intl" import { Lang, languages } from "@/constants/languages" import useDropdownStore from "@/stores/main-menu" -import { CheckIcon, ChevronDownIcon, GlobeIcon } from "@/components/Icons" +import { + CheckIcon, + ChevronDownIcon, + ChevronLeftIcon, + GlobeIcon, +} from "@/components/Icons" +import { useHandleKeyUp } from "@/hooks/useHandleKeyUp" import useLang from "@/hooks/useLang" +import { useTrapFocus } from "@/hooks/useTrapFocus" + +import Subtitle from "../TempDesignSystem/Text/Subtitle" import styles from "./languageSwitcher.module.css" import { LanguageSwitcherProps } from "@/types/components/current/languageSwitcher" -export default function LanguageSwitcher({ urls }: LanguageSwitcherProps) { +export default function LanguageSwitcher({ + urls, + location = "header", +}: LanguageSwitcherProps) { + const intl = useIntl() + const languageSwitcherRef = useTrapFocus() const currentLanguage = useLang() const { toggleLanguageSwitcher, isLanguageSwitcherOpen } = useDropdownStore() const urlKeys = Object.keys(urls) as Lang[] + useHandleKeyUp((event: KeyboardEvent) => { + if (event.key === "Escape" && isLanguageSwitcherOpen) { + toggleLanguageSwitcher() + } + }) + return ( -
+
+
+ +
+ + {intl.formatMessage({ id: "Select your language" })} + +
    + {urlKeys.map((key) => { + const url = urls[key]?.url + const isActive = currentLanguage === key + if (url) { + return ( +
  • + + {languages[key]} + {isActive ? : null} + +
  • + ) + } + })} +
+
) diff --git a/components/LanguageSwitcher/languageSwitcher.module.css b/components/LanguageSwitcher/languageSwitcher.module.css index 1eeeab77b..9bdf4f188 100644 --- a/components/LanguageSwitcher/languageSwitcher.module.css +++ b/components/LanguageSwitcher/languageSwitcher.module.css @@ -1,5 +1,11 @@ -.languageSwitcher { - position: relative; +@keyframes slide-in { + from { + right: -100vw; + } + + to { + right: 0; + } } .button { @@ -27,33 +33,50 @@ } .dropdown { - position: absolute; - top: 2.25rem; - right: 0; + position: fixed; + top: var(--main-menu-mobile-height); + right: -100vw; + bottom: 0; + width: 100%; background-color: var(--Base-Surface-Primary-light-Normal); - padding: var(--Spacing-x2) var(--Spacing-x3); - border-radius: var(--Corner-radius-Large); - box-shadow: 0px 0px 14px 6px #0000001a; - display: none; - min-width: 12.5rem; - z-index: 1; -} - -/* Triangle above dropdown */ -.dropdown::before { - content: ""; - position: absolute; - top: -1.25rem; - right: 2.4rem; - transform: rotate(180deg); - border-width: 0.75rem; - border-style: solid; - border-color: var(--Base-Surface-Primary-light-Normal) transparent transparent - transparent; + transition: right 0.3s; } .dropdown.isExpanded { display: block; + right: 0; +} + +.backWrapper { + background-color: var(--Base-Surface-Secondary-light-Normal); + padding: var(--Spacing-x2); +} + +.backButton { + background-color: transparent; + border: none; + color: var(--Base-Text-High-contrast); + font-family: var(--typography-Subtitle-1-fontFamily); + font-weight: var(--typography-Subtitle-1-fontWeight); + font-size: var(--typography-Subtitle-1-Mobile-fontSize); + padding: 0; + cursor: pointer; + display: flex; + align-items: center; + gap: var(--Spacing-x1); +} + +.languageWrapper { + display: grid; + gap: var(--Spacing-x3); + padding: var(--Spacing-x3) var(--Spacing-x2); +} + +.subtitle { + font-family: var(--typography-Subtitle-2-fontFamily); + font-size: var(--typography-Subtitle-2-Mobile-fontSize); + font-weight: var(--typography-Subtitle-2-fontWeight); + color: var(--Base-Text-High-contrast, #4d001b); } .list { @@ -73,22 +96,64 @@ justify-content: space-between; align-items: center; text-decoration: none; -} - -.link:hover { - background-color: var(--Base-Surface-Primary-light-Hover-alt); border-radius: var(--Corner-radius-Medium); } .link.active, .link:hover { + background-color: var(--Base-Surface-Primary-light-Hover-alt); font-weight: 600; } @media screen and (min-width: 768px) { + .languageSwitcher { + position: relative; + } + + .backWrapper { + display: none; + } + + .languageWrapper { + padding: var(--Spacing-x2) var(--Spacing-x3); + } + + .subtitle { + display: none; + } + + .dropdown { + position: absolute; + top: 2.25rem; + background-color: var(--Base-Surface-Primary-light-Normal); + border-radius: var(--Corner-radius-Large); + box-shadow: 0px 0px 14px 6px #0000001a; + display: none; + min-width: 12.5rem; + z-index: 1; + bottom: auto; + } + + /* Triangle above dropdown */ + .dropdown::before { + content: ""; + position: absolute; + top: -1.25rem; + right: 2.4rem; + transform: rotate(180deg); + border-width: 0.75rem; + border-style: solid; + border-color: var(--Base-Surface-Primary-light-Normal) transparent + transparent transparent; + } + .button { grid-template-columns: repeat(3, max-content); font-size: var(--typography-Body-Bold-fontSize); font-family: var(--typography-Body-Bold-fontFamily); } + + .link.active:not(:hover) { + background-color: transparent; + } } diff --git a/hooks/useHandleKeyPress.ts b/hooks/useHandleKeyPress.ts index d9fdc0ea2..b240650d1 100644 --- a/hooks/useHandleKeyPress.ts +++ b/hooks/useHandleKeyPress.ts @@ -1,11 +1,11 @@ "use client" -import { useEffect } from 'react'; +import { useEffect } from "react" export function useHandleKeyPress(callback: (event: KeyboardEvent) => void) { useEffect(() => { - window.addEventListener('keydown', callback); + window.addEventListener("keydown", callback) return () => { - window.removeEventListener('keydown', callback); - }; - }, [callback]); -} \ No newline at end of file + window.removeEventListener("keydown", callback) + } + }, [callback]) +} diff --git a/hooks/useHandleKeyUp.ts b/hooks/useHandleKeyUp.ts new file mode 100644 index 000000000..b44f82d5a --- /dev/null +++ b/hooks/useHandleKeyUp.ts @@ -0,0 +1,12 @@ +"use client" + +import { useEffect } from "react" + +export function useHandleKeyUp(callback: (event: KeyboardEvent) => void) { + useEffect(() => { + window.addEventListener("keyup", callback) + return () => { + window.removeEventListener("keyup", callback) + } + }, [callback]) +} diff --git a/hooks/useTrapFocus.ts b/hooks/useTrapFocus.ts new file mode 100644 index 000000000..7d2e214c7 --- /dev/null +++ b/hooks/useTrapFocus.ts @@ -0,0 +1,82 @@ +"use client" + +import { useCallback, useEffect, useRef, useState } from "react" + +import { useHandleKeyPress } from "@/hooks/useHandleKeyPress" +import findTabbableDescendants from "@/utils/tabbable" + +const TAB_KEY = "Tab" +const optionsDefault = { focusOnRender: true, returnFocus: true } +type OptionsType = { + focusOnRender?: boolean + returnFocus?: boolean +} +export function useTrapFocus(opts?: OptionsType) { + const options = opts ? { ...optionsDefault, ...opts } : optionsDefault + const ref = useRef(null) + const previouseFocusedElement = useRef( + document.activeElement as HTMLElement + ) + const [tabbableElements, setTabbableElements] = useState([]) + // Handle initial focus of the referenced element, and return focus to previously focused element on cleanup + // and find all the tabbable elements in the referenced element + + useEffect(() => { + const { current } = ref + if (current) { + const focusableChildNodes = findTabbableDescendants(current) + if (options.focusOnRender) { + current.focus() + } + + setTabbableElements(focusableChildNodes) + } + return () => { + const { current } = previouseFocusedElement + if (current instanceof HTMLElement && options.returnFocus) { + current.focus() + } + } + }, [options.focusOnRender, options.returnFocus, ref, setTabbableElements]) + + const handleUserKeyPress = useCallback( + (event: KeyboardEvent) => { + const { code, shiftKey } = event + const first = tabbableElements[0] + const last = tabbableElements[tabbableElements.length - 1] + const currentActiveElement = document.activeElement + // Scope current tabs to current root element + if (isWithinCurrentElementScope([...tabbableElements, ref.current])) { + if (code === TAB_KEY) { + if ( + currentActiveElement === first || + currentActiveElement === ref.current + ) { + // move focus to last element if shift+tab while currently focusing the first tabbable element + if (shiftKey) { + event.preventDefault() + last.focus() + } + } + if (currentActiveElement === last) { + // move focus back to first if tabbing while currently focusing the last tabbable element + if (!shiftKey) { + event.preventDefault() + first.focus() + } + } + } + } + }, + [ref, tabbableElements] + ) + useHandleKeyPress(handleUserKeyPress) + + return ref +} +function isWithinCurrentElementScope( + elementList: (HTMLInputElement | Element | null)[] +) { + const currentActiveElement = document.activeElement + return elementList.includes(currentActiveElement) +} diff --git a/stores/main-menu.ts b/stores/main-menu.ts index 008cce9b7..1cfcd3039 100644 --- a/stores/main-menu.ts +++ b/stores/main-menu.ts @@ -19,7 +19,15 @@ const useDropdownStore = create((set) => ({ isMyPagesMenuOpen: false, isLanguageSwitcherOpen: false, toggleHamburgerMenu: () => - set((state) => ({ isHamburgerMenuOpen: !state.isHamburgerMenuOpen })), + set(({ isHamburgerMenuOpen, isMyPagesMenuOpen }) => { + // Close the other dropdown if it's open + if (!isHamburgerMenuOpen && isMyPagesMenuOpen) { + set({ isMyPagesMenuOpen: false }) + } + return { isHamburgerMenuOpen: !isHamburgerMenuOpen } + }), + // toggleHamburgerMenu: () => + // set((state) => ({ isHamburgerMenuOpen: !state.isHamburgerMenuOpen })), toggleMyPagesMobileMenu: () => set((state) => { // Close the other dropdown if it's open @@ -29,13 +37,18 @@ const useDropdownStore = create((set) => ({ return { isMyPagesMobileMenuOpen: !state.isMyPagesMobileMenuOpen } }), toggleMyPagesMenu: () => - set(({ isLanguageSwitcherOpen, isMyPagesMenuOpen }) => { - // Close the other dropdown if it's open - if (!isMyPagesMenuOpen && isLanguageSwitcherOpen) { - set({ isLanguageSwitcherOpen: false }) + set( + ({ isHamburgerMenuOpen, isLanguageSwitcherOpen, isMyPagesMenuOpen }) => { + // Close the other dropdown if it's open + if (!isMyPagesMenuOpen && isLanguageSwitcherOpen) { + set({ isLanguageSwitcherOpen: false }) + } + if (!isMyPagesMenuOpen && isHamburgerMenuOpen) { + set({ isHamburgerMenuOpen: false }) + } + return { isMyPagesMenuOpen: !isMyPagesMenuOpen } } - return { isMyPagesMenuOpen: !isMyPagesMenuOpen } - }), + ), toggleLanguageSwitcher: () => set(({ isLanguageSwitcherOpen, isMyPagesMenuOpen }) => { // Close the other dropdown if it's open diff --git a/types/components/current/languageSwitcher.ts b/types/components/current/languageSwitcher.ts index 0f409d36e..dd39494a9 100644 --- a/types/components/current/languageSwitcher.ts +++ b/types/components/current/languageSwitcher.ts @@ -1,5 +1,3 @@ -import { Lang } from "@/constants/languages" - import type { LanguageSwitcherData } from "@/types/requests/languageSwitcher" export type LanguageSwitcherLink = { @@ -9,4 +7,5 @@ export type LanguageSwitcherLink = { export type LanguageSwitcherProps = { urls: LanguageSwitcherData + location?: "header" | "footer" } diff --git a/types/components/icon.ts b/types/components/icon.ts index 0e705b7e6..b0f6359d8 100644 --- a/types/components/icon.ts +++ b/types/components/icon.ts @@ -19,6 +19,7 @@ export enum IconName { CrossCircle = "CrossCircle", CheckCircle = "CheckCircle", ChevronDown = "ChevronDown", + ChevronLeft = "ChevronLeft", ChevronRight = "ChevronRight", Close = "Close", CloseLarge = "CloseLarge", diff --git a/utils/tabbable.ts b/utils/tabbable.ts new file mode 100644 index 000000000..b38352c99 --- /dev/null +++ b/utils/tabbable.ts @@ -0,0 +1,62 @@ +/*! + * Adapted from jQuery UI core + * + * http://jqueryui.com + * + * Copyright 2014 jQuery Foundation and other contributors + * Released under the MIT license. + * http://jquery.org/license + * + * http://api.jqueryui.com/category/ui-core/ + */ + +const tabbableNode = /input|select|textarea|button|object/ + +function hidesContents(element: HTMLElement) { + const zeroSize = element.offsetWidth <= 0 && element.offsetHeight <= 0 + + // If the node is empty, this is good enough + if (zeroSize && !element.innerHTML) return true + + // Otherwise we need to check some styles + const style = window.getComputedStyle(element) + return zeroSize + ? style.getPropertyValue("overflow") !== "visible" + : style.getPropertyValue("display") === "none" +} + +function visible(element: any) { + let parentElement = element + while (parentElement) { + if (parentElement === document.body) break + if (hidesContents(parentElement)) return false + parentElement = parentElement.parentNode + } + return true +} + +export function focusable(element: HTMLElement, isTabIndexNotNaN: boolean) { + const nodeName = element.nodeName.toLowerCase() + const res = + //@ts-ignore + (tabbableNode.test(nodeName) && !element.disabled) || + //@ts-ignore + (nodeName === "a" ? element.href || isTabIndexNotNaN : isTabIndexNotNaN) + return res && visible(element) +} + +export function tabbable(element: HTMLElement) { + let tabIndex = element.getAttribute("tabindex") + //@ts-ignore + if (tabIndex === null) tabIndex = undefined + //@ts-ignore + const isTabIndexNaN = isNaN(tabIndex) + //@ts-ignore + return (isTabIndexNaN || tabIndex >= 0) && focusable(element, !isTabIndexNaN) +} + +export default function findTabbableDescendants( + element: HTMLElement +): HTMLElement[] { + return [].slice.call(element.querySelectorAll("*"), 0).filter(tabbable) +}