diff --git a/src/app/components/jsx-helpers/raw-html.tsx b/src/app/components/jsx-helpers/raw-html.tsx index 26a767c9b..bee01d2a7 100644 --- a/src/app/components/jsx-helpers/raw-html.tsx +++ b/src/app/components/jsx-helpers/raw-html.tsx @@ -34,11 +34,12 @@ function activateScripts(el: HTMLElement) { processOne(); } -type RawHTMLArgs = { +type RawHTMLArgs = ({ Tag?: string; html?: TrustedHTML; embed?: boolean; -} & React.HTMLAttributes; + href?: string; +} & React.HTMLAttributes); export default function RawHTML({ Tag = 'div', diff --git a/src/app/layouts/default/header/menus/main-menu/dropdown/dropdown.js b/src/app/layouts/default/header/menus/main-menu/dropdown/dropdown.tsx similarity index 81% rename from src/app/layouts/default/header/menus/main-menu/dropdown/dropdown.js rename to src/app/layouts/default/header/menus/main-menu/dropdown/dropdown.tsx index 69f4732bd..aa004bec9 100644 --- a/src/app/layouts/default/header/menus/main-menu/dropdown/dropdown.js +++ b/src/app/layouts/default/header/menus/main-menu/dropdown/dropdown.tsx @@ -15,7 +15,11 @@ import './dropdown.scss'; // for ordinary website navigations, per // https://www.w3.org/WAI/ARIA/apg/patterns/menubar/examples/menubar-navigation/ -export function MenuItem({label, url, local=undefined}) { +export function MenuItem({label, url, local = undefined}: { + label: string; + url: string; + local?: string; +}) { const {innerWidth: _} = useWindowContext(); const urlPath = url.replace('/view-all', ''); const {pathname} = useLocation(); @@ -33,7 +37,10 @@ export function MenuItem({label, url, local=undefined}) { ); } -function OptionalWrapper({isWrapper = true, children}) { +function OptionalWrapper({isWrapper, children}: { + isWrapper: boolean; + children?: React.ReactNode; +}) { return isWrapper ? (
{children}
) : ( @@ -48,9 +55,16 @@ export default function Dropdown({ children, excludeWrapper = false, navAnalytics +}: { + Tag?: React.ElementType; + className?: string; + label: string; + children?: React.ReactNode; + excludeWrapper?: boolean; + navAnalytics?: string; }) { - const topRef = useRef(); - const dropdownRef = useRef(null); + const topRef = useRef(null); + const dropdownRef = useRef(null); const ddId = `ddId-${label}`; const { closeMenu, closeDesktopMenu, openMenu, openDesktopMenu @@ -98,12 +112,19 @@ function DropdownController({ closeMenu, openMenu, label +}: { + ddId: string; + closeDesktopMenu: () => void; + topRef: React.RefObject; + closeMenu: () => void; + openMenu: (event: React.MouseEvent) => void; + label: string; }) { const {activeDropdown, prefix} = useDropdownContext(); const isOpen = activeDropdown === topRef; const labelId = `${prefix}-${label}`; const toggleMenu = React.useCallback( - (event) => { + (event: React.MouseEvent) => { if (activeDropdown === topRef) { event.preventDefault(); closeMenu(); @@ -114,8 +135,8 @@ function DropdownController({ [openMenu, closeMenu, activeDropdown, topRef] ); const closeOnBlur = React.useCallback( - ({currentTarget, relatedTarget}) => { - if (currentTarget.parentNode.contains(relatedTarget)) { + ({currentTarget, relatedTarget}: React.FocusEvent) => { + if (currentTarget.parentNode?.contains(relatedTarget)) { return; } closeDesktopMenu(); @@ -154,7 +175,13 @@ function DropdownController({ ); } -function DropdownContents({id, label, dropdownRef, navAnalytics, children}) { +function DropdownContents({id, label, dropdownRef, navAnalytics, children}: { + id: string; + label: string; + dropdownRef: React.RefObject; + navAnalytics?: string; + children?: React.ReactNode; +}) { return (
; + topRef: React.MutableRefObject; label: string; }) { const {setSubmenuLabel, setActiveDropdown} = useDropdownContext(); diff --git a/src/app/layouts/default/header/menus/main-menu/dropdown/use-navigate-by-key.ts b/src/app/layouts/default/header/menus/main-menu/dropdown/use-navigate-by-key.ts index 2ed36ed99..97d91977a 100644 --- a/src/app/layouts/default/header/menus/main-menu/dropdown/use-navigate-by-key.ts +++ b/src/app/layouts/default/header/menus/main-menu/dropdown/use-navigate-by-key.ts @@ -1,29 +1,31 @@ import useDropdownContext from '../../dropdown-context'; import {isMobileDisplay} from '~/helpers/device'; +import { assertDefined } from '~/helpers/data'; -function findNext(dropdownRef: React.MutableRefObject) { +function findNext(dropdownRef: React.MutableRefObject) { const nextSib = document.activeElement?.nextElementSibling; if (nextSib?.matches('a')) { return nextSib as HTMLAnchorElement; } - const targets = Array.from(dropdownRef.current.querySelectorAll('a')); + const targets = Array.from(assertDefined(dropdownRef.current?.querySelectorAll('a'))); const idx = targets.indexOf(document.activeElement as HTMLAnchorElement); const nextIdx = (idx + 1) % targets.length; return targets[nextIdx]; } +// eslint-disable-next-line complexity function findPrev( - topRef: React.MutableRefObject, - dropdownRef: React.MutableRefObject + topRef: React.MutableRefObject, + dropdownRef: React.MutableRefObject ) { const prevSib = document.activeElement?.previousElementSibling; if (prevSib?.matches('a')) { return prevSib as HTMLAnchorElement; } - const targets = Array.from(dropdownRef.current.querySelectorAll('a')); + const targets = Array.from(assertDefined(dropdownRef.current?.querySelectorAll('a'))); const idx = targets.indexOf(document.activeElement as HTMLAnchorElement); if (idx === 0) { @@ -40,8 +42,8 @@ export default function useNavigateByKey({ closeMenu, closeDesktopMenu }: { - topRef: React.MutableRefObject; - dropdownRef: React.MutableRefObject; + topRef: React.MutableRefObject; + dropdownRef: React.MutableRefObject; closeMenu: () => void; closeDesktopMenu: () => void; }) { @@ -69,7 +71,7 @@ export default function useNavigateByKey({ case 'ArrowDown': event.preventDefault(); if (document.activeElement === topRef.current) { - (dropdownRef.current.firstChild as HTMLAnchorElement)?.focus(); + (dropdownRef.current?.firstChild as HTMLAnchorElement)?.focus(); } else { findNext(dropdownRef).focus(); } @@ -77,7 +79,7 @@ export default function useNavigateByKey({ case 'ArrowUp': event.preventDefault(); if (document.activeElement !== topRef.current) { - findPrev(topRef, dropdownRef).focus(); + findPrev(topRef, dropdownRef)?.focus(); } break; case 'Escape': diff --git a/src/app/layouts/default/header/menus/main-menu/login-menu/login-menu-with-dropdown.js b/src/app/layouts/default/header/menus/main-menu/login-menu/login-menu-with-dropdown.tsx similarity index 88% rename from src/app/layouts/default/header/menus/main-menu/login-menu/login-menu-with-dropdown.js rename to src/app/layouts/default/header/menus/main-menu/login-menu/login-menu-with-dropdown.tsx index bd4dda4ce..696c04e2e 100644 --- a/src/app/layouts/default/header/menus/main-menu/login-menu/login-menu-with-dropdown.js +++ b/src/app/layouts/default/header/menus/main-menu/login-menu/login-menu-with-dropdown.tsx @@ -3,8 +3,10 @@ import {useLocation} from 'react-router-dom'; import useUserContext from '~/contexts/user'; import linkHelper from '~/helpers/link'; import Dropdown, {MenuItem} from '../dropdown/dropdown'; +import type {WindowWithSettings} from '~/helpers/window-settings'; +import {assertDefined} from '~/helpers/data'; -const settings = window.SETTINGS; +const settings = (window as WindowWithSettings).SETTINGS; const reqFacultyAccessLink = `${settings.accountHref}/i/signup/educator/cs_form`; const profileLink = `${settings.accountHref}/profile`; @@ -21,7 +23,7 @@ function AccountItem() { export default function LoginMenuWithDropdown() { - const {userModel} = useUserContext(); + const userModel = assertDefined(useUserContext().userModel); // updates logoutLink useLocation(); diff --git a/src/app/layouts/default/header/menus/main-menu/login-menu/login-menu.js b/src/app/layouts/default/header/menus/main-menu/login-menu/login-menu.tsx similarity index 75% rename from src/app/layouts/default/header/menus/main-menu/login-menu/login-menu.js rename to src/app/layouts/default/header/menus/main-menu/login-menu/login-menu.tsx index f25f0b325..f348cfec2 100644 --- a/src/app/layouts/default/header/menus/main-menu/login-menu/login-menu.js +++ b/src/app/layouts/default/header/menus/main-menu/login-menu/login-menu.tsx @@ -7,20 +7,12 @@ import linkHelper from '~/helpers/link'; function LoginLink() { // It's not used directly, but loginLink changes when it does useLocation(); - const addressHinkyQAIssue = React.useCallback( - (e) => { - if (e.defaultPrevented) { - e.defaultPrevented = false; - } - }, - [] - ); return (
  • Log in diff --git a/src/app/layouts/default/header/menus/main-menu/main-menu.js b/src/app/layouts/default/header/menus/main-menu/main-menu.tsx similarity index 77% rename from src/app/layouts/default/header/menus/main-menu/main-menu.js rename to src/app/layouts/default/header/menus/main-menu/main-menu.tsx index ad7826eaa..b72920da7 100644 --- a/src/app/layouts/default/header/menus/main-menu/main-menu.js +++ b/src/app/layouts/default/header/menus/main-menu/main-menu.tsx @@ -14,36 +14,43 @@ import GiveButton from '../give-button/give-button'; import {treatSpaceOrEnterAsClick} from '~/helpers/events'; import './main-menu.scss'; -function DropdownOrMenuItem({item}) { +type MenuItemData = { + name?: string; + label?: string; + partial_url?: string; + menu?: MenuItemData[]; +}; + +function DropdownOrMenuItem({item}: {item: MenuItemData}) { if (! item.name && ! item.label) { return null; } if ('menu' in item) { return ( - + ); } - return ; + return ; } -function MenusFromStructure({structure}) { +function MenusFromStructure({structure}: {structure: MenuItemData[]}) { return ( - {structure.map((item) => ( - + {structure.map((item, index) => ( + ))} ); } function MenusFromCMS() { - const structure = useDataFromSlug('oxmenus'); + const structure = useDataFromSlug('oxmenus') as MenuItemData[] | undefined; if (!structure) { return null; @@ -74,8 +81,8 @@ function SubjectsMenu() { navAnalytics="Main Menu (Subjects)" > {categories - .filter((obj) => obj.html !== 'K12') - .map((obj) => ( + .filter((obj: {html: string; value: string}) => obj.html !== 'K12') + .map((obj: {html: string; value: string}) => ( ) { switch (event.key) { case 'ArrowRight': event.preventDefault(); event.stopPropagation(); - event.target + (event.target as HTMLElement) .closest('li') - .nextElementSibling?.querySelector('a') - .focus(); + ?.nextElementSibling?.querySelector('a') + ?.focus(); break; case 'ArrowLeft': event.preventDefault(); event.stopPropagation(); - event.target + (event.target as HTMLElement) .closest('li') - .previousElementSibling?.querySelector('a') - .focus(); + ?.previousElementSibling?.querySelector('a') + ?.focus(); break; default: break; diff --git a/test/src/layouts/default/default.test.tsx b/test/src/layouts/default/default.test.tsx index 9c4ff236d..7f0d3541f 100644 --- a/test/src/layouts/default/default.test.tsx +++ b/test/src/layouts/default/default.test.tsx @@ -17,6 +17,12 @@ import oxmenuData from './data/osmenu.json'; import giveTodayData from './data/give-today.json'; import giveBannerData from './data/givebanner.json'; import * as CF from '~/helpers/cms-fetch'; +import MemoryRouter from '../../../helpers/future-memory-router'; +import {Link} from 'react-router-dom'; +import * as UUC from '~/contexts/user'; +import LoginMenu from '~/layouts/default/header/menus/main-menu/login-menu/login-menu-with-dropdown'; +import MenuExpander from '~/layouts/default/header/menus/menu-expander/menu-expander'; +import {DropdownContextProvider} from '~/layouts/default/header/menus/dropdown-context'; import '@testing-library/jest-dom'; // Mock external dependencies @@ -35,13 +41,6 @@ jest.mock('~/contexts/window', () => ({ default: () => ({innerWidth: 1024}) })); -// Having it defined inline caused location updates on every call -let mockPathname = {pathname: '/test-path'}; - -jest.mock('react-router-dom', () => ({ - useLocation: () => mockPathname -})); - const mockUseSharedDataContext = jest .fn() .mockReturnValue({stickyFooterState: [false, () => undefined]}); @@ -281,6 +280,19 @@ describe('default layout', () => { }); } + const myOpenStaxUser = { + contact: { + firstName: 'Roy', + lastName: 'Johnson' + } + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const loggedInUser = {userModel: {id: 16249}, myOpenStaxUser} as any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const loggedOutUser = {} as any; + + const spyUseUserContext = jest.spyOn(UUC, 'default').mockReturnValue(loggedOutUser); + beforeAll(() => { global.fetch = jest .fn() @@ -307,10 +319,11 @@ describe('default layout', () => { }); }); it('renders; menu opens and closes', async () => { - render(); - expect(await screen.findAllByText('JIT Load Component')).toHaveLength( - 2 - ); + render( + + Change route + ); + expect(await screen.findAllByText('JIT Load Component')).toHaveLength(2); const toggle = screen.getByRole('button', { name: 'Toggle Meta Navigation Menu' }); @@ -328,6 +341,8 @@ describe('default layout', () => { await user.click(overlay as Element); expect(toggle.getAttribute('aria-expanded')).toBe('false'); + spyUseUserContext.mockReturnValue(loggedInUser); + // close on Escape (but not on other keypress) await user.click(toggle); expect(toggle.getAttribute('aria-expanded')).toBe('true'); @@ -336,9 +351,44 @@ describe('default layout', () => { fireEvent.keyDown(menuContainer, {key: 'Escape'}); expect(toggle.getAttribute('aria-expanded')).toBe('false'); - // close on location change (the open is immediately reversed because the path has changed) - mockPathname = {pathname: '/test-path'}; - await user.click(toggle); + // close on location change + fireEvent.click(screen.getByText('Change route')); expect(toggle.getAttribute('aria-expanded')).toBe('false'); + + const techMenu = screen.getAllByRole('button', {name: 'Technology'})[0]; + + await user.click(techMenu); + expect(techMenu.getAttribute('aria-expanded')).toBe('false'); + await user.click(techMenu); + expect(techMenu.getAttribute('aria-expanded')).toBe('true'); + + fireEvent.focus(techMenu); + fireEvent.keyDown(techMenu, {key: 'ArrowDown'}); + expect(document.activeElement?.textContent).toBe('OpenStax Assignable'); + fireEvent.keyDown(techMenu, {key: 'ArrowUp'}); + expect(document.activeElement?.textContent).toBe('Technology arrow'); + fireEvent.keyDown(techMenu, {key: 'ArrowRight'}); + expect(document.activeElement?.textContent).toBe('What we do arrow'); + fireEvent.keyDown(techMenu, {key: 'ArrowLeft'}); + expect(document.activeElement?.textContent).toBe('What we do arrow'); + expect(techMenu.getAttribute('aria-expanded')).toBe('false'); + }); + it('renders login menu', async () => { + render( + + ); + await screen.findByText('Account Dashboard'); + }); + it('closes mobile menu on location change', () => { + const toggleActive = jest.fn(); + + render( + + + Change route + + ); + fireEvent.click(screen.getByText('Change route')); + expect(toggleActive).toHaveBeenCalledWith(false); }); });