feat(SW-184): language switcher mobile/desktop functionality

This commit is contained in:
Erik Tiekstra
2024-08-22 14:21:24 +02:00
parent a2e2cf575e
commit 7ef7b4a544
15 changed files with 407 additions and 83 deletions

View File

@@ -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 (
<>
<button
@@ -33,11 +40,7 @@ export default function MobileMenu({
>
<span className={styles.bar}></span>
</button>
<Modal
className={styles.modal}
isOpen={isHamburgerMenuOpen}
onOpenChange={toggleHamburgerMenu}
>
<Modal className={styles.modal} isOpen={isHamburgerMenuOpen}>
<Dialog
className={styles.dialog}
aria-label={intl.formatMessage({ id: "Menu" })}

View File

@@ -80,11 +80,12 @@
.modal {
position: fixed;
right: auto;
top: var(--main-menu-mobile-height);
right: auto;
bottom: 0;
width: 100%;
background-color: var(--Base-Surface-Primary-light-Normal);
transition: right 0.3s;
}
.modal[data-entering] {

View File

@@ -7,27 +7,27 @@ export default function CheckIcon({ className, color, ...props }: IconProps) {
return (
<svg
className={classNames}
fill="none"
height="25"
viewBox="0 0 24 25"
width="24"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
{...props}
>
<mask
height="25"
id="mask0_1333_19690"
maskUnits="userSpaceOnUse"
id="mask0_2570_1776"
style={{ maskType: "alpha" }}
width="24"
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="24"
height="24"
>
<rect y="0.629639" width="24" height="24" fill="#D9D9D9" />
<rect width="24" height="24" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_1333_19690)">
<g mask="url(#mask0_2570_1776)">
<path
d="M9.99967 16.6738L6.35547 13.0296L7.39967 11.9854L9.99967 14.5854L16.5997 7.98535L17.6439 9.02955L9.99967 16.6738Z"
d="M9.57552 15.1752L17.9505 6.8002C18.1422 6.60853 18.3651 6.5127 18.6193 6.5127C18.8734 6.5127 19.0964 6.60853 19.288 6.8002C19.4797 6.99186 19.5755 7.21478 19.5755 7.46895C19.5755 7.72311 19.4797 7.94603 19.288 8.1377L10.238 17.1877C10.0464 17.3794 9.82552 17.4752 9.57552 17.4752C9.32552 17.4752 9.10469 17.3794 8.91302 17.1877L4.71302 12.9877C4.52136 12.796 4.42761 12.5731 4.43177 12.3189C4.43594 12.0648 4.53386 11.8419 4.72552 11.6502C4.91719 11.4585 5.14011 11.3627 5.39427 11.3627C5.64844 11.3627 5.87136 11.4585 6.06302 11.6502L9.57552 15.1752Z"
fill="#4D001B"
/>
</g>

View File

@@ -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 (
<svg
className={classNames}
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
{...props}
>
<mask
id="mask0_2291_1760"
style={{ maskType: "alpha" }}
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="20"
height="20"
>
<rect width="20" height="20" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_2291_1760)">
<path
d="M7.75 10.0001L13.5729 15.823C13.7535 16.0036 13.8455 16.2258 13.849 16.4897C13.8524 16.7536 13.7639 16.9758 13.5833 17.1563C13.4028 17.3369 13.1806 17.4272 12.9167 17.4272C12.6528 17.4272 12.4306 17.3369 12.25 17.1563L6.08333 10.9793C5.9375 10.8334 5.83507 10.6807 5.77604 10.5209C5.71701 10.3612 5.6875 10.1876 5.6875 10.0001C5.6875 9.8126 5.71701 9.63899 5.77604 9.47927C5.83507 9.31954 5.9375 9.16677 6.08333 9.02093L12.25 2.85426C12.4306 2.67371 12.651 2.5817 12.9115 2.57822C13.1719 2.57475 13.3924 2.66329 13.5729 2.84385C13.7535 3.0244 13.8438 3.24663 13.8438 3.51051C13.8438 3.7744 13.7535 3.99663 13.5729 4.17718L7.75 10.0001Z"
fill="#CD0921"
/>
</g>
</svg>
)
}

View File

@@ -15,6 +15,7 @@ import {
CheckCircleIcon,
CheckIcon,
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
CloseIcon,
CloseLarge,
@@ -75,6 +76,8 @@ export function getIconByIconName(icon?: IconName): FC<IconProps> | null {
return CheckCircleIcon
case IconName.ChevronDown:
return ChevronDownIcon
case IconName.ChevronLeft:
return ChevronLeftIcon
case IconName.ChevronRight:
return ChevronRightIcon
case IconName.Close:

View File

@@ -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"

View File

@@ -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 (
<div className={styles.languageSwitcher}>
<div className={styles.languageSwitcher} ref={languageSwitcherRef}>
<button
type="button"
className={styles.button}
aria-label={intl.formatMessage({
id: isLanguageSwitcherOpen
? "Close language menu"
: "Open language menu",
})}
onClick={toggleLanguageSwitcher}
>
<GlobeIcon width={20} height={20} color="burgundy" />
@@ -38,26 +64,42 @@ export default function LanguageSwitcher({ urls }: LanguageSwitcherProps) {
<div
className={`${styles.dropdown} ${isLanguageSwitcherOpen ? styles.isExpanded : ""}`}
>
<ul className={styles.list}>
{urlKeys.map((key) => {
const url = urls[key]?.url
const isActive = currentLanguage === key
if (url) {
return (
<li key={key}>
<Link
className={`${styles.link} ${isActive ? styles.active : ""}`}
color="burgundy"
href={url}
>
{languages[key]}
{isActive ? <CheckIcon color="burgundy" /> : null}
</Link>
</li>
)
}
})}
</ul>
<div className={styles.backWrapper}>
<button
type="button"
className={styles.backButton}
onClick={toggleLanguageSwitcher}
>
<ChevronLeftIcon color="red" />
<Subtitle type="one">Main Menu</Subtitle>
</button>
</div>
<div className={styles.languageWrapper}>
<Subtitle className={styles.subtitle} type="two">
{intl.formatMessage({ id: "Select your language" })}
</Subtitle>
<ul className={styles.list}>
{urlKeys.map((key) => {
const url = urls[key]?.url
const isActive = currentLanguage === key
if (url) {
return (
<li key={key}>
<Link
className={`${styles.link} ${isActive ? styles.active : ""}`}
color="burgundy"
href={url}
>
{languages[key]}
{isActive ? <CheckIcon color="burgundy" /> : null}
</Link>
</li>
)
}
})}
</ul>
</div>
</div>
</div>
)

View File

@@ -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;
}
}

View File

@@ -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]);
}
window.removeEventListener("keydown", callback)
}
}, [callback])
}

12
hooks/useHandleKeyUp.ts Normal file
View File

@@ -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])
}

82
hooks/useTrapFocus.ts Normal file
View File

@@ -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<HTMLDivElement>(null)
const previouseFocusedElement = useRef<HTMLElement>(
document.activeElement as HTMLElement
)
const [tabbableElements, setTabbableElements] = useState<HTMLElement[]>([])
// 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)
}

View File

@@ -19,7 +19,15 @@ const useDropdownStore = create<DropdownState>((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<DropdownState>((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

View File

@@ -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"
}

View File

@@ -19,6 +19,7 @@ export enum IconName {
CrossCircle = "CrossCircle",
CheckCircle = "CheckCircle",
ChevronDown = "ChevronDown",
ChevronLeft = "ChevronLeft",
ChevronRight = "ChevronRight",
Close = "Close",
CloseLarge = "CloseLarge",

62
utils/tabbable.ts Normal file
View File

@@ -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)
}